From 8030f3d62a8ad7d0c1231566a266b7de4cbf1b07 Mon Sep 17 00:00:00 2001 From: Toby Chui Date: Mon, 30 Jun 2025 20:34:42 +0800 Subject: [PATCH 01/16] Fixed #688 - Added auto restart after config change in static web server --- src/mod/webserv/handler.go | 17 +++++++++++++++++ src/mod/webserv/webserv.go | 21 +++++++++++++++++++++ 2 files changed, 38 insertions(+) diff --git a/src/mod/webserv/handler.go b/src/mod/webserv/handler.go index e040b1f..d8ac735 100644 --- a/src/mod/webserv/handler.go +++ b/src/mod/webserv/handler.go @@ -69,6 +69,12 @@ func (ws *WebServer) HandlePortChange(w http.ResponseWriter, r *http.Request) { return } + // Check if newPort is a valid TCP port number (1-65535) + if newPort < 1 || newPort > 65535 { + utils.SendErrorResponse(w, "invalid port number given") + return + } + err = ws.ChangePort(strconv.Itoa(newPort)) if err != nil { utils.SendErrorResponse(w, err.Error()) @@ -106,6 +112,17 @@ func (ws *WebServer) SetDisableListenToAllInterface(w http.ResponseWriter, r *ht utils.SendErrorResponse(w, "unable to save setting") return } + + // Update the option in the web server instance ws.option.DisableListenToAllInterface = disableListen + + // If the server is running and the setting is changed, we need to restart the server + if ws.IsRunning() { + err = ws.Restart() + if err != nil { + utils.SendErrorResponse(w, "unable to restart web server: "+err.Error()) + return + } + } utils.SendOK(w) } diff --git a/src/mod/webserv/webserv.go b/src/mod/webserv/webserv.go index 85214e4..44830ec 100644 --- a/src/mod/webserv/webserv.go +++ b/src/mod/webserv/webserv.go @@ -210,6 +210,27 @@ func (ws *WebServer) Stop() error { return nil } +func (ws *WebServer) Restart() error { + if ws.isRunning { + if err := ws.Stop(); err != nil { + return err + } + } + + if err := ws.Start(); err != nil { + return err + } + + ws.option.Logger.PrintAndLog("static-webserv", "Static Web Server restarted. Listening on :"+ws.option.Port, nil) + return nil +} + +func (ws *WebServer) IsRunning() bool { + ws.mu.Lock() + defer ws.mu.Unlock() + return ws.isRunning +} + // UpdateDirectoryListing enables or disables directory listing. func (ws *WebServer) UpdateDirectoryListing(enable bool) { ws.option.EnableDirectoryListing = enable From b59ac47c8c699d85c5413dfa4cf2c7200e0c6b3e Mon Sep 17 00:00:00 2001 From: Jemmy Date: Wed, 2 Jul 2025 16:50:14 +0800 Subject: [PATCH 02/16] Added Proxy Protocol V1 function. - Added useProxyProtocol in ProxyRelayConfig - Added writeProxyProtocolHeaderV1 function --- src/mod/streamproxy/handler.go | 15 +++++++------ src/mod/streamproxy/streamproxy.go | 15 +++++++------ src/mod/streamproxy/tcpprox.go | 34 +++++++++++++++++++++++++++++- 3 files changed, 51 insertions(+), 13 deletions(-) diff --git a/src/mod/streamproxy/handler.go b/src/mod/streamproxy/handler.go index bc78148..1d2aaf9 100644 --- a/src/mod/streamproxy/handler.go +++ b/src/mod/streamproxy/handler.go @@ -47,15 +47,18 @@ func (m *Manager) HandleAddProxyConfig(w http.ResponseWriter, r *http.Request) { useTCP, _ := utils.PostBool(r, "useTCP") useUDP, _ := utils.PostBool(r, "useUDP") + // useProxyProtocol, _ := utils.PostBool(r, "useProxyProtocol") + useProxyProtocol := true //Create the target config newConfigUUID := m.NewConfig(&ProxyRelayOptions{ - Name: name, - ListeningAddr: strings.TrimSpace(listenAddr), - ProxyAddr: strings.TrimSpace(proxyAddr), - Timeout: timeout, - UseTCP: useTCP, - UseUDP: useUDP, + Name: name, + ListeningAddr: strings.TrimSpace(listenAddr), + ProxyAddr: strings.TrimSpace(proxyAddr), + Timeout: timeout, + UseTCP: useTCP, + UseUDP: useUDP, + UseProxyProtocol: useProxyProtocol, }) js, _ := json.Marshal(newConfigUUID) diff --git a/src/mod/streamproxy/streamproxy.go b/src/mod/streamproxy/streamproxy.go index 36155a3..e3f8057 100644 --- a/src/mod/streamproxy/streamproxy.go +++ b/src/mod/streamproxy/streamproxy.go @@ -24,12 +24,13 @@ import ( */ type ProxyRelayOptions struct { - Name string - ListeningAddr string - ProxyAddr string - Timeout int - UseTCP bool - UseUDP bool + Name string + ListeningAddr string + ProxyAddr string + Timeout int + UseTCP bool + UseUDP bool + UseProxyProtocol bool } type ProxyRelayConfig struct { @@ -41,6 +42,7 @@ type ProxyRelayConfig struct { ProxyTargetAddr string //Proxy target address UseTCP bool //Enable TCP proxy UseUDP bool //Enable UDP proxy + UseProxyProtocol bool //Enable Proxy Protocol Timeout int //Timeout for connection in sec tcpStopChan chan bool //Stop channel for TCP listener udpStopChan chan bool //Stop channel for UDP listener @@ -157,6 +159,7 @@ func (m *Manager) NewConfig(config *ProxyRelayOptions) string { ProxyTargetAddr: config.ProxyAddr, UseTCP: config.UseTCP, UseUDP: config.UseUDP, + UseProxyProtocol: config.UseProxyProtocol, Timeout: config.Timeout, tcpStopChan: nil, udpStopChan: nil, diff --git a/src/mod/streamproxy/tcpprox.go b/src/mod/streamproxy/tcpprox.go index 6fcaed0..439a8ee 100644 --- a/src/mod/streamproxy/tcpprox.go +++ b/src/mod/streamproxy/tcpprox.go @@ -2,6 +2,7 @@ package streamproxy import ( "errors" + "fmt" "io" "log" "net" @@ -43,6 +44,23 @@ func connCopy(conn1 net.Conn, conn2 net.Conn, wg *sync.WaitGroup, accumulator *a wg.Done() } +func writeProxyProtocolHeaderV1(dst net.Conn, src net.Conn) error { + clientAddr, ok1 := src.RemoteAddr().(*net.TCPAddr) + proxyAddr, ok2 := src.LocalAddr().(*net.TCPAddr) + if !ok1 || !ok2 { + return errors.New("invalid TCP address for proxy protocol") + } + + header := fmt.Sprintf("PROXY TCP4 %s %s %d %d\r\n", + clientAddr.IP.String(), + proxyAddr.IP.String(), + clientAddr.Port, + proxyAddr.Port) + + _, err := dst.Write([]byte(header)) + return err +} + func forward(conn1 net.Conn, conn2 net.Conn, aTob *atomic.Int64, bToa *atomic.Int64) { log.Printf("[+] start transmit. [%s],[%s] <-> [%s],[%s] \n", conn1.LocalAddr().String(), conn1.RemoteAddr().String(), conn2.LocalAddr().String(), conn2.RemoteAddr().String()) var wg sync.WaitGroup @@ -127,7 +145,7 @@ func (c *ProxyRelayConfig) Port2host(allowPort string, targetAddress string, sto //Connection error. Retry continue } - + log.Println("[+]", "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA") go func(targetAddress string) { log.Println("[+]", "start connect host:["+targetAddress+"]") target, err := net.Dial("tcp", targetAddress) @@ -140,6 +158,20 @@ func (c *ProxyRelayConfig) Port2host(allowPort string, targetAddress string, sto return } log.Println("[→]", "connect target address ["+targetAddress+"] success.") + + if c.UseProxyProtocol { + log.Println("[+]", "write proxy protocol header to target address ["+targetAddress+"]") + err = writeProxyProtocolHeaderV1(target, conn) + if err != nil { + log.Println("[x]", "Write proxy protocol header faild: ", err) + target.Close() + conn.Close() + log.Println("[←]", "close the connect at local:["+conn.LocalAddr().String()+"] and remote:["+conn.RemoteAddr().String()+"]") + time.Sleep(time.Duration(c.Timeout) * time.Second) + return + } + } + forward(target, conn, &c.aTobAccumulatedByteTransfer, &c.bToaAccumulatedByteTransfer) }(targetAddress) } From f92ff068f38255150e9a214438029ba51545eed9 Mon Sep 17 00:00:00 2001 From: Jemmy Date: Wed, 2 Jul 2025 17:53:33 +0800 Subject: [PATCH 03/16] Added Proxy Protocol V1 to Stream Proxy UI - Added a checkbox for Proxy Protocol V1. - Modified related Config setting function. --- src/mod/streamproxy/handler.go | 6 +++--- src/mod/streamproxy/streamproxy.go | 5 +++-- src/mod/streamproxy/tcpprox.go | 2 +- src/web/components/streamprox.html | 21 +++++++++++++++++++++ 4 files changed, 28 insertions(+), 6 deletions(-) diff --git a/src/mod/streamproxy/handler.go b/src/mod/streamproxy/handler.go index 1d2aaf9..22d523a 100644 --- a/src/mod/streamproxy/handler.go +++ b/src/mod/streamproxy/handler.go @@ -47,8 +47,7 @@ func (m *Manager) HandleAddProxyConfig(w http.ResponseWriter, r *http.Request) { useTCP, _ := utils.PostBool(r, "useTCP") useUDP, _ := utils.PostBool(r, "useUDP") - // useProxyProtocol, _ := utils.PostBool(r, "useProxyProtocol") - useProxyProtocol := true + useProxyProtocol, _ := utils.PostBool(r, "useProxyProtocol") //Create the target config newConfigUUID := m.NewConfig(&ProxyRelayOptions{ @@ -78,6 +77,7 @@ func (m *Manager) HandleEditProxyConfigs(w http.ResponseWriter, r *http.Request) proxyAddr, _ := utils.PostPara(r, "proxyAddr") useTCP, _ := utils.PostBool(r, "useTCP") useUDP, _ := utils.PostBool(r, "useUDP") + useProxyProtocol, _ := utils.PostBool(r, "useProxyProtocol") newTimeoutStr, _ := utils.PostPara(r, "timeout") newTimeout := -1 @@ -90,7 +90,7 @@ func (m *Manager) HandleEditProxyConfigs(w http.ResponseWriter, r *http.Request) } // Call the EditConfig method to modify the configuration - err = m.EditConfig(configUUID, newName, listenAddr, proxyAddr, useTCP, useUDP, newTimeout) + err = m.EditConfig(configUUID, newName, listenAddr, proxyAddr, useTCP, useUDP, useProxyProtocol, newTimeout) if err != nil { utils.SendErrorResponse(w, err.Error()) return diff --git a/src/mod/streamproxy/streamproxy.go b/src/mod/streamproxy/streamproxy.go index e3f8057..4e3cc90 100644 --- a/src/mod/streamproxy/streamproxy.go +++ b/src/mod/streamproxy/streamproxy.go @@ -42,7 +42,7 @@ type ProxyRelayConfig struct { ProxyTargetAddr string //Proxy target address UseTCP bool //Enable TCP proxy UseUDP bool //Enable UDP proxy - UseProxyProtocol bool //Enable Proxy Protocol + UseProxyProtocol bool //Enable Proxy Protocol Timeout int //Timeout for connection in sec tcpStopChan chan bool //Stop channel for TCP listener udpStopChan chan bool //Stop channel for UDP listener @@ -184,7 +184,7 @@ func (m *Manager) GetConfigByUUID(configUUID string) (*ProxyRelayConfig, error) } // Edit the config based on config UUID, leave empty for unchange fields -func (m *Manager) EditConfig(configUUID string, newName string, newListeningAddr string, newProxyAddr string, useTCP bool, useUDP bool, newTimeout int) error { +func (m *Manager) EditConfig(configUUID string, newName string, newListeningAddr string, newProxyAddr string, useTCP bool, useUDP bool, useProxyProtocol bool, newTimeout int) error { // Find the config with the specified UUID foundConfig, err := m.GetConfigByUUID(configUUID) if err != nil { @@ -204,6 +204,7 @@ func (m *Manager) EditConfig(configUUID string, newName string, newListeningAddr foundConfig.UseTCP = useTCP foundConfig.UseUDP = useUDP + foundConfig.UseProxyProtocol = useProxyProtocol if newTimeout != -1 { if newTimeout < 0 { diff --git a/src/mod/streamproxy/tcpprox.go b/src/mod/streamproxy/tcpprox.go index 439a8ee..f817edf 100644 --- a/src/mod/streamproxy/tcpprox.go +++ b/src/mod/streamproxy/tcpprox.go @@ -145,7 +145,7 @@ func (c *ProxyRelayConfig) Port2host(allowPort string, targetAddress string, sto //Connection error. Retry continue } - log.Println("[+]", "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA") + go func(targetAddress string) { log.Println("[+]", "start connect host:["+targetAddress+"]") target, err := net.Dial("tcp", targetAddress) diff --git a/src/web/components/streamprox.html b/src/web/components/streamprox.html index d30ddbd..f1593d5 100644 --- a/src/web/components/streamprox.html +++ b/src/web/components/streamprox.html @@ -73,6 +73,14 @@ Forward UDP request on this listening socket +
+
+ + +
+
@@ -195,6 +203,10 @@ modeText.push("UDP") } + if (config.UseProxyProtocol){ + modeText.push("ProxyProtocol V1") + } + modeText = modeText.join(" & ") var thisConfig = encodeURIComponent(JSON.stringify(config)); @@ -252,6 +264,14 @@ $(checkboxEle).checkbox("set unchecked"); } return; + }else if (key == "UseProxyProtocol"){ + let checkboxEle = $("#streamProxyForm input[name=useProxyProtocol]").parent(); + if (value === true){ + $(checkboxEle).checkbox("set checked"); + }else{ + $(checkboxEle).checkbox("set unchecked"); + } + return; }else if (key == "ListeningAddress"){ field = $("#streamProxyForm input[name=listenAddr]"); }else if (key == "ProxyTargetAddr"){ @@ -301,6 +321,7 @@ proxyAddr: $("#streamProxyForm input[name=proxyAddr]").val().trim(), useTCP: $("#streamProxyForm input[name=useTCP]")[0].checked , useUDP: $("#streamProxyForm input[name=useUDP]")[0].checked , + useProxyProtocol: $("#streamProxyForm input[name=useProxyProtocol]")[0].checked , timeout: parseInt($("#streamProxyForm input[name=timeout]").val().trim()), }, success: function(response) { From 6c5eba01c2327440e1c0010cb7e4e93b0a1c481c Mon Sep 17 00:00:00 2001 From: Toby Chui Date: Wed, 2 Jul 2025 20:42:14 +0800 Subject: [PATCH 04/16] Update README.md Added more contributors in community maintained section name list --- README.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/README.md b/README.md index 5fee611..5588512 100644 --- a/README.md +++ b/README.md @@ -200,6 +200,10 @@ Some section of Zoraxy are contributed by our amazing community and if you have - Docker Container List by [@eyerrock](https://github.com/eyerrock) +- Stream Proxy [@jemmy1794](https://github.com/jemmy1794) + +- Change Log [@Morethanevil](https://github.com/Morethanevil) + ### Looking for Maintainer - ACME DNS Challenge Module From 2d611a559a344020f1a131d1075c16bbdc4aca0f Mon Sep 17 00:00:00 2001 From: Toby Chui Date: Wed, 2 Jul 2025 21:03:57 +0800 Subject: [PATCH 05/16] Optimized structure for stream proxy - Separated instance and config for stream proxy --- src/mod/streamproxy/handler.go | 14 +- src/mod/streamproxy/instances.go | 97 ++++++++++++++ src/mod/streamproxy/streamproxy.go | 169 +++++++----------------- src/mod/streamproxy/streamproxy_test.go | 2 +- src/mod/streamproxy/tcpprox.go | 4 +- src/mod/streamproxy/udpprox.go | 6 +- 6 files changed, 164 insertions(+), 128 deletions(-) create mode 100644 src/mod/streamproxy/instances.go diff --git a/src/mod/streamproxy/handler.go b/src/mod/streamproxy/handler.go index 22d523a..50a7cb4 100644 --- a/src/mod/streamproxy/handler.go +++ b/src/mod/streamproxy/handler.go @@ -89,8 +89,20 @@ func (m *Manager) HandleEditProxyConfigs(w http.ResponseWriter, r *http.Request) } } + // Create a new ProxyRuleUpdateConfig with the extracted parameters + newConfig := &ProxyRuleUpdateConfig{ + InstanceUUID: configUUID, + NewName: newName, + NewListeningAddr: listenAddr, + NewProxyAddr: proxyAddr, + UseTCP: useTCP, + UseUDP: useUDP, + UseProxyProtocol: useProxyProtocol, + NewTimeout: newTimeout, + } + // Call the EditConfig method to modify the configuration - err = m.EditConfig(configUUID, newName, listenAddr, proxyAddr, useTCP, useUDP, useProxyProtocol, newTimeout) + err = m.EditConfig(newConfig) if err != nil { utils.SendErrorResponse(w, err.Error()) return diff --git a/src/mod/streamproxy/instances.go b/src/mod/streamproxy/instances.go new file mode 100644 index 0000000..2447143 --- /dev/null +++ b/src/mod/streamproxy/instances.go @@ -0,0 +1,97 @@ +package streamproxy + +/* + Instances.go + + This file contains the methods to start, stop, and manage the proxy relay instances. + +*/ + +import ( + "errors" + "time" +) + +// Start a proxy if stopped +func (c *ProxyRelayInstance) Start() error { + if c.IsRunning() { + c.Running = true + return errors.New("proxy already running") + } + + // Create a stopChan to control the loop + tcpStopChan := make(chan bool) + udpStopChan := make(chan bool) + + //Start the proxy service + if c.UseUDP { + c.udpStopChan = udpStopChan + go func() { + err := c.ForwardUDP(c.ListeningAddress, c.ProxyTargetAddr, udpStopChan) + if err != nil { + if !c.UseTCP { + c.Running = false + c.udpStopChan = nil + c.parent.SaveConfigToDatabase() + } + c.parent.logf("[proto:udp] Error starting stream proxy "+c.Name+"("+c.UUID+")", err) + } + }() + } + + if c.UseTCP { + c.tcpStopChan = tcpStopChan + go func() { + //Default to transport mode + err := c.Port2host(c.ListeningAddress, c.ProxyTargetAddr, tcpStopChan) + if err != nil { + c.Running = false + c.tcpStopChan = nil + c.parent.SaveConfigToDatabase() + c.parent.logf("[proto:tcp] Error starting stream proxy "+c.Name+"("+c.UUID+")", err) + } + }() + } + + //Successfully spawned off the proxy routine + c.Running = true + c.parent.SaveConfigToDatabase() + return nil +} + +// Return if a proxy config is running +func (c *ProxyRelayInstance) IsRunning() bool { + return c.tcpStopChan != nil || c.udpStopChan != nil +} + +// Restart a proxy config +func (c *ProxyRelayInstance) Restart() { + if c.IsRunning() { + c.Stop() + } + time.Sleep(3000 * time.Millisecond) + c.Start() +} + +// Stop a running proxy if running +func (c *ProxyRelayInstance) Stop() { + c.parent.logf("Stopping Stream Proxy "+c.Name, nil) + + if c.udpStopChan != nil { + c.parent.logf("Stopping UDP for "+c.Name, nil) + c.udpStopChan <- true + c.udpStopChan = nil + } + + if c.tcpStopChan != nil { + c.parent.logf("Stopping TCP for "+c.Name, nil) + c.tcpStopChan <- true + c.tcpStopChan = nil + } + + c.parent.logf("Stopped Stream Proxy "+c.Name, nil) + c.Running = false + + //Update the running status + c.parent.SaveConfigToDatabase() +} diff --git a/src/mod/streamproxy/streamproxy.go b/src/mod/streamproxy/streamproxy.go index 4e3cc90..6804459 100644 --- a/src/mod/streamproxy/streamproxy.go +++ b/src/mod/streamproxy/streamproxy.go @@ -8,7 +8,6 @@ import ( "path/filepath" "sync" "sync/atomic" - "time" "github.com/google/uuid" "imuslab.com/zoraxy/mod/info/logger" @@ -33,17 +32,32 @@ type ProxyRelayOptions struct { UseProxyProtocol bool } -type ProxyRelayConfig struct { - UUID string //A UUIDv4 representing this config - Name string //Name of the config - Running bool //Status, read only - AutoStart bool //If the service suppose to started automatically - ListeningAddress string //Listening Address, usually 127.0.0.1:port - ProxyTargetAddr string //Proxy target address - UseTCP bool //Enable TCP proxy - UseUDP bool //Enable UDP proxy - UseProxyProtocol bool //Enable Proxy Protocol - Timeout int //Timeout for connection in sec +// ProxyRuleUpdateConfig is used to update the proxy rule config +type ProxyRuleUpdateConfig struct { + InstanceUUID string //The target instance UUID to update + NewName string //New name for the instance, leave empty for no change + NewListeningAddr string //New listening address, leave empty for no change + NewProxyAddr string //New proxy target address, leave empty for no change + UseTCP bool //Enable TCP proxy, default to false + UseUDP bool //Enable UDP proxy, default to false + UseProxyProtocol bool //Enable Proxy Protocol, default to false + NewTimeout int //New timeout for the connection, leave -1 for no change +} + +type ProxyRelayInstance struct { + /* Runtime Config */ + UUID string //A UUIDv4 representing this config + Name string //Name of the config + Running bool //Status, read only + AutoStart bool //If the service suppose to started automatically + ListeningAddress string //Listening Address, usually 127.0.0.1:port + ProxyTargetAddr string //Proxy target address + UseTCP bool //Enable TCP proxy + UseUDP bool //Enable UDP proxy + UseProxyProtocol bool //Enable Proxy Protocol + Timeout int //Timeout for connection in sec + + /* Internal */ tcpStopChan chan bool //Stop channel for TCP listener udpStopChan chan bool //Stop channel for UDP listener aTobAccumulatedByteTransfer atomic.Int64 //Accumulated byte transfer from A to B @@ -62,13 +76,14 @@ type Options struct { type Manager struct { //Config and stores Options *Options - Configs []*ProxyRelayConfig + Configs []*ProxyRelayInstance //Realtime Statistics Connections int //currently connected connect counts } +// NewStreamProxy creates a new stream proxy manager with the given options func NewStreamProxy(options *Options) (*Manager, error) { if !utils.FileExists(options.ConfigStore) { err := os.MkdirAll(options.ConfigStore, 0775) @@ -78,7 +93,7 @@ func NewStreamProxy(options *Options) (*Manager, error) { } //Load relay configs from db - previousRules := []*ProxyRelayConfig{} + previousRules := []*ProxyRelayInstance{} streamProxyConfigFiles, err := filepath.Glob(options.ConfigStore + "/*.config") if err != nil { return nil, err @@ -91,7 +106,7 @@ func NewStreamProxy(options *Options) (*Manager, error) { options.Logger.PrintAndLog("stream-prox", "Read stream proxy config failed", err) continue } - thisRelayConfig := &ProxyRelayConfig{} + thisRelayConfig := &ProxyRelayInstance{} err = json.Unmarshal(configBytes, thisRelayConfig) if err != nil { options.Logger.PrintAndLog("stream-prox", "Unmarshal stream proxy config failed", err) @@ -144,6 +159,7 @@ func (m *Manager) logf(message string, originalError error) { m.Options.Logger.PrintAndLog("stream-prox", message, originalError) } +// NewConfig creates a new proxy relay config with the given options func (m *Manager) NewConfig(config *ProxyRelayOptions) string { //Generate two zero value for atomic int64 aAcc := atomic.Int64{} @@ -152,7 +168,7 @@ func (m *Manager) NewConfig(config *ProxyRelayOptions) string { bAcc.Store(0) //Generate a new config from options configUUID := uuid.New().String() - thisConfig := ProxyRelayConfig{ + thisConfig := ProxyRelayInstance{ UUID: configUUID, Name: config.Name, ListeningAddress: config.ListeningAddr, @@ -173,7 +189,7 @@ func (m *Manager) NewConfig(config *ProxyRelayOptions) string { return configUUID } -func (m *Manager) GetConfigByUUID(configUUID string) (*ProxyRelayConfig, error) { +func (m *Manager) GetConfigByUUID(configUUID string) (*ProxyRelayInstance, error) { // Find and return the config with the specified UUID for _, config := range m.Configs { if config.UUID == configUUID { @@ -184,33 +200,33 @@ func (m *Manager) GetConfigByUUID(configUUID string) (*ProxyRelayConfig, error) } // Edit the config based on config UUID, leave empty for unchange fields -func (m *Manager) EditConfig(configUUID string, newName string, newListeningAddr string, newProxyAddr string, useTCP bool, useUDP bool, useProxyProtocol bool, newTimeout int) error { +func (m *Manager) EditConfig(newConfig *ProxyRuleUpdateConfig) error { // Find the config with the specified UUID - foundConfig, err := m.GetConfigByUUID(configUUID) + foundConfig, err := m.GetConfigByUUID(newConfig.InstanceUUID) if err != nil { return err } // Validate and update the fields - if newName != "" { - foundConfig.Name = newName + if newConfig.NewName != "" { + foundConfig.Name = newConfig.NewName } - if newListeningAddr != "" { - foundConfig.ListeningAddress = newListeningAddr + if newConfig.NewListeningAddr != "" { + foundConfig.ListeningAddress = newConfig.NewListeningAddr } - if newProxyAddr != "" { - foundConfig.ProxyTargetAddr = newProxyAddr + if newConfig.NewProxyAddr != "" { + foundConfig.ProxyTargetAddr = newConfig.NewProxyAddr } - foundConfig.UseTCP = useTCP - foundConfig.UseUDP = useUDP - foundConfig.UseProxyProtocol = useProxyProtocol + foundConfig.UseTCP = newConfig.UseTCP + foundConfig.UseUDP = newConfig.UseUDP + foundConfig.UseProxyProtocol = newConfig.UseProxyProtocol - if newTimeout != -1 { - if newTimeout < 0 { + if newConfig.NewTimeout != -1 { + if newConfig.NewTimeout < 0 { return errors.New("invalid timeout value given") } - foundConfig.Timeout = newTimeout + foundConfig.Timeout = newConfig.NewTimeout } m.SaveConfigToDatabase() @@ -219,12 +235,11 @@ func (m *Manager) EditConfig(configUUID string, newName string, newListeningAddr if foundConfig.IsRunning() { foundConfig.Restart() } - return nil } +// Remove the config from file by UUID func (m *Manager) RemoveConfig(configUUID string) error { - //Remove the config from file err := os.Remove(filepath.Join(m.Options.ConfigStore, configUUID+".config")) if err != nil { return err @@ -254,91 +269,3 @@ func (m *Manager) SaveConfigToDatabase() { } } } - -/* - Config Functions -*/ - -// Start a proxy if stopped -func (c *ProxyRelayConfig) Start() error { - if c.IsRunning() { - c.Running = true - return errors.New("proxy already running") - } - - // Create a stopChan to control the loop - tcpStopChan := make(chan bool) - udpStopChan := make(chan bool) - - //Start the proxy service - if c.UseUDP { - c.udpStopChan = udpStopChan - go func() { - err := c.ForwardUDP(c.ListeningAddress, c.ProxyTargetAddr, udpStopChan) - if err != nil { - if !c.UseTCP { - c.Running = false - c.udpStopChan = nil - c.parent.SaveConfigToDatabase() - } - c.parent.logf("[proto:udp] Error starting stream proxy "+c.Name+"("+c.UUID+")", err) - } - }() - } - - if c.UseTCP { - c.tcpStopChan = tcpStopChan - go func() { - //Default to transport mode - err := c.Port2host(c.ListeningAddress, c.ProxyTargetAddr, tcpStopChan) - if err != nil { - c.Running = false - c.tcpStopChan = nil - c.parent.SaveConfigToDatabase() - c.parent.logf("[proto:tcp] Error starting stream proxy "+c.Name+"("+c.UUID+")", err) - } - }() - } - - //Successfully spawned off the proxy routine - c.Running = true - c.parent.SaveConfigToDatabase() - return nil -} - -// Return if a proxy config is running -func (c *ProxyRelayConfig) IsRunning() bool { - return c.tcpStopChan != nil || c.udpStopChan != nil -} - -// Restart a proxy config -func (c *ProxyRelayConfig) Restart() { - if c.IsRunning() { - c.Stop() - } - time.Sleep(3000 * time.Millisecond) - c.Start() -} - -// Stop a running proxy if running -func (c *ProxyRelayConfig) Stop() { - c.parent.logf("Stopping Stream Proxy "+c.Name, nil) - - if c.udpStopChan != nil { - c.parent.logf("Stopping UDP for "+c.Name, nil) - c.udpStopChan <- true - c.udpStopChan = nil - } - - if c.tcpStopChan != nil { - c.parent.logf("Stopping TCP for "+c.Name, nil) - c.tcpStopChan <- true - c.tcpStopChan = nil - } - - c.parent.logf("Stopped Stream Proxy "+c.Name, nil) - c.Running = false - - //Update the running status - c.parent.SaveConfigToDatabase() -} diff --git a/src/mod/streamproxy/streamproxy_test.go b/src/mod/streamproxy/streamproxy_test.go index a9ccb04..35c777c 100644 --- a/src/mod/streamproxy/streamproxy_test.go +++ b/src/mod/streamproxy/streamproxy_test.go @@ -12,7 +12,7 @@ func TestPort2Port(t *testing.T) { stopChan := make(chan bool) // Create a ProxyRelayConfig with dummy values - config := &streamproxy.ProxyRelayConfig{ + config := &streamproxy.ProxyRelayInstance{ Timeout: 1, } diff --git a/src/mod/streamproxy/tcpprox.go b/src/mod/streamproxy/tcpprox.go index f817edf..64cf468 100644 --- a/src/mod/streamproxy/tcpprox.go +++ b/src/mod/streamproxy/tcpprox.go @@ -72,7 +72,7 @@ func forward(conn1 net.Conn, conn2 net.Conn, aTob *atomic.Int64, bToa *atomic.In wg.Wait() } -func (c *ProxyRelayConfig) accept(listener net.Listener) (net.Conn, error) { +func (c *ProxyRelayInstance) accept(listener net.Listener) (net.Conn, error) { conn, err := listener.Accept() if err != nil { return nil, err @@ -110,7 +110,7 @@ func startListener(address string) (net.Listener, error) { portA -> server server -> portB */ -func (c *ProxyRelayConfig) Port2host(allowPort string, targetAddress string, stopChan chan bool) error { +func (c *ProxyRelayInstance) Port2host(allowPort string, targetAddress string, stopChan chan bool) error { listenerStartingAddr := allowPort if isValidPort(allowPort) { //number only, e.g. 8080 diff --git a/src/mod/streamproxy/udpprox.go b/src/mod/streamproxy/udpprox.go index 9e78639..405739c 100644 --- a/src/mod/streamproxy/udpprox.go +++ b/src/mod/streamproxy/udpprox.go @@ -53,7 +53,7 @@ func initUDPConnections(listenAddr string, targetAddress string) (*net.UDPConn, } // Go routine which manages connection from server to single client -func (c *ProxyRelayConfig) RunUDPConnectionRelay(conn *udpClientServerConn, lisenter *net.UDPConn) { +func (c *ProxyRelayInstance) RunUDPConnectionRelay(conn *udpClientServerConn, lisenter *net.UDPConn) { var buffer [1500]byte for { // Read from server @@ -74,7 +74,7 @@ func (c *ProxyRelayConfig) RunUDPConnectionRelay(conn *udpClientServerConn, lise } // Close all connections that waiting for read from server -func (c *ProxyRelayConfig) CloseAllUDPConnections() { +func (c *ProxyRelayInstance) CloseAllUDPConnections() { c.udpClientMap.Range(func(clientAddr, clientServerConn interface{}) bool { conn := clientServerConn.(*udpClientServerConn) conn.ServerConn.Close() @@ -82,7 +82,7 @@ func (c *ProxyRelayConfig) CloseAllUDPConnections() { }) } -func (c *ProxyRelayConfig) ForwardUDP(address1, address2 string, stopChan chan bool) error { +func (c *ProxyRelayInstance) ForwardUDP(address1, address2 string, stopChan chan bool) error { //By default the incoming listen Address is int //We need to add the loopback address into it if isValidPort(address1) { From 6b3b89f7bfdc54a7bd47b3041321a26c4bf41c06 Mon Sep 17 00:00:00 2001 From: jemmy1794 Date: Thu, 3 Jul 2025 08:54:11 +0800 Subject: [PATCH 06/16] Add EnableLogging to Stream Proxy for log control - Add `EnableLogging` to control TCP/UDP Connection logs to reduce log latency. - Add `Enable Logging` Option in Stream Proxy rule. - Update Stream Proxy UI. --- src/mod/streamproxy/handler.go | 4 +++ src/mod/streamproxy/instances.go | 13 +++++++++ src/mod/streamproxy/streamproxy.go | 5 ++++ src/mod/streamproxy/tcpprox.go | 46 ++++++++++++++++-------------- src/mod/streamproxy/udpprox.go | 4 +-- src/web/components/streamprox.html | 22 ++++++++++++++ 6 files changed, 70 insertions(+), 24 deletions(-) diff --git a/src/mod/streamproxy/handler.go b/src/mod/streamproxy/handler.go index 50a7cb4..147100e 100644 --- a/src/mod/streamproxy/handler.go +++ b/src/mod/streamproxy/handler.go @@ -48,6 +48,7 @@ func (m *Manager) HandleAddProxyConfig(w http.ResponseWriter, r *http.Request) { useTCP, _ := utils.PostBool(r, "useTCP") useUDP, _ := utils.PostBool(r, "useUDP") useProxyProtocol, _ := utils.PostBool(r, "useProxyProtocol") + enableLogging, _ := utils.PostBool(r, "enableLogging") //Create the target config newConfigUUID := m.NewConfig(&ProxyRelayOptions{ @@ -58,6 +59,7 @@ func (m *Manager) HandleAddProxyConfig(w http.ResponseWriter, r *http.Request) { UseTCP: useTCP, UseUDP: useUDP, UseProxyProtocol: useProxyProtocol, + EnableLogging: enableLogging, }) js, _ := json.Marshal(newConfigUUID) @@ -78,6 +80,7 @@ func (m *Manager) HandleEditProxyConfigs(w http.ResponseWriter, r *http.Request) useTCP, _ := utils.PostBool(r, "useTCP") useUDP, _ := utils.PostBool(r, "useUDP") useProxyProtocol, _ := utils.PostBool(r, "useProxyProtocol") + enableLogging, _ := utils.PostBool(r, "enableLogging") newTimeoutStr, _ := utils.PostPara(r, "timeout") newTimeout := -1 @@ -98,6 +101,7 @@ func (m *Manager) HandleEditProxyConfigs(w http.ResponseWriter, r *http.Request) UseTCP: useTCP, UseUDP: useUDP, UseProxyProtocol: useProxyProtocol, + EnableLogging: enableLogging, NewTimeout: newTimeout, } diff --git a/src/mod/streamproxy/instances.go b/src/mod/streamproxy/instances.go index 2447143..e7c8e49 100644 --- a/src/mod/streamproxy/instances.go +++ b/src/mod/streamproxy/instances.go @@ -9,9 +9,22 @@ package streamproxy import ( "errors" + "log" "time" ) +func (c *ProxyRelayInstance) LogMsg(message string, originalError error) { + if !c.EnableLogging { + return + } + + if originalError != nil { + log.Println(message, "error:", originalError) + } else { + log.Println(message) + } +} + // Start a proxy if stopped func (c *ProxyRelayInstance) Start() error { if c.IsRunning() { diff --git a/src/mod/streamproxy/streamproxy.go b/src/mod/streamproxy/streamproxy.go index 6804459..c473e33 100644 --- a/src/mod/streamproxy/streamproxy.go +++ b/src/mod/streamproxy/streamproxy.go @@ -30,6 +30,7 @@ type ProxyRelayOptions struct { UseTCP bool UseUDP bool UseProxyProtocol bool + EnableLogging bool } // ProxyRuleUpdateConfig is used to update the proxy rule config @@ -41,6 +42,7 @@ type ProxyRuleUpdateConfig struct { UseTCP bool //Enable TCP proxy, default to false UseUDP bool //Enable UDP proxy, default to false UseProxyProtocol bool //Enable Proxy Protocol, default to false + EnableLogging bool //Enable Logging TCP/UDP Message, default to true NewTimeout int //New timeout for the connection, leave -1 for no change } @@ -55,6 +57,7 @@ type ProxyRelayInstance struct { UseTCP bool //Enable TCP proxy UseUDP bool //Enable UDP proxy UseProxyProtocol bool //Enable Proxy Protocol + EnableLogging bool //Enable logging for ProxyInstance Timeout int //Timeout for connection in sec /* Internal */ @@ -176,6 +179,7 @@ func (m *Manager) NewConfig(config *ProxyRelayOptions) string { UseTCP: config.UseTCP, UseUDP: config.UseUDP, UseProxyProtocol: config.UseProxyProtocol, + EnableLogging: config.EnableLogging, Timeout: config.Timeout, tcpStopChan: nil, udpStopChan: nil, @@ -221,6 +225,7 @@ func (m *Manager) EditConfig(newConfig *ProxyRuleUpdateConfig) error { foundConfig.UseTCP = newConfig.UseTCP foundConfig.UseUDP = newConfig.UseUDP foundConfig.UseProxyProtocol = newConfig.UseProxyProtocol + foundConfig.EnableLogging = newConfig.EnableLogging if newConfig.NewTimeout != -1 { if newConfig.NewTimeout < 0 { diff --git a/src/mod/streamproxy/tcpprox.go b/src/mod/streamproxy/tcpprox.go index 64cf468..d16ad8d 100644 --- a/src/mod/streamproxy/tcpprox.go +++ b/src/mod/streamproxy/tcpprox.go @@ -31,16 +31,16 @@ func isValidPort(port string) bool { return true } -func connCopy(conn1 net.Conn, conn2 net.Conn, wg *sync.WaitGroup, accumulator *atomic.Int64) { +func (c *ProxyRelayInstance) connCopy(conn1 net.Conn, conn2 net.Conn, wg *sync.WaitGroup, accumulator *atomic.Int64) { n, err := io.Copy(conn1, conn2) if err != nil { return } accumulator.Add(n) //Add to accumulator conn1.Close() - log.Println("[←]", "close the connect at local:["+conn1.LocalAddr().String()+"] and remote:["+conn1.RemoteAddr().String()+"]") + c.LogMsg("[←] close the connect at local:["+conn1.LocalAddr().String()+"] and remote:["+conn1.RemoteAddr().String()+"]", nil) //conn2.Close() - //log.Println("[←]", "close the connect at local:["+conn2.LocalAddr().String()+"] and remote:["+conn2.RemoteAddr().String()+"]") + //c.LogMsg("[←] close the connect at local:["+conn2.LocalAddr().String()+"] and remote:["+conn2.RemoteAddr().String()+"]", nil) wg.Done() } @@ -61,14 +61,16 @@ func writeProxyProtocolHeaderV1(dst net.Conn, src net.Conn) error { return err } -func forward(conn1 net.Conn, conn2 net.Conn, aTob *atomic.Int64, bToa *atomic.Int64) { - log.Printf("[+] start transmit. [%s],[%s] <-> [%s],[%s] \n", conn1.LocalAddr().String(), conn1.RemoteAddr().String(), conn2.LocalAddr().String(), conn2.RemoteAddr().String()) +func (c *ProxyRelayInstance) forward(conn1 net.Conn, conn2 net.Conn, aTob *atomic.Int64, bToa *atomic.Int64) { + msg := fmt.Sprintf("[+] start transmit. [%s],[%s] <-> [%s],[%s]", + conn1.LocalAddr().String(), conn1.RemoteAddr().String(), + conn2.LocalAddr().String(), conn2.RemoteAddr().String()) + c.LogMsg(msg, nil) + var wg sync.WaitGroup - // wait tow goroutines wg.Add(2) - go connCopy(conn1, conn2, &wg, aTob) - go connCopy(conn2, conn1, &wg, bToa) - //blocking when the wg is locked + go c.connCopy(conn1, conn2, &wg, aTob) + go c.connCopy(conn2, conn1, &wg, bToa) wg.Wait() } @@ -78,18 +80,18 @@ func (c *ProxyRelayInstance) accept(listener net.Listener) (net.Conn, error) { return nil, err } - //Check if connection in blacklist or whitelist + // Check if connection in blacklist or whitelist if addr, ok := conn.RemoteAddr().(*net.TCPAddr); ok { if !c.parent.Options.AccessControlHandler(conn) { time.Sleep(300 * time.Millisecond) conn.Close() - log.Println("[x]", "Connection from "+addr.IP.String()+" rejected by access control policy") + c.LogMsg("[x] Connection from "+addr.IP.String()+" rejected by access control policy", nil) return nil, errors.New("Connection from " + addr.IP.String() + " rejected by access control policy") } } - log.Println("[√]", "accept a new client. remote address:["+conn.RemoteAddr().String()+"], local address:["+conn.LocalAddr().String()+"]") - return conn, err + c.LogMsg("[√] accept a new client. remote address:["+conn.RemoteAddr().String()+"], local address:["+conn.LocalAddr().String()+"]", nil) + return conn, nil } func startListener(address string) (net.Listener, error) { @@ -130,7 +132,7 @@ func (c *ProxyRelayInstance) Port2host(allowPort string, targetAddress string, s //Start stop handler go func() { <-stopChan - log.Println("[x]", "Received stop signal. Exiting Port to Host forwarder") + c.LogMsg("[x] Received stop signal. Exiting Port to Host forwarder", nil) server.Close() }() @@ -147,32 +149,32 @@ func (c *ProxyRelayInstance) Port2host(allowPort string, targetAddress string, s } go func(targetAddress string) { - log.Println("[+]", "start connect host:["+targetAddress+"]") + c.LogMsg("[+] start connect host:["+targetAddress+"]", nil) target, err := net.Dial("tcp", targetAddress) if err != nil { // temporarily unavailable, don't use fatal. - log.Println("[x]", "connect target address ["+targetAddress+"] faild. retry in ", c.Timeout, "seconds. ") + c.LogMsg("[x] connect target address ["+targetAddress+"] failed. retry in "+strconv.Itoa(c.Timeout)+" seconds.", nil) conn.Close() - log.Println("[←]", "close the connect at local:["+conn.LocalAddr().String()+"] and remote:["+conn.RemoteAddr().String()+"]") + c.LogMsg("[←] close the connect at local:["+conn.LocalAddr().String()+"] and remote:["+conn.RemoteAddr().String()+"]", nil) time.Sleep(time.Duration(c.Timeout) * time.Second) return } - log.Println("[→]", "connect target address ["+targetAddress+"] success.") + c.LogMsg("[→] connect target address ["+targetAddress+"] success.", nil) if c.UseProxyProtocol { - log.Println("[+]", "write proxy protocol header to target address ["+targetAddress+"]") + c.LogMsg("[+] write proxy protocol header to target address ["+targetAddress+"]", nil) err = writeProxyProtocolHeaderV1(target, conn) if err != nil { - log.Println("[x]", "Write proxy protocol header faild: ", err) + c.LogMsg("[x] Write proxy protocol header failed: "+err.Error(), nil) target.Close() conn.Close() - log.Println("[←]", "close the connect at local:["+conn.LocalAddr().String()+"] and remote:["+conn.RemoteAddr().String()+"]") + c.LogMsg("[←] close the connect at local:["+conn.LocalAddr().String()+"] and remote:["+conn.RemoteAddr().String()+"]", nil) time.Sleep(time.Duration(c.Timeout) * time.Second) return } } - forward(target, conn, &c.aTobAccumulatedByteTransfer, &c.bToaAccumulatedByteTransfer) + c.forward(target, conn, &c.aTobAccumulatedByteTransfer, &c.bToaAccumulatedByteTransfer) }(targetAddress) } } diff --git a/src/mod/streamproxy/udpprox.go b/src/mod/streamproxy/udpprox.go index 405739c..dc761df 100644 --- a/src/mod/streamproxy/udpprox.go +++ b/src/mod/streamproxy/udpprox.go @@ -138,12 +138,12 @@ func (c *ProxyRelayInstance) ForwardUDP(address1, address2 string, stopChan chan continue } c.udpClientMap.Store(saddr, conn) - log.Println("[UDP] Created new connection for client " + saddr) + c.LogMsg("[UDP] Created new connection for client "+saddr, nil) // Fire up routine to manage new connection go c.RunUDPConnectionRelay(conn, lisener) } else { - log.Println("[UDP] Found connection for client " + saddr) + c.LogMsg("[UDP] Found connection for client "+saddr, nil) conn = rawConn.(*udpClientServerConn) } diff --git a/src/web/components/streamprox.html b/src/web/components/streamprox.html index f1593d5..c8883bd 100644 --- a/src/web/components/streamprox.html +++ b/src/web/components/streamprox.html @@ -16,6 +16,7 @@ Target Address Mode Timeout (s) + Enable Logging Actions @@ -81,6 +82,14 @@ +
+
+ + +
+
@@ -219,6 +228,10 @@ row.append($('').text(config.ProxyTargetAddr)); row.append($('').text(modeText)); row.append($('').text(config.Timeout)); + row.append($('').html(config.EnableLogging ? + '' : + '' + )); row.append($('').html(` ${startButton} @@ -272,6 +285,14 @@ $(checkboxEle).checkbox("set unchecked"); } return; + }else if (key == "EnableLogging"){ + let checkboxEle = $("#streamProxyForm input[name=enableLogging]").parent(); + if (value === true){ + $(checkboxEle).checkbox("set checked"); + }else{ + $(checkboxEle).checkbox("set unchecked"); + } + return; }else if (key == "ListeningAddress"){ field = $("#streamProxyForm input[name=listenAddr]"); }else if (key == "ProxyTargetAddr"){ @@ -322,6 +343,7 @@ useTCP: $("#streamProxyForm input[name=useTCP]")[0].checked , useUDP: $("#streamProxyForm input[name=useUDP]")[0].checked , useProxyProtocol: $("#streamProxyForm input[name=useProxyProtocol]")[0].checked , + enableLogging: $("#streamProxyForm input[name=enableLogging]")[0].checked , timeout: parseInt($("#streamProxyForm input[name=timeout]").val().trim()), }, success: function(response) { From e225407b038aaa03a5f9d00feba3d83cbccd62c7 Mon Sep 17 00:00:00 2001 From: Borys Anikiyenko <7brend7@gmail.com> Date: Sun, 6 Jul 2025 22:25:17 +0300 Subject: [PATCH 07/16] fix empty sso advanced parameters --- src/mod/auth/sso/forward/forward.go | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/src/mod/auth/sso/forward/forward.go b/src/mod/auth/sso/forward/forward.go index 44a2abb..4c59d63 100644 --- a/src/mod/auth/sso/forward/forward.go +++ b/src/mod/auth/sso/forward/forward.go @@ -58,11 +58,20 @@ func NewAuthRouter(options *AuthRouterOptions) *AuthRouter { options.Database.Read(DatabaseTable, DatabaseKeyRequestIncludedCookies, &requestIncludedCookies) options.Database.Read(DatabaseTable, DatabaseKeyRequestExcludedCookies, &requestExcludedCookies) - options.ResponseHeaders = strings.Split(responseHeaders, ",") - options.ResponseClientHeaders = strings.Split(responseClientHeaders, ",") - options.RequestHeaders = strings.Split(requestHeaders, ",") - options.RequestIncludedCookies = strings.Split(requestIncludedCookies, ",") - options.RequestExcludedCookies = strings.Split(requestExcludedCookies, ",") + // Helper function to clean empty strings from split results + cleanSplit := func(s string) []string { + parts := strings.Split(s, ",") + if len(parts) == 1 && parts[0] == "" { + return []string{} + } + return parts + } + + options.ResponseHeaders = cleanSplit(responseHeaders) + options.ResponseClientHeaders = cleanSplit(responseClientHeaders) + options.RequestHeaders = cleanSplit(requestHeaders) + options.RequestIncludedCookies = cleanSplit(requestIncludedCookies) + options.RequestExcludedCookies = cleanSplit(requestExcludedCookies) return &AuthRouter{ client: &http.Client{ From e53724d6e50362363f5843a8ec4d6d32acf1c095 Mon Sep 17 00:00:00 2001 From: Borys Anikiyenko <7brend7@gmail.com> Date: Sun, 6 Jul 2025 22:40:10 +0300 Subject: [PATCH 08/16] sort list of loaded certificates by ExpireDate --- src/cert.go | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/cert.go b/src/cert.go index a887151..1fc26ef 100644 --- a/src/cert.go +++ b/src/cert.go @@ -9,6 +9,7 @@ import ( "net/http" "os" "path/filepath" + "sort" "strings" "time" @@ -101,6 +102,13 @@ func handleListCertificate(w http.ResponseWriter, r *http.Request) { results = append(results, &thisCertInfo) } + // convert ExpireDate to date object and sort asc + sort.Slice(results, func(i, j int) bool { + date1, _ := time.Parse("2006-01-02 15:04:05", results[i].ExpireDate) + date2, _ := time.Parse("2006-01-02 15:04:05", results[j].ExpireDate) + return date1.Before(date2) + }) + js, _ := json.Marshal(results) w.Header().Set("Content-Type", "application/json") w.Write(js) From c091b9d1cabec334b1d054820827739861b1f920 Mon Sep 17 00:00:00 2001 From: Toby Chui Date: Mon, 7 Jul 2025 13:25:07 +0800 Subject: [PATCH 09/16] Added content security policy structure - Added content security policy header generators structure (current not in used) --- .../permissionpolicy/contentsecuritypolicy.go | 123 ++++++++++++++++++ 1 file changed, 123 insertions(+) create mode 100644 src/mod/dynamicproxy/permissionpolicy/contentsecuritypolicy.go diff --git a/src/mod/dynamicproxy/permissionpolicy/contentsecuritypolicy.go b/src/mod/dynamicproxy/permissionpolicy/contentsecuritypolicy.go new file mode 100644 index 0000000..1de8e70 --- /dev/null +++ b/src/mod/dynamicproxy/permissionpolicy/contentsecuritypolicy.go @@ -0,0 +1,123 @@ +package permissionpolicy + +import ( + "net/http" + "strings" +) + +/* + Content Security Policy + + This is a content security policy header modifier that changes + the request content security policy fields + + author: tobychui + + //TODO: intergrate this with the dynamic proxy module +*/ + +type ContentSecurityPolicy struct { + DefaultSrc []string `json:"default_src"` + ScriptSrc []string `json:"script_src"` + StyleSrc []string `json:"style_src"` + ImgSrc []string `json:"img_src"` + ConnectSrc []string `json:"connect_src"` + FontSrc []string `json:"font_src"` + ObjectSrc []string `json:"object_src"` + MediaSrc []string `json:"media_src"` + FrameSrc []string `json:"frame_src"` + WorkerSrc []string `json:"worker_src"` + ChildSrc []string `json:"child_src"` + ManifestSrc []string `json:"manifest_src"` + PrefetchSrc []string `json:"prefetch_src"` + FormAction []string `json:"form_action"` + FrameAncestors []string `json:"frame_ancestors"` + BaseURI []string `json:"base_uri"` + Sandbox []string `json:"sandbox"` + ReportURI []string `json:"report_uri"` + ReportTo []string `json:"report_to"` + UpgradeInsecureRequests bool `json:"upgrade_insecure_requests"` + BlockAllMixedContent bool `json:"block_all_mixed_content"` +} + +// GetDefaultContentSecurityPolicy returns a ContentSecurityPolicy struct with default permissive settings +func GetDefaultContentSecurityPolicy() *ContentSecurityPolicy { + return &ContentSecurityPolicy{ + DefaultSrc: []string{"*"}, + ScriptSrc: []string{"*"}, + StyleSrc: []string{"*"}, + ImgSrc: []string{"*"}, + ConnectSrc: []string{"*"}, + FontSrc: []string{"*"}, + ObjectSrc: []string{"*"}, + MediaSrc: []string{"*"}, + FrameSrc: []string{"*"}, + WorkerSrc: []string{"*"}, + ChildSrc: []string{"*"}, + ManifestSrc: []string{"*"}, + PrefetchSrc: []string{"*"}, + FormAction: []string{"*"}, + FrameAncestors: []string{"*"}, + BaseURI: []string{"*"}, + Sandbox: []string{}, + ReportURI: []string{}, + ReportTo: []string{}, + UpgradeInsecureRequests: false, + BlockAllMixedContent: false, + } +} + +// ToHeader converts a ContentSecurityPolicy struct into a CSP header key-value pair +func (csp *ContentSecurityPolicy) ToHeader() []string { + directives := []string{} + + addDirective := func(name string, sources []string) { + if len(sources) > 0 { + directives = append(directives, name+" "+strings.Join(sources, " ")) + } + } + + addDirective("default-src", csp.DefaultSrc) + addDirective("script-src", csp.ScriptSrc) + addDirective("style-src", csp.StyleSrc) + addDirective("img-src", csp.ImgSrc) + addDirective("connect-src", csp.ConnectSrc) + addDirective("font-src", csp.FontSrc) + addDirective("object-src", csp.ObjectSrc) + addDirective("media-src", csp.MediaSrc) + addDirective("frame-src", csp.FrameSrc) + addDirective("worker-src", csp.WorkerSrc) + addDirective("child-src", csp.ChildSrc) + addDirective("manifest-src", csp.ManifestSrc) + addDirective("prefetch-src", csp.PrefetchSrc) + addDirective("form-action", csp.FormAction) + addDirective("frame-ancestors", csp.FrameAncestors) + addDirective("base-uri", csp.BaseURI) + if len(csp.Sandbox) > 0 { + directives = append(directives, "sandbox "+strings.Join(csp.Sandbox, " ")) + } + if len(csp.ReportURI) > 0 { + addDirective("report-uri", csp.ReportURI) + } + if len(csp.ReportTo) > 0 { + addDirective("report-to", csp.ReportTo) + } + if csp.UpgradeInsecureRequests { + directives = append(directives, "upgrade-insecure-requests") + } + if csp.BlockAllMixedContent { + directives = append(directives, "block-all-mixed-content") + } + + headerValue := strings.Join(directives, "; ") + return []string{"Content-Security-Policy", headerValue} +} + +// InjectContentSecurityPolicyHeader injects the CSP header into the response +func InjectContentSecurityPolicyHeader(w http.ResponseWriter, csp *ContentSecurityPolicy) { + if csp == nil || w.Header().Get("Content-Security-Policy") != "" { + return + } + headerKV := csp.ToHeader() + w.Header().Set(headerKV[0], headerKV[1]) +} From 45506c8772a48346ff28175386179e3e2c696b10 Mon Sep 17 00:00:00 2001 From: Toby Chui Date: Mon, 7 Jul 2025 14:18:10 +0800 Subject: [PATCH 10/16] Added cert resolve viewer - Added certificate resolve viewer on HTTP proxy rule editor - Exposed SNI options (wip) - Code optimize --- src/api.go | 2 + src/cert.go | 81 ++ src/config.go | 13 +- src/mod/dynamicproxy/router.go | 47 + src/mod/dynamicproxy/typedef.go | 3 +- src/mod/tlscert/tlscert.go | 105 +- src/reverseproxy.go | 68 +- src/web/components/httprp.html | 107 +- src/web/components/httprp_new.html | 1455 ---------------------------- 9 files changed, 393 insertions(+), 1488 deletions(-) delete mode 100644 src/web/components/httprp_new.html diff --git a/src/api.go b/src/api.go index 203fa01..59c5df4 100644 --- a/src/api.go +++ b/src/api.go @@ -34,6 +34,7 @@ func RegisterHTTPProxyAPIs(authRouter *auth.RouterDef) { authRouter.HandleFunc("/api/proxy/detail", ReverseProxyListDetail) authRouter.HandleFunc("/api/proxy/edit", ReverseProxyHandleEditEndpoint) authRouter.HandleFunc("/api/proxy/setAlias", ReverseProxyHandleAlias) + authRouter.HandleFunc("/api/proxy/setTlsConfig", ReverseProxyHandleSetTlsConfig) authRouter.HandleFunc("/api/proxy/setHostname", ReverseProxyHandleSetHostname) authRouter.HandleFunc("/api/proxy/del", DeleteProxyEndpoint) authRouter.HandleFunc("/api/proxy/updateCredentials", UpdateProxyBasicAuthCredentials) @@ -79,6 +80,7 @@ func RegisterTLSAPIs(authRouter *auth.RouterDef) { authRouter.HandleFunc("/api/cert/listdomains", handleListDomains) authRouter.HandleFunc("/api/cert/checkDefault", handleDefaultCertCheck) authRouter.HandleFunc("/api/cert/delete", handleCertRemove) + authRouter.HandleFunc("/api/cert/resolve", handleCertTryResolve) } // Register the APIs for Authentication handlers like Forward Auth and OAUTH2 diff --git a/src/cert.go b/src/cert.go index 1fc26ef..8d2a4ed 100644 --- a/src/cert.go +++ b/src/cert.go @@ -360,6 +360,87 @@ func handleCertUpload(w http.ResponseWriter, r *http.Request) { fmt.Fprintln(w, "File upload successful!") } +func handleCertTryResolve(w http.ResponseWriter, r *http.Request) { + // get the domain + domain, err := utils.GetPara(r, "domain") + if err != nil { + utils.SendErrorResponse(w, "invalid domain given") + return + } + + // get the proxy rule, the pass in domain value must be root or matching domain + proxyRule, err := dynamicProxyRouter.GetProxyEndpointById(domain, false) + if err != nil { + //Try to resolve the domain via alias + proxyRule, err = dynamicProxyRouter.GetProxyEndpointByAlias(domain) + if err != nil { + //No matching rule found + utils.SendErrorResponse(w, "proxy rule not found for domain: "+domain) + return + } + } + + // list all the alias domains for this rule + allDomains := []string{proxyRule.RootOrMatchingDomain} + aliasDomains := []string{} + for _, alias := range proxyRule.MatchingDomainAlias { + if alias != "" { + aliasDomains = append(aliasDomains, alias) + allDomains = append(allDomains, alias) + } + } + + // Try to resolve the domain + domainKeyPairs := map[string]string{} + for _, thisDomain := range allDomains { + pubkey, prikey, err := tlsCertManager.GetCertificateByHostname(thisDomain) + if err != nil { + utils.SendErrorResponse(w, err.Error()) + return + } + + //Make sure pubkey and private key are not empty + if pubkey == "" || prikey == "" { + domainKeyPairs[thisDomain] = "" + } else { + //Store the key pair + keyname := strings.TrimSuffix(filepath.Base(pubkey), filepath.Ext(pubkey)) + if keyname == "localhost" { + //Internal certs like localhost should not be used + //report as "fallback" key + keyname = "fallback certificate" + } + domainKeyPairs[thisDomain] = keyname + } + + } + + //A domain must be UseDNSValidation if it is a wildcard domain or its alias is a wildcard domain + useDNSValidation := strings.HasPrefix(proxyRule.RootOrMatchingDomain, "*") + for _, alias := range aliasDomains { + if strings.HasPrefix(alias, "*") || strings.HasPrefix(domain, "*") { + useDNSValidation = true + } + } + + type CertInfo struct { + Domain string `json:"domain"` + AliasDomains []string `json:"alias_domains"` + DomainKeyPair map[string]string `json:"domain_key_pair"` + UseDNSValidation bool `json:"use_dns_validation"` + } + + result := &CertInfo{ + Domain: proxyRule.RootOrMatchingDomain, + AliasDomains: aliasDomains, + DomainKeyPair: domainKeyPairs, + UseDNSValidation: useDNSValidation, + } + + js, _ := json.Marshal(result) + utils.SendJSONResponse(w, string(js)) +} + // Handle cert remove func handleCertRemove(w http.ResponseWriter, r *http.Request) { domain, err := utils.PostPara(r, "domain") diff --git a/src/config.go b/src/config.go index 62468f8..e3257b3 100644 --- a/src/config.go +++ b/src/config.go @@ -15,6 +15,7 @@ import ( "imuslab.com/zoraxy/mod/dynamicproxy" "imuslab.com/zoraxy/mod/dynamicproxy/loadbalance" + "imuslab.com/zoraxy/mod/tlscert" "imuslab.com/zoraxy/mod/utils" ) @@ -59,12 +60,18 @@ func LoadReverseProxyConfig(configFilepath string) error { thisConfigEndpoint.Tags = []string{} } + //Make sure the TLS options are not nil + if thisConfigEndpoint.TlsOptions == nil { + thisConfigEndpoint.TlsOptions = tlscert.GetDefaultHostSpecificTlsBehavior() + } + //Matching domain not set. Assume root if thisConfigEndpoint.RootOrMatchingDomain == "" { thisConfigEndpoint.RootOrMatchingDomain = "/" } - if thisConfigEndpoint.ProxyType == dynamicproxy.ProxyTypeRoot { + switch thisConfigEndpoint.ProxyType { + case dynamicproxy.ProxyTypeRoot: //This is a root config file rootProxyEndpoint, err := dynamicProxyRouter.PrepareProxyRoute(&thisConfigEndpoint) if err != nil { @@ -73,7 +80,7 @@ func LoadReverseProxyConfig(configFilepath string) error { dynamicProxyRouter.SetProxyRouteAsRoot(rootProxyEndpoint) - } else if thisConfigEndpoint.ProxyType == dynamicproxy.ProxyTypeHost { + case dynamicproxy.ProxyTypeHost: //This is a host config file readyProxyEndpoint, err := dynamicProxyRouter.PrepareProxyRoute(&thisConfigEndpoint) if err != nil { @@ -81,7 +88,7 @@ func LoadReverseProxyConfig(configFilepath string) error { } dynamicProxyRouter.AddProxyRouteToRuntime(readyProxyEndpoint) - } else { + default: return errors.New("not supported proxy type") } diff --git a/src/mod/dynamicproxy/router.go b/src/mod/dynamicproxy/router.go index 62807bc..2e484d2 100644 --- a/src/mod/dynamicproxy/router.go +++ b/src/mod/dynamicproxy/router.go @@ -8,6 +8,7 @@ import ( "time" "imuslab.com/zoraxy/mod/dynamicproxy/dpcore" + "imuslab.com/zoraxy/mod/utils" ) /* @@ -105,3 +106,49 @@ func (router *Router) RemoveProxyEndpointByRootname(rootnameOrMatchingDomain str return targetEpt.Remove() } + +// GetProxyEndpointById retrieves a proxy endpoint by its ID from the Router's ProxyEndpoints map. +// It returns the ProxyEndpoint if found, or an error if not found. +func (h *Router) GetProxyEndpointById(searchingDomain string, includeAlias bool) (*ProxyEndpoint, error) { + var found *ProxyEndpoint + h.ProxyEndpoints.Range(func(key, value interface{}) bool { + proxy, ok := value.(*ProxyEndpoint) + if ok && (proxy.RootOrMatchingDomain == searchingDomain || (includeAlias && utils.StringInArray(proxy.MatchingDomainAlias, searchingDomain))) { + found = proxy + return false // stop iteration + } + return true // continue iteration + }) + if found != nil { + return found, nil + } + return nil, errors.New("proxy rule with given id not found") +} + +func (h *Router) GetProxyEndpointByAlias(alias string) (*ProxyEndpoint, error) { + var found *ProxyEndpoint + h.ProxyEndpoints.Range(func(key, value interface{}) bool { + proxy, ok := value.(*ProxyEndpoint) + if !ok { + return true + } + //Also check for wildcard aliases that matches the alias + for _, thisAlias := range proxy.MatchingDomainAlias { + if ok && thisAlias == alias { + found = proxy + return false // stop iteration + } else if ok && strings.HasPrefix(thisAlias, "*") { + //Check if the alias matches a wildcard alias + if strings.HasSuffix(alias, thisAlias[1:]) { + found = proxy + return false // stop iteration + } + } + } + return true // continue iteration + }) + if found != nil { + return found, nil + } + return nil, errors.New("proxy rule with given alias not found") +} diff --git a/src/mod/dynamicproxy/typedef.go b/src/mod/dynamicproxy/typedef.go index 7a5b334..9f6d63f 100644 --- a/src/mod/dynamicproxy/typedef.go +++ b/src/mod/dynamicproxy/typedef.go @@ -175,7 +175,8 @@ type ProxyEndpoint struct { 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) + BypassGlobalTLS bool //Bypass global TLS setting options if TLS Listener enabled (parent.tlsListener != nil) + TlsOptions *tlscert.HostSpecificTlsBehavior //TLS options for this endpoint, if nil, use global TLS options //Virtual Directories VirtualDirectories []*VirtualDirectoryEndpoint diff --git a/src/mod/tlscert/tlscert.go b/src/mod/tlscert/tlscert.go index 9f1269a..aae4401 100644 --- a/src/mod/tlscert/tlscert.go +++ b/src/mod/tlscert/tlscert.go @@ -20,11 +20,21 @@ type CertCache struct { PriKey string } +type HostSpecificTlsBehavior struct { + DisableSNI bool //If SNI is enabled for this server name + DisableLegacyCertificateMatching bool //If legacy certificate matching is disabled for this server name + EnableAutoHTTPS bool //If auto HTTPS is enabled for this server name + PreferredCertificate string //Preferred certificate for this server name, if empty, use the first matching certificate +} + type Manager struct { CertStore string //Path where all the certs are stored LoadedCerts []*CertCache //A list of loaded certs Logger *logger.Logger //System wide logger for debug mesage - verbal bool + + /* External handlers */ + hostSpecificTlsBehavior func(serverName string) (*HostSpecificTlsBehavior, error) // Function to get host specific TLS behavior, if nil, use global TLS options + verbal bool } //go:embed localhost.pem localhost.key @@ -50,10 +60,11 @@ func NewManager(certStore string, verbal bool, logger *logger.Logger) (*Manager, } thisManager := Manager{ - CertStore: certStore, - LoadedCerts: []*CertCache{}, - verbal: verbal, - Logger: logger, + CertStore: certStore, + LoadedCerts: []*CertCache{}, + hostSpecificTlsBehavior: defaultHostSpecificTlsBehavior, //Default to no SNI and no auto HTTPS + verbal: verbal, + Logger: logger, } err := thisManager.UpdateLoadedCertList() @@ -64,6 +75,21 @@ func NewManager(certStore string, verbal bool, logger *logger.Logger) (*Manager, return &thisManager, nil } +// Default host specific TLS behavior +// This is used when no specific TLS behavior is defined for a server name +func GetDefaultHostSpecificTlsBehavior() *HostSpecificTlsBehavior { + return &HostSpecificTlsBehavior{ + DisableSNI: false, + DisableLegacyCertificateMatching: false, + EnableAutoHTTPS: false, + PreferredCertificate: "", + } +} + +func defaultHostSpecificTlsBehavior(serverName string) (*HostSpecificTlsBehavior, error) { + return GetDefaultHostSpecificTlsBehavior(), nil +} + // Update domain mapping from file func (m *Manager) UpdateLoadedCertList() error { //Get a list of certificates from file @@ -161,24 +187,11 @@ func (m *Manager) ListCerts() ([]string, error) { // Get a certificate from disk where its certificate matches with the helloinfo func (m *Manager) GetCert(helloInfo *tls.ClientHelloInfo) (*tls.Certificate, error) { - //Check if the domain corrisponding cert exists - pubKey := "./tmp/localhost.pem" - priKey := "./tmp/localhost.key" - - if utils.FileExists(filepath.Join(m.CertStore, helloInfo.ServerName+".pem")) && utils.FileExists(filepath.Join(m.CertStore, helloInfo.ServerName+".key")) { - //Direct hit - pubKey = filepath.Join(m.CertStore, helloInfo.ServerName+".pem") - priKey = filepath.Join(m.CertStore, helloInfo.ServerName+".key") - } else if m.CertMatchExists(helloInfo.ServerName) { - //Use x509 - pubKey, priKey = m.GetCertByX509CNHostname(helloInfo.ServerName) - } else { - //Fallback to legacy method of matching certificates - if m.DefaultCertExists() { - //Use default.pem and default.key - pubKey = filepath.Join(m.CertStore, "default.pem") - priKey = filepath.Join(m.CertStore, "default.key") - } + //Look for the certificate by hostname + pubKey, priKey, err := m.GetCertificateByHostname(helloInfo.ServerName) + if err != nil { + m.Logger.PrintAndLog("tls-router", "Failed to get certificate for "+helloInfo.ServerName, err) + return nil, err } //Load the cert and serve it @@ -190,6 +203,51 @@ func (m *Manager) GetCert(helloInfo *tls.ClientHelloInfo) (*tls.Certificate, err return &cer, nil } +// GetCertificateByHostname returns the certificate and private key for a given hostname +func (m *Manager) GetCertificateByHostname(hostname string) (string, string, error) { + //Check if the domain corrisponding cert exists + pubKey := "./tmp/localhost.pem" + priKey := "./tmp/localhost.key" + + tlsBehavior, err := m.hostSpecificTlsBehavior(hostname) + if err != nil { + tlsBehavior, _ = defaultHostSpecificTlsBehavior(hostname) + } + + if tlsBehavior.DisableSNI && tlsBehavior.PreferredCertificate != "" && + utils.FileExists(filepath.Join(m.CertStore, tlsBehavior.PreferredCertificate+".pem")) && + utils.FileExists(filepath.Join(m.CertStore, tlsBehavior.PreferredCertificate+".key")) { + //User setup a Preferred certificate, use the preferred certificate directly + pubKey = filepath.Join(m.CertStore, tlsBehavior.PreferredCertificate+".pem") + priKey = filepath.Join(m.CertStore, tlsBehavior.PreferredCertificate+".key") + } else { + if !tlsBehavior.DisableLegacyCertificateMatching && + utils.FileExists(filepath.Join(m.CertStore, hostname+".pem")) && + utils.FileExists(filepath.Join(m.CertStore, hostname+".key")) { + //Legacy filename matching, use the file names directly + //This is the legacy method of matching certificates, it will match the file names directly + //This is used for compatibility with Zoraxy v2 setups + pubKey = filepath.Join(m.CertStore, hostname+".pem") + priKey = filepath.Join(m.CertStore, hostname+".key") + } else if !tlsBehavior.DisableSNI && + m.CertMatchExists(hostname) { + //SNI scan match, find the first matching certificate + pubKey, priKey = m.GetCertByX509CNHostname(hostname) + } else if tlsBehavior.EnableAutoHTTPS { + //Get certificate from CA, WIP + //TODO: Implement AutoHTTPS + } else { + //Fallback to legacy method of matching certificates + if m.DefaultCertExists() { + //Use default.pem and default.key + pubKey = filepath.Join(m.CertStore, "default.pem") + priKey = filepath.Join(m.CertStore, "default.key") + } + } + } + return pubKey, priKey, nil +} + // Check if both the default cert public key and private key exists func (m *Manager) DefaultCertExists() bool { return utils.FileExists(filepath.Join(m.CertStore, "default.pem")) && utils.FileExists(filepath.Join(m.CertStore, "default.key")) @@ -220,7 +278,6 @@ func (m *Manager) RemoveCert(domain string) error { //Update the cert list m.UpdateLoadedCertList() - return nil } diff --git a/src/reverseproxy.go b/src/reverseproxy.go index aaccaaf..0054f1e 100644 --- a/src/reverseproxy.go +++ b/src/reverseproxy.go @@ -15,6 +15,7 @@ import ( "imuslab.com/zoraxy/mod/dynamicproxy/permissionpolicy" "imuslab.com/zoraxy/mod/dynamicproxy/rewrite" "imuslab.com/zoraxy/mod/netutils" + "imuslab.com/zoraxy/mod/tlscert" "imuslab.com/zoraxy/mod/uptime" "imuslab.com/zoraxy/mod/utils" ) @@ -334,7 +335,8 @@ func ReverseProxyHandleAddEndpoint(w http.ResponseWriter, r *http.Request) { tags = filteredTags var proxyEndpointCreated *dynamicproxy.ProxyEndpoint - if eptype == "host" { + switch eptype { + case "host": rootOrMatchingDomain, err := utils.PostPara(r, "rootname") if err != nil { utils.SendErrorResponse(w, "hostname not defined") @@ -415,7 +417,7 @@ func ReverseProxyHandleAddEndpoint(w http.ResponseWriter, r *http.Request) { dynamicProxyRouter.AddProxyRouteToRuntime(preparedEndpoint) proxyEndpointCreated = &thisProxyEndpoint - } else if eptype == "root" { + case "root": //Get the default site options and target dsOptString, err := utils.PostPara(r, "defaultSiteOpt") if err != nil { @@ -469,7 +471,7 @@ func ReverseProxyHandleAddEndpoint(w http.ResponseWriter, r *http.Request) { return } proxyEndpointCreated = &rootRoutingEndpoint - } else { + default: //Invalid eptype utils.SendErrorResponse(w, "invalid endpoint type") return @@ -677,6 +679,65 @@ func ReverseProxyHandleAlias(w http.ResponseWriter, r *http.Request) { utils.SendOK(w) } +func ReverseProxyHandleSetTlsConfig(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + utils.SendErrorResponse(w, "Method not supported") + return + } + + rootnameOrMatchingDomain, err := utils.PostPara(r, "ep") + if err != nil { + utils.SendErrorResponse(w, "Invalid ep given") + return + } + + tlsConfig, err := utils.PostPara(r, "tlsConfig") + if err != nil { + utils.SendErrorResponse(w, "Invalid TLS config given") + return + } + + tlsConfig = strings.TrimSpace(tlsConfig) + if tlsConfig == "" { + utils.SendErrorResponse(w, "TLS config cannot be empty") + return + } + + newTlsConfig := &tlscert.HostSpecificTlsBehavior{} + err = json.Unmarshal([]byte(tlsConfig), newTlsConfig) + if err != nil { + utils.SendErrorResponse(w, "Invalid TLS config given: "+err.Error()) + return + } + + //Load the target endpoint + ept, err := dynamicProxyRouter.LoadProxy(rootnameOrMatchingDomain) + if err != nil { + utils.SendErrorResponse(w, err.Error()) + return + } + + ept.TlsOptions = newTlsConfig + + //Prepare to replace the current routing rule + readyRoutingRule, err := dynamicProxyRouter.PrepareProxyRoute(ept) + if err != nil { + utils.SendErrorResponse(w, err.Error()) + return + } + + dynamicProxyRouter.AddProxyRouteToRuntime(readyRoutingRule) + + //Save it to file + err = SaveReverseProxyConfig(ept) + if err != nil { + utils.SendErrorResponse(w, "Failed to save TLS config: "+err.Error()) + return + } + + utils.SendOK(w) +} + func ReverseProxyHandleSetHostname(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { utils.SendErrorResponse(w, "Method not supported") @@ -1015,6 +1076,7 @@ func RemoveProxyBasicAuthExceptionPaths(w http.ResponseWriter, r *http.Request) func ReverseProxyStatus(w http.ResponseWriter, r *http.Request) { js, err := json.Marshal(dynamicProxyRouter) if err != nil { + SystemWideLogger.PrintAndLog("proxy-config", "Unable to marshal status data", err) utils.SendErrorResponse(w, "Unable to marshal status data") return } diff --git a/src/web/components/httprp.html b/src/web/components/httprp.html index 5310130..1f29a5f 100644 --- a/src/web/components/httprp.html +++ b/src/web/components/httprp.html @@ -338,8 +338,37 @@
-

Work In Progress
- Please use the outer-most menu TLS / SSL tab for now.

+

The table below shows which certificate will be served by Zoraxy when a client request the following hostnames.

+ + + + + + + + + + +
HostnameResolve to Certificate
+
+ + +
+
+ + +
+
+ + +
+
@@ -711,6 +740,66 @@ $("#httpProxyList").find(".editBtn").removeClass("disabled"); } + function saveTlsConfigs(uuid){ + let enableSNI = $("#httprpEditModal .Tls_EnableSNI")[0].checked; + let enableLegacyCertificateMatching = $("#httprpEditModal .Tls_EnableLegacyCertificateMatching")[0].checked; + let enableAutoHTTPS = $("#httprpEditModal .Tls_EnableAutoHTTPS")[0].checked; + let newTlsOption = { + "DisableSNI": !enableSNI, + "DisableLegacyCertificateMatching": !enableLegacyCertificateMatching, + "EnableAutoHTTPS": enableAutoHTTPS + } + $.cjax({ + url: "/api/proxy/setTlsConfig", + method: "POST", + data: { + "ep": uuid, + "tlsConfig": JSON.stringify(newTlsOption) + }, + success: function(data){ + if (data.error !== undefined){ + msgbox(data.error, false, 3000); + }else{ + msgbox("TLS Config updated"); + } + updateTlsResolveList(uuid); + } + }); + } + + function updateTlsResolveList(uuid){ + let editor = $("#httprpEditModalWrapper"); + //Update the TLS resolve list + $.ajax({ + url: "/api/cert/resolve?domain=" + uuid, + method: "GET", + success: function(data) { + // Populate the TLS resolve list + let resolveList = editor.find(".Tls_resolve_list tbody"); + resolveList.empty(); // Clear existing entries + let primaryDomain = data.domain; + let aliasDomains = data.alias_domains || []; + let certMap = data.domain_key_pair; + + // Add primary domain entry + resolveList.append(` + + ${primaryDomain} + ${certMap[primaryDomain] || "Fallback Certificate"} + + `); + aliasDomains.forEach(alias => { + resolveList.append(` + + ${alias} + ${certMap[alias] || "Fallback Certificate"} + + `); + }); + } + }); + } + function saveProxyInlineEdit(uuid){ let editor = $("#httprpEditModal"); @@ -1245,6 +1334,20 @@ editor.find(".RateLimit").off("change").on("change", rateLimitChangeEvent); /* ------------ TLS ------------ */ + updateTlsResolveList(uuid); + editor.find(".Tls_EnableSNI").prop("checked", !subd.TlsOptions.DisableSNI); + editor.find(".Tls_EnableLegacyCertificateMatching").prop("checked", !subd.TlsOptions.DisableLegacyCertificateMatching); + editor.find(".Tls_EnableAutoHTTPS").prop("checked", !!subd.TlsOptions.EnableAutoHTTPS); + + editor.find(".Tls_EnableSNI").off("change").on("change", function() { + saveTlsConfigs(uuid); + }); + editor.find(".Tls_EnableLegacyCertificateMatching").off("change").on("change", function() { + saveTlsConfigs(uuid); + }); + editor.find(".Tls_EnableAutoHTTPS").off("change").on("change", function() { + saveTlsConfigs(uuid); + }); /* ------------ Tags ------------ */ (()=>{ diff --git a/src/web/components/httprp_new.html b/src/web/components/httprp_new.html deleted file mode 100644 index e7b4f4f..0000000 --- a/src/web/components/httprp_new.html +++ /dev/null @@ -1,1455 +0,0 @@ -
-
-

HTTP Proxy

-

Proxy HTTP server with HTTP or HTTPS for multiple hosts. If you are only proxying for one host / domain, use Default Site instead.

-
- -
- -
- - - -
-
- -
- - - - - - - - - - - - - - -
HostDestinationVirtual DirectoryTags
-
- - -

-
- - -
-
-
- -
-
- -
-
-

- -

-
- -
-
-
-
- - -
-
-
-
- -
-
-
- -
- -
-
- - -
-
-
-
- - Advanced Settings -
-
-
- - -
-
-
- - -
-
-
-
-
-
- -
-
-
- -
-
- -
-
- -
- -
- -
-
-

Work In Progress

-
- -
-
- -
- -
- -
- -
- -
-
-
- - -
-
- - -
-
-
-
- - -
-
-
-
- - -
-
-
-
- - -
-
-
-
- - -
- -
- - -

-
- - -
-
-
- -
- -
-
- - - -
-
-
-
-
- - - From ad53b894c00e2f7d20300045eb7a39291c4a63bd Mon Sep 17 00:00:00 2001 From: Toby Chui Date: Tue, 8 Jul 2025 12:38:08 +0800 Subject: [PATCH 11/16] Update src/mod/auth/sso/forward/forward.go Co-authored-by: James Elliott --- src/mod/auth/sso/forward/forward.go | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/mod/auth/sso/forward/forward.go b/src/mod/auth/sso/forward/forward.go index 4c59d63..00ea450 100644 --- a/src/mod/auth/sso/forward/forward.go +++ b/src/mod/auth/sso/forward/forward.go @@ -60,11 +60,11 @@ func NewAuthRouter(options *AuthRouterOptions) *AuthRouter { // Helper function to clean empty strings from split results cleanSplit := func(s string) []string { - parts := strings.Split(s, ",") - if len(parts) == 1 && parts[0] == "" { - return []string{} - } - return parts + if s == "" { + return nil + } + + return strings.Split(s, ",") } options.ResponseHeaders = cleanSplit(responseHeaders) From 4d3d1b25cb3a2414e2a0a2a29a96907c42e3e7b0 Mon Sep 17 00:00:00 2001 From: Toby Chui Date: Sat, 12 Jul 2025 19:30:55 +0800 Subject: [PATCH 12/16] Restructure TLS options - Moved certification related functions into tlscert module - Added specific host TLS behavior logic - Added support for disabling SNI and manually overwrite preferred certificate to serve - Fixed SSO requestHeaders null bug --- src/api.go | 17 +- src/cert.go | 336 ++----------------- src/mod/dynamicproxy/Server.go | 2 +- src/mod/dynamicproxy/certificate.go | 59 ++++ src/mod/dynamicproxy/dynamicproxy.go | 4 +- src/mod/dynamicproxy/proxyRequestHandler.go | 4 +- src/mod/dynamicproxy/typedef.go | 22 +- src/mod/tlscert/certgen.go | 93 ++++++ src/mod/tlscert/handler.go | 352 ++++++++++++++++++++ src/mod/tlscert/helper.go | 27 ++ src/mod/tlscert/tlscert.go | 32 +- src/reverseproxy.go | 7 +- src/start.go | 8 +- src/web/components/httprp.html | 193 ++++++++--- src/web/components/sso.html | 30 +- 15 files changed, 803 insertions(+), 383 deletions(-) create mode 100644 src/mod/dynamicproxy/certificate.go create mode 100644 src/mod/tlscert/certgen.go create mode 100644 src/mod/tlscert/handler.go diff --git a/src/api.go b/src/api.go index 59c5df4..17fd3c4 100644 --- a/src/api.go +++ b/src/api.go @@ -72,15 +72,20 @@ func RegisterHTTPProxyAPIs(authRouter *auth.RouterDef) { // Register the APIs for TLS / SSL certificate management functions func RegisterTLSAPIs(authRouter *auth.RouterDef) { + //Global certificate settings authRouter.HandleFunc("/api/cert/tls", handleToggleTLSProxy) authRouter.HandleFunc("/api/cert/tlsRequireLatest", handleSetTlsRequireLatest) - authRouter.HandleFunc("/api/cert/upload", handleCertUpload) - authRouter.HandleFunc("/api/cert/download", handleCertDownload) - authRouter.HandleFunc("/api/cert/list", handleListCertificate) - authRouter.HandleFunc("/api/cert/listdomains", handleListDomains) - authRouter.HandleFunc("/api/cert/checkDefault", handleDefaultCertCheck) - authRouter.HandleFunc("/api/cert/delete", handleCertRemove) authRouter.HandleFunc("/api/cert/resolve", handleCertTryResolve) + authRouter.HandleFunc("/api/cert/setPreferredCertificate", handleSetDomainPreferredCertificate) + + //Certificate store functions + authRouter.HandleFunc("/api/cert/upload", tlsCertManager.HandleCertUpload) + authRouter.HandleFunc("/api/cert/download", tlsCertManager.HandleCertDownload) + authRouter.HandleFunc("/api/cert/list", tlsCertManager.HandleListCertificate) + authRouter.HandleFunc("/api/cert/listdomains", tlsCertManager.HandleListDomains) + authRouter.HandleFunc("/api/cert/checkDefault", tlsCertManager.HandleDefaultCertCheck) + authRouter.HandleFunc("/api/cert/delete", tlsCertManager.HandleCertRemove) + authRouter.HandleFunc("/api/cert/selfsign", tlsCertManager.HandleSelfSignCertGenerate) } // Register the APIs for Authentication handlers like Forward Auth and OAUTH2 diff --git a/src/cert.go b/src/cert.go index 8d2a4ed..62b4f66 100644 --- a/src/cert.go +++ b/src/cert.go @@ -1,188 +1,14 @@ package main import ( - "crypto/x509" "encoding/json" - "encoding/pem" - "fmt" - "io" "net/http" - "os" "path/filepath" - "sort" "strings" - "time" - "imuslab.com/zoraxy/mod/acme" "imuslab.com/zoraxy/mod/utils" ) -// Check if the default certificates is correctly setup -func handleDefaultCertCheck(w http.ResponseWriter, r *http.Request) { - type CheckResult struct { - DefaultPubExists bool - DefaultPriExists bool - } - - pub, pri := tlsCertManager.DefaultCertExistsSep() - js, _ := json.Marshal(CheckResult{ - pub, - pri, - }) - - utils.SendJSONResponse(w, string(js)) -} - -// Return a list of domains where the certificates covers -func handleListCertificate(w http.ResponseWriter, r *http.Request) { - filenames, err := tlsCertManager.ListCertDomains() - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - - showDate, _ := utils.GetPara(r, "date") - if showDate == "true" { - type CertInfo struct { - Domain string - LastModifiedDate string - ExpireDate string - RemainingDays int - UseDNS bool - } - - results := []*CertInfo{} - - for _, filename := range filenames { - certFilepath := filepath.Join(tlsCertManager.CertStore, filename+".pem") - //keyFilepath := filepath.Join(tlsCertManager.CertStore, filename+".key") - fileInfo, err := os.Stat(certFilepath) - if err != nil { - utils.SendErrorResponse(w, "invalid domain certificate discovered: "+filename) - return - } - modifiedTime := fileInfo.ModTime().Format("2006-01-02 15:04:05") - - certExpireTime := "Unknown" - certBtyes, err := os.ReadFile(certFilepath) - expiredIn := 0 - if err != nil { - //Unable to load this file - continue - } else { - //Cert loaded. Check its expire time - block, _ := pem.Decode(certBtyes) - if block != nil { - cert, err := x509.ParseCertificate(block.Bytes) - if err == nil { - certExpireTime = cert.NotAfter.Format("2006-01-02 15:04:05") - - duration := cert.NotAfter.Sub(time.Now()) - - // Convert the duration to days - expiredIn = int(duration.Hours() / 24) - } - } - } - certInfoFilename := filepath.Join(tlsCertManager.CertStore, filename+".json") - useDNSValidation := false //Default to false for HTTP TLS certificates - certInfo, err := acme.LoadCertInfoJSON(certInfoFilename) //Note: Not all certs have info json - if err == nil { - useDNSValidation = certInfo.UseDNS - } - - thisCertInfo := CertInfo{ - Domain: filename, - LastModifiedDate: modifiedTime, - ExpireDate: certExpireTime, - RemainingDays: expiredIn, - UseDNS: useDNSValidation, - } - - results = append(results, &thisCertInfo) - } - - // convert ExpireDate to date object and sort asc - sort.Slice(results, func(i, j int) bool { - date1, _ := time.Parse("2006-01-02 15:04:05", results[i].ExpireDate) - date2, _ := time.Parse("2006-01-02 15:04:05", results[j].ExpireDate) - return date1.Before(date2) - }) - - js, _ := json.Marshal(results) - w.Header().Set("Content-Type", "application/json") - w.Write(js) - } else { - response, err := json.Marshal(filenames) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - - w.Header().Set("Content-Type", "application/json") - w.Write(response) - } - -} - -// List all certificates and map all their domains to the cert filename -func handleListDomains(w http.ResponseWriter, r *http.Request) { - filenames, err := os.ReadDir("./conf/certs/") - - if err != nil { - utils.SendErrorResponse(w, err.Error()) - return - } - - certnameToDomainMap := map[string]string{} - for _, filename := range filenames { - if filename.IsDir() { - continue - } - certFilepath := filepath.Join("./conf/certs/", filename.Name()) - - certBtyes, err := os.ReadFile(certFilepath) - if err != nil { - // Unable to load this file - SystemWideLogger.PrintAndLog("TLS", "Unable to load certificate: "+certFilepath, err) - continue - } else { - // Cert loaded. Check its expiry time - block, _ := pem.Decode(certBtyes) - if block != nil { - cert, err := x509.ParseCertificate(block.Bytes) - if err == nil { - certname := strings.TrimSuffix(filepath.Base(certFilepath), filepath.Ext(certFilepath)) - for _, dnsName := range cert.DNSNames { - certnameToDomainMap[dnsName] = certname - } - certnameToDomainMap[cert.Subject.CommonName] = certname - } - } - } - } - - requireCompact, _ := utils.GetPara(r, "compact") - if requireCompact == "true" { - result := make(map[string][]string) - - for key, value := range certnameToDomainMap { - if _, ok := result[value]; !ok { - result[value] = make([]string, 0) - } - - result[value] = append(result[value], key) - } - - js, _ := json.Marshal(result) - utils.SendJSONResponse(w, string(js)) - return - } - - js, _ := json.Marshal(certnameToDomainMap) - utils.SendJSONResponse(w, string(js)) -} - // Handle front-end toggling TLS mode func handleToggleTLSProxy(w http.ResponseWriter, r *http.Request) { currentTlsSetting := true //Default to true @@ -193,11 +19,12 @@ func handleToggleTLSProxy(w http.ResponseWriter, r *http.Request) { sysdb.Read("settings", "usetls", ¤tTlsSetting) } - if r.Method == http.MethodGet { + switch r.Method { + case http.MethodGet: //Get the current status js, _ := json.Marshal(currentTlsSetting) utils.SendJSONResponse(w, string(js)) - } else if r.Method == http.MethodPost { + case http.MethodPost: newState, err := utils.PostBool(r, "set") if err != nil { utils.SendErrorResponse(w, "new state not set or invalid") @@ -213,7 +40,7 @@ func handleToggleTLSProxy(w http.ResponseWriter, r *http.Request) { dynamicProxyRouter.UpdateTLSSetting(false) } utils.SendOK(w) - } else { + default: http.Error(w, "405 - Method not allowed", http.StatusMethodNotAllowed) } } @@ -231,135 +58,21 @@ func handleSetTlsRequireLatest(w http.ResponseWriter, r *http.Request) { js, _ := json.Marshal(reqLatestTLS) utils.SendJSONResponse(w, string(js)) } else { - if newState == "true" { + switch newState { + case "true": sysdb.Write("settings", "forceLatestTLS", true) SystemWideLogger.Println("Updating minimum TLS version to v1.2 or above") dynamicProxyRouter.UpdateTLSVersion(true) - } else if newState == "false" { + case "false": sysdb.Write("settings", "forceLatestTLS", false) SystemWideLogger.Println("Updating minimum TLS version to v1.0 or above") dynamicProxyRouter.UpdateTLSVersion(false) - } else { + default: utils.SendErrorResponse(w, "invalid state given") } } } -// Handle download of the selected certificate -func handleCertDownload(w http.ResponseWriter, r *http.Request) { - // get the certificate name - certname, err := utils.GetPara(r, "certname") - if err != nil { - utils.SendErrorResponse(w, "invalid certname given") - return - } - certname = filepath.Base(certname) //prevent path escape - - // check if the cert exists - pubKey := filepath.Join(filepath.Join("./conf/certs"), certname+".key") - priKey := filepath.Join(filepath.Join("./conf/certs"), certname+".pem") - - if utils.FileExists(pubKey) && utils.FileExists(priKey) { - //Zip them and serve them via http download - seeking, _ := utils.GetBool(r, "seek") - if seeking { - //This request only check if the key exists. Do not provide download - utils.SendOK(w) - return - } - - //Serve both file in zip - zipTmpFolder := "./tmp/download" - os.MkdirAll(zipTmpFolder, 0775) - zipFileName := filepath.Join(zipTmpFolder, certname+".zip") - err := utils.ZipFiles(zipFileName, pubKey, priKey) - if err != nil { - http.Error(w, "Failed to create zip file", http.StatusInternalServerError) - return - } - defer os.Remove(zipFileName) // Clean up the zip file after serving - - // Serve the zip file - w.Header().Set("Content-Disposition", "attachment; filename=\""+certname+"_export.zip\"") - w.Header().Set("Content-Type", "application/zip") - http.ServeFile(w, r, zipFileName) - } else { - //Not both key exists - utils.SendErrorResponse(w, "invalid key-pairs: private key or public key not found in key store") - return - } -} - -// Handle upload of the certificate -func handleCertUpload(w http.ResponseWriter, r *http.Request) { - // check if request method is POST - if r.Method != "POST" { - http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) - return - } - - // get the key type - keytype, err := utils.GetPara(r, "ktype") - overWriteFilename := "" - if err != nil { - http.Error(w, "Not defined key type (pub / pri)", http.StatusBadRequest) - return - } - - // get the domain - domain, err := utils.GetPara(r, "domain") - if err != nil { - //Assume localhost - domain = "default" - } - - if keytype == "pub" { - overWriteFilename = domain + ".pem" - } else if keytype == "pri" { - overWriteFilename = domain + ".key" - } else { - http.Error(w, "Not supported keytype: "+keytype, http.StatusBadRequest) - return - } - - // parse multipart form data - err = r.ParseMultipartForm(10 << 20) // 10 MB - if err != nil { - http.Error(w, "Failed to parse form data", http.StatusBadRequest) - return - } - - // get file from form data - file, _, err := r.FormFile("file") - if err != nil { - http.Error(w, "Failed to get file", http.StatusBadRequest) - return - } - defer file.Close() - - // create file in upload directory - os.MkdirAll("./conf/certs", 0775) - f, err := os.Create(filepath.Join("./conf/certs", overWriteFilename)) - if err != nil { - http.Error(w, "Failed to create file", http.StatusInternalServerError) - return - } - defer f.Close() - - // copy file contents to destination file - _, err = io.Copy(f, file) - if err != nil { - http.Error(w, "Failed to save file", http.StatusInternalServerError) - return - } - - //Update cert list - tlsCertManager.UpdateLoadedCertList() - - // send response - fmt.Fprintln(w, "File upload successful!") -} - func handleCertTryResolve(w http.ResponseWriter, r *http.Request) { // get the domain domain, err := utils.GetPara(r, "domain") @@ -441,15 +154,40 @@ func handleCertTryResolve(w http.ResponseWriter, r *http.Request) { utils.SendJSONResponse(w, string(js)) } -// Handle cert remove -func handleCertRemove(w http.ResponseWriter, r *http.Request) { +func handleSetDomainPreferredCertificate(w http.ResponseWriter, r *http.Request) { + //Get the domain domain, err := utils.PostPara(r, "domain") if err != nil { utils.SendErrorResponse(w, "invalid domain given") return } - err = tlsCertManager.RemoveCert(domain) + + //Get the certificate name + certName, err := utils.PostPara(r, "certname") if err != nil { - utils.SendErrorResponse(w, err.Error()) + utils.SendErrorResponse(w, "invalid certificate name given") + return } + + //Load the target endpoint + ept, err := dynamicProxyRouter.GetProxyEndpointById(domain, true) + if err != nil { + utils.SendErrorResponse(w, "proxy rule not found for domain: "+domain) + return + } + + //Set the preferred certificate for the domain + err = dynamicProxyRouter.SetPreferredCertificateForDomain(ept, domain, certName) + if err != nil { + utils.SendErrorResponse(w, "failed to set preferred certificate: "+err.Error()) + return + } + + err = SaveReverseProxyConfig(ept) + if err != nil { + utils.SendErrorResponse(w, "failed to save reverse proxy config: "+err.Error()) + return + } + + utils.SendOK(w) } diff --git a/src/mod/dynamicproxy/Server.go b/src/mod/dynamicproxy/Server.go index 7c195e6..e0b16bb 100644 --- a/src/mod/dynamicproxy/Server.go +++ b/src/mod/dynamicproxy/Server.go @@ -61,7 +61,7 @@ func (h *ProxyHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { hostPath := strings.Split(r.Host, ":") domainOnly = hostPath[0] } - sep := h.Parent.getProxyEndpointFromHostname(domainOnly) + sep := h.Parent.GetProxyEndpointFromHostname(domainOnly) if sep != nil && !sep.Disabled { //Matching proxy rule found //Access Check (blacklist / whitelist) diff --git a/src/mod/dynamicproxy/certificate.go b/src/mod/dynamicproxy/certificate.go new file mode 100644 index 0000000..9635bea --- /dev/null +++ b/src/mod/dynamicproxy/certificate.go @@ -0,0 +1,59 @@ +package dynamicproxy + +import ( + "encoding/json" + "errors" + "fmt" + + "imuslab.com/zoraxy/mod/tlscert" +) + +func (router *Router) ResolveHostSpecificTlsBehaviorForHostname(hostname string) (*tlscert.HostSpecificTlsBehavior, error) { + if hostname == "" { + return nil, errors.New("hostname cannot be empty") + } + + ept := router.GetProxyEndpointFromHostname(hostname) + if ept == nil { + return tlscert.GetDefaultHostSpecificTlsBehavior(), nil + } + + // Check if the endpoint has a specific TLS behavior + if ept.TlsOptions != nil { + imported := &tlscert.HostSpecificTlsBehavior{} + router.tlsBehaviorMutex.RLock() + // Deep copy the TlsOptions using JSON marshal/unmarshal + data, err := json.Marshal(ept.TlsOptions) + if err != nil { + router.tlsBehaviorMutex.RUnlock() + return nil, fmt.Errorf("failed to deepcopy TlsOptions: %w", err) + } + router.tlsBehaviorMutex.RUnlock() + if err := json.Unmarshal(data, imported); err != nil { + return nil, fmt.Errorf("failed to deepcopy TlsOptions: %w", err) + } + return imported, nil + } + + return tlscert.GetDefaultHostSpecificTlsBehavior(), nil +} + +func (router *Router) SetPreferredCertificateForDomain(ept *ProxyEndpoint, domain string, certName string) error { + if ept == nil || certName == "" { + return errors.New("endpoint and certificate name cannot be empty") + } + + // Set the preferred certificate for the endpoint + if ept.TlsOptions == nil { + ept.TlsOptions = tlscert.GetDefaultHostSpecificTlsBehavior() + } + + router.tlsBehaviorMutex.Lock() + if ept.TlsOptions.PreferredCertificate == nil { + ept.TlsOptions.PreferredCertificate = make(map[string]string) + } + ept.TlsOptions.PreferredCertificate[domain] = certName + router.tlsBehaviorMutex.Unlock() + + return nil +} diff --git a/src/mod/dynamicproxy/dynamicproxy.go b/src/mod/dynamicproxy/dynamicproxy.go index f17181e..8b234f5 100644 --- a/src/mod/dynamicproxy/dynamicproxy.go +++ b/src/mod/dynamicproxy/dynamicproxy.go @@ -111,7 +111,7 @@ func (router *Router) StartProxyService() error { hostPath := strings.Split(r.Host, ":") domainOnly = hostPath[0] } - sep := router.getProxyEndpointFromHostname(domainOnly) + sep := router.GetProxyEndpointFromHostname(domainOnly) if sep != nil && sep.BypassGlobalTLS { //Allow routing via non-TLS handler originalHostHeader := r.Host @@ -335,7 +335,7 @@ func (router *Router) IsProxiedSubdomain(r *http.Request) bool { hostname = r.Host } hostname = strings.Split(hostname, ":")[0] - subdEndpoint := router.getProxyEndpointFromHostname(hostname) + subdEndpoint := router.GetProxyEndpointFromHostname(hostname) return subdEndpoint != nil } diff --git a/src/mod/dynamicproxy/proxyRequestHandler.go b/src/mod/dynamicproxy/proxyRequestHandler.go index 9ba8813..65cb14f 100644 --- a/src/mod/dynamicproxy/proxyRequestHandler.go +++ b/src/mod/dynamicproxy/proxyRequestHandler.go @@ -34,7 +34,7 @@ func (router *Router) getTargetProxyEndpointFromRequestURI(requestURI string) *P } // Get the proxy endpoint from hostname, which might includes checking of wildcard certificates -func (router *Router) getProxyEndpointFromHostname(hostname string) *ProxyEndpoint { +func (router *Router) GetProxyEndpointFromHostname(hostname string) *ProxyEndpoint { var targetSubdomainEndpoint *ProxyEndpoint = nil hostname = strings.ToLower(hostname) ep, ok := router.ProxyEndpoints.Load(hostname) @@ -63,7 +63,7 @@ func (router *Router) getProxyEndpointFromHostname(hostname string) *ProxyEndpoi } //Wildcard not match. Check for alias - if ep.MatchingDomainAlias != nil && len(ep.MatchingDomainAlias) > 0 { + if len(ep.MatchingDomainAlias) > 0 { for _, aliasDomain := range ep.MatchingDomainAlias { match, err := filepath.Match(aliasDomain, hostname) if err != nil { diff --git a/src/mod/dynamicproxy/typedef.go b/src/mod/dynamicproxy/typedef.go index 9f6d63f..f6238a3 100644 --- a/src/mod/dynamicproxy/typedef.go +++ b/src/mod/dynamicproxy/typedef.go @@ -75,16 +75,20 @@ type RouterOption struct { /* Router Object */ type Router struct { Option *RouterOption - ProxyEndpoints *sync.Map //Map of ProxyEndpoint objects, each ProxyEndpoint object is a routing rule that handle incoming requests - Running bool //If the router is running - Root *ProxyEndpoint //Root proxy endpoint, default site - mux http.Handler //HTTP handler - server *http.Server //HTTP server - tlsListener net.Listener //TLS listener, handle SNI routing - loadBalancer *loadbalance.RouteManager //Load balancer routing manager - routingRules []*RoutingRule //Special routing rules, handle high priority routing like ACME request handling + ProxyEndpoints *sync.Map //Map of ProxyEndpoint objects, each ProxyEndpoint object is a routing rule that handle incoming requests + Running bool //If the router is running + Root *ProxyEndpoint //Root proxy endpoint, default site + + /* Internals */ + mux http.Handler //HTTP handler + server *http.Server //HTTP server + loadBalancer *loadbalance.RouteManager //Load balancer routing manager + routingRules []*RoutingRule //Special routing rules, handle high priority routing like ACME request handling + + tlsListener net.Listener //TLS listener, handle SNI routing + tlsBehaviorMutex sync.RWMutex //Mutex for tlsBehavior map + tlsRedirectStop chan bool //Stop channel for tls redirection server - tlsRedirectStop chan bool //Stop channel for tls redirection server rateLimterStop chan bool //Stop channel for rate limiter rateLimitCounter RequestCountPerIpTable //Request counter for rate limter } diff --git a/src/mod/tlscert/certgen.go b/src/mod/tlscert/certgen.go new file mode 100644 index 0000000..adebad9 --- /dev/null +++ b/src/mod/tlscert/certgen.go @@ -0,0 +1,93 @@ +package tlscert + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "math/big" + "os" + "path/filepath" + "time" +) + +// GenerateSelfSignedCertificate generates a self-signed ECDSA certificate and saves it to the specified files. +func (m *Manager) GenerateSelfSignedCertificate(cn string, sans []string, certFile string, keyFile string) error { + // Generate private key (ECDSA P-256) + privKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + m.Logger.PrintAndLog("tls-router", "Failed to generate private key", err) + return err + } + + // Create certificate template + template := x509.Certificate{ + SerialNumber: big.NewInt(time.Now().UnixNano()), + Subject: pkix.Name{ + CommonName: cn, // Common Name for the certificate + Organization: []string{"aroz.org"}, // Organization name + OrganizationalUnit: []string{"Zoraxy"}, // Organizational Unit + Country: []string{"US"}, // Country code + }, + NotBefore: time.Now(), + NotAfter: time.Now().Add(365 * 24 * time.Hour), // valid for 1 year + KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature | x509.KeyUsageCertSign, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth, x509.ExtKeyUsageServerAuth}, + BasicConstraintsValid: true, + DNSNames: sans, // Subject Alternative Names + } + + // Create self-signed certificate + certDER, err := x509.CreateCertificate(rand.Reader, &template, &template, &privKey.PublicKey, privKey) + if err != nil { + m.Logger.PrintAndLog("tls-router", "Failed to create certificate", err) + return err + } + + // Remove old certificate file if it exists + certPath := filepath.Join(m.CertStore, certFile) + if _, err := os.Stat(certPath); err == nil { + os.Remove(certPath) + } + + // Remove old key file if it exists + keyPath := filepath.Join(m.CertStore, keyFile) + if _, err := os.Stat(keyPath); err == nil { + os.Remove(keyPath) + } + + // Write certificate to file + certOut, err := os.Create(filepath.Join(m.CertStore, certFile)) + if err != nil { + m.Logger.PrintAndLog("tls-router", "Failed to open cert file for writing: "+certFile, err) + return err + } + defer certOut.Close() + err = pem.Encode(certOut, &pem.Block{Type: "CERTIFICATE", Bytes: certDER}) + if err != nil { + m.Logger.PrintAndLog("tls-router", "Failed to write certificate to file: "+certFile, err) + return err + } + + // Encode private key to PEM + privBytes, err := x509.MarshalECPrivateKey(privKey) + if err != nil { + m.Logger.PrintAndLog("tls-router", "Unable to marshal ECDSA private key", err) + return err + } + keyOut, err := os.Create(filepath.Join(m.CertStore, keyFile)) + if err != nil { + m.Logger.PrintAndLog("tls-router", "Failed to open key file for writing: "+keyFile, err) + return err + } + defer keyOut.Close() + err = pem.Encode(keyOut, &pem.Block{Type: "EC PRIVATE KEY", Bytes: privBytes}) + if err != nil { + m.Logger.PrintAndLog("tls-router", "Failed to write private key to file: "+keyFile, err) + return err + } + m.Logger.PrintAndLog("tls-router", "Certificate and key generated: "+certFile+", "+keyFile, nil) + return nil +} diff --git a/src/mod/tlscert/handler.go b/src/mod/tlscert/handler.go new file mode 100644 index 0000000..5e9cc7b --- /dev/null +++ b/src/mod/tlscert/handler.go @@ -0,0 +1,352 @@ +package tlscert + +import ( + "crypto/x509" + "encoding/json" + "encoding/pem" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "sort" + "strings" + "time" + + "imuslab.com/zoraxy/mod/acme" + "imuslab.com/zoraxy/mod/utils" +) + +// Handle cert remove +func (m *Manager) HandleCertRemove(w http.ResponseWriter, r *http.Request) { + domain, err := utils.PostPara(r, "domain") + if err != nil { + utils.SendErrorResponse(w, "invalid domain given") + return + } + err = m.RemoveCert(domain) + if err != nil { + utils.SendErrorResponse(w, err.Error()) + } +} + +// Handle download of the selected certificate +func (m *Manager) HandleCertDownload(w http.ResponseWriter, r *http.Request) { + // get the certificate name + certname, err := utils.GetPara(r, "certname") + if err != nil { + utils.SendErrorResponse(w, "invalid certname given") + return + } + certname = filepath.Base(certname) //prevent path escape + + // check if the cert exists + pubKey := filepath.Join(filepath.Join(m.CertStore), certname+".key") + priKey := filepath.Join(filepath.Join(m.CertStore), certname+".pem") + + if utils.FileExists(pubKey) && utils.FileExists(priKey) { + //Zip them and serve them via http download + seeking, _ := utils.GetBool(r, "seek") + if seeking { + //This request only check if the key exists. Do not provide download + utils.SendOK(w) + return + } + + //Serve both file in zip + zipTmpFolder := "./tmp/download" + os.MkdirAll(zipTmpFolder, 0775) + zipFileName := filepath.Join(zipTmpFolder, certname+".zip") + err := utils.ZipFiles(zipFileName, pubKey, priKey) + if err != nil { + http.Error(w, "Failed to create zip file", http.StatusInternalServerError) + return + } + defer os.Remove(zipFileName) // Clean up the zip file after serving + + // Serve the zip file + w.Header().Set("Content-Disposition", "attachment; filename=\""+certname+"_export.zip\"") + w.Header().Set("Content-Type", "application/zip") + http.ServeFile(w, r, zipFileName) + } else { + //Not both key exists + utils.SendErrorResponse(w, "invalid key-pairs: private key or public key not found in key store") + return + } +} + +// Handle upload of the certificate +func (m *Manager) HandleCertUpload(w http.ResponseWriter, r *http.Request) { + // check if request method is POST + if r.Method != "POST" { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + // get the key type + keytype, err := utils.GetPara(r, "ktype") + overWriteFilename := "" + if err != nil { + http.Error(w, "Not defined key type (pub / pri)", http.StatusBadRequest) + return + } + + // get the domain + domain, err := utils.GetPara(r, "domain") + if err != nil { + //Assume localhost + domain = "default" + } + + switch keytype { + case "pub": + overWriteFilename = domain + ".pem" + case "pri": + overWriteFilename = domain + ".key" + default: + http.Error(w, "Not supported keytype: "+keytype, http.StatusBadRequest) + return + } + + // parse multipart form data + err = r.ParseMultipartForm(10 << 20) // 10 MB + if err != nil { + http.Error(w, "Failed to parse form data", http.StatusBadRequest) + return + } + + // get file from form data + file, _, err := r.FormFile("file") + if err != nil { + http.Error(w, "Failed to get file", http.StatusBadRequest) + return + } + defer file.Close() + + // create file in upload directory + os.MkdirAll(m.CertStore, 0775) + f, err := os.Create(filepath.Join(m.CertStore, overWriteFilename)) + if err != nil { + http.Error(w, "Failed to create file", http.StatusInternalServerError) + return + } + defer f.Close() + + // copy file contents to destination file + _, err = io.Copy(f, file) + if err != nil { + http.Error(w, "Failed to save file", http.StatusInternalServerError) + return + } + + //Update cert list + m.UpdateLoadedCertList() + + // send response + fmt.Fprintln(w, "File upload successful!") +} + +// List all certificates and map all their domains to the cert filename +func (m *Manager) HandleListDomains(w http.ResponseWriter, r *http.Request) { + filenames, err := os.ReadDir(m.CertStore) + + if err != nil { + utils.SendErrorResponse(w, err.Error()) + return + } + + certnameToDomainMap := map[string]string{} + for _, filename := range filenames { + if filename.IsDir() { + continue + } + certFilepath := filepath.Join(m.CertStore, filename.Name()) + + certBtyes, err := os.ReadFile(certFilepath) + if err != nil { + // Unable to load this file + m.Logger.PrintAndLog("TLS", "Unable to load certificate: "+certFilepath, err) + continue + } else { + // Cert loaded. Check its expiry time + block, _ := pem.Decode(certBtyes) + if block != nil { + cert, err := x509.ParseCertificate(block.Bytes) + if err == nil { + certname := strings.TrimSuffix(filepath.Base(certFilepath), filepath.Ext(certFilepath)) + for _, dnsName := range cert.DNSNames { + certnameToDomainMap[dnsName] = certname + } + certnameToDomainMap[cert.Subject.CommonName] = certname + } + } + } + } + + requireCompact, _ := utils.GetPara(r, "compact") + if requireCompact == "true" { + result := make(map[string][]string) + + for key, value := range certnameToDomainMap { + if _, ok := result[value]; !ok { + result[value] = make([]string, 0) + } + + result[value] = append(result[value], key) + } + + js, _ := json.Marshal(result) + utils.SendJSONResponse(w, string(js)) + return + } + + js, _ := json.Marshal(certnameToDomainMap) + utils.SendJSONResponse(w, string(js)) +} + +// Return a list of domains where the certificates covers +func (m *Manager) HandleListCertificate(w http.ResponseWriter, r *http.Request) { + filenames, err := m.ListCertDomains() + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + showDate, _ := utils.GetBool(r, "date") + if showDate { + type CertInfo struct { + Domain string + LastModifiedDate string + ExpireDate string + RemainingDays int + UseDNS bool + } + + results := []*CertInfo{} + + for _, filename := range filenames { + certFilepath := filepath.Join(m.CertStore, filename+".pem") + //keyFilepath := filepath.Join(tlsCertManager.CertStore, filename+".key") + fileInfo, err := os.Stat(certFilepath) + if err != nil { + utils.SendErrorResponse(w, "invalid domain certificate discovered: "+filename) + return + } + modifiedTime := fileInfo.ModTime().Format("2006-01-02 15:04:05") + + certExpireTime := "Unknown" + certBtyes, err := os.ReadFile(certFilepath) + expiredIn := 0 + if err != nil { + //Unable to load this file + continue + } else { + //Cert loaded. Check its expire time + block, _ := pem.Decode(certBtyes) + if block != nil { + cert, err := x509.ParseCertificate(block.Bytes) + if err == nil { + certExpireTime = cert.NotAfter.Format("2006-01-02 15:04:05") + + duration := cert.NotAfter.Sub(time.Now()) + + // Convert the duration to days + expiredIn = int(duration.Hours() / 24) + } + } + } + certInfoFilename := filepath.Join(m.CertStore, filename+".json") + useDNSValidation := false //Default to false for HTTP TLS certificates + certInfo, err := acme.LoadCertInfoJSON(certInfoFilename) //Note: Not all certs have info json + if err == nil { + useDNSValidation = certInfo.UseDNS + } + + thisCertInfo := CertInfo{ + Domain: filename, + LastModifiedDate: modifiedTime, + ExpireDate: certExpireTime, + RemainingDays: expiredIn, + UseDNS: useDNSValidation, + } + + results = append(results, &thisCertInfo) + } + + // convert ExpireDate to date object and sort asc + sort.Slice(results, func(i, j int) bool { + date1, _ := time.Parse("2006-01-02 15:04:05", results[i].ExpireDate) + date2, _ := time.Parse("2006-01-02 15:04:05", results[j].ExpireDate) + return date1.Before(date2) + }) + + js, _ := json.Marshal(results) + w.Header().Set("Content-Type", "application/json") + w.Write(js) + return + } + + response, err := json.Marshal(filenames) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + w.Write(response) +} + +// Check if the default certificates is correctly setup +func (m *Manager) HandleDefaultCertCheck(w http.ResponseWriter, r *http.Request) { + type CheckResult struct { + DefaultPubExists bool + DefaultPriExists bool + } + + pub, pri := m.DefaultCertExistsSep() + js, _ := json.Marshal(CheckResult{ + pub, + pri, + }) + + utils.SendJSONResponse(w, string(js)) +} + +func (m *Manager) HandleSelfSignCertGenerate(w http.ResponseWriter, r *http.Request) { + // Get the common name from the request + cn, err := utils.GetPara(r, "cn") + if err != nil { + utils.SendErrorResponse(w, "Common name not provided") + return + } + + domains, err := utils.PostPara(r, "domains") + if err != nil { + //No alias domains provided, use the common name as the only domain + domains = "[]" + } + + SANs := []string{} + if err := json.Unmarshal([]byte(domains), &SANs); err != nil { + utils.SendErrorResponse(w, "Invalid domains format: "+err.Error()) + return + } + //SANs = append([]string{cn}, SANs...) + priKeyFilename := domainToFilename(cn, ".key") + pubKeyFilename := domainToFilename(cn, ".pem") + + // Generate self-signed certificate + err = m.GenerateSelfSignedCertificate(cn, SANs, pubKeyFilename, priKeyFilename) + if err != nil { + utils.SendErrorResponse(w, "Failed to generate self-signed certificate: "+err.Error()) + return + } + + //Update the certificate store + err = m.UpdateLoadedCertList() + if err != nil { + utils.SendErrorResponse(w, "Failed to update certificate store: "+err.Error()) + return + } + utils.SendOK(w) +} diff --git a/src/mod/tlscert/helper.go b/src/mod/tlscert/helper.go index a637d65..0704723 100644 --- a/src/mod/tlscert/helper.go +++ b/src/mod/tlscert/helper.go @@ -43,3 +43,30 @@ func matchClosestDomainCertificate(subdomain string, domains []string) string { return matchingDomain } + +// Convert a domain name to a filename format +func domainToFilename(domain string, ext string) string { + // Replace wildcard '*' with '_' + domain = strings.TrimSpace(domain) + if strings.HasPrefix(domain, "*") { + domain = "_" + strings.TrimPrefix(domain, "*") + } + + // Add .pem extension + ext = strings.TrimPrefix(ext, ".") // Ensure ext does not start with a dot + return domain + "." + ext +} + +func filenameToDomain(filename string) string { + // Remove the extension + ext := filepath.Ext(filename) + if ext != "" { + filename = strings.TrimSuffix(filename, ext) + } + + if strings.HasPrefix(filename, "_") { + filename = "*" + filename[1:] + } + + return filename +} diff --git a/src/mod/tlscert/tlscert.go b/src/mod/tlscert/tlscert.go index aae4401..a4157c1 100644 --- a/src/mod/tlscert/tlscert.go +++ b/src/mod/tlscert/tlscert.go @@ -21,10 +21,10 @@ type CertCache struct { } type HostSpecificTlsBehavior struct { - DisableSNI bool //If SNI is enabled for this server name - DisableLegacyCertificateMatching bool //If legacy certificate matching is disabled for this server name - EnableAutoHTTPS bool //If auto HTTPS is enabled for this server name - PreferredCertificate string //Preferred certificate for this server name, if empty, use the first matching certificate + DisableSNI bool //If SNI is enabled for this server name + DisableLegacyCertificateMatching bool //If legacy certificate matching is disabled for this server name + EnableAutoHTTPS bool //If auto HTTPS is enabled for this server name + PreferredCertificate map[string]string //Preferred certificate for this server name, if empty, use the first matching certificate } type Manager struct { @@ -34,13 +34,12 @@ type Manager struct { /* External handlers */ hostSpecificTlsBehavior func(serverName string) (*HostSpecificTlsBehavior, error) // Function to get host specific TLS behavior, if nil, use global TLS options - verbal bool } //go:embed localhost.pem localhost.key var buildinCertStore embed.FS -func NewManager(certStore string, verbal bool, logger *logger.Logger) (*Manager, error) { +func NewManager(certStore string, logger *logger.Logger) (*Manager, error) { if !utils.FileExists(certStore) { os.MkdirAll(certStore, 0775) } @@ -63,7 +62,6 @@ func NewManager(certStore string, verbal bool, logger *logger.Logger) (*Manager, CertStore: certStore, LoadedCerts: []*CertCache{}, hostSpecificTlsBehavior: defaultHostSpecificTlsBehavior, //Default to no SNI and no auto HTTPS - verbal: verbal, Logger: logger, } @@ -82,7 +80,7 @@ func GetDefaultHostSpecificTlsBehavior() *HostSpecificTlsBehavior { DisableSNI: false, DisableLegacyCertificateMatching: false, EnableAutoHTTPS: false, - PreferredCertificate: "", + PreferredCertificate: map[string]string{}, // No preferred certificate, use the first matching certificate } } @@ -90,6 +88,10 @@ func defaultHostSpecificTlsBehavior(serverName string) (*HostSpecificTlsBehavior return GetDefaultHostSpecificTlsBehavior(), nil } +func (m *Manager) SetHostSpecificTlsBehavior(fn func(serverName string) (*HostSpecificTlsBehavior, error)) { + m.hostSpecificTlsBehavior = fn +} + // Update domain mapping from file func (m *Manager) UpdateLoadedCertList() error { //Get a list of certificates from file @@ -213,13 +215,17 @@ func (m *Manager) GetCertificateByHostname(hostname string) (string, string, err if err != nil { tlsBehavior, _ = defaultHostSpecificTlsBehavior(hostname) } + preferredCertificate, ok := tlsBehavior.PreferredCertificate[hostname] + if !ok { + preferredCertificate = "" + } - if tlsBehavior.DisableSNI && tlsBehavior.PreferredCertificate != "" && - utils.FileExists(filepath.Join(m.CertStore, tlsBehavior.PreferredCertificate+".pem")) && - utils.FileExists(filepath.Join(m.CertStore, tlsBehavior.PreferredCertificate+".key")) { + if tlsBehavior.DisableSNI && preferredCertificate != "" && + utils.FileExists(filepath.Join(m.CertStore, preferredCertificate+".pem")) && + utils.FileExists(filepath.Join(m.CertStore, preferredCertificate+".key")) { //User setup a Preferred certificate, use the preferred certificate directly - pubKey = filepath.Join(m.CertStore, tlsBehavior.PreferredCertificate+".pem") - priKey = filepath.Join(m.CertStore, tlsBehavior.PreferredCertificate+".key") + pubKey = filepath.Join(m.CertStore, preferredCertificate+".pem") + priKey = filepath.Join(m.CertStore, preferredCertificate+".key") } else { if !tlsBehavior.DisableLegacyCertificateMatching && utils.FileExists(filepath.Join(m.CertStore, hostname+".pem")) && diff --git a/src/reverseproxy.go b/src/reverseproxy.go index 0054f1e..6bc5e07 100644 --- a/src/reverseproxy.go +++ b/src/reverseproxy.go @@ -140,7 +140,7 @@ func ReverseProxtInit() { err := LoadReverseProxyConfig(conf) if err != nil { SystemWideLogger.PrintAndLog("proxy-config", "Failed to load config file: "+filepath.Base(conf), err) - return + continue } } @@ -717,6 +717,11 @@ func ReverseProxyHandleSetTlsConfig(w http.ResponseWriter, r *http.Request) { return } + if newTlsConfig.PreferredCertificate == nil { + //No update needed, reuse the current TLS config + newTlsConfig.PreferredCertificate = ept.TlsOptions.PreferredCertificate + } + ept.TlsOptions = newTlsConfig //Prepare to replace the current routing rule diff --git a/src/start.go b/src/start.go index 7f191ea..359da3a 100644 --- a/src/start.go +++ b/src/start.go @@ -1,7 +1,6 @@ package main import ( - "imuslab.com/zoraxy/mod/auth/sso/oauth2" "log" "net/http" "os" @@ -10,6 +9,8 @@ import ( "strings" "time" + "imuslab.com/zoraxy/mod/auth/sso/oauth2" + "github.com/gorilla/csrf" "imuslab.com/zoraxy/mod/access" "imuslab.com/zoraxy/mod/acme" @@ -99,7 +100,7 @@ func startupSequence() { }) //Create a TLS certificate manager - tlsCertManager, err = tlscert.NewManager(CONF_CERT_STORE, *development_build, SystemWideLogger) + tlsCertManager, err = tlscert.NewManager(CONF_CERT_STORE, SystemWideLogger) if err != nil { panic(err) } @@ -366,6 +367,9 @@ func finalSequence() { //Inject routing rules registerBuildInRoutingRules() + + //Set the host specific TLS behavior resolver for resolving TLS behavior for each hostname + tlsCertManager.SetHostSpecificTlsBehavior(dynamicProxyRouter.ResolveHostSpecificTlsBehaviorForHostname) } /* Shutdown Sequence */ diff --git a/src/web/components/httprp.html b/src/web/components/httprp.html index 1f29a5f..1293b07 100644 --- a/src/web/components/httprp.html +++ b/src/web/components/httprp.html @@ -339,11 +339,15 @@

The table below shows which certificate will be served by Zoraxy when a client request the following hostnames.

- + +
- + @@ -359,18 +363,20 @@
-
+
-

+
+
@@ -747,7 +753,7 @@ let newTlsOption = { "DisableSNI": !enableSNI, "DisableLegacyCertificateMatching": !enableLegacyCertificateMatching, - "EnableAutoHTTPS": enableAutoHTTPS + "EnableAutoHTTPS": enableAutoHTTPS, } $.cjax({ url: "/api/proxy/setTlsConfig", @@ -769,6 +775,9 @@ function updateTlsResolveList(uuid){ let editor = $("#httprpEditModalWrapper"); + editor.find(".certificateDropdown .ui.dropdown").off("change"); + editor.find(".certificateDropdown .ui.dropdown").remove(); + //Update the TLS resolve list $.ajax({ url: "/api/cert/resolve?domain=" + uuid, @@ -785,17 +794,60 @@ resolveList.append(` - + `); aliasDomains.forEach(alias => { resolveList.append(` - + `); }); + + //Generate the certificate dropdown + generateCertificateDropdown(function(dropdown) { + let SNIEnabled = editor.find(".Tls_EnableSNI")[0].checked; + editor.find(".certificateDropdown").html(dropdown); + editor.find(".certificateDropdown").each(function() { + let dropdownDomain = $(this).attr("domain"); + let selectedCertname = certMap[dropdownDomain]; + if (selectedCertname) { + $(this).find(".ui.dropdown").dropdown("set selected", selectedCertname); + } + }); + + editor.find(".certificateDropdown .ui.dropdown").dropdown({ + onChange: function(value, text, $selectedItem) { + console.log("Selected certificate for domain:", $(this).parent().attr("domain"), "Value:", value); + let domain = $(this).parent().attr("domain"); + let newCertificateName = value; + $.cjax({ + url: "/api/cert/setPreferredCertificate", + method: "POST", + data: { + "domain": domain, + "certname": newCertificateName + }, + success: function(data) { + if (data.error !== undefined) { + msgbox(data.error, false, 3000); + } else { + msgbox("Preferred Certificate updated"); + } + } + }); + } + }); + + if (SNIEnabled) { + editor.find(".certificateDropdown .ui.dropdown").addClass("disabled"); + editor.find(".sni_grey_out_info").show(); + }else{ + editor.find(".sni_grey_out_info").hide(); + } + }); } }); } @@ -946,6 +998,29 @@ renewCertificate(renewDomainKey, false, btn); } + function generateSelfSignedCertificate(uuid, domains, btn=undefined){ + let payload = JSON.stringify(domains); + $.cjax({ + url: "/api/cert/selfsign", + data: { + "cn": uuid, + "domains": payload + }, + success: function(data){ + if (data.error == undefined){ + msgbox("Self-Signed Certificate Generated", true); + resyncProxyEditorConfig(); + if (typeof(initManagedDomainCertificateList) != undefined){ + //Re-init the managed domain certificate list + initManagedDomainCertificateList(); + } + }else{ + msgbox(data.error, false); + } + } + }); + } + /* Tags & Search */ function handleSearchInput(event){ if (event.key == "Escape"){ @@ -1074,6 +1149,28 @@ return subd; } + // Generate a certificate dropdown for the HTTP Proxy Rule Editor + // so user can pick which certificate they want to use for the current editing hostname + function generateCertificateDropdown(callback){ + $.ajax({ + url: "/api/cert/list", + method: "GET", + success: function(data) { + let dropdown = $(''); + let menu = $(''); + data.forEach(cert => { + menu.append(`
${cert}
`); + }); + // Add a hidden input to store the selected certificate + dropdown.append(''); + dropdown.append(''); + dropdown.append('
Fallback Certificate
'); + dropdown.append(menu); + callback(dropdown); + } + }) + } + //Initialize the http proxy rule editor function initHttpProxyRuleEditorModal(rulepayload){ let subd = JSON.parse(JSON.stringify(rulepayload)); @@ -1175,39 +1272,6 @@ }); editor.find(".downstream_alias_hostname").html(aliasHTML); - - //TODO: Move this to SSL TLS section - let enableQuickRequestButton = true; - let domains = [subd.RootOrMatchingDomain]; //Domain for getting certificate if needed - for (var i = 0; i < subd.MatchingDomainAlias.length; i++){ - let thisAliasName = subd.MatchingDomainAlias[i]; - domains.push(thisAliasName); - } - - //Check if the domain or alias contains wildcard, if yes, disabled the get certificate button - if (subd.RootOrMatchingDomain.indexOf("*") > -1){ - enableQuickRequestButton = false; - } - - if (subd.MatchingDomainAlias != undefined){ - for (var i = 0; i < subd.MatchingDomainAlias.length; i++){ - if (subd.MatchingDomainAlias[i].indexOf("*") > -1){ - enableQuickRequestButton = false; - break; - } - } - } - - let certificateDomains = encodeURIComponent(JSON.stringify(domains)); - if (enableQuickRequestButton){ - editor.find(".getCertificateBtn").removeClass("disabled"); - }else{ - editor.find(".getCertificateBtn").addClass("disabled"); - } - - editor.find(".getCertificateBtn").off("click").on("click", function(){ - requestCertificateForExistingHost(uuid, certificateDomains, this); - }); /* ------------ Upstreams ------------ */ editor.find(".upstream_list").html(renderUpstreamList(subd)); @@ -1237,6 +1301,8 @@ editor.find(".vdir_list").html(renderVirtualDirectoryList(subd)); editor.find(".editVdirBtn").off("click").on("click", function(){ quickEditVdir(uuid); + //Temporary restore scroll + $("body").css("overflow", "auto"); }); /* ------------ Alias ------------ */ @@ -1336,6 +1402,7 @@ /* ------------ TLS ------------ */ updateTlsResolveList(uuid); editor.find(".Tls_EnableSNI").prop("checked", !subd.TlsOptions.DisableSNI); + editor.find(".Tls_EnableLegacyCertificateMatching").prop("checked", !subd.TlsOptions.DisableLegacyCertificateMatching); editor.find(".Tls_EnableAutoHTTPS").prop("checked", !!subd.TlsOptions.EnableAutoHTTPS); @@ -1349,6 +1416,45 @@ saveTlsConfigs(uuid); }); + /* Quick access to get certificate for the current host */ + let enableQuickRequestButton = true; + let domains = [subd.RootOrMatchingDomain]; //Domain for getting certificate if needed + for (var i = 0; i < subd.MatchingDomainAlias.length; i++){ + let thisAliasName = subd.MatchingDomainAlias[i]; + domains.push(thisAliasName); + } + + //Check if the domain or alias contains wildcard, if yes, disabled the get certificate button + if (subd.RootOrMatchingDomain.indexOf("*") > -1){ + enableQuickRequestButton = false; + } + + if (subd.MatchingDomainAlias != undefined){ + for (var i = 0; i < subd.MatchingDomainAlias.length; i++){ + if (subd.MatchingDomainAlias[i].indexOf("*") > -1){ + enableQuickRequestButton = false; + break; + } + } + } + if (enableQuickRequestButton){ + editor.find(".getCertificateBtn").removeClass("disabled"); + }else{ + editor.find(".getCertificateBtn").addClass("disabled"); + } + + editor.find(".getCertificateBtn").off("click").on("click", function(){ + let certificateDomains = encodeURIComponent(JSON.stringify(domains)); + requestCertificateForExistingHost(uuid, certificateDomains, this); + }); + + // Bind event to self-signed certificate button + editor.find(".getSelfSignCertBtn").off("click").on("click", function() { + generateSelfSignedCertificate(uuid, domains, this); + }); + + + /* ------------ Tags ------------ */ (()=>{ let payload = encodeURIComponent(JSON.stringify({ @@ -1411,7 +1517,6 @@ }); } - /* Page Initialization Functions */ @@ -1436,7 +1541,9 @@ // there is a chance where the user has modified the Vdir // we need to get the latest setting from server side and // render it again - updateVdirInProxyEditor(); + resyncProxyEditorConfig(); + window.scrollTo(0, 0); + $("body").css("overflow", "hidden"); } else { listProxyEndpoints(); //Reset the tag filter diff --git a/src/web/components/sso.html b/src/web/components/sso.html index e8531c7..0224f04 100644 --- a/src/web/components/sso.html +++ b/src/web/components/sso.html @@ -151,11 +151,31 @@ dataType: 'json', success: function(data) { $('#forwardAuthAddress').val(data.address); - $('#forwardAuthResponseHeaders').val(data.responseHeaders.join(",")); - $('#forwardAuthResponseClientHeaders').val(data.responseClientHeaders.join(",")); - $('#forwardAuthRequestHeaders').val(data.requestHeaders.join(",")); - $('#forwardAuthRequestIncludedCookies').val(data.requestIncludedCookies.join(",")); - $('#forwardAuthRequestExcludedCookies').val(data.requestExcludedCookies.join(",")); + if (data.responseHeaders != null) { + $('#forwardAuthResponseHeaders').val(data.responseHeaders.join(",")); + } else { + $('#forwardAuthResponseHeaders').val(""); + } + if (data.responseClientHeaders != null) { + $('#forwardAuthResponseClientHeaders').val(data.responseClientHeaders.join(",")); + } else { + $('#forwardAuthResponseClientHeaders').val(""); + } + if (data.requestHeaders != null) { + $('#forwardAuthRequestHeaders').val(data.requestHeaders.join(",")); + } else { + $('#forwardAuthRequestHeaders').val(""); + } + if (data.requestIncludedCookies != null) { + $('#forwardAuthRequestIncludedCookies').val(data.requestIncludedCookies.join(",")); + } else { + $('#forwardAuthRequestIncludedCookies').val(""); + } + if (data.requestExcludedCookies != null) { + $('#forwardAuthRequestExcludedCookies').val(data.requestExcludedCookies.join(",")); + } else { + $('#forwardAuthRequestExcludedCookies').val(""); + } }, error: function(jqXHR, textStatus, errorThrown) { console.error('Error fetching SSO settings:', textStatus, errorThrown); From c4c10d213000d89430a646897f1dc48a5f47ac89 Mon Sep 17 00:00:00 2001 From: Toby Chui Date: Sat, 12 Jul 2025 19:52:59 +0800 Subject: [PATCH 13/16] Fixed #713 - Fixed sorting destination not working bug --- src/web/components/httprp.html | 24 +++++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/src/web/components/httprp.html b/src/web/components/httprp.html index 1293b07..e5fcc27 100644 --- a/src/web/components/httprp.html +++ b/src/web/components/httprp.html @@ -586,6 +586,28 @@ aliasDomains += `
`; } + //Build the sorting value + let destSortValue = subd.ActiveOrigins.map(o => { + // Check if it's an IP address (with optional port) + let upstreamAddr = o.OriginIpOrDomain; + let subpath = ""; + if (upstreamAddr.indexOf("/") !== -1) { + let parts = upstreamAddr.split("/"); + subpath = parts.slice(1).join("/"); + upstreamAddr = parts[0]; + } + let ipPortRegex = /^(\d{1,3}\.){3}\d{1,3}(:\d+)?$/; + if (ipPortRegex.test(upstreamAddr)) { + let [ip, port] = upstreamAddr.split(":"); + // Convert IP to hex + let hexIp = ip.split('.').map(x => ('00' + parseInt(x).toString(16)).slice(-2)).join(''); + let hexPort = port ? (port.length < 5 ? port.padStart(5, '0') : port) : ''; + return hexIp + (hexPort ? ':' + hexPort : '') + "/" + subpath; + } + // Otherwise, treat it as a domain name + return upstreamAddr; + }).join(","); + //Build tag list let tagList = renderTagList(subd); let tagListEmpty = (subd.Tags.length == 0); @@ -602,7 +624,7 @@ ${aliasDomains} -
HostnameResolve to CertificateResolve to Certificate
${primaryDomain}${certMap[primaryDomain] || "Fallback Certificate"}${certMap[primaryDomain] || "Fallback Certificate"}
${alias}${certMap[alias] || "Fallback Certificate"}${certMap[alias] || "Fallback Certificate"}
+
${upstreams}
From a33600d3e23bd6a1d7fce4df23dd35e487d02085 Mon Sep 17 00:00:00 2001 From: Jemmy Date: Wed, 16 Jul 2025 08:11:25 +0800 Subject: [PATCH 14/16] Fix Stream Proxy TCP/UDP selection not saved initially #742 - Reset the value of the form correctly in `streamprox.html`. Ref #742. --- src/web/components/streamprox.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/web/components/streamprox.html b/src/web/components/streamprox.html index c8883bd..bddac54 100644 --- a/src/web/components/streamprox.html +++ b/src/web/components/streamprox.html @@ -137,7 +137,7 @@ }); function clearStreamProxyAddEditForm(){ - $('#streamProxyForm input, #streamProxyForm select').val(''); + $('#streamProxyForm').find('input:not([type=checkbox]), select').val(''); $('#streamProxyForm select').dropdown('clear'); $("#streamProxyForm input[name=timeout]").val(10); $("#streamProxyForm .toggle.checkbox").checkbox("set unchecked"); From 100c1e9c046daf65d91486a9e1fdc91631148e38 Mon Sep 17 00:00:00 2001 From: Anthony Rubick <68485672+AnthonyMichaelTDM@users.noreply.github.com> Date: Sun, 13 Jul 2025 16:22:49 -0700 Subject: [PATCH 15/16] fix: typo in dynamic_router.go SniffResultAccpet should be SniffResultAccept --- src/mod/plugins/zoraxy_plugin/dynamic_router.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/mod/plugins/zoraxy_plugin/dynamic_router.go b/src/mod/plugins/zoraxy_plugin/dynamic_router.go index 1dc53ce..22e56be 100644 --- a/src/mod/plugins/zoraxy_plugin/dynamic_router.go +++ b/src/mod/plugins/zoraxy_plugin/dynamic_router.go @@ -17,7 +17,7 @@ import ( type SniffResult int const ( - SniffResultAccpet SniffResult = iota // Forward the request to this plugin dynamic capture ingress + SniffResultAccept SniffResult = iota // Forward the request to this plugin dynamic capture ingress SniffResultSkip // Skip this plugin and let the next plugin handle the request ) @@ -62,7 +62,7 @@ func (p *PathRouter) RegisterDynamicSniffHandler(sniff_ingress string, mux *http payload.rawRequest = r sniffResult := handler(&payload) - if sniffResult == SniffResultAccpet { + if sniffResult == SniffResultAccept { w.WriteHeader(http.StatusOK) w.Write([]byte("OK")) } else { From aff1975c5aba80a540272f5bb2756a1c66e92a67 Mon Sep 17 00:00:00 2001 From: Toby Chui Date: Sun, 20 Jul 2025 14:03:39 +0800 Subject: [PATCH 16/16] Updated version code and defs - Updated version code - Replaced hardcoded path of some config folder string with const value --- src/config.go | 16 ++++++++-------- src/def.go | 23 ++++++++++++++--------- src/main.go | 2 +- src/reverseproxy.go | 2 +- 4 files changed, 24 insertions(+), 19 deletions(-) diff --git a/src/config.go b/src/config.go index e3257b3..43b6886 100644 --- a/src/config.go +++ b/src/config.go @@ -108,9 +108,9 @@ func filterProxyConfigFilename(filename string) string { func SaveReverseProxyConfig(endpoint *dynamicproxy.ProxyEndpoint) error { //Get filename for saving - filename := filepath.Join("./conf/proxy/", endpoint.RootOrMatchingDomain+".config") + filename := filepath.Join(CONF_HTTP_PROXY, endpoint.RootOrMatchingDomain+".config") if endpoint.ProxyType == dynamicproxy.ProxyTypeRoot { - filename = "./conf/proxy/root.config" + filename = filepath.Join(CONF_HTTP_PROXY, "root.config") } filename = filterProxyConfigFilename(filename) @@ -125,9 +125,9 @@ func SaveReverseProxyConfig(endpoint *dynamicproxy.ProxyEndpoint) error { } func RemoveReverseProxyConfig(endpoint string) error { - filename := filepath.Join("./conf/proxy/", endpoint+".config") + filename := filepath.Join(CONF_HTTP_PROXY, endpoint+".config") if endpoint == "/" { - filename = "./conf/proxy/root.config" + filename = filepath.Join(CONF_HTTP_PROXY, "/root.config") } filename = filterProxyConfigFilename(filename) @@ -179,11 +179,11 @@ func ExportConfigAsZip(w http.ResponseWriter, r *http.Request) { } // Specify the folder path to be zipped - if !utils.FileExists("./conf") { + if !utils.FileExists(CONF_FOLDER) { SystemWideLogger.PrintAndLog("Backup", "Configuration folder not found", nil) return } - folderPath := "./conf" + folderPath := CONF_FOLDER // Set the Content-Type header to indicate it's a zip file w.Header().Set("Content-Type", "application/zip") @@ -284,12 +284,12 @@ func ImportConfigFromZip(w http.ResponseWriter, r *http.Request) { return } // Create the target directory to unzip the files - targetDir := "./conf" + targetDir := CONF_FOLDER if utils.FileExists(targetDir) { //Backup the old config to old //backupPath := filepath.Dir(*path_conf) + filepath.Base(*path_conf) + ".old_" + strconv.Itoa(int(time.Now().Unix())) //os.Rename(*path_conf, backupPath) - os.Rename("./conf", "./conf.old_"+strconv.Itoa(int(time.Now().Unix()))) + os.Rename(CONF_FOLDER, CONF_FOLDER+".old_"+strconv.Itoa(int(time.Now().Unix()))) } err = os.MkdirAll(targetDir, os.ModePerm) diff --git a/src/def.go b/src/def.go index 2e47323..e36f08b 100644 --- a/src/def.go +++ b/src/def.go @@ -44,7 +44,7 @@ import ( const ( /* Build Constants */ SYSTEM_NAME = "Zoraxy" - SYSTEM_VERSION = "3.2.4" + SYSTEM_VERSION = "3.2.5" DEVELOPMENT_BUILD = false /* System Constants */ @@ -63,14 +63,19 @@ const ( LOG_EXTENSION = ".log" STATISTIC_AUTO_SAVE_INTERVAL = 600 /* Seconds */ - /* Configuration Folder Storage Path Constants */ - CONF_HTTP_PROXY = "./conf/proxy" - CONF_STREAM_PROXY = "./conf/streamproxy" - CONF_CERT_STORE = "./conf/certs" - CONF_REDIRECTION = "./conf/redirect" - CONF_ACCESS_RULE = "./conf/access" - CONF_PATH_RULE = "./conf/rules/pathrules" - CONF_PLUGIN_GROUPS = "./conf/plugin_groups.json" + /* + Configuration Folder Storage Path Constants + Note: No tailing slash in the path + */ + CONF_FOLDER = "./conf" + CONF_HTTP_PROXY = CONF_FOLDER + "/proxy" + CONF_STREAM_PROXY = CONF_FOLDER + "/streamproxy" + CONF_CERT_STORE = CONF_FOLDER + "/certs" + CONF_REDIRECTION = CONF_FOLDER + "/redirect" + CONF_ACCESS_RULE = CONF_FOLDER + "/access" + CONF_PATH_RULE = CONF_FOLDER + "/rules/pathrules" + CONF_PLUGIN_GROUPS = CONF_FOLDER + "/plugin_groups.json" + CONF_GEODB_PATH = CONF_FOLDER + "/geodb" ) /* System Startup Flags */ diff --git a/src/main.go b/src/main.go index f577d50..c848f59 100644 --- a/src/main.go +++ b/src/main.go @@ -69,7 +69,7 @@ func main() { os.Exit(0) } if *geoDbUpdate { - geodb.DownloadGeoDBUpdate("./conf/geodb") + geodb.DownloadGeoDBUpdate(CONF_GEODB_PATH) os.Exit(0) } diff --git a/src/reverseproxy.go b/src/reverseproxy.go index 6bc5e07..61c14e9 100644 --- a/src/reverseproxy.go +++ b/src/reverseproxy.go @@ -135,7 +135,7 @@ func ReverseProxtInit() { Load all conf from files */ - confs, _ := filepath.Glob("./conf/proxy/*.config") + confs, _ := filepath.Glob(CONF_HTTP_PROXY + "/*.config") for _, conf := range confs { err := LoadReverseProxyConfig(conf) if err != nil {