package engine import ( "errors" "fmt" "net" "net/http" "os" "path/filepath" "strings" "sync" "github.com/sirupsen/logrus" "github.com/xyproto/algernon/utils" lua "github.com/xyproto/gopher-lua" bolt "github.com/xyproto/permissionbolt/v2" redis "github.com/xyproto/permissions" mariadb "github.com/xyproto/permissionsql/v2" sqlite "github.com/xyproto/permissionsqlite_noc" "github.com/xyproto/pinterface/v2" postgres "github.com/xyproto/pstore" "github.com/xyproto/simpleredis/v2" ) // Info returns a string with various info about the current configuration func (ac *Config) Info() string { var sb strings.Builder if !ac.singleFileMode { sb.WriteString("Server directory:\t" + ac.serverDirOrFilename + "\n") } else { sb.WriteString("Filename:\t\t" + ac.serverDirOrFilename + "\n") } if ac.dbName == "" { sb.WriteString("Database:\t\tDisabled\n") } else { sb.WriteString("Database:\t\t" + ac.dbName + "\n") } if ac.luaServerFilename != "" { sb.WriteString("Server filename:\t" + ac.luaServerFilename + "\n") } // Write the status of flags that can be toggled utils.WriteStatus(&sb, "Options", map[string]bool{ "Debug": ac.debugMode, "Production": ac.productionMode, "Auto-refresh": ac.autoRefresh, "Dev": ac.devMode, "NonInteractive": ac.serverMode, "StatCache": ac.cacheFileStat, }) sb.WriteString("Cache mode:\t\t" + ac.cacheMode.String() + "\n") if ac.cacheSize != 0 { sb.WriteString(fmt.Sprintf("Cache size:\t\t%d bytes\n", ac.cacheSize)) } if ac.serverLogFile != "" { sb.WriteString("Log file:\t\t" + ac.serverLogFile + "\n") } if !(ac.serveJustHTTP2 || ac.serveJustHTTP) { sb.WriteString("TLS certificate:\t" + ac.serve.serverCert + "\n") sb.WriteString("TLS key:\t\t" + ac.serve.serverKey + "\n") } if ac.autoRefresh { if ac.separateEventServer { sb.WriteString("Event server:\t\t" + ac.eventAddr + "\n") } else { sb.WriteString("Event path:\t\t" + ac.defaultEventPath + " (on main server)\n") } } if ac.autoRefreshDir != "" { sb.WriteString("Only watching:\t\t" + ac.autoRefreshDir + "\n") } if ac.redisAddr != ac.defaultRedisColonPort { sb.WriteString("Redis address:\t\t" + ac.redisAddr + "\n") } if ac.disableRateLimiting { sb.WriteString("Request limit:\t\tOff\n") } else { sb.WriteString(fmt.Sprintf("Request limit:\t\t%d/sec per visitor\n", ac.limitRequests)) } if ac.redisDBindex != 0 { sb.WriteString(fmt.Sprintf("Redis database index:\t%d\n", ac.redisDBindex)) } if ac.largeFileSize > 0 { sb.WriteString(fmt.Sprintf("Large file threshold:\t%v bytes\n", ac.largeFileSize)) } if ac.writeTimeout > 0 { sb.WriteString(fmt.Sprintf("Large file timeout:\t%v sec\n", ac.writeTimeout)) } if len(ac.serverConfigurationFilenames) > 0 { sb.WriteString(fmt.Sprintf("Server configuration:\t%v\n", ac.serverConfigurationFilenames)) } if ac.internalLogFilename != os.DevNull { sb.WriteString("Internal log file:\t" + ac.internalLogFilename + "\n") } sb.WriteString("Listening for:\t\t" + ac.listeningSummary()) return strings.TrimSpace(sb.String()) } // listeningSummary returns all configured servers/listeners as a table func (ac *Config) listeningSummary() string { type row struct{ proto, host, port, tls string } var rows []row add := func(protocol, addr string, tls bool) { host, port, err := net.SplitHostPort(addr) if err != nil { host, port = addr, "" } if host == "" { host = "localhost" } tlsStr := "plain" if tls { tlsStr = "TLS" } rows = append(rows, row{protocolName(protocol), host, port, tlsStr}) } switch { case len(ac.serve.portSettings) > 0: for _, ps := range ac.serve.portSettings { add(ps.Protocol, ps.Addr, ps.TLS) } case ac.serve.httpAddr != "" || ac.serve.httpsAddr != "": if ac.serve.httpAddr != "" { add("http", ac.serve.httpAddr, false) } if ac.serve.httpsAddr != "" { add("http2", ac.serve.httpsAddr, true) } case ac.productionMode: add("http", utils.JoinHostPort(ac.serverHost, ":80"), false) add("http2", utils.JoinHostPort(ac.serverHost, ":443"), true) case ac.serveJustHTTP: add("http", ac.serverAddr, false) case ac.serveJustHTTP2: add("http2", ac.serverAddr, false) default: add("http2", ac.serverAddr, true) } if len(rows) == 0 { return "nothing" } var pW, hW, portW int for _, r := range rows { if n := len(r.proto); n > pW { pW = n } if n := len(r.host); n > hW { hW = n } if n := len(r.port); n > portW { portW = n } } var sb strings.Builder for i, r := range rows { if i > 0 { sb.WriteString("\t\t\t") } fmt.Fprintf(&sb, "%-*s %-*s %*s %s\n", pW, r.proto, hW, r.host, portW, r.port, r.tls) } return sb.String() } // protocolName returns the short name used in the listener table. func protocolName(protocol string) string { switch protocol { case "http": return "HTTP" case "http2": return "HTTP/2" case "http3", "quic": return "HTTP/3" case "event": return "SSE" } return strings.ToUpper(protocol) } // LoadServerConfigFunctions makes functions related to server configuration and // permissions available to the given Lua struct. func (ac *Config) LoadServerConfigFunctions(L *lua.LState, filename string) error { if ac.perm == nil { return errors.New("perm is nil when loading server config functions") } // Clear the default path prefixes. This makes everything public. L.SetGlobal("ClearPermissions", L.NewFunction(func(_ *lua.LState) int { ac.perm.Clear() return 0 // number of results })) // Registers a path prefix, for instance "/secret", // as having *user* rights. L.SetGlobal("AddUserPrefix", L.NewFunction(func(L *lua.LState) int { path := L.ToString(1) ac.perm.AddUserPath(path) return 0 // number of results })) // Registers a path prefix, for instance "/secret", // as having *admin* rights. L.SetGlobal("AddAdminPrefix", L.NewFunction(func(L *lua.LState) int { path := L.ToString(1) ac.perm.AddAdminPath(path) return 0 // number of results })) // Sets a Lua function as a custom "permissions denied" page handler. L.SetGlobal("DenyHandler", L.NewFunction(func(L *lua.LState) int { luaDenyFunc := L.ToFunction(1) // L is shared by all requests, and a Lua state is not goroutine-safe var denyMut sync.Mutex // Custom handler for when permissions are denied ac.perm.SetDenyFunction(func(w http.ResponseWriter, req *http.Request) { denyMut.Lock() defer denyMut.Unlock() fw := &forbiddenWriter{ResponseWriter: w} defer fw.finish() w = fw // Set up a new Lua state with the current http.ResponseWriter and *http.Request, without caching ac.LoadCommonFunctions(w, req, filename, L, nil, nil) // Then run the given Lua function L.Push(luaDenyFunc) if err := L.PCall(0, lua.MultRet, nil); err != nil { // Non-fatal error logrus.Error("Permission denied handler failed:", err) // Use the default permission handler from now on if the lua function fails ac.perm.SetDenyFunction(redis.PermissionDenied) ac.perm.DenyFunction()(w, req) } }) return 0 // number of results })) return nil } // loadServerSettingsFunctions registers Lua functions for server settings // that do not require a database backend or permissions. func (ac *Config) loadServerSettingsFunctions(L *lua.LState, filename string) { // Set a default host and port. Maybe useful for alg applications. L.SetGlobal("SetAddr", L.NewFunction(func(L *lua.LState) int { ac.serverAddrLua = L.ToString(1) return 0 // number of results })) // Set the HTTP (non-TLS) listen address (only if not already set by flags/positional) L.SetGlobal("SetHTTPAddr", L.NewFunction(func(L *lua.LState) int { if !ac.serve.portConfigFromCLI { ac.serve.httpAddr = L.ToString(1) } return 0 // number of results })) // Set the HTTPS (TLS) listen address (only if not already set by flags/positional) L.SetGlobal("SetHTTPSAddr", L.NewFunction(func(L *lua.LState) int { if !ac.serve.portConfigFromCLI { ac.serve.httpsAddr = L.ToString(1) } return 0 // number of results })) // Enable or disable HTTP to HTTPS redirect (only if not already set by flags) L.SetGlobal("SetRedirect", L.NewFunction(func(L *lua.LState) int { if !ac.serve.redirectFromCLI { ac.serve.redirectHTTP = bool(L.ToBool(1)) } return 0 // number of results })) // Enable or disable Let's Encrypt / CertMagic (only if not already set by flags) L.SetGlobal("SetLetsEncrypt", L.NewFunction(func(L *lua.LState) int { if !ac.serve.certMagicFromCLI { ac.serve.useCertMagic = bool(L.ToBool(1)) } return 0 // number of results })) // Enable or disable interactive mode (only if not already set by flags) L.SetGlobal("SetInteractive", L.NewFunction(func(L *lua.LState) int { if !ac.serverModeFromCLI { ac.serverMode = !bool(L.ToBool(1)) } return 0 // number of results })) // Set a base URL for the links in the directory listing, unless it was // already set with --dirbaseurl L.SetGlobal("SetDirBaseURL", L.NewFunction(func(L *lua.LState) int { if ac.dirBaseURL == "" { ac.dirBaseURL = L.ToString(1) } return 0 // number of results })) // Configure listeners with full control over protocol, port and TLS. // Takes a table of tables: SetPorts{{":8080","http",false},{":8443","http2",true}} // Only applies if port configuration was not already set by flags or positional args. L.SetGlobal("SetPorts", L.NewFunction(func(L *lua.LState) int { if ac.serve.portConfigFromCLI { return 0 } tbl := L.ToTable(1) if tbl == nil { logrus.Error("SetPorts: expected a table argument") return 0 } var settings []PortSetting tbl.ForEach(func(_ lua.LValue, value lua.LValue) { entry, ok := value.(*lua.LTable) if !ok { return } var ps PortSetting // Support both positional {addr, protocol, tls} and named {addr=, protocol=, tls=} if addrVal := entry.RawGetString("addr"); addrVal != lua.LNil { ps.Addr = addrVal.String() } else if addrVal := entry.RawGetInt(1); addrVal != lua.LNil { ps.Addr = addrVal.String() } if protoVal := entry.RawGetString("protocol"); protoVal != lua.LNil { ps.Protocol = protoVal.String() } else if protoVal := entry.RawGetInt(2); protoVal != lua.LNil { ps.Protocol = protoVal.String() } if tlsVal := entry.RawGetString("tls"); tlsVal != lua.LNil { ps.TLS = lua.LVAsBool(tlsVal) } else if tlsVal := entry.RawGetInt(3); tlsVal != lua.LNil { ps.TLS = lua.LVAsBool(tlsVal) } // Normalize "quic" to "http3" if ps.Protocol == "quic" { ps.Protocol = "http3" } // Warn about HTTP/3 without TLS if ps.Protocol == "http3" && !ps.TLS { logrus.Warn("HTTP/3 without TLS is non-standard and not supported by browsers") } settings = append(settings, ps) }) ac.serve.portSettings = settings return 0 // number of results })) // Set the default cookie secret. This is for the server config, before // the userstate has been instantiated. L.SetGlobal("SetCookieSecret", L.NewFunction(func(L *lua.LState) int { ac.cookieSecret = L.ToString(1) return 0 // number of results })) // Get the default cookie secret. This is for the server config, before // the userstate has been instantiated. L.SetGlobal("CookieSecret", L.NewFunction(func(L *lua.LState) int { L.Push(lua.LString(ac.cookieSecret)) return 1 // number of results })) // Add a new reverse proxy given a: path prefix, endpoint and endpoint URL L.SetGlobal("AddReverseProxy", L.NewFunction(func(L *lua.LState) int { rp, err := NewReverseProxy(L.ToString(1), L.ToString(2)) if err != nil { logrus.Errorf("AddReverseProxy: %v", err) return 0 // number of results } if ac.reverseProxyConfig == nil { ac.reverseProxyConfig = NewReverseProxyConfig() } ac.reverseProxyConfig.Add(rp) return 0 // number of results })) // Sets a Lua function to be run once the server is done parsing configuration and arguments. L.SetGlobal("OnReady", L.NewFunction(func(L *lua.LState) int { luaReadyFunc := L.ToFunction(1) // Custom handler for when permissions are denied. // Put the *lua.LState in a closure. ac.serverReadyFunctionLua = func() { // Run the given Lua function L.Push(luaReadyFunc) if err := L.PCall(0, lua.MultRet, nil); err != nil { // Non-fatal error logrus.Error("The OnReady function failed:", err) } } return 0 // number of results })) // Set a access log filename. If blank, the log will go to the console (or browser, if debug mode is set). L.SetGlobal("LogTo", L.NewFunction(func(L *lua.LState) int { filename := L.ToString(1) ac.serverLogFile = filename // Log as JSON by default logrus.SetFormatter(&logrus.JSONFormatter{}) // Log to stderr if an empty filename is given if filename == "" { logrus.SetOutput(os.Stderr) L.Push(lua.LBool(true)) return 1 // number of results } // Try opening/creating the given filename, for appending lw, err := openLogWriter(filename, defaultLogPermissions) if err != nil { logrus.Error(err) L.Push(lua.LBool(false)) return 1 // number of results } // Set the file to log to and return if ac.serverLog != nil { ac.serverLog.Close() } ac.serverLog = lw logrus.SetOutput(lw) L.Push(lua.LBool(true)) return 1 // number of results })) // Use a single Lua file as the server, instead of directory structure L.SetGlobal("ServerFile", L.NewFunction(func(L *lua.LState) int { givenFilename := L.ToString(1) serverFilename := filepath.Join(filepath.Dir(filename), givenFilename) if !ac.fs.Exists(serverFilename) { logrus.Error("Could not find", serverFilename) L.Push(lua.LBool(false)) return 1 // number of results } ac.luaServerFilename = serverFilename L.Push(lua.LBool(true)) return 1 // number of results })) // Set the server directory L.SetGlobal("ServerDir", L.NewFunction(func(L *lua.LState) int { givenDirectory := L.ToString(1) if !ac.fs.Exists(givenDirectory) { logrus.Error("Could not find", givenDirectory) L.Push(lua.LBool(false)) return 1 // number of results } ac.serverDirOrFilename = filepath.Clean(givenDirectory) L.Push(lua.LBool(true)) return 1 // number of results })) L.SetGlobal("ServerInfo", L.NewFunction(func(L *lua.LState) int { // Return the string, but drop the final newline L.Push(lua.LString(ac.Info())) return 1 // number of results })) } // DatabaseBackend tries to retrieve a database backend, using one of the // available permission middleware packages. It assign a name to dbName // (used for the status output) and returns a IPermissions struct. func (ac *Config) DatabaseBackend() (pinterface.IPermissions, error) { var ( err error perm pinterface.IPermissions ) // If Bolt is to be used and no filename is given if ac.useBolt && (ac.boltFilename == "") { ac.boltFilename = ac.defaultBoltFilename } if ac.boltFilename != "" { // New permissions middleware, using a Bolt database perm, err = bolt.NewWithConf(ac.boltFilename) if err != nil { if err.Error() == "timeout" { tempFile, errTemp := os.CreateTemp("", "algernon") if errTemp != nil { logrus.Fatal("Unable to find a temporary file to use:", errTemp) } ac.boltFilename = tempFile.Name() + ".db" } else { logrus.Errorf("Could not use Bolt as database backend: %s", err) } } else { ac.dbName = "Bolt (" + ac.boltFilename + ")" } // Try the new database filename if there was a timeout if ac.dbName == "" && ac.boltFilename != ac.defaultBoltFilename { perm, err = bolt.NewWithConf(ac.boltFilename) if err != nil { if err.Error() == "timeout" { logrus.Error("The Bolt database timed out!") } else { logrus.Errorf("Could not use Bolt as database backend: %s", err) } } else { ac.dbName = "Bolt, temporary" } } } if ac.dbName == "" && ac.mariadbDSN != "" { // New permissions middleware, using a MariaDB/MySQL database perm, err = mariadb.NewWithDSN(ac.mariadbDSN, ac.mariaDatabase) if err != nil { logrus.Errorf("Could not use MariaDB/MySQL as database backend: %s", err) } else { // The connection string may contain a password, so don't include it in the dbName ac.dbName = "MariaDB/MySQL" } } if ac.dbName == "" && ac.sqliteConnectionString != "" { // example connection string: sqlite.db&cache=shared&mode=memory perm, err = sqlite.NewWithConf(ac.sqliteConnectionString) if err != nil { logrus.Errorf("Could not use SQLite as database backend: %s", err) } else { // The connection string may contain a password, so don't include it in the dbName ac.dbName = "SQLite" } } if ac.dbName == "" && ac.mariaDatabase != "" { // Given a database, but not a host, connect to localhost // New permissions middleware, using a MariaDB/MySQL database perm, err = mariadb.NewWithConf("test:@127.0.0.1/" + ac.mariaDatabase) if err != nil { if ac.mariaDatabase != "" { logrus.Errorf("Could not use MariaDB/MySQL as database backend: %s", err) } else { logrus.Warnf("Could not use MariaDB/MySQL as database backend: %s", err) } } else { // The connection string may contain a password, so don't include it in the dbName ac.dbName = "MariaDB/MySQL" } } if ac.dbName == "" && ac.postgresDSN != "" { // New permissions middleware, using a PostgreSQL database perm, err = postgres.NewWithDSN(ac.postgresDSN, ac.postgresDatabase) if err != nil { logrus.Errorf("Could not use PostgreSQL as database backend: %s", err) } else { // The connection string may contain a password, so don't include it in the dbName ac.dbName = "PostgreSQL" } } if ac.dbName == "" && ac.postgresDatabase != "" { // Given a database, but not a host, connect to localhost // New permissions middleware, using a PostgreSQL database perm, err = postgres.NewWithConf("postgres:@127.0.0.1/" + ac.postgresDatabase) if err != nil { if ac.postgresDatabase != "" { logrus.Errorf("Could not use PostgreSQL as database backend: %s", err) } else { logrus.Warnf("Could not use PostgreSQL as database backend: %s", err) } } else { // The connection string may contain a password, so don't include it in the dbName ac.dbName = "PostgreSQL" } } if ac.dbName == "" && ac.redisAddrSpecified { // New permissions middleware, using a Redis database logrus.Info("Testing redis connection") if err := simpleredis.TestConnectionHost(ac.redisAddr); err != nil { logrus.Info("Redis connection failed") // Only output an error when a Redis host other than the default host+port was specified if ac.singleFileMode { logrus.Warnf("Could not use Redis as database backend: %s", err) } else { logrus.Errorf("Could not use Redis as database backend: %s", err) } } else { logrus.Info("Redis connection worked out") var err error logrus.Info("Connecting to Redis...") perm, err = redis.NewWithRedisConf2(ac.redisDBindex, ac.redisAddr) if err != nil { logrus.Warnf("Could not use Redis as database backend: %s", err) } else { ac.dbName = "Redis" } } } if ac.dbName == "" && ac.boltFilename == "" { ac.boltFilename = ac.defaultBoltFilename perm, err = bolt.NewWithConf(ac.boltFilename) if err != nil { if err.Error() == "timeout" { tempFile, errTemp := os.CreateTemp("", "algernon") if errTemp != nil { logrus.Fatal("Unable to find a temporary file to use:", errTemp) } ac.boltFilename = tempFile.Name() + ".db" } else { logrus.Errorf("Could not use Bolt as database backend: %s", err) } } else { ac.dbName = "Bolt (" + ac.boltFilename + ")" } // Try the new database filename if there was a timeout if ac.boltFilename != ac.defaultBoltFilename { perm, err = bolt.NewWithConf(ac.boltFilename) if err != nil { if err.Error() == "timeout" { logrus.Error("The Bolt database timed out!") } else { logrus.Errorf("Could not use Bolt as database backend: %s", err) } } else { ac.dbName = "Bolt, temporary" } } } if ac.dbName == "" { // This may typically happen if Algernon is already running return nil, errors.New("could not find a usable database backend") } if ac.verboseMode { logrus.Info("Database backend success: " + ac.dbName) } if perm != nil && ac.clearDefaultPathPrefixes { perm.Clear() } return perm, nil }