diff --git a/src/api.go b/src/api.go index 01adf20..090e5d8 100644 --- a/src/api.go +++ b/src/api.go @@ -80,10 +80,9 @@ func RegisterTLSAPIs(authRouter *auth.RouterDef) { authRouter.HandleFunc("/api/cert/delete", handleCertRemove) } -// Register the APIs for Authentication handlers like Authelia and OAUTH2 +// Register the APIs for Authentication handlers like Forward Auth and OAUTH2 func RegisterAuthenticationHandlerAPIs(authRouter *auth.RouterDef) { - authRouter.HandleFunc("/api/sso/Authelia", autheliaRouter.HandleSetAutheliaURLAndHTTPS) - authRouter.HandleFunc("/api/sso/Authentik", authentikRouter.HandleSetAuthentikURLAndHTTPS) + authRouter.HandleFunc("/api/sso/forward-auth", forwardAuthRouter.HandleAPIOptions) } // Register the APIs for redirection rules management functions @@ -239,6 +238,10 @@ func RegisterPluginAPIs(authRouter *auth.RouterDef) { authRouter.HandleFunc("/api/plugins/store/resync", pluginManager.HandleResyncPluginList) authRouter.HandleFunc("/api/plugins/store/install", pluginManager.HandleInstallPlugin) authRouter.HandleFunc("/api/plugins/store/uninstall", pluginManager.HandleUninstallPlugin) + + // Developer options + authRouter.HandleFunc("/api/plugins/developer/enableAutoReload", pluginManager.HandleEnableHotReload) + authRouter.HandleFunc("/api/plugins/developer/setAutoReloadInterval", pluginManager.HandleSetHotReloadInterval) } // Register the APIs for Auth functions, due to scoping issue some functions are defined here diff --git a/src/def.go b/src/def.go index 7efd638..66995ff 100644 --- a/src/def.go +++ b/src/def.go @@ -13,12 +13,10 @@ import ( "net/http" "time" - "imuslab.com/zoraxy/mod/auth/sso/authentik" - "imuslab.com/zoraxy/mod/access" "imuslab.com/zoraxy/mod/acme" "imuslab.com/zoraxy/mod/auth" - "imuslab.com/zoraxy/mod/auth/sso/authelia" + "imuslab.com/zoraxy/mod/auth/sso/forward" "imuslab.com/zoraxy/mod/database" "imuslab.com/zoraxy/mod/dockerux" "imuslab.com/zoraxy/mod/dynamicproxy/loadbalance" @@ -43,8 +41,9 @@ import ( const ( /* Build Constants */ - SYSTEM_NAME = "Zoraxy" - SYSTEM_VERSION = "3.2.1" + SYSTEM_NAME = "Zoraxy" + SYSTEM_VERSION = "3.2.2" + DEVELOPMENT_BUILD = false /* System Constants */ TMP_FOLDER = "./tmp" @@ -144,8 +143,7 @@ var ( pluginManager *plugins.Manager //Plugin manager for managing plugins //Authentication Provider - autheliaRouter *authelia.AutheliaRouter //Authelia router for Authelia authentication - authentikRouter *authentik.AuthentikRouter //Authentik router for Authentik authentication + forwardAuthRouter *forward.AuthRouter // Forward Auth router for Authelia/Authentik/etc authentication //Helper modules EmailSender *email.Sender //Email sender that handle email sending diff --git a/src/mod/auth/sso/deprecated/authelia/README.txt b/src/mod/auth/sso/deprecated/authelia/README.txt new file mode 100644 index 0000000..24c4e91 --- /dev/null +++ b/src/mod/auth/sso/deprecated/authelia/README.txt @@ -0,0 +1,7 @@ +Module: authelia + +Notice: +This module is **deprecated** and is no longer in use. It has been retained here for reference purposes only. +Consider using the updated implementation or alternative solutions as this module may be removed in future updates. + +Original implementation: https://github.com/tobychui/zoraxy/pull/421 \ No newline at end of file diff --git a/src/mod/auth/sso/authelia/authelia.go b/src/mod/auth/sso/deprecated/authelia/authelia.go similarity index 100% rename from src/mod/auth/sso/authelia/authelia.go rename to src/mod/auth/sso/deprecated/authelia/authelia.go diff --git a/src/mod/auth/sso/deprecated/authentik/README.txt b/src/mod/auth/sso/deprecated/authentik/README.txt new file mode 100644 index 0000000..5a0912d --- /dev/null +++ b/src/mod/auth/sso/deprecated/authentik/README.txt @@ -0,0 +1,7 @@ +Module: authentik + +Notice: +This module is **deprecated** and is no longer in use. It has been retained here for reference purposes only. +Consider using the updated implementation or alternative solutions as this module may be removed in future updates. + +Original implementation: https://github.com/tobychui/zoraxy/pull/568 \ No newline at end of file diff --git a/src/mod/auth/sso/authentik/authentik.go b/src/mod/auth/sso/deprecated/authentik/authentik.go similarity index 100% rename from src/mod/auth/sso/authentik/authentik.go rename to src/mod/auth/sso/deprecated/authentik/authentik.go diff --git a/src/mod/auth/sso/forward/const.go b/src/mod/auth/sso/forward/const.go new file mode 100644 index 0000000..164ef79 --- /dev/null +++ b/src/mod/auth/sso/forward/const.go @@ -0,0 +1,46 @@ +package forward + +import "errors" + +const ( + LogTitle = "Forward Auth" + + DatabaseTable = "auth_sso_forward" + + DatabaseKeyAddress = "address" + DatabaseKeyResponseHeaders = "responseHeaders" + DatabaseKeyResponseClientHeaders = "responseClientHeaders" + DatabaseKeyRequestHeaders = "requestHeaders" + DatabaseKeyRequestExcludedCookies = "requestExcludedCookies" + + HeaderXForwardedProto = "X-Forwarded-Proto" + HeaderXForwardedHost = "X-Forwarded-Host" + HeaderXForwardedFor = "X-Forwarded-For" + HeaderXForwardedURI = "X-Forwarded-URI" + HeaderXForwardedMethod = "X-Forwarded-Method" + + HeaderCookie = "Cookie" + + HeaderUpgrade = "Upgrade" + HeaderConnection = "Connection" + HeaderTransferEncoding = "Transfer-Encoding" + HeaderTE = "TE" + HeaderTrailers = "Trailers" + HeaderKeepAlive = "Keep-Alive" +) + +var ( + ErrInternalServerError = errors.New("internal server error") + ErrUnauthorized = errors.New("unauthorized") +) + +var ( + doNotCopyHeaders = []string{ + HeaderUpgrade, + HeaderConnection, + HeaderTransferEncoding, + HeaderTE, + HeaderTrailers, + HeaderKeepAlive, + } +) diff --git a/src/mod/auth/sso/forward/forward.go b/src/mod/auth/sso/forward/forward.go new file mode 100644 index 0000000..16a84c9 --- /dev/null +++ b/src/mod/auth/sso/forward/forward.go @@ -0,0 +1,334 @@ +package forward + +import ( + "encoding/json" + "io" + "net" + "net/http" + "strings" + + "imuslab.com/zoraxy/mod/database" + "imuslab.com/zoraxy/mod/info/logger" + "imuslab.com/zoraxy/mod/utils" +) + +type AuthRouterOptions struct { + // Address of the forward auth endpoint. + Address string + + // ResponseHeaders is a list of headers to be copied from the response if provided by the forward auth endpoint to + // the request. + ResponseHeaders []string + + // ResponseClientHeaders is a list of headers to be copied from the response if provided by the forward auth + // endpoint to the response to the client. + ResponseClientHeaders []string + + // RequestHeaders is a list of headers to be copied from the request to the authorization server. If empty all + // headers are copied. + RequestHeaders []string + + // RequestExcludedCookies is a list of cookie keys that should be removed from every request sent to the upstream. + RequestExcludedCookies []string + + Logger *logger.Logger + Database *database.Database +} + +type AuthRouter struct { + client *http.Client + options *AuthRouterOptions +} + +// NewAuthRouter creates a new AuthRouter object +func NewAuthRouter(options *AuthRouterOptions) *AuthRouter { + options.Database.NewTable(DatabaseTable) + + //Read settings from database if available. + options.Database.Read(DatabaseTable, DatabaseKeyAddress, &options.Address) + + responseHeaders, responseClientHeaders, requestHeaders, requestExcludedCookies := "", "", "", "" + + options.Database.Read(DatabaseTable, DatabaseKeyResponseHeaders, &responseHeaders) + options.Database.Read(DatabaseTable, DatabaseKeyResponseClientHeaders, &responseClientHeaders) + options.Database.Read(DatabaseTable, DatabaseKeyRequestHeaders, &requestHeaders) + options.Database.Read(DatabaseTable, DatabaseKeyRequestExcludedCookies, &requestExcludedCookies) + + options.ResponseHeaders = strings.Split(responseHeaders, ",") + options.ResponseClientHeaders = strings.Split(responseClientHeaders, ",") + options.RequestHeaders = strings.Split(requestHeaders, ",") + options.RequestExcludedCookies = strings.Split(requestExcludedCookies, ",") + + return &AuthRouter{ + client: &http.Client{ + CheckRedirect: func(r *http.Request, via []*http.Request) (err error) { + return http.ErrUseLastResponse + }, + }, + options: options, + } +} + +// HandleAPIOptions is the internal handler for setting the options. +func (ar *AuthRouter) HandleAPIOptions(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + ar.handleOptionsGET(w, r) + case http.MethodPost: + ar.handleOptionsPOST(w, r) + default: + ar.handleOptionsMethodNotAllowed(w, r) + } +} + +func (ar *AuthRouter) handleOptionsGET(w http.ResponseWriter, r *http.Request) { + js, _ := json.Marshal(map[string]interface{}{ + DatabaseKeyAddress: ar.options.Address, + DatabaseKeyResponseHeaders: ar.options.ResponseHeaders, + DatabaseKeyResponseClientHeaders: ar.options.ResponseClientHeaders, + DatabaseKeyRequestHeaders: ar.options.RequestHeaders, + DatabaseKeyRequestExcludedCookies: ar.options.RequestExcludedCookies, + }) + + utils.SendJSONResponse(w, string(js)) + + return +} + +func (ar *AuthRouter) handleOptionsPOST(w http.ResponseWriter, r *http.Request) { + // Update the settings + address, err := utils.PostPara(r, DatabaseKeyAddress) + if err != nil { + utils.SendErrorResponse(w, "address not found") + + return + } + + // These are optional fields and can be empty strings. + responseHeaders, _ := utils.PostPara(r, DatabaseKeyResponseHeaders) + responseClientHeaders, _ := utils.PostPara(r, DatabaseKeyResponseClientHeaders) + requestHeaders, _ := utils.PostPara(r, DatabaseKeyRequestHeaders) + requestExcludedCookies, _ := utils.PostPara(r, DatabaseKeyRequestExcludedCookies) + + // Write changes to runtime + ar.options.Address = address + ar.options.ResponseHeaders = strings.Split(responseHeaders, ",") + ar.options.ResponseClientHeaders = strings.Split(responseClientHeaders, ",") + ar.options.RequestHeaders = strings.Split(requestHeaders, ",") + ar.options.RequestExcludedCookies = strings.Split(requestExcludedCookies, ",") + + // Write changes to database + ar.options.Database.Write(DatabaseTable, DatabaseKeyAddress, address) + ar.options.Database.Write(DatabaseTable, DatabaseKeyResponseHeaders, responseHeaders) + ar.options.Database.Write(DatabaseTable, DatabaseKeyResponseClientHeaders, responseClientHeaders) + ar.options.Database.Write(DatabaseTable, DatabaseKeyRequestHeaders, requestHeaders) + ar.options.Database.Write(DatabaseTable, DatabaseKeyRequestExcludedCookies, requestExcludedCookies) + + utils.SendOK(w) +} + +func (ar *AuthRouter) handleOptionsMethodNotAllowed(w http.ResponseWriter, r *http.Request) { + http.Error(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed) + + return +} + +// HandleAuthProviderRouting is the internal handler for Forward Auth authentication. +func (ar *AuthRouter) HandleAuthProviderRouting(w http.ResponseWriter, r *http.Request) error { + if ar.options.Address == "" { + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + + ar.options.Logger.PrintAndLog(LogTitle, "Address not set", nil) + + return ErrInternalServerError + } + + // Make a request to Authz Server to verify the request + req, err := http.NewRequest(http.MethodGet, ar.options.Address, nil) + if err != nil { + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + + ar.options.Logger.PrintAndLog(LogTitle, "Unable to create request", err) + + return ErrInternalServerError + } + + // TODO: Add opt-in support for copying the request body to the forward auth request. + headerCopyIncluded(r.Header, req.Header, ar.options.RequestHeaders, true) + + // TODO: Add support for upstream headers. + rSetForwardedHeaders(r, req) + + // Make the Authz Request. + respForwarded, err := ar.client.Do(req) + if err != nil { + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + + ar.options.Logger.PrintAndLog(LogTitle, "Unable to perform forwarded auth due to a request error", err) + + return ErrInternalServerError + } + + defer respForwarded.Body.Close() + + body, err := io.ReadAll(respForwarded.Body) + if err != nil { + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + + ar.options.Logger.PrintAndLog(LogTitle, "Unable to read response to forward auth request", err) + + return ErrInternalServerError + } + + // Responses within the 200-299 range are considered successful and allow the proxy to handle the request. + if respForwarded.StatusCode >= http.StatusOK && respForwarded.StatusCode < http.StatusMultipleChoices { + if len(ar.options.ResponseClientHeaders) != 0 { + headerCopyIncluded(respForwarded.Header, w.Header(), ar.options.ResponseClientHeaders, false) + } + + if len(ar.options.RequestExcludedCookies) != 0 { + // If the user has specified a list of cookies to be removed from the request, deterministically remove them. + headerCookieRedact(r, ar.options.RequestExcludedCookies) + } + + if len(ar.options.ResponseHeaders) != 0 { + // Copy specific user-specified headers from the response of the forward auth request to the request sent to the + // upstream server/next hop. + headerCopyIncluded(respForwarded.Header, w.Header(), ar.options.ResponseHeaders, false) + } + + return nil + } + + // Copy the response. + headerCopyExcluded(respForwarded.Header, w.Header(), nil) + + w.WriteHeader(respForwarded.StatusCode) + if _, err = w.Write(body); err != nil { + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + + ar.options.Logger.PrintAndLog(LogTitle, "Unable to write response", err) + + return ErrInternalServerError + } + + return ErrUnauthorized +} + +func scheme(r *http.Request) string { + if r.TLS != nil { + return "https" + } + + return "http" +} + +func headerCookieRedact(r *http.Request, excluded []string) { + original := r.Cookies() + + if len(original) == 0 { + return + } + + var cookies []string + + for _, cookie := range original { + if stringInSlice(cookie.Name, excluded) { + continue + } + + cookies = append(cookies, cookie.String()) + } + + r.Header.Set(HeaderCookie, strings.Join(cookies, "; ")) +} + +func headerCopyExcluded(original, destination http.Header, excludedHeaders []string) { + for key, values := range original { + // We should never copy the headers in the below list. + if stringInSliceFold(key, doNotCopyHeaders) { + continue + } + + if stringInSliceFold(key, excludedHeaders) { + continue + } + + destination[key] = append(destination[key], values...) + } +} + +func headerCopyIncluded(original, destination http.Header, includedHeaders []string, allIfEmpty bool) { + if allIfEmpty && len(includedHeaders) == 0 { + headerCopyAll(original, destination) + } else { + headerCopyIncludedExact(original, destination, includedHeaders) + } +} + +func headerCopyAll(original, destination http.Header) { + for key, values := range original { + // We should never copy the headers in the below list, even if they're in the list provided by a user. + if stringInSliceFold(key, doNotCopyHeaders) { + continue + } + + destination[key] = append(destination[key], values...) + } +} + +func headerCopyIncludedExact(original, destination http.Header, keys []string) { + for _, key := range keys { + // We should never copy the headers in the below list, even if they're in the list provided by a user. + if stringInSliceFold(key, doNotCopyHeaders) { + continue + } + + if values, ok := original[key]; ok { + destination[key] = append(destination[key], values...) + } + } +} + +func stringInSlice(needle string, haystack []string) bool { + if len(haystack) == 0 { + return false + } + + for _, v := range haystack { + if needle == v { + return true + } + } + + return false +} + +func stringInSliceFold(needle string, haystack []string) bool { + if len(haystack) == 0 { + return false + } + + for _, v := range haystack { + if strings.EqualFold(needle, v) { + return true + } + } + + return false +} + +func rSetForwardedHeaders(r, req *http.Request) { + if r.RemoteAddr != "" { + before, _, _ := strings.Cut(r.RemoteAddr, ":") + + if ip := net.ParseIP(before); ip != nil { + req.Header.Set(HeaderXForwardedFor, ip.String()) + } + } + + req.Header.Set(HeaderXForwardedMethod, r.Method) + req.Header.Set(HeaderXForwardedProto, scheme(r)) + req.Header.Set(HeaderXForwardedHost, r.Host) + req.Header.Set(HeaderXForwardedURI, r.URL.Path) +} diff --git a/src/mod/dynamicproxy/authProviders.go b/src/mod/dynamicproxy/authProviders.go index 8099b55..1397a45 100644 --- a/src/mod/dynamicproxy/authProviders.go +++ b/src/mod/dynamicproxy/authProviders.go @@ -32,20 +32,16 @@ and return a boolean indicate if the request is written to http.ResponseWriter */ func handleAuthProviderRouting(sep *ProxyEndpoint, w http.ResponseWriter, r *http.Request, h *ProxyHandler) bool { requestHostname := r.Host - if sep.AuthenticationProvider.AuthMethod == AuthMethodBasic { + + switch sep.AuthenticationProvider.AuthMethod { + case AuthMethodBasic: err := h.handleBasicAuthRouting(w, r, sep) if err != nil { h.Parent.Option.Logger.LogHTTPRequest(r, "host-http", 401, requestHostname, "") return true } - } else if sep.AuthenticationProvider.AuthMethod == AuthMethodAuthelia { - err := h.handleAutheliaAuth(w, r) - if err != nil { - h.Parent.Option.Logger.LogHTTPRequest(r, "host-http", 401, requestHostname, "") - return true - } - } else if sep.AuthenticationProvider.AuthMethod == AuthMethodAuthentik { - err := h.handleAuthentikAuth(w, r) + case AuthMethodForward: + err := h.handleForwardAuth(w, r) if err != nil { h.Parent.Option.Logger.LogHTTPRequest(r, "host-http", 401, requestHostname, "") return true @@ -106,13 +102,9 @@ func handleBasicAuth(w http.ResponseWriter, r *http.Request, pe *ProxyEndpoint) return nil } -/* Authelia */ +/* Forward Auth */ -// Handle authelia auth routing -func (h *ProxyHandler) handleAutheliaAuth(w http.ResponseWriter, r *http.Request) error { - return h.Parent.Option.AutheliaRouter.HandleAutheliaAuth(w, r) -} - -func (h *ProxyHandler) handleAuthentikAuth(w http.ResponseWriter, r *http.Request) error { - return h.Parent.Option.AuthentikRouter.HandleAuthentikAuth(w, r) +// Handle forward auth routing +func (h *ProxyHandler) handleForwardAuth(w http.ResponseWriter, r *http.Request) error { + return h.Parent.Option.ForwardAuthRouter.HandleAuthProviderRouting(w, r) } diff --git a/src/mod/dynamicproxy/default.go b/src/mod/dynamicproxy/default.go index 39f2b50..aea7100 100644 --- a/src/mod/dynamicproxy/default.go +++ b/src/mod/dynamicproxy/default.go @@ -17,12 +17,15 @@ import ( // GetDefaultAuthenticationProvider return a default authentication provider func GetDefaultAuthenticationProvider() *AuthenticationProvider { return &AuthenticationProvider{ - AuthMethod: AuthMethodNone, - BasicAuthCredentials: []*BasicAuthCredentials{}, - BasicAuthExceptionRules: []*BasicAuthExceptionRule{}, - BasicAuthGroupIDs: []string{}, - AutheliaURL: "", - UseHTTPS: false, + AuthMethod: AuthMethodNone, + BasicAuthCredentials: []*BasicAuthCredentials{}, + BasicAuthExceptionRules: []*BasicAuthExceptionRule{}, + BasicAuthGroupIDs: []string{}, + ForwardAuthURL: "", + ForwardAuthResponseHeaders: []string{}, + ForwardAuthResponseClientHeaders: []string{}, + ForwardAuthRequestHeaders: []string{}, + ForwardAuthRequestExcludedCookies: []string{}, } } diff --git a/src/mod/dynamicproxy/typedef.go b/src/mod/dynamicproxy/typedef.go index 29d20ad..bf181f4 100644 --- a/src/mod/dynamicproxy/typedef.go +++ b/src/mod/dynamicproxy/typedef.go @@ -9,13 +9,12 @@ package dynamicproxy */ import ( _ "embed" - "imuslab.com/zoraxy/mod/auth/sso/authentik" "net" "net/http" "sync" "imuslab.com/zoraxy/mod/access" - "imuslab.com/zoraxy/mod/auth/sso/authelia" + "imuslab.com/zoraxy/mod/auth/sso/forward" "imuslab.com/zoraxy/mod/dynamicproxy/dpcore" "imuslab.com/zoraxy/mod/dynamicproxy/loadbalance" "imuslab.com/zoraxy/mod/dynamicproxy/permissionpolicy" @@ -64,8 +63,7 @@ type RouterOption struct { PluginManager *plugins.Manager //Plugin manager for handling plugin routing /* Authentication Providers */ - AutheliaRouter *authelia.AutheliaRouter //Authelia router for Authelia authentication - AuthentikRouter *authentik.AuthentikRouter //Authentik router for Authentik authentication + ForwardAuthRouter *forward.AuthRouter /* Utilities */ Logger *logger.Logger //Logger for reverse proxy requets @@ -141,11 +139,10 @@ type HeaderRewriteRules struct { type AuthMethod int const ( - AuthMethodNone AuthMethod = iota //No authentication required - AuthMethodBasic //Basic Auth - AuthMethodAuthelia //Authelia - AuthMethodOauth2 //Oauth2 - AuthMethodAuthentik + AuthMethodNone AuthMethod = iota //No authentication required + AuthMethodBasic //Basic Auth + AuthMethodForward //Forward + AuthMethodOauth2 //Oauth2 ) type AuthenticationProvider struct { @@ -155,9 +152,12 @@ type AuthenticationProvider struct { BasicAuthExceptionRules []*BasicAuthExceptionRule //Path to exclude in a basic auth enabled proxy target BasicAuthGroupIDs []string //Group IDs that are allowed to access this endpoint - /* Authelia Settings */ - AutheliaURL string //URL of the Authelia server, leave empty to use global settings e.g. authelia.example.com - UseHTTPS bool //Whether to use HTTPS for the Authelia server + /* Forward Auth Settings */ + ForwardAuthURL string // Full URL of the Forward Auth endpoint. Example: https://auth.example.com/api/authz/forward-auth + ForwardAuthResponseHeaders []string // List of headers to copy from the forward auth server response to the request. + ForwardAuthResponseClientHeaders []string // List of headers to copy from the forward auth server response to the client response. + ForwardAuthRequestHeaders []string // List of headers to copy from the original request to the auth server. If empty all are copied. + ForwardAuthRequestExcludedCookies []string // List of cookies to exclude from the request after sending it to the forward auth server. } // A proxy endpoint record, a general interface for handling inbound routing diff --git a/src/mod/plugins/development.go b/src/mod/plugins/development.go new file mode 100644 index 0000000..5db1b86 --- /dev/null +++ b/src/mod/plugins/development.go @@ -0,0 +1,214 @@ +package plugins + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "io" + "net/http" + "os" + "strconv" + "time" + + "imuslab.com/zoraxy/mod/utils" +) + +// StartHotReloadTicker starts the hot reload ticker +func (m *Manager) StartHotReloadTicker() error { + if m.pluginReloadTicker != nil { + m.Options.Logger.PrintAndLog("plugin-manager", "Hot reload ticker already started", nil) + return errors.New("hot reload ticker already started") + } + + m.pluginReloadTicker = time.NewTicker(time.Duration(m.Options.HotReloadInterval) * time.Second) + m.pluginReloadStop = make(chan bool) + go func() { + for { + select { + case <-m.pluginReloadTicker.C: + err := m.UpdatePluginHashList(false) + if err != nil { + m.Options.Logger.PrintAndLog("plugin-manager", "Failed to update plugin hash list", err) + } + case <-m.pluginReloadStop: + return + } + } + }() + m.Options.Logger.PrintAndLog("plugin-manager", "Hot reload ticker started", nil) + return nil + +} + +// StopHotReloadTicker stops the hot reload ticker +func (m *Manager) StopHotReloadTicker() error { + if m.pluginReloadTicker != nil { + m.pluginReloadStop <- true + m.pluginReloadTicker.Stop() + m.pluginReloadTicker = nil + m.pluginReloadStop = nil + m.Options.Logger.PrintAndLog("plugin-manager", "Hot reload ticker stopped", nil) + } else { + m.Options.Logger.PrintAndLog("plugin-manager", "Hot reload ticker already stopped", nil) + } + return nil +} + +func (m *Manager) InitPluginHashList() error { + return m.UpdatePluginHashList(true) +} + +// Update the plugin hash list and if there are change, reload the plugin +func (m *Manager) UpdatePluginHashList(noReload bool) error { + for pluginId, plugin := range m.LoadedPlugins { + //Get the plugin Entry point + pluginEntryPoint, err := m.GetPluginEntryPoint(plugin.RootDir) + if err != nil { + //Unable to get the entry point of the plugin + return err + } + + file, err := os.Open(pluginEntryPoint) + if err != nil { + m.Options.Logger.PrintAndLog("plugin-manager", "Failed to open plugin entry point: "+pluginEntryPoint, err) + return err + } + defer file.Close() + + //Calculate the hash of the file + hasher := sha256.New() + if _, err := file.Seek(0, 0); err != nil { + m.Options.Logger.PrintAndLog("plugin-manager", "Failed to seek plugin entry point: "+pluginEntryPoint, err) + return err + } + if _, err := io.Copy(hasher, file); err != nil { + m.Options.Logger.PrintAndLog("plugin-manager", "Failed to copy plugin entry point: "+pluginEntryPoint, err) + return err + } + hash := hex.EncodeToString(hasher.Sum(nil)) + m.pluginCheckMutex.Lock() + if m.PluginHash[pluginId] != hash { + m.PluginHash[pluginId] = hash + m.pluginCheckMutex.Unlock() + if !noReload { + //Plugin file changed, reload the plugin + m.Options.Logger.PrintAndLog("plugin-manager", "Plugin file changed, reloading plugin: "+pluginId, nil) + err := m.HotReloadPlugin(pluginId) + if err != nil { + m.Options.Logger.PrintAndLog("plugin-manager", "Failed to reload plugin: "+pluginId, err) + return err + } else { + m.Options.Logger.PrintAndLog("plugin-manager", "Plugin reloaded: "+pluginId, nil) + } + } else { + m.Options.Logger.PrintAndLog("plugin-manager", "Plugin hash generated for: "+pluginId, nil) + } + } else { + m.pluginCheckMutex.Unlock() + } + + } + return nil +} + +// Reload the plugin from file system +func (m *Manager) HotReloadPlugin(pluginId string) error { + //Check if the plugin is currently running + thisPlugin, err := m.GetPluginByID(pluginId) + if err != nil { + return err + } + + if thisPlugin.IsRunning() { + err = m.StopPlugin(pluginId) + if err != nil { + return err + } + } + + //Remove the plugin from the loaded plugins list + m.loadedPluginsMutex.Lock() + if _, ok := m.LoadedPlugins[pluginId]; ok { + delete(m.LoadedPlugins, pluginId) + } else { + m.loadedPluginsMutex.Unlock() + return nil + } + m.loadedPluginsMutex.Unlock() + + //Reload the plugin from disk, it should reload the plugin from latest version + m.ReloadPluginFromDisk() + + return nil +} + +/* +Request handlers for developer options +*/ +func (m *Manager) HandleEnableHotReload(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet { + //Return the current status of hot reload + js, _ := json.Marshal(m.Options.EnableHotReload) + utils.SendJSONResponse(w, string(js)) + return + } + + enabled, err := utils.PostBool(r, "enabled") + if err != nil { + utils.SendErrorResponse(w, "enabled not found") + return + } + m.Options.EnableHotReload = enabled + if enabled { + //Start the hot reload ticker + err := m.StartHotReloadTicker() + if err != nil { + m.Options.Logger.PrintAndLog("plugin-manager", "Failed to start hot reload ticker", err) + utils.SendErrorResponse(w, "Failed to start hot reload ticker") + return + } + m.Options.Logger.PrintAndLog("plugin-manager", "Hot reload enabled", nil) + } else { + //Stop the hot reload ticker + err := m.StopHotReloadTicker() + if err != nil { + m.Options.Logger.PrintAndLog("plugin-manager", "Failed to stop hot reload ticker", err) + utils.SendErrorResponse(w, "Failed to stop hot reload ticker") + return + } + m.Options.Logger.PrintAndLog("plugin-manager", "Hot reload disabled", nil) + } + utils.SendOK(w) +} + +func (m *Manager) HandleSetHotReloadInterval(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet { + //Return the current status of hot reload + js, _ := json.Marshal(m.Options.HotReloadInterval) + utils.SendJSONResponse(w, string(js)) + return + } + + interval, err := utils.PostInt(r, "interval") + if err != nil { + utils.SendErrorResponse(w, "interval not found") + return + } + + if interval < 1 { + utils.SendErrorResponse(w, "interval must be at least 1 second") + return + } + m.Options.HotReloadInterval = interval + + //Restart the hot reload ticker + if m.pluginReloadTicker != nil { + m.StopHotReloadTicker() + time.Sleep(1 * time.Second) + //Start the hot reload ticker again + m.StartHotReloadTicker() + } + m.Options.Logger.PrintAndLog("plugin-manager", "Hot reload interval set to "+strconv.Itoa(interval)+" sec", nil) + utils.SendOK(w) +} diff --git a/src/mod/plugins/groups.go b/src/mod/plugins/groups.go index 0ca787c..59317ae 100644 --- a/src/mod/plugins/groups.go +++ b/src/mod/plugins/groups.go @@ -11,11 +11,11 @@ import ( // ListPluginGroups returns a map of plugin groups func (m *Manager) ListPluginGroups() map[string][]string { pluginGroup := map[string][]string{} - m.Options.pluginGroupsMutex.RLock() + m.pluginGroupsMutex.RLock() for k, v := range m.Options.PluginGroups { pluginGroup[k] = append([]string{}, v...) } - m.Options.pluginGroupsMutex.RUnlock() + m.pluginGroupsMutex.RUnlock() return pluginGroup } @@ -32,26 +32,26 @@ func (m *Manager) AddPluginToGroup(tag, pluginID string) error { return errors.New("plugin is not a router type plugin") } - m.Options.pluginGroupsMutex.Lock() + m.pluginGroupsMutex.Lock() //Check if the tag exists _, ok = m.Options.PluginGroups[tag] if !ok { m.Options.PluginGroups[tag] = []string{pluginID} - m.Options.pluginGroupsMutex.Unlock() + m.pluginGroupsMutex.Unlock() return nil } //Add the plugin to the group m.Options.PluginGroups[tag] = append(m.Options.PluginGroups[tag], pluginID) - m.Options.pluginGroupsMutex.Unlock() + m.pluginGroupsMutex.Unlock() return nil } // RemovePluginFromGroup removes a plugin from a group func (m *Manager) RemovePluginFromGroup(tag, pluginID string) error { - m.Options.pluginGroupsMutex.Lock() - defer m.Options.pluginGroupsMutex.Unlock() + m.pluginGroupsMutex.Lock() + defer m.pluginGroupsMutex.Unlock() //Check if the tag exists _, ok := m.Options.PluginGroups[tag] if !ok { @@ -72,8 +72,8 @@ func (m *Manager) RemovePluginFromGroup(tag, pluginID string) error { // RemovePluginGroup removes a plugin group func (m *Manager) RemovePluginGroup(tag string) error { - m.Options.pluginGroupsMutex.Lock() - defer m.Options.pluginGroupsMutex.Unlock() + m.pluginGroupsMutex.Lock() + defer m.pluginGroupsMutex.Unlock() _, ok := m.Options.PluginGroups[tag] if !ok { return errors.New("tag not found") @@ -84,12 +84,12 @@ func (m *Manager) RemovePluginGroup(tag string) error { // SavePluginGroupsFromFile loads plugin groups from a file func (m *Manager) SavePluginGroupsToFile() error { - m.Options.pluginGroupsMutex.RLock() + m.pluginGroupsMutex.RLock() pluginGroupsCopy := make(map[string][]string) for k, v := range m.Options.PluginGroups { pluginGroupsCopy[k] = append([]string{}, v...) } - m.Options.pluginGroupsMutex.RUnlock() + m.pluginGroupsMutex.RUnlock() //Write to file js, _ := json.Marshal(pluginGroupsCopy) diff --git a/src/mod/plugins/plugins.go b/src/mod/plugins/plugins.go index 039eaa1..6beb138 100644 --- a/src/mod/plugins/plugins.go +++ b/src/mod/plugins/plugins.go @@ -47,15 +47,26 @@ func NewPluginManager(options *ManagerOptions) *Manager { //Create database table options.Database.NewTable("plugins") - return &Manager{ + thisManager := &Manager{ LoadedPlugins: make(map[string]*Plugin), tagPluginMap: sync.Map{}, tagPluginListMutex: sync.RWMutex{}, tagPluginList: make(map[string][]*Plugin), Options: options, + PluginHash: make(map[string]string), /* Internal */ loadedPluginsMutex: sync.RWMutex{}, } + + //Check if hot reload is enabled + if options.EnableHotReload { + err := thisManager.StartHotReloadTicker() + if err != nil { + options.Logger.PrintAndLog("plugin-manager", "Failed to start hot reload ticker", err) + } + } + + return thisManager } // Reload all plugins from disk @@ -104,11 +115,16 @@ func (m *Manager) ReloadPluginFromDisk() { m.loadedPluginsMutex.Lock() m.LoadedPlugins[thisPlugin.Spec.ID] = thisPlugin m.loadedPluginsMutex.Unlock() - m.Log("Added new plugin: "+thisPlugin.Spec.Name, nil) + versionNumber := strconv.Itoa(thisPlugin.Spec.VersionMajor) + "." + strconv.Itoa(thisPlugin.Spec.VersionMinor) + "." + strconv.Itoa(thisPlugin.Spec.VersionPatch) + //Check if the plugin is enabled + m.Log("Found plugin: "+thisPlugin.Spec.Name+" (v"+versionNumber+")", nil) // The default state of the plugin is disabled, so no need to start it } } + + //Generate a hash list for plugins + m.InitPluginHashList() } // LoadPluginsFromDisk loads all plugins from the plugin directory @@ -156,6 +172,8 @@ func (m *Manager) LoadPluginsFromDisk() error { //Generate the static forwarder radix tree m.UpdateTagsToPluginMaps() + //Generate a hash list for plugins + m.InitPluginHashList() return nil } diff --git a/src/mod/plugins/tags.go b/src/mod/plugins/tags.go index 9c53c7b..d4a9b27 100644 --- a/src/mod/plugins/tags.go +++ b/src/mod/plugins/tags.go @@ -17,8 +17,8 @@ import ( // This will only load the plugin tags to option.PluginGroups map // to push the changes to runtime, call UpdateTagsToPluginMaps() func (m *Manager) LoadPluginGroupsFromConfig() error { - m.Options.pluginGroupsMutex.RLock() - defer m.Options.pluginGroupsMutex.RUnlock() + m.pluginGroupsMutex.RLock() + defer m.pluginGroupsMutex.RUnlock() //Read the config file rawConfig, err := os.ReadFile(m.Options.PluginGroupsConfig) @@ -39,8 +39,8 @@ func (m *Manager) LoadPluginGroupsFromConfig() error { // AddPluginToTag adds a plugin to a tag func (m *Manager) AddPluginToTag(tag string, pluginID string) error { - m.Options.pluginGroupsMutex.RLock() - defer m.Options.pluginGroupsMutex.RUnlock() + m.pluginGroupsMutex.RLock() + defer m.pluginGroupsMutex.RUnlock() //Check if the plugin exists _, err := m.GetPluginByID(pluginID) @@ -66,8 +66,8 @@ func (m *Manager) AddPluginToTag(tag string, pluginID string) error { // RemovePluginFromTag removes a plugin from a tag func (m *Manager) RemovePluginFromTag(tag string, pluginID string) error { // Check if the plugin exists in Options.PluginGroups - m.Options.pluginGroupsMutex.RLock() - defer m.Options.pluginGroupsMutex.RUnlock() + m.pluginGroupsMutex.RLock() + defer m.pluginGroupsMutex.RUnlock() pluginList, ok := m.Options.PluginGroups[tag] if !ok { return nil @@ -91,8 +91,8 @@ func (m *Manager) RemovePluginFromTag(tag string, pluginID string) error { // savePluginTagMap saves the plugin tag map to the config file func (m *Manager) savePluginTagMap() error { - m.Options.pluginGroupsMutex.RLock() - defer m.Options.pluginGroupsMutex.RUnlock() + m.pluginGroupsMutex.RLock() + defer m.pluginGroupsMutex.RUnlock() js, _ := json.Marshal(m.Options.PluginGroups) return os.WriteFile(m.Options.PluginGroupsConfig, js, 0644) diff --git a/src/mod/plugins/typdef.go b/src/mod/plugins/typdef.go index 23ad083..6b64068 100644 --- a/src/mod/plugins/typdef.go +++ b/src/mod/plugins/typdef.go @@ -5,6 +5,7 @@ import ( "net/http" "os/exec" "sync" + "time" "imuslab.com/zoraxy/mod/database" "imuslab.com/zoraxy/mod/dynamicproxy/dpcore" @@ -45,8 +46,9 @@ type ManagerOptions struct { Database *database.Database `json:"-"` Logger *logger.Logger `json:"-"` - /* Internal */ - pluginGroupsMutex sync.RWMutex //Mutex for the pluginGroups + /* Development */ + EnableHotReload bool //Check if the plugin file is changed and reload the plugin automatically + HotReloadInterval int //The interval for checking the plugin file change, in seconds } type Manager struct { @@ -56,6 +58,12 @@ type Manager struct { tagPluginList map[string][]*Plugin //Storing the plugin list for each tag, only concurrent READ is allowed Options *ManagerOptions + PluginHash map[string]string //The hash of the plugin file, used to check if the plugin file is changed + /* Internal */ loadedPluginsMutex sync.RWMutex //Mutex for the loadedPlugins + pluginGroupsMutex sync.RWMutex //Mutex for the pluginGroups + pluginCheckMutex sync.RWMutex //Mutex for the plugin hash + pluginReloadTicker *time.Ticker //Ticker for the plugin reload + pluginReloadStop chan bool //Channel to stop the plugin reload ticker } diff --git a/src/mod/update/updatelogic.go b/src/mod/update/updatelogic.go index 8577e7b..ccb7b24 100644 --- a/src/mod/update/updatelogic.go +++ b/src/mod/update/updatelogic.go @@ -3,6 +3,7 @@ package update import ( v308 "imuslab.com/zoraxy/mod/update/v308" v315 "imuslab.com/zoraxy/mod/update/v315" + v322 "imuslab.com/zoraxy/mod/update/v322" ) // Updater Core logic @@ -19,6 +20,12 @@ func runUpdateRoutineWithVersion(fromVersion int, toVersion int) { if err != nil { panic(err) } + } else if fromVersion == 321 && toVersion == 322 { + //Updating from v3.2.1 to v3.2.2 + err := v322.UpdateFrom321To322() + if err != nil { + panic(err) + } } //ADD MORE VERSIONS HERE diff --git a/src/mod/update/v322/typedef321.go b/src/mod/update/v322/typedef321.go new file mode 100644 index 0000000..7d3e72d --- /dev/null +++ b/src/mod/update/v322/typedef321.go @@ -0,0 +1,141 @@ +package v322 + +import ( + "net/http" + "sync" + + "imuslab.com/zoraxy/mod/dynamicproxy/loadbalance" + "imuslab.com/zoraxy/mod/dynamicproxy/permissionpolicy" + "imuslab.com/zoraxy/mod/dynamicproxy/rewrite" +) + +type ProxyType int + +// Pull from ratelimit.go +type RequestCountPerIpTable struct { + table sync.Map +} + +// Pull from special.go +type RoutingRule struct { + ID string //ID of the routing rule + Enabled bool //If the routing rule enabled + UseSystemAccessControl bool //Pass access control check to system white/black list, set this to false to bypass white/black list + MatchRule func(r *http.Request) bool + RoutingHandler func(http.ResponseWriter, *http.Request) +} + +const ( + ProxyTypeRoot ProxyType = iota //Root Proxy, everything not matching will be routed here + ProxyTypeHost //Host Proxy, match by host (domain) name + ProxyTypeVdir //Virtual Directory Proxy, match by path prefix +) + +/* Basic Auth Related Data structure*/ +// Auth credential for basic auth on certain endpoints +type BasicAuthCredentials struct { + Username string + PasswordHash string +} + +// Auth credential for basic auth on certain endpoints +type BasicAuthUnhashedCredentials struct { + Username string + Password string +} + +// Paths to exclude in basic auth enabled proxy handler +type BasicAuthExceptionRule struct { + PathPrefix string +} + +/* Routing Rule Data Structures */ + +// A Virtual Directory endpoint, provide a subset of ProxyEndpoint for better +// program structure than directly using ProxyEndpoint +type VirtualDirectoryEndpoint struct { + MatchingPath string //Matching prefix of the request path, also act as key + Domain string //Domain or IP to proxy to + RequireTLS bool //Target domain require TLS + SkipCertValidations bool //Set to true to accept self signed certs + Disabled bool //If the rule is enabled +} + +// Rules and settings for header rewriting +type HeaderRewriteRules struct { + UserDefinedHeaders []*rewrite.UserDefinedHeader //Custom headers to append when proxying requests from this endpoint + RequestHostOverwrite string //If not empty, this domain will be used to overwrite the Host field in request header + HSTSMaxAge int64 //HSTS max age, set to 0 for disable HSTS headers + EnablePermissionPolicyHeader bool //Enable injection of permission policy header + PermissionPolicy *permissionpolicy.PermissionsPolicy //Permission policy header + DisableHopByHopHeaderRemoval bool //Do not remove hop-by-hop headers + +} + +/* + + Authentication Providers + +*/ + +const ( + AuthMethodNone AuthMethod = iota //No authentication required + AuthMethodBasic //Basic Auth + AuthMethodAuthelia //Authelia + AuthMethodOauth2 //Oauth2 + AuthMethodAuthentik +) + +type AuthenticationProvider struct { + AuthMethod AuthMethod //The authentication method to use + /* Basic Auth Settings */ + BasicAuthCredentials []*BasicAuthCredentials //Basic auth credentials + BasicAuthExceptionRules []*BasicAuthExceptionRule //Path to exclude in a basic auth enabled proxy target + BasicAuthGroupIDs []string //Group IDs that are allowed to access this endpoint + + /* Authelia Settings */ + AutheliaURL string //URL of the Authelia server, leave empty to use global settings e.g. authelia.example.com + UseHTTPS bool //Whether to use HTTPS for the Authelia server +} + +// A proxy endpoint record, a general interface for handling inbound routing +type ProxyEndpointv321 struct { + ProxyType ProxyType //The type of this proxy, see const def + RootOrMatchingDomain string //Matching domain for host, also act as key + MatchingDomainAlias []string //A list of domains that alias to this rule + ActiveOrigins []*loadbalance.Upstream //Activated Upstream or origin servers IP or domain to proxy to + InactiveOrigins []*loadbalance.Upstream //Disabled Upstream or origin servers IP or domain to proxy to + UseStickySession bool //Use stick session for load balancing + UseActiveLoadBalance bool //Use active loadbalancing, default passive + Disabled bool //If the rule is disabled + + //Inbound TLS/SSL Related + BypassGlobalTLS bool //Bypass global TLS setting options if TLS Listener enabled (parent.tlsListener != nil) + + //Virtual Directories + VirtualDirectories []*VirtualDirectoryEndpoint + + //Custom Headers + HeaderRewriteRules *HeaderRewriteRules + EnableWebsocketCustomHeaders bool //Enable custom headers for websocket connections as well (default only http reqiests) + + //Authentication + AuthenticationProvider *AuthenticationProvider + + // Rate Limiting + RequireRateLimit bool + RateLimit int64 // Rate limit in requests per second + + //Uptime Monitor + DisableUptimeMonitor bool //Disable uptime monitor for this endpoint + + //Access Control + AccessFilterUUID string //Access filter ID + + //Fallback routing logic (Special Rule Sets Only) + DefaultSiteOption int //Fallback routing logic options + DefaultSiteValue string //Fallback routing target, optional + + //Internal Logic Elements + Tags []string // Tags for the proxy endpoint +} diff --git a/src/mod/update/v322/typedef322.go b/src/mod/update/v322/typedef322.go new file mode 100644 index 0000000..cd3ca2c --- /dev/null +++ b/src/mod/update/v322/typedef322.go @@ -0,0 +1,93 @@ +package v322 + +import "imuslab.com/zoraxy/mod/dynamicproxy/loadbalance" + +/* + + Authentication Provider in v3.2.2 + + The only change is the removal of the deprecated Authelia and Authentik SSO + provider, and the addition of the new Forward Auth provider. + + Need to map all provider with ID = 4 into 2 and remove the old provider configs +*/ + +type AuthMethod int + +/* +v3.2.1 Authentication Provider +const ( + AuthMethodNone AuthMethod = iota //No authentication required + AuthMethodBasic //Basic Auth + AuthMethodAuthelia //Authelia => 2 + AuthMethodOauth2 //Oauth2 + AuthMethodAuthentik //Authentik => 4 +) + +v3.2.2 Authentication Provider +const ( + AuthMethodNone AuthMethod = iota //No authentication required + AuthMethodBasic //Basic Auth + AuthMethodForward //Forward => 2 + AuthMethodOauth2 //Oauth2 +) + +We need to merge both Authelia and Authentik into the Forward Auth provider, and remove +*/ +//The updated structure of the authentication provider +type AuthenticationProviderV322 struct { + AuthMethod AuthMethod //The authentication method to use + /* Basic Auth Settings */ + BasicAuthCredentials []*BasicAuthCredentials //Basic auth credentials + BasicAuthExceptionRules []*BasicAuthExceptionRule //Path to exclude in a basic auth enabled proxy target + BasicAuthGroupIDs []string //Group IDs that are allowed to access this endpoint + + /* Forward Auth Settings */ + ForwardAuthURL string // Full URL of the Forward Auth endpoint. Example: https://auth.example.com/api/authz/forward-auth + ForwardAuthResponseHeaders []string // List of headers to copy from the forward auth server response to the request. + ForwardAuthResponseClientHeaders []string // List of headers to copy from the forward auth server response to the client response. + ForwardAuthRequestHeaders []string // List of headers to copy from the original request to the auth server. If empty all are copied. + ForwardAuthRequestExcludedCookies []string // List of cookies to exclude from the request after sending it to the forward auth server. +} + +// A proxy endpoint record, a general interface for handling inbound routing +type ProxyEndpointv322 struct { + ProxyType ProxyType //The type of this proxy, see const def + RootOrMatchingDomain string //Matching domain for host, also act as key + MatchingDomainAlias []string //A list of domains that alias to this rule + ActiveOrigins []*loadbalance.Upstream //Activated Upstream or origin servers IP or domain to proxy to + InactiveOrigins []*loadbalance.Upstream //Disabled Upstream or origin servers IP or domain to proxy to + UseStickySession bool //Use stick session for load balancing + UseActiveLoadBalance bool //Use active loadbalancing, default passive + Disabled bool //If the rule is disabled + + //Inbound TLS/SSL Related + BypassGlobalTLS bool //Bypass global TLS setting options if TLS Listener enabled (parent.tlsListener != nil) + + //Virtual Directories + VirtualDirectories []*VirtualDirectoryEndpoint + + //Custom Headers + HeaderRewriteRules *HeaderRewriteRules + EnableWebsocketCustomHeaders bool //Enable custom headers for websocket connections as well (default only http reqiests) + + //Authentication + AuthenticationProvider *AuthenticationProviderV322 + + // Rate Limiting + RequireRateLimit bool + RateLimit int64 // Rate limit in requests per second + + //Uptime Monitor + DisableUptimeMonitor bool //Disable uptime monitor for this endpoint + + //Access Control + AccessFilterUUID string //Access filter ID + + //Fallback routing logic (Special Rule Sets Only) + DefaultSiteOption int //Fallback routing logic options + DefaultSiteValue string //Fallback routing target, optional + + //Internal Logic Elements + Tags []string // Tags for the proxy endpoint +} diff --git a/src/mod/update/v322/v322.go b/src/mod/update/v322/v322.go new file mode 100644 index 0000000..a586193 --- /dev/null +++ b/src/mod/update/v322/v322.go @@ -0,0 +1,191 @@ +package v322 + +import ( + "encoding/json" + "fmt" + "log" + "os" + "path/filepath" + + "imuslab.com/zoraxy/mod/dynamicproxy/permissionpolicy" + "imuslab.com/zoraxy/mod/dynamicproxy/rewrite" + "imuslab.com/zoraxy/mod/update/updateutil" +) + +// UpdateFrom321To322 updates proxy config files from v3.2.1 to v3.2.2 +func UpdateFrom321To322() error { + // Load the configs + oldConfigFiles, err := filepath.Glob("./conf/proxy/*.config") + if err != nil { + return err + } + + // Backup all the files + err = os.MkdirAll("./conf/proxy-321.old/", 0775) + if err != nil { + return err + } + + for _, oldConfigFile := range oldConfigFiles { + // Extract the file name from the path + fileName := filepath.Base(oldConfigFile) + // Construct the backup file path + backupFile := filepath.Join("./conf/proxy-321.old/", fileName) + + // Copy the file to the backup directory + err := updateutil.CopyFile(oldConfigFile, backupFile) + if err != nil { + return err + } + } + + // Read the config into the old struct + for _, oldConfigFile := range oldConfigFiles { + configContent, err := os.ReadFile(oldConfigFile) + if err != nil { + log.Println("Unable to read config file "+filepath.Base(oldConfigFile), err.Error()) + continue + } + + thisOldConfigStruct := ProxyEndpointv321{} + err = json.Unmarshal(configContent, &thisOldConfigStruct) + if err != nil { + log.Println("Unable to parse file "+filepath.Base(oldConfigFile), err.Error()) + continue + } + + // Convert the old struct to the new struct + thisNewConfigStruct := convertV321ToV322(thisOldConfigStruct) + + // Write the new config to file + newConfigContent, err := json.MarshalIndent(thisNewConfigStruct, "", " ") + if err != nil { + log.Println("Unable to marshal new config "+filepath.Base(oldConfigFile), err.Error()) + continue + } + + err = os.WriteFile(oldConfigFile, newConfigContent, 0664) + if err != nil { + log.Println("Unable to write new config "+filepath.Base(oldConfigFile), err.Error()) + continue + } + } + + return nil +} + +func convertV321ToV322(thisOldConfigStruct ProxyEndpointv321) ProxyEndpointv322 { + // Merge both Authelia and Authentik into the Forward Auth provider, and remove the old provider configs + if thisOldConfigStruct.AuthenticationProvider == nil { + //Configs before v3.1.7 with no authentication provider + // Set the default authentication provider + thisOldConfigStruct.AuthenticationProvider = &AuthenticationProvider{ + AuthMethod: AuthMethodNone, // Default to no authentication + BasicAuthCredentials: []*BasicAuthCredentials{}, + BasicAuthExceptionRules: []*BasicAuthExceptionRule{}, + BasicAuthGroupIDs: []string{}, + AutheliaURL: "", + UseHTTPS: false, + } + } else { + //Override the old authentication provider with the new one + if thisOldConfigStruct.AuthenticationProvider.AuthMethod == AuthMethodAuthelia { + thisOldConfigStruct.AuthenticationProvider.AuthMethod = 2 + } else if thisOldConfigStruct.AuthenticationProvider.AuthMethod == AuthMethodAuthentik { + thisOldConfigStruct.AuthenticationProvider.AuthMethod = 2 + } + + } + + if thisOldConfigStruct.AuthenticationProvider.BasicAuthGroupIDs == nil { + //Create an empty basic auth group IDs array if it does not exist + thisOldConfigStruct.AuthenticationProvider.BasicAuthGroupIDs = []string{} + } + + newAuthenticationProvider := AuthenticationProviderV322{ + AuthMethod: AuthMethodNone, // Default to no authentication + //Fill in the empty arrays + BasicAuthCredentials: []*BasicAuthCredentials{}, + BasicAuthExceptionRules: []*BasicAuthExceptionRule{}, + BasicAuthGroupIDs: []string{}, + ForwardAuthURL: "", + ForwardAuthResponseHeaders: []string{}, + ForwardAuthResponseClientHeaders: []string{}, + ForwardAuthRequestHeaders: []string{}, + ForwardAuthRequestExcludedCookies: []string{}, + } + + // In theory the old config should have a matching itoa value that + // can be converted to the new config + js, err := json.Marshal(thisOldConfigStruct.AuthenticationProvider) + if err != nil { + fmt.Println("Unable to marshal authentication provider "+thisOldConfigStruct.RootOrMatchingDomain, err.Error()) + fmt.Println("Using default authentication provider") + } + + err = json.Unmarshal(js, &newAuthenticationProvider) + if err != nil { + fmt.Println("Unable to unmarshal authentication provider "+thisOldConfigStruct.RootOrMatchingDomain, err.Error()) + fmt.Println("Using default authentication provider") + } else { + fmt.Println("Authentication provider for " + thisOldConfigStruct.RootOrMatchingDomain + " updated") + } + + // Fill in any null values in the old config struct + // these are non-upgrader requires values that updates between v3.1.5 to v3.2.1 + // will be in null state if not set by the user + if thisOldConfigStruct.VirtualDirectories == nil { + //Create an empty virtual directories array if it does not exist + thisOldConfigStruct.VirtualDirectories = []*VirtualDirectoryEndpoint{} + } + + if thisOldConfigStruct.HeaderRewriteRules == nil { + //Create an empty header rewrite rules array if it does not exist + thisOldConfigStruct.HeaderRewriteRules = &HeaderRewriteRules{ + UserDefinedHeaders: []*rewrite.UserDefinedHeader{}, + RequestHostOverwrite: "", + HSTSMaxAge: 0, + EnablePermissionPolicyHeader: false, + PermissionPolicy: permissionpolicy.GetDefaultPermissionPolicy(), + DisableHopByHopHeaderRemoval: false, + } + } + + if thisOldConfigStruct.Tags == nil { + //Create an empty tags array if it does not exist + thisOldConfigStruct.Tags = []string{} + } + + if thisOldConfigStruct.MatchingDomainAlias == nil { + //Create an empty matching domain alias array if it does not exist + thisOldConfigStruct.MatchingDomainAlias = []string{} + } + + // Update the config struct + thisNewConfigStruct := ProxyEndpointv322{ + ProxyType: thisOldConfigStruct.ProxyType, + RootOrMatchingDomain: thisOldConfigStruct.RootOrMatchingDomain, + MatchingDomainAlias: thisOldConfigStruct.MatchingDomainAlias, + ActiveOrigins: thisOldConfigStruct.ActiveOrigins, + InactiveOrigins: thisOldConfigStruct.InactiveOrigins, + UseStickySession: thisOldConfigStruct.UseStickySession, + UseActiveLoadBalance: thisOldConfigStruct.UseActiveLoadBalance, + Disabled: thisOldConfigStruct.Disabled, + BypassGlobalTLS: thisOldConfigStruct.BypassGlobalTLS, + VirtualDirectories: thisOldConfigStruct.VirtualDirectories, + HeaderRewriteRules: thisOldConfigStruct.HeaderRewriteRules, + EnableWebsocketCustomHeaders: thisOldConfigStruct.EnableWebsocketCustomHeaders, + RequireRateLimit: thisOldConfigStruct.RequireRateLimit, + RateLimit: thisOldConfigStruct.RateLimit, + DisableUptimeMonitor: thisOldConfigStruct.DisableUptimeMonitor, + AccessFilterUUID: thisOldConfigStruct.AccessFilterUUID, + DefaultSiteOption: thisOldConfigStruct.DefaultSiteOption, + DefaultSiteValue: thisOldConfigStruct.DefaultSiteValue, + Tags: thisOldConfigStruct.Tags, + } + + // Set the new authentication provider + thisNewConfigStruct.AuthenticationProvider = &newAuthenticationProvider + + return thisNewConfigStruct +} diff --git a/src/reverseproxy.go b/src/reverseproxy.go index d00b606..8e60335 100644 --- a/src/reverseproxy.go +++ b/src/reverseproxy.go @@ -115,8 +115,7 @@ func ReverseProxtInit() { StatisticCollector: statisticCollector, WebDirectory: *path_webserver, AccessController: accessController, - AutheliaRouter: autheliaRouter, - AuthentikRouter: authentikRouter, + ForwardAuthRouter: forwardAuthRouter, LoadBalancer: loadBalancer, PluginManager: pluginManager, /* Utilities */ @@ -585,11 +584,9 @@ func ReverseProxyHandleEditEndpoint(w http.ResponseWriter, r *http.Request) { if authProviderType == 1 { newProxyEndpoint.AuthenticationProvider.AuthMethod = dynamicproxy.AuthMethodBasic } else if authProviderType == 2 { - newProxyEndpoint.AuthenticationProvider.AuthMethod = dynamicproxy.AuthMethodAuthelia + newProxyEndpoint.AuthenticationProvider.AuthMethod = dynamicproxy.AuthMethodForward } else if authProviderType == 3 { newProxyEndpoint.AuthenticationProvider.AuthMethod = dynamicproxy.AuthMethodOauth2 - } else if authProviderType == 4 { - newProxyEndpoint.AuthenticationProvider.AuthMethod = dynamicproxy.AuthMethodAuthentik } else { newProxyEndpoint.AuthenticationProvider.AuthMethod = dynamicproxy.AuthMethodNone } diff --git a/src/start.go b/src/start.go index 0fadc05..d847744 100644 --- a/src/start.go +++ b/src/start.go @@ -9,13 +9,11 @@ import ( "strings" "time" - "imuslab.com/zoraxy/mod/auth/sso/authentik" - "github.com/gorilla/csrf" "imuslab.com/zoraxy/mod/access" "imuslab.com/zoraxy/mod/acme" "imuslab.com/zoraxy/mod/auth" - "imuslab.com/zoraxy/mod/auth/sso/authelia" + "imuslab.com/zoraxy/mod/auth/sso/forward" "imuslab.com/zoraxy/mod/database" "imuslab.com/zoraxy/mod/database/dbinc" "imuslab.com/zoraxy/mod/dockerux" @@ -143,18 +141,10 @@ func startupSequence() { } //Create authentication providers - autheliaRouter = authelia.NewAutheliaRouter(&authelia.AutheliaRouterOptions{ - UseHTTPS: false, // Automatic populate in router initiation - AutheliaURL: "", // Automatic populate in router initiation - Logger: SystemWideLogger, - Database: sysdb, - }) - - authentikRouter = authentik.NewAuthentikRouter(&authentik.AuthentikRouterOptions{ - UseHTTPS: false, // Automatic populate in router initiation - AuthentikURL: "", // Automatic populate in router initiation - Logger: SystemWideLogger, - Database: sysdb, + forwardAuthRouter = forward.NewAuthRouter(&forward.AuthRouterOptions{ + Address: "", + Logger: SystemWideLogger, + Database: sysdb, }) //Create a statistic collector @@ -317,21 +307,26 @@ func startupSequence() { pluginFolder := *path_plugin pluginFolder = strings.TrimSuffix(pluginFolder, "/") pluginManager = plugins.NewPluginManager(&plugins.ManagerOptions{ - PluginDir: pluginFolder, - SystemConst: &zoraxy_plugin.RuntimeConstantValue{ - ZoraxyVersion: SYSTEM_VERSION, - ZoraxyUUID: nodeUUID, - DevelopmentBuild: *development_build, - }, - PluginStoreURLs: []string{ - "https://raw.githubusercontent.com/aroz-online/zoraxy-official-plugins/refs/heads/main/directories/index.json", - }, + PluginDir: pluginFolder, Database: sysdb, Logger: SystemWideLogger, PluginGroupsConfig: CONF_PLUGIN_GROUPS, CSRFTokenGen: func(r *http.Request) string { return csrf.Token(r) }, + SystemConst: &zoraxy_plugin.RuntimeConstantValue{ + ZoraxyVersion: SYSTEM_VERSION, + ZoraxyUUID: nodeUUID, + DevelopmentBuild: *development_build, + }, + /* Plugin Store URLs */ + PluginStoreURLs: []string{ + "https://raw.githubusercontent.com/aroz-online/zoraxy-official-plugins/refs/heads/main/directories/index.json", + //TO BE ADDED + }, + /* Developer Options */ + EnableHotReload: *development_build, //Default to true if development build + HotReloadInterval: 5, //seconds }) //Sync latest plugin list from the plugin store diff --git a/src/web/components/httprp.html b/src/web/components/httprp.html index 1a9d653..69a773b 100644 --- a/src/web/components/httprp.html +++ b/src/web/components/httprp.html @@ -185,9 +185,8 @@ ${subd.AuthenticationProvider.AuthMethod == 0x1?` Basic Auth`:``} - ${subd.AuthenticationProvider.AuthMethod == 0x2?` Authelia`:``} - ${subd.AuthenticationProvider.AuthMethod == 0x3?` Oauth2`:``} - ${subd.AuthenticationProvider.AuthMethod == 0x4?` Authentik`:``} + ${subd.AuthenticationProvider.AuthMethod == 0x2?` Forward Auth`:``} + ${subd.AuthenticationProvider.AuthMethod == 0x3?` OAuth2`:``} ${subd.AuthenticationProvider.AuthMethod != 0x0 && subd.RequireRateLimit?"
":""} ${subd.RequireRateLimit?` Rate Limit @ ${subd.RateLimit} req/s`:``} ${subd.AuthenticationProvider.AuthMethod == 0x0 && !subd.RequireRateLimit?`No Special Settings`:""} @@ -393,13 +392,7 @@
- -
-
-
-
- - +
diff --git a/src/web/components/plugins.html b/src/web/components/plugins.html index 9592723..870d701 100644 --- a/src/web/components/plugins.html +++ b/src/web/components/plugins.html @@ -185,6 +185,33 @@ +
+
+
+ + Developer Settings +
+
+
+
Developer Only
+

These functions are intended for developers only. Enabling them may add latency to plugin loading & routing. Proceed with caution.
+ Tips: You can start zoraxy with -dev=true to enable auto-reload when start

+
+
+ + +
+

+
+ + +
+ Specify the interval (in seconds) for checking plugin changes.
Minimum is 1 second, maximum is 60 seconds.
+
+
+
+
@@ -592,6 +619,95 @@ function uninstallPlugin(pluginId, pluginName, btn=undefined) { } } + + /* Developer Settings */ + + function initDeveloperSettings() { + // Fetch the auto reload status + $.get('/api/plugins/developer/enableAutoReload', function(data) { + if (data.error != undefined) { + msgbox(data.error, false); + return; + } + + // Set the checkbox for Plugin Auto Reload + if (data == true) { + $("#enablePluginAutoReload").checkbox('set checked'); + } else { + $("#enablePluginAutoReload").checkbox('set unchecked'); + } + + // Fetch the auto reload interval + $.get('/api/plugins/developer/setAutoReloadInterval', function(data) { + if (data.error != undefined) { + msgbox(data.error, false); + return; + } + + // Set the input value for Auto Reload Interval + if (data) { + $("#autoreload-interval").val(data); + } + + bindEventsToDeveloperSettings(); + }); + }); + } + + function bindEventsToDeveloperSettings(){ + $("#enablePluginAutoReload").checkbox({ + onChecked: function() { + $.cjax({ + url: '/api/plugins/developer/enableAutoReload', + type: 'POST', + data: { "enabled": true }, + success: function(data) { + if (data.error != undefined) { + msgbox(data.error, false); + } else { + msgbox("Plugin Auto Reload enabled", true); + } + } + }); + }, + onUnchecked: function() { + $.cjax({ + url: '/api/plugins/developer/enableAutoReload', + type: 'POST', + data: { "enabled": false }, + success: function(data) { + if (data.error != undefined) { + msgbox(data.error, false); + } else { + msgbox("Plugin Auto Reload disabled", true); + } + } + }); + } + }); + + $("#autoreload-interval").on("change", function() { + const interval = $(this).val(); + if (interval < 1 || interval > 60) { + msgbox("Interval must be between 1 and 60 seconds", false); + return; + } + $.cjax({ + url: '/api/plugins/developer/setAutoReloadInterval', + type: 'POST', + data: { "interval": interval }, + success: function(data) { + if (data.error != undefined) { + msgbox(data.error, false); + } else { + msgbox("Auto Reload Interval updated to " + interval + " seconds", true); + } + } + }); + }); + } + + initDeveloperSettings(); diff --git a/src/web/components/sso.html b/src/web/components/sso.html index 820735a..ee2a3ab 100644 --- a/src/web/components/sso.html +++ b/src/web/components/sso.html @@ -14,44 +14,59 @@
-

Authelia

-

Configuration settings for Authelia authentication provider.

- +

Forward Auth

+

Configuration settings for the Forward Auth provider.

+

The Forward Auth provider makes a subrequest to an authorization server that supports Forward Auth, then either:

+ +

Example authorization servers that support this:

+
- - - Example: auth.example.com + + + The full remote address or URL of the authorization servers forward auth endpoint. Example: https://auth.example.com/authz/forward-auth
-
-
- - - Check this if your authelia server uses HTTPS +
+
+
+ + Advanced Options +
+
+
+ + + Comma separated list of case-insensitive headers to copy from the authorization servers response to the request sent to the backend. If not set no headers are copied.
+ Example: Remote-User,Remote-Groups,Remote-Email,Remote-Name
+
+
+ + + Comma separated list of case-insensitive headers to copy from the authorization servers response to the response sent to the client. If not set no headers are copied.
+ Example: Set-Cookie,WWW-Authenticate
+
+
+ + + Comma separated list of case-insensitive headers to copy from the original request to the request made to the authorization server. If not set all headers are copied.
+ Example: Cookie,Authorization
+
+
+ + + Comma separated list of case-sensitive cookie names to exclude from the request to the backend. If not set no cookies are excluded.
+ Example: authelia_session,another_session
+
+
- - -
-
-
-

Authentik

-

Configuration settings for Authentik authentication provider.

- -
-
- - - Example: auth.example.com -
-
-
- - - Check this if your Authentik server uses HTTPS -
-
- +
@@ -60,24 +75,15 @@