14 Commits
main ... v3.2.9

Author SHA1 Message Date
Toby Chui
9d0a2a94f7 Add option to disable request logging per endpoint 2025-10-23 22:41:09 +08:00
Toby Chui
f9ef648664 Fix TLS cert key and pem file handling order 2025-10-22 20:38:19 +08:00
Toby Chui
8f95b622ff Removed redundant error check 2025-10-22 20:09:53 +08:00
Toby Chui
fa941e26a7 Merge pull request #860 from kjagosz/main
- Add PKCE support with S256 challenge method for OAuth2 (fixes #852)
2025-10-22 15:03:22 +08:00
Toby Chui
72e5d3ce3f Fixed #855
- Updated timeout to 600s
- Fixed dns challenge certificate set default bug
2025-10-21 21:22:43 +08:00
kjagosz
7efc7da9ab - Introduce configurable OAuth2 configuration cache time via UI and backend
- Refactor OAuth2Router to use `OAuth2ConfigurationCacheTime` with a default of 60s
2025-10-21 13:04:28 +02:00
kjagosz
944a8651ea Update src/mod/auth/sso/oauth2/oauth2.go
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-10-21 11:59:31 +02:00
kjagosz
f3143e52b3 Update src/mod/auth/sso/oauth2/oauth2.go
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-10-21 11:59:03 +02:00
Toby Chui
5c8e4a7df1 Fixed priority sort logic for HTTP proxy rule
Fixed proxy rule sort logic from alphabetical to best match
2025-10-21 07:52:07 +08:00
Toby Chui
0f295185f1 Update src/mod/auth/sso/oauth2/oauth2.go
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-10-20 21:50:39 +08:00
Toby Chui
322e5239a8 Update src/web/components/sso.html
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-10-20 21:34:31 +08:00
kjagosz
71bf844dc1 - Add PKCE support with S256 challenge method for OAuth2 (fixes #852)
- Update UI for OAuth2 Code Challenge Method selection (closes #854 )
- Introduce OAuth2 configuration cache to optimize requests (fixes #852)
- Fixes using DNS Challange for Custom ACME Server
2025-10-20 14:55:17 +02:00
Toby Chui
298444a53f Merge pull request #858 from tobychui/main
master branch sync
2025-10-19 22:18:48 +08:00
Toby Chui
41cf0cc2c7 Fixed #856
Updated upstreamHostSwap to accept currentTarget and avoid swapping to the same proxy endpoint, preventing unnecessary loopback handling. Also bumped SYSTEM_VERSION to 3.2.9.
2025-10-19 22:11:58 +08:00
18 changed files with 304 additions and 77 deletions

View File

@@ -44,7 +44,7 @@ import (
const (
/* Build Constants */
SYSTEM_NAME = "Zoraxy"
SYSTEM_VERSION = "3.2.8"
SYSTEM_VERSION = "3.2.9"
DEVELOPMENT_BUILD = false
/* System Constants */

View File

@@ -14,6 +14,7 @@ require (
github.com/gorilla/sessions v1.2.2
github.com/gorilla/websocket v1.5.1
github.com/grandcat/zeroconf v1.0.0
github.com/jellydator/ttlcache/v3 v3.4.0
github.com/likexian/whois v1.15.1
github.com/microcosm-cc/bluemonday v1.0.26
github.com/monperrus/crawler-user-agents v1.1.0

View File

@@ -445,6 +445,8 @@ github.com/infobloxopen/infoblox-go-client/v2 v2.10.0/go.mod h1:NeNJpz09efw/edzq
github.com/jarcoal/httpmock v1.0.8/go.mod h1:ATjnClrvW/3tijVmpL/va5Z3aAyGvqU3gCT8nX0Txik=
github.com/jarcoal/httpmock v1.4.0 h1:BvhqnH0JAYbNudL2GMJKgOHe2CtKlzJ/5rWKyp+hc2k=
github.com/jarcoal/httpmock v1.4.0/go.mod h1:ftW1xULwo+j0R0JJkJIIi7UKigZUXCLLanykgjwBXL0=
github.com/jellydator/ttlcache/v3 v3.4.0 h1:YS4P125qQS0tNhtL6aeYkheEaB/m8HCqdMMP4mnWdTY=
github.com/jellydator/ttlcache/v3 v3.4.0/go.mod h1:Hw9EgjymziQD3yGsQdf1FqFdpp7YjFMd4Srg5EJlgD4=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/jonboulle/clockwork v0.1.0/go.mod h1:Ii8DK3G1RaLaWxj9trq07+26W01tbo22gdxWY5EU2bo=

View File

@@ -508,8 +508,8 @@ func (a *ACMEHandler) HandleRenewCertificate(w http.ResponseWriter, r *http.Requ
dns = true
}
// Default propagation timeout is 300 seconds
propagationTimeout := 300
// Default propagation timeout is 600 seconds (10 minutes)
propagationTimeout := 600
if dns {
ppgTimeout, err := utils.PostPara(r, "ppgTimeout")
if err == nil {

View File

@@ -7,23 +7,33 @@ import (
"net/http"
"net/url"
"strings"
"time"
"github.com/jellydator/ttlcache/v3"
"golang.org/x/oauth2"
"imuslab.com/zoraxy/mod/database"
"imuslab.com/zoraxy/mod/info/logger"
"imuslab.com/zoraxy/mod/utils"
)
const (
// DefaultOAuth2ConfigCacheTime defines the default cache duration for OAuth2 configuration
DefaultOAuth2ConfigCacheTime = 60 * time.Second
)
type OAuth2RouterOptions struct {
OAuth2ServerURL string //The URL of the OAuth 2.0 server server
OAuth2TokenURL string //The URL of the OAuth 2.0 token server
OAuth2ClientId string //The client id for OAuth 2.0 Application
OAuth2ClientSecret string //The client secret for OAuth 2.0 Application
OAuth2WellKnownUrl string //The well-known url for OAuth 2.0 server
OAuth2UserInfoUrl string //The URL of the OAuth 2.0 user info endpoint
OAuth2Scopes string //The scopes for OAuth 2.0 Application
Logger *logger.Logger
Database *database.Database
OAuth2ServerURL string //The URL of the OAuth 2.0 server server
OAuth2TokenURL string //The URL of the OAuth 2.0 token server
OAuth2ClientId string //The client id for OAuth 2.0 Application
OAuth2ClientSecret string //The client secret for OAuth 2.0 Application
OAuth2WellKnownUrl string //The well-known url for OAuth 2.0 server
OAuth2UserInfoUrl string //The URL of the OAuth 2.0 user info endpoint
OAuth2Scopes string //The scopes for OAuth 2.0 Application
OAuth2CodeChallengeMethod string //The authorization code challenge method
OAuth2ConfigurationCacheTime *time.Duration
Logger *logger.Logger
Database *database.Database
OAuth2ConfigCache *ttlcache.Cache[string, *oauth2.Config]
}
type OIDCDiscoveryDocument struct {
@@ -57,11 +67,26 @@ func NewOAuth2Router(options *OAuth2RouterOptions) *OAuth2Router {
options.Database.Read("oauth2", "oauth2ClientId", &options.OAuth2ClientId)
options.Database.Read("oauth2", "oauth2ClientSecret", &options.OAuth2ClientSecret)
options.Database.Read("oauth2", "oauth2UserInfoUrl", &options.OAuth2UserInfoUrl)
options.Database.Read("oauth2", "oauth2CodeChallengeMethod", &options.OAuth2CodeChallengeMethod)
options.Database.Read("oauth2", "oauth2Scopes", &options.OAuth2Scopes)
options.Database.Read("oauth2", "oauth2ConfigurationCacheTime", &options.OAuth2ConfigurationCacheTime)
return &OAuth2Router{
ar := &OAuth2Router{
options: options,
}
if options.OAuth2ConfigurationCacheTime == nil ||
options.OAuth2ConfigurationCacheTime.Seconds() == 0 {
cacheTime := DefaultOAuth2ConfigCacheTime
options.OAuth2ConfigurationCacheTime = &cacheTime
}
options.OAuth2ConfigCache = ttlcache.New[string, *oauth2.Config](
ttlcache.WithTTL[string, *oauth2.Config](*options.OAuth2ConfigurationCacheTime),
)
go options.OAuth2ConfigCache.Start()
return ar
}
// HandleSetOAuth2Settings is the internal handler for setting the OAuth URL and HTTPS
@@ -81,13 +106,15 @@ func (ar *OAuth2Router) HandleSetOAuth2Settings(w http.ResponseWriter, r *http.R
func (ar *OAuth2Router) handleSetOAuthSettingsGET(w http.ResponseWriter, r *http.Request) {
//Return the current settings
js, _ := json.Marshal(map[string]interface{}{
"oauth2WellKnownUrl": ar.options.OAuth2WellKnownUrl,
"oauth2ServerUrl": ar.options.OAuth2ServerURL,
"oauth2TokenUrl": ar.options.OAuth2TokenURL,
"oauth2UserInfoUrl": ar.options.OAuth2UserInfoUrl,
"oauth2Scopes": ar.options.OAuth2Scopes,
"oauth2ClientSecret": ar.options.OAuth2ClientSecret,
"oauth2ClientId": ar.options.OAuth2ClientId,
"oauth2WellKnownUrl": ar.options.OAuth2WellKnownUrl,
"oauth2ServerUrl": ar.options.OAuth2ServerURL,
"oauth2TokenUrl": ar.options.OAuth2TokenURL,
"oauth2UserInfoUrl": ar.options.OAuth2UserInfoUrl,
"oauth2Scopes": ar.options.OAuth2Scopes,
"oauth2ClientSecret": ar.options.OAuth2ClientSecret,
"oauth2ClientId": ar.options.OAuth2ClientId,
"oauth2CodeChallengeMethod": ar.options.OAuth2CodeChallengeMethod,
"oauth2ConfigurationCacheTime": ar.options.OAuth2ConfigurationCacheTime.String(),
})
utils.SendJSONResponse(w, string(js))
@@ -95,7 +122,8 @@ func (ar *OAuth2Router) handleSetOAuthSettingsGET(w http.ResponseWriter, r *http
func (ar *OAuth2Router) handleSetOAuthSettingsPOST(w http.ResponseWriter, r *http.Request) {
//Update the settings
var oauth2ServerUrl, oauth2TokenURL, oauth2Scopes, oauth2UserInfoUrl string
var oauth2ServerUrl, oauth2TokenURL, oauth2Scopes, oauth2UserInfoUrl, oauth2CodeChallengeMethod string
var oauth2ConfigurationCacheTime *time.Duration
oauth2ClientId, err := utils.PostPara(r, "oauth2ClientId")
if err != nil {
@@ -109,6 +137,18 @@ func (ar *OAuth2Router) handleSetOAuthSettingsPOST(w http.ResponseWriter, r *htt
return
}
oauth2CodeChallengeMethod, err = utils.PostPara(r, "oauth2CodeChallengeMethod")
if err != nil {
utils.SendErrorResponse(w, "oauth2CodeChallengeMethod not found")
return
}
oauth2ConfigurationCacheTime, err = utils.PostDuration(r, "oauth2ConfigurationCacheTime")
if err != nil {
utils.SendErrorResponse(w, "oauth2ConfigurationCacheTime not found")
return
}
oauth2WellKnownUrl, err := utils.PostPara(r, "oauth2WellKnownUrl")
if err != nil {
oauth2ServerUrl, err = utils.PostPara(r, "oauth2ServerUrl")
@@ -146,6 +186,8 @@ func (ar *OAuth2Router) handleSetOAuthSettingsPOST(w http.ResponseWriter, r *htt
ar.options.OAuth2ClientId = oauth2ClientId
ar.options.OAuth2ClientSecret = oauth2ClientSecret
ar.options.OAuth2Scopes = oauth2Scopes
ar.options.OAuth2CodeChallengeMethod = oauth2CodeChallengeMethod
ar.options.OAuth2ConfigurationCacheTime = oauth2ConfigurationCacheTime
//Write changes to database
ar.options.Database.Write("oauth2", "oauth2WellKnownUrl", oauth2WellKnownUrl)
@@ -155,6 +197,11 @@ func (ar *OAuth2Router) handleSetOAuthSettingsPOST(w http.ResponseWriter, r *htt
ar.options.Database.Write("oauth2", "oauth2ClientId", oauth2ClientId)
ar.options.Database.Write("oauth2", "oauth2ClientSecret", oauth2ClientSecret)
ar.options.Database.Write("oauth2", "oauth2Scopes", oauth2Scopes)
ar.options.Database.Write("oauth2", "oauth2CodeChallengeMethod", oauth2CodeChallengeMethod)
ar.options.Database.Write("oauth2", "oauth2ConfigurationCacheTime", oauth2ConfigurationCacheTime)
// Flush caches
ar.options.OAuth2ConfigCache.DeleteAll()
utils.SendOK(w)
}
@@ -167,6 +214,7 @@ func (ar *OAuth2Router) handleSetOAuthSettingsDELETE(w http.ResponseWriter, r *h
ar.options.OAuth2ClientId = ""
ar.options.OAuth2ClientSecret = ""
ar.options.OAuth2Scopes = ""
ar.options.OAuth2CodeChallengeMethod = ""
ar.options.Database.Delete("oauth2", "oauth2WellKnownUrl")
ar.options.Database.Delete("oauth2", "oauth2ServerUrl")
@@ -175,6 +223,8 @@ func (ar *OAuth2Router) handleSetOAuthSettingsDELETE(w http.ResponseWriter, r *h
ar.options.Database.Delete("oauth2", "oauth2ClientId")
ar.options.Database.Delete("oauth2", "oauth2ClientSecret")
ar.options.Database.Delete("oauth2", "oauth2Scopes")
ar.options.Database.Delete("oauth2", "oauth2CodeChallengeMethod")
ar.options.Database.Delete("oauth2", "oauth2ConfigurationCacheTime")
utils.SendOK(w)
}
@@ -189,12 +239,10 @@ func (ar *OAuth2Router) fetchOAuth2Configuration(config *oauth2.Config) (*oauth2
return nil, err
} else {
defer resp.Body.Close()
oidcDiscoveryDocument := OIDCDiscoveryDocument{}
if err := json.NewDecoder(resp.Body).Decode(&oidcDiscoveryDocument); err != nil {
return nil, err
}
if len(config.Scopes) == 0 {
config.Scopes = oidcDiscoveryDocument.ScopesSupported
}
@@ -241,14 +289,24 @@ func (ar *OAuth2Router) newOAuth2Conf(redirectUrl string) (*oauth2.Config, error
func (ar *OAuth2Router) HandleOAuth2Auth(w http.ResponseWriter, r *http.Request) error {
const callbackPrefix = "/internal/oauth2"
const tokenCookie = "z-token"
const verifierCookie = "z-verifier"
scheme := "http"
if r.TLS != nil {
scheme = "https"
}
reqUrl := scheme + "://" + r.Host + r.RequestURI
oauthConfig, err := ar.newOAuth2Conf(scheme + "://" + r.Host + callbackPrefix)
if err != nil {
ar.options.Logger.PrintAndLog("OAuth2Router", "Failed to fetch OIDC configuration:", err)
oauthConfigCache, _ := ar.options.OAuth2ConfigCache.GetOrSetFunc(r.Host, func() *oauth2.Config {
oauthConfig, err := ar.newOAuth2Conf(scheme + "://" + r.Host + callbackPrefix)
if err != nil {
ar.options.Logger.PrintAndLog("OAuth2Router", "Failed to fetch OIDC configuration:", err)
return nil
}
return oauthConfig
})
oauthConfig := oauthConfigCache.Value()
if oauthConfig == nil {
w.WriteHeader(500)
return errors.New("failed to fetch OIDC configuration")
}
@@ -263,26 +321,48 @@ func (ar *OAuth2Router) HandleOAuth2Auth(w http.ResponseWriter, r *http.Request)
state := r.URL.Query().Get("state")
if r.Method == http.MethodGet && strings.HasPrefix(r.RequestURI, callbackPrefix) && code != "" && state != "" {
ctx := context.Background()
token, err := oauthConfig.Exchange(ctx, code)
var authCodeOptions []oauth2.AuthCodeOption
if ar.options.OAuth2CodeChallengeMethod == "PKCE" || ar.options.OAuth2CodeChallengeMethod == "PKCE_S256" {
verifierCookie, err := r.Cookie(verifierCookie)
if err != nil || verifierCookie.Value == "" {
ar.options.Logger.PrintAndLog("OAuth2Router", "Read OAuth2 verifier cookie failed", err)
w.WriteHeader(401)
return errors.New("unauthorized")
}
authCodeOptions = append(authCodeOptions, oauth2.VerifierOption(verifierCookie.Value))
}
token, err := oauthConfig.Exchange(ctx, code, authCodeOptions...)
if err != nil {
ar.options.Logger.PrintAndLog("OAuth2", "Token exchange failed", err)
w.WriteHeader(401)
return errors.New("unauthorized")
}
if !token.Valid() {
ar.options.Logger.PrintAndLog("OAuth2", "Invalid token", err)
w.WriteHeader(401)
return errors.New("unauthorized")
}
cookie := http.Cookie{Name: tokenCookie, Value: token.AccessToken, Path: "/"}
cookieExpiry := token.Expiry
if cookieExpiry.IsZero() || cookieExpiry.Before(time.Now()) {
cookieExpiry = time.Now().Add(time.Hour)
}
cookie := http.Cookie{Name: tokenCookie, Value: token.AccessToken, Path: "/", Expires: cookieExpiry}
if scheme == "https" {
cookie.Secure = true
cookie.SameSite = http.SameSiteLaxMode
}
w.Header().Add("Set-Cookie", cookie.String())
if ar.options.OAuth2CodeChallengeMethod == "PKCE" || ar.options.OAuth2CodeChallengeMethod == "PKCE_S256" {
cookie := http.Cookie{Name: verifierCookie, Value: "", Path: "/", Expires: time.Now().Add(-time.Hour * 1)}
if scheme == "https" {
cookie.Secure = true
cookie.SameSite = http.SameSiteLaxMode
}
w.Header().Add("Set-Cookie", cookie.String())
}
//Fix for #695
location := strings.TrimPrefix(state, "/internal/")
//Check if the location starts with http:// or https://. if yes, this is full URL
@@ -321,7 +401,25 @@ func (ar *OAuth2Router) HandleOAuth2Auth(w http.ResponseWriter, r *http.Request)
}
if unauthorized {
state := url.QueryEscape(reqUrl)
url := oauthConfig.AuthCodeURL(state, oauth2.AccessTypeOffline)
var url string
if ar.options.OAuth2CodeChallengeMethod == "PKCE" || ar.options.OAuth2CodeChallengeMethod == "PKCE_S256" {
cookie := http.Cookie{Name: verifierCookie, Value: oauth2.GenerateVerifier(), Path: "/", Expires: time.Now().Add(time.Hour * 1)}
if scheme == "https" {
cookie.Secure = true
cookie.SameSite = http.SameSiteLaxMode
}
w.Header().Add("Set-Cookie", cookie.String())
if ar.options.OAuth2CodeChallengeMethod == "PKCE" {
url = oauthConfig.AuthCodeURL(state, oauth2.AccessTypeOffline, oauth2.SetAuthURLParam("code_challenge", cookie.Value))
} else {
url = oauthConfig.AuthCodeURL(state, oauth2.AccessTypeOffline, oauth2.S256ChallengeOption(cookie.Value))
}
} else {
url = oauthConfig.AuthCodeURL(state, oauth2.AccessTypeOffline)
}
http.Redirect(w, r, url, http.StatusFound)
return errors.New("unauthorized")

View File

@@ -48,7 +48,7 @@ func (h *ProxyHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
//Check if this is a redirection url
if h.Parent.Option.RedirectRuleTable.IsRedirectable(r) {
statusCode := h.Parent.Option.RedirectRuleTable.HandleRedirect(w, r)
h.Parent.logRequest(r, statusCode != 500, statusCode, "redirect", r.Host, "")
h.Parent.logRequest(r, statusCode != 500, statusCode, "redirect", r.Host, "", nil)
return
}
@@ -70,7 +70,7 @@ func (h *ProxyHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
//Use default rule
ruleID = "default"
}
if h.handleAccessRouting(ruleID, w, r) {
if h.handleAccessRouting(ruleID, w, r, sep) {
//Request handled by subroute
return
}
@@ -79,7 +79,9 @@ func (h *ProxyHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if sep.RequireRateLimit {
err := h.handleRateLimitRouting(w, r, sep)
if err != nil {
h.Parent.Option.Logger.LogHTTPRequest(r, "host", 307, r.Host, "")
if !sep.DisableLogging {
h.Parent.Option.Logger.LogHTTPRequest(r, "host", 307, r.Host, "")
}
return
}
}
@@ -109,7 +111,9 @@ func (h *ProxyHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if potentialProxtEndpoint != nil && !potentialProxtEndpoint.Disabled {
//Missing tailing slash. Redirect to target proxy endpoint
http.Redirect(w, r, r.RequestURI+"/", http.StatusTemporaryRedirect)
h.Parent.Option.Logger.LogHTTPRequest(r, "redirect", 307, r.Host, "")
if !sep.DisableLogging {
h.Parent.Option.Logger.LogHTTPRequest(r, "redirect", 307, r.Host, "")
}
return
}
}
@@ -124,7 +128,7 @@ func (h *ProxyHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
*/
//Root access control based on default rule
blocked := h.handleAccessRouting("default", w, r)
blocked := h.handleAccessRouting("default", w, r, h.Parent.Root)
if blocked {
return
}
@@ -210,19 +214,19 @@ func (h *ProxyHandler) handleRootRouting(w http.ResponseWriter, r *http.Request)
}
hostname := parsedURL.Hostname()
if hostname == domainOnly {
h.Parent.logRequest(r, false, 500, "root-redirect", domainOnly, "")
h.Parent.logRequest(r, false, 500, "root-redirect", domainOnly, "", h.Parent.Root)
http.Error(w, "Loopback redirects due to invalid settings", 500)
return
}
h.Parent.logRequest(r, false, 307, "root-redirect", domainOnly, "")
h.Parent.logRequest(r, false, 307, "root-redirect", domainOnly, "", h.Parent.Root)
http.Redirect(w, r, redirectTarget, http.StatusTemporaryRedirect)
case DefaultSite_NotFoundPage:
//Serve the not found page, use template if exists
h.serve404PageWithTemplate(w, r)
case DefaultSite_NoResponse:
//No response. Just close the connection
h.Parent.logRequest(r, false, 444, "root-no_resp", domainOnly, "")
h.Parent.logRequest(r, false, 444, "root-no_resp", domainOnly, "", h.Parent.Root)
hijacker, ok := w.(http.Hijacker)
if !ok {
w.WriteHeader(http.StatusNoContent)
@@ -236,11 +240,11 @@ func (h *ProxyHandler) handleRootRouting(w http.ResponseWriter, r *http.Request)
conn.Close()
case DefaultSite_TeaPot:
//I'm a teapot
h.Parent.logRequest(r, false, 418, "root-teapot", domainOnly, "")
h.Parent.logRequest(r, false, 418, "root-teapot", domainOnly, "", h.Parent.Root)
http.Error(w, "I'm a teapot", http.StatusTeapot)
default:
//Unknown routing option. Send empty response
h.Parent.logRequest(r, false, 544, "root-unknown", domainOnly, "")
h.Parent.logRequest(r, false, 544, "root-unknown", domainOnly, "", h.Parent.Root)
http.Error(w, "544 - No Route Defined", 544)
}
}

View File

@@ -13,7 +13,7 @@ import (
// Handle access check (blacklist / whitelist), return true if request is handled (aka blocked)
// if the return value is false, you can continue process the response writer
func (h *ProxyHandler) handleAccessRouting(ruleID string, w http.ResponseWriter, r *http.Request) bool {
func (h *ProxyHandler) handleAccessRouting(ruleID string, w http.ResponseWriter, r *http.Request, sep *ProxyEndpoint) bool {
accessRule, err := h.Parent.Option.AccessController.GetAccessRuleByID(ruleID)
if err != nil {
//Unable to load access rule. Target rule not found?
@@ -25,7 +25,7 @@ func (h *ProxyHandler) handleAccessRouting(ruleID string, w http.ResponseWriter,
isBlocked, blockedReason := accessRequestBlocked(accessRule, h.Parent.Option.WebDirectory, w, r)
if isBlocked {
h.Parent.logRequest(r, false, 403, blockedReason, r.Host, "")
h.Parent.logRequest(r, false, 403, blockedReason, r.Host, "", sep)
}
return isBlocked
}

View File

@@ -156,7 +156,7 @@ func (router *Router) StartProxyService() error {
if err != nil {
http.ServeFile(w, r, "./web/hosterror.html")
router.Option.Logger.PrintAndLog("dprouter", "failed to get upstream for hostname", err)
router.logRequest(r, false, 404, "vdir-http", r.Host, "")
router.logRequest(r, false, 404, "vdir-http", r.Host, "", sep)
}
endpointProxyRewriteRules := GetDefaultHeaderRewriteRules()

View File

@@ -51,6 +51,11 @@ func (router *Router) GetProxyEndpointFromHostname(hostname string) *ProxyEndpoi
matchProxyEndpoints := []*ProxyEndpoint{}
router.ProxyEndpoints.Range(func(k, v interface{}) bool {
ep := v.(*ProxyEndpoint)
if ep.Disabled {
//Skip disabled endpoint
return true
}
match, err := filepath.Match(ep.RootOrMatchingDomain, hostname)
if err != nil {
//Bad pattern. Skip this rule
@@ -83,12 +88,24 @@ func (router *Router) GetProxyEndpointFromHostname(hostname string) *ProxyEndpoi
})
if len(matchProxyEndpoints) == 1 {
//Only 1 match
return matchProxyEndpoints[0]
} else if len(matchProxyEndpoints) > 1 {
//More than one match. Get the best match one
sort.Slice(matchProxyEndpoints, func(i, j int) bool {
return matchProxyEndpoints[i].RootOrMatchingDomain < matchProxyEndpoints[j].RootOrMatchingDomain
// More than one match, pick one that is:
// 1. longer RootOrMatchingDomain (more specific)
// 2. fewer wildcard characters (* and ?) (more specific)
// 3. fallback to lexicographic order
sort.SliceStable(matchProxyEndpoints, func(i, j int) bool {
a := matchProxyEndpoints[i].RootOrMatchingDomain
b := matchProxyEndpoints[j].RootOrMatchingDomain
if len(a) != len(b) {
return len(a) > len(b)
}
aw := strings.Count(a, "*") + strings.Count(a, "?")
bw := strings.Count(b, "*") + strings.Count(b, "?")
if aw != bw {
return aw < bw
}
return a < b
})
return matchProxyEndpoints[0]
}
@@ -110,13 +127,13 @@ func (router *Router) rewriteURL(rooturl string, requestURL string) string {
// upstreamHostSwap check if this loopback to one of the proxy rule in the system. If yes, do a shortcut target swap
// this prevents unnecessary external DNS lookup and connection, return true if swapped and request is already handled
// by the loopback handler. Only continue if return is false
func (h *ProxyHandler) upstreamHostSwap(w http.ResponseWriter, r *http.Request, selectedUpstream *loadbalance.Upstream) bool {
func (h *ProxyHandler) upstreamHostSwap(w http.ResponseWriter, r *http.Request, selectedUpstream *loadbalance.Upstream, currentTarget *ProxyEndpoint) bool {
upstreamHostname := selectedUpstream.OriginIpOrDomain
if strings.Contains(upstreamHostname, ":") {
upstreamHostname = strings.Split(upstreamHostname, ":")[0]
}
loopbackProxyEndpoint := h.Parent.GetProxyEndpointFromHostname(upstreamHostname)
if loopbackProxyEndpoint != nil {
if loopbackProxyEndpoint != nil && loopbackProxyEndpoint != currentTarget {
//This is a loopback request. Swap the target to the loopback target
//h.Parent.Option.Logger.PrintAndLog("proxy", "Detected a loopback request to self. Swap the target to "+loopbackProxyEndpoint.RootOrMatchingDomain, nil)
if loopbackProxyEndpoint.IsEnabled() {
@@ -124,7 +141,7 @@ func (h *ProxyHandler) upstreamHostSwap(w http.ResponseWriter, r *http.Request,
} else {
//Endpoint disabled, return 503
http.ServeFile(w, r, "./web/rperror.html")
h.Parent.logRequest(r, false, 521, "host-http", r.Host, upstreamHostname)
h.Parent.logRequest(r, false, 521, "host-http", r.Host, upstreamHostname, currentTarget)
}
return true
}
@@ -142,12 +159,12 @@ func (h *ProxyHandler) hostRequest(w http.ResponseWriter, r *http.Request, targe
if err != nil {
http.ServeFile(w, r, "./web/rperror.html")
h.Parent.Option.Logger.PrintAndLog("proxy", "Failed to assign an upstream for this request", err)
h.Parent.logRequest(r, false, 521, "subdomain-http", r.URL.Hostname(), r.Host)
h.Parent.logRequest(r, false, 521, "subdomain-http", r.URL.Hostname(), r.Host, target)
return
}
/* Upstream Host Swap (use to detect loopback to self) */
if h.upstreamHostSwap(w, r, selectedUpstream) {
if h.upstreamHostSwap(w, r, selectedUpstream, target) {
//Request handled by the loopback handler
return
}
@@ -170,7 +187,7 @@ func (h *ProxyHandler) hostRequest(w http.ResponseWriter, r *http.Request, targe
if selectedUpstream.RequireTLS {
u, _ = url.Parse("wss://" + wsRedirectionEndpoint + requestURL)
}
h.Parent.logRequest(r, true, 101, "host-websocket", reqHostname, selectedUpstream.OriginIpOrDomain)
h.Parent.logRequest(r, true, 101, "host-websocket", reqHostname, selectedUpstream.OriginIpOrDomain, target)
if target.HeaderRewriteRules == nil {
target.HeaderRewriteRules = GetDefaultHeaderRewriteRules()
@@ -232,18 +249,18 @@ func (h *ProxyHandler) hostRequest(w http.ResponseWriter, r *http.Request, targe
if err != nil {
if errors.As(err, &dnsError) {
http.ServeFile(w, r, "./web/hosterror.html")
h.Parent.logRequest(r, false, 404, "host-http", reqHostname, upstreamHostname)
h.Parent.logRequest(r, false, 404, "host-http", reqHostname, upstreamHostname, target)
} else if errors.Is(err, context.Canceled) {
//Request canceled by client, usually due to manual refresh before page load
http.Error(w, "Request canceled", http.StatusRequestTimeout)
h.Parent.logRequest(r, false, http.StatusRequestTimeout, "host-http", reqHostname, upstreamHostname)
h.Parent.logRequest(r, false, http.StatusRequestTimeout, "host-http", reqHostname, upstreamHostname, target)
} else {
http.ServeFile(w, r, "./web/rperror.html")
h.Parent.logRequest(r, false, 521, "host-http", reqHostname, upstreamHostname)
h.Parent.logRequest(r, false, 521, "host-http", reqHostname, upstreamHostname, target)
}
}
h.Parent.logRequest(r, true, statusCode, "host-http", reqHostname, upstreamHostname)
h.Parent.logRequest(r, true, statusCode, "host-http", reqHostname, upstreamHostname, target)
}
// Handle vdir type request
@@ -269,7 +286,7 @@ func (h *ProxyHandler) vdirRequest(w http.ResponseWriter, r *http.Request, targe
target.parent.HeaderRewriteRules = GetDefaultHeaderRewriteRules()
}
h.Parent.logRequest(r, true, 101, "vdir-websocket", r.Host, target.Domain)
h.Parent.logRequest(r, true, 101, "vdir-websocket", r.Host, target.Domain, target.parent)
wspHandler := websocketproxy.NewProxy(u, websocketproxy.Options{
SkipTLSValidation: target.SkipCertValidations,
SkipOriginCheck: true, //You should not use websocket via virtual directory. But keep this to true for compatibility
@@ -325,19 +342,25 @@ func (h *ProxyHandler) vdirRequest(w http.ResponseWriter, r *http.Request, targe
if errors.As(err, &dnsError) {
http.ServeFile(w, r, "./web/hosterror.html")
log.Println(err.Error())
h.Parent.logRequest(r, false, 404, "vdir-http", reqHostname, target.Domain)
h.Parent.logRequest(r, false, 404, "vdir-http", reqHostname, target.Domain, target.parent)
} else {
http.ServeFile(w, r, "./web/rperror.html")
log.Println(err.Error())
h.Parent.logRequest(r, false, 521, "vdir-http", reqHostname, target.Domain)
h.Parent.logRequest(r, false, 521, "vdir-http", reqHostname, target.Domain, target.parent)
}
}
h.Parent.logRequest(r, true, statusCode, "vdir-http", reqHostname, target.Domain)
h.Parent.logRequest(r, true, statusCode, "vdir-http", reqHostname, target.Domain, target.parent)
}
// This logger collect data for the statistical analysis. For log to file logger, check the Logger and LogHTTPRequest handler
func (router *Router) logRequest(r *http.Request, succ bool, statusCode int, forwardType string, originalHostname string, upstreamHostname string) {
func (router *Router) logRequest(r *http.Request, succ bool, statusCode int, forwardType string, originalHostname string, upstreamHostname string, endpoint *ProxyEndpoint) {
if endpoint != nil && endpoint.DisableLogging {
// Notes: endpoint can be nil if the request has been handled before a host name can be resolved
// e.g. Redirection matching rule
// in that case we will log it by default and will not enter this routine
return
}
if router.Option.StatisticCollector != nil {
go func() {
requestInfo := statistic.RequestInfo{

View File

@@ -51,7 +51,7 @@ func (t *RequestCountPerIpTable) Clear() {
func (h *ProxyHandler) handleRateLimitRouting(w http.ResponseWriter, r *http.Request, pe *ProxyEndpoint) error {
err := h.Parent.handleRateLimit(w, r, pe)
if err != nil {
h.Parent.logRequest(r, false, 429, "ratelimit", r.URL.Hostname(), "")
h.Parent.logRequest(r, false, 429, "ratelimit", r.URL.Hostname(), "", pe)
}
return err
}

View File

@@ -208,6 +208,7 @@ type ProxyEndpoint struct {
//Uptime Monitor
DisableUptimeMonitor bool //Disable uptime monitor for this endpoint
DisableLogging bool //Disable logging of reverse proxy requests
// Chunked Transfer Encoding
DisableChunkedTransferEncoding bool //Disable chunked transfer encoding for this endpoint

View File

@@ -84,18 +84,30 @@ func (m *Manager) SetCertAsDefault(w http.ResponseWriter, r *http.Request) {
}
//Check if the previous default cert exists. If yes, get its hostname from cert contents
defaultPubKey := filepath.Join(m.CertStore, "default.key")
defaultPriKey := filepath.Join(m.CertStore, "default.pem")
defaultPubKey := filepath.Join(m.CertStore, "default.pem")
defaultPriKey := filepath.Join(m.CertStore, "default.key")
defaultJSON := filepath.Join(m.CertStore, "default.json")
fmt.Println(defaultPubKey, defaultPriKey, defaultJSON)
if utils.FileExists(defaultPubKey) && utils.FileExists(defaultPriKey) {
//Move the existing default cert to its original name
certBytes, err := os.ReadFile(defaultPriKey)
certBytes, err := os.ReadFile(defaultPubKey)
if err == nil {
block, _ := pem.Decode(certBytes)
if block != nil {
cert, err := x509.ParseCertificate(block.Bytes)
if err == nil {
os.Rename(defaultPubKey, filepath.Join(m.CertStore, domainToFilename(cert.Subject.CommonName, "key")))
os.Rename(defaultPriKey, filepath.Join(m.CertStore, domainToFilename(cert.Subject.CommonName, "pem")))
originalKeyName := filepath.Join(m.CertStore, domainToFilename(cert.Subject.CommonName, "key"))
originalPemName := filepath.Join(m.CertStore, domainToFilename(cert.Subject.CommonName, "pem"))
originalJSONName := filepath.Join(m.CertStore, domainToFilename(cert.Subject.CommonName, "json"))
fmt.Println(defaultPubKey, originalPemName)
os.Rename(defaultPubKey, originalPemName)
fmt.Println(defaultPriKey, originalKeyName)
os.Rename(defaultPriKey, originalKeyName)
if utils.FileExists(defaultJSON) {
os.Rename(defaultJSON, originalJSONName)
}
}
}
}
@@ -103,11 +115,15 @@ func (m *Manager) SetCertAsDefault(w http.ResponseWriter, r *http.Request) {
//Check if the cert exists
certname = filepath.Base(certname) //prevent path escape
pubKey := filepath.Join(filepath.Join(m.CertStore), certname+".key")
priKey := filepath.Join(filepath.Join(m.CertStore), certname+".pem")
pubKey := filepath.Join(filepath.Join(m.CertStore), certname+".pem")
priKey := filepath.Join(filepath.Join(m.CertStore), certname+".key")
certJSON := filepath.Join(filepath.Join(m.CertStore), certname+".json")
if utils.FileExists(pubKey) && utils.FileExists(priKey) {
os.Rename(pubKey, filepath.Join(m.CertStore, "default.key"))
os.Rename(priKey, filepath.Join(m.CertStore, "default.pem"))
os.Rename(pubKey, filepath.Join(m.CertStore, "default.pem"))
os.Rename(priKey, filepath.Join(m.CertStore, "default.key"))
if utils.FileExists(certJSON) {
os.Rename(certJSON, filepath.Join(m.CertStore, "default.json"))
}
utils.SendOK(w)
//Update cert list

View File

@@ -81,6 +81,26 @@ func PostPara(r *http.Request, key string) (string, error) {
return x, nil
}
// Get POST parameter as time.Duration
func PostDuration(r *http.Request, key string) (*time.Duration, error) {
// Try to parse the form
if err := r.ParseForm(); err != nil {
return nil, err
}
// Get first value from the form
x := r.Form.Get(key)
if len(x) == 0 {
return nil, errors.New("invalid " + key + " given")
}
duration, err := time.ParseDuration(x)
if err != nil {
return nil, errors.Join(errors.New("invalid "+key+" duration"), err)
}
return &duration, nil
}
// Get POST paramter as boolean, accept 1 or true
func PostBool(r *http.Request, key string) (bool, error) {
x, err := PostPara(r, key)

View File

@@ -244,6 +244,9 @@ func ReverseProxyHandleAddEndpoint(w http.ResponseWriter, r *http.Request) {
enableUtm = true
}
// Disable logging?
disableLog, _ := utils.PostBool(r, "disableLog")
useBypassGlobalTLS := bypassGlobalTLS == "true"
//Enable TLS validation?
@@ -416,6 +419,7 @@ func ReverseProxyHandleAddEndpoint(w http.ResponseWriter, r *http.Request) {
Tags: tags,
DisableUptimeMonitor: !enableUtm,
DisableLogging: disableLog,
}
preparedEndpoint, err := dynamicProxyRouter.PrepareProxyRoute(&thisProxyEndpoint)
@@ -570,6 +574,9 @@ func ReverseProxyHandleEditEndpoint(w http.ResponseWriter, r *http.Request) {
// Disable chunked Encoding
disableChunkedEncoding, _ := utils.PostBool(r, "dChunkedEnc")
// Disable logging
disableLogging, _ := utils.PostBool(r, "dLogging")
//Load the previous basic auth credentials from current proxy rules
targetProxyEntry, err := dynamicProxyRouter.LoadProxy(rootNameOrMatchingDomain)
if err != nil {
@@ -611,6 +618,7 @@ func ReverseProxyHandleEditEndpoint(w http.ResponseWriter, r *http.Request) {
newProxyEndpoint.UseStickySession = useStickySession
newProxyEndpoint.DisableUptimeMonitor = disbleUtm
newProxyEndpoint.DisableChunkedTransferEncoding = disableChunkedEncoding
newProxyEndpoint.DisableLogging = disableLogging
newProxyEndpoint.Tags = tags
//Prepare to replace the current routing rule

View File

@@ -284,6 +284,12 @@
<label>Allow plain HTTP access<br>
<small>Allow inbound connections without TLS/SSL</small></label>
</div>
<br>
<div class="ui checkbox" style="margin-top: 0.4em;">
<input type="checkbox" class="DisableLogging">
<label>Disable Requests Logging<br>
<small>Disable logging for all incoming requests for this hostname</small></label>
</div>
</div>
</div>
@@ -885,6 +891,7 @@
let rateLimit = $(editor).find(".RateLimit").val();
let bypassGlobalTLS = $(editor).find(".BypassGlobalTLS")[0].checked;
let disableChunkedTransferEncoding = $(editor).find(".DisableChunkedTransferEncoding")[0].checked;
let disableLogging = $(editor).find(".DisableLogging")[0].checked;
let tags = getTagsArrayFromEndpoint(uuid);
if (tags.length > 0){
tags = tags.join(",");
@@ -901,6 +908,7 @@
"authprovider" :authProviderType,
"rate" :requireRateLimit,
"dChunkedEnc": disableChunkedTransferEncoding,
"dLogging": disableLogging,
"ratenum" :rateLimit,
"tags": tags,
};
@@ -1238,6 +1246,12 @@
editor.find(".BypassGlobalTLS").on("change", function() {
saveProxyInlineEdit(uuid);
});
editor.find(".DisableLogging").off('change');
editor.find(".DisableLogging").prop("checked", subd.DisableLogging);
editor.find(".DisableLogging").on("change", function() {
saveProxyInlineEdit(uuid);
});
//Bind the edit button
editor.find(".downstream_primary_hostname_edit_btn").off("click").on("click", function(){

View File

@@ -63,13 +63,20 @@
<label>Sticky Session<br><small>Enable stick session on upstream load balancing</small></label>
</div>
</div>
<div class="field">
<div class="field">
<div class="ui checkbox">
<input type="checkbox" id="enableUtm" checked>
<label>Enable uptime monitor<br><small>Automatically check upstream status and switch to another if offline</small>
</label>
</div>
</div>
<div class="field">
<div class="ui checkbox">
<input type="checkbox" id="disableLog">
<label>Disable Requests Logging<br><small>Disable requests logging for this host, recommended for high traffic sites</small>
</label>
</div>
</div>
<div class="field">
<label>Tags</label>
<input type="text" id="proxyTags" placeholder="e.g. mediaserver, management">
@@ -268,6 +275,7 @@
let useStickySessionLB = $("#useStickySessionLB")[0].checked;
let tags = $("#proxyTags").val().trim();
let enableUtm = $("#enableUtm")[0].checked;
let disableLog = $("#disableLog")[0].checked;
if (rootname.trim() == ""){
$("#rootname").parent().addClass("error");
@@ -302,7 +310,8 @@
access: accessRuleToUse,
stickysess: useStickySessionLB,
tags: tags,
enableUtm: enableUtm,
enableUtm: enableUtm,
disableLog: disableLog,
},
success: function(data){
if (data.error != undefined){

View File

@@ -109,6 +109,26 @@
<input type="password" id="oauth2ClientSecret" name="oauth2ClientSecret" placeholder="Enter Client Secret">
<small>Secret key of the OAuth2 application</small>
</div>
<div class="field">
<label for="oauth2CodeChallengeMethod">Code Challenge Method</label>
<div class="ui selection dropdown" id="oauth2CodeChallengeMethod">
<input type="hidden" name="oauth2CodeChallengeMethod">
<i class="dropdown icon"></i>
<div class="default text">Plain</div>
<div class="menu">
<div class="item" data-value="plain">Plain</div>
<div class="item" data-value="PKCE">PKCE</div>
<div class="item" data-value="PKCE_S256">PKCE (S256)</div>
</div>
</div>
<small>Options: <br>
Plain: No code challenge is used.<br>
PKCE: Uses a code challenge for added security.<br>
PKCE (S256): Uses a hashed code challenge (SHA-256) for maximum protection.<br>
<strong>Note:</strong> PKCE (especially S256) is recommended for better security.
</small>
</div>
<div class="field">
<label for="oauth2WellKnownUrl">Discovery URL</label>
<input type="text" id="oauth2WellKnownUrl" name="oauth2WellKnownUrl" placeholder="Enter Well-Known URL">
@@ -138,6 +158,13 @@
<input type="text" id="oauth2Scopes" name="oauth2Scopes" placeholder="Enter Scopes">
<small>Scopes required by the OAuth2 provider to retrieve information about the authenticated user. Refer to your OAuth2 provider documentation for more information about this. Optional if Well-Known url is configured.</small>
</div>
<div class="field">
<label for="oauth2ConfigurationCacheTime">Configuration cache time</label>
<input type="text" id="oauth2ConfigurationCacheTime" name="oauth2ConfigurationCacheTime" placeholder="Enter Configuration Cache Time">
<small>Time to cache OAuth2 configuration before refresh. Accepts Go time.Duration format (e.g. 1m, 10m, 1h). Defaults to 60s.</small>
</div>
<button class="ui basic button" type="submit"><i class="green check icon"></i> Apply Change</button>
<button class="ui basic button" type="button" id="oauth2Clear"><i class="red trash icon"></i> Clear</button>
</form>
@@ -299,6 +326,8 @@
$('#oauth2ClientId').val(data.oauth2ClientId);
$('#oauth2ClientSecret').val(data.oauth2ClientSecret);
$('#oauth2Scopes').val(data.oauth2Scopes);
$('#oauth2ConfigurationCacheTime').val(data.oauth2ConfigurationCacheTime);
$('[data-value="'+data.oauth2CodeChallengeMethod+'"]').click();
},
error: function(jqXHR, textStatus, errorThrown) {
console.error('Error fetching SSO settings:', textStatus, errorThrown);

View File

@@ -408,8 +408,10 @@
$("#kidInput").show();
$("#hmacInput").show();
$("#skipTLS").show();
$("#dnsChallenge").hide();
$(".dnsChallengeOnly").hide();
$("#dnsChallenge").show();
if ($("#useDnsChallenge")[0].checked){
$(".dnsChallengeOnly").show();
}
} else if (this.value == "ZeroSSL") {
$("#kidInput").show();
$("#hmacInput").show();
@@ -468,7 +470,7 @@
defaultIntValue = 2;
defaultMinValue = 1;
}else if (key == "PropagationTimeout"){
defaultIntValue = 120;
defaultIntValue = 600;
defaultMinValue = 30;
}
optionalFieldsHTML += (`<div class="ui fluid labeled dnsConfigField small input" key="${key}" style="margin-top: 0.2em;">