Explorar el Código

feat: add reverse proxy with domain/IP/path routing, subfolder forwarding, and SSL/TLS certificate integration

Proxmox Router hace 2 días
padre
commit
b8c4894d02

+ 1 - 1
VERSION

@@ -1 +1 @@
-1.4.1
+1.5.0

BIN
bin/debtik


BIN
bin/debtik-cli


+ 90 - 1
cmd/server/main.go

@@ -27,6 +27,7 @@ import (
 	"debtik/internal/firewall"
 	"debtik/internal/logging"
 	"debtik/internal/network"
+	"debtik/internal/reverseproxy"
 	"debtik/internal/routing"
 	"debtik/internal/services"
 	"debtik/internal/tools"
@@ -76,6 +77,10 @@ func main() {
 	renewCtx, stopRenewal := context.WithCancel(context.Background())
 	defer stopRenewal()
 	go certMgr.RunRenewal(renewCtx)
+
+	proxyMgr := reverseproxy.NewManager("", certMgr)
+	defer proxyMgr.Stop()
+
 	webHandler := web.Handler()
 
 	// Build Unified RouterOS CLI Executor
@@ -94,6 +99,7 @@ func main() {
 		bwtestMgr,
 		servicesMgr,
 		certMgr,
+		proxyMgr,
 	)
 
 	// Determine Telnet & SSH addresses from services config or flags
@@ -167,9 +173,81 @@ func main() {
 		authMgr,
 		servicesMgr,
 		certMgr,
+		proxyMgr,
 		webHandler,
 	)
 
+	// Standalone HTTPS Proxy listener when www-ssl is disabled
+	var (
+		proxyHTTPSServer  *http.Server
+		proxyHTTPSMu      sync.Mutex
+		proxyHTTPSRunning bool
+	)
+
+	startOrRestartProxyHTTPS := func() error {
+		proxyHTTPSMu.Lock()
+		defer proxyHTTPSMu.Unlock()
+
+		if proxyHTTPSRunning && proxyHTTPSServer != nil {
+			ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
+			_ = proxyHTTPSServer.Shutdown(ctx)
+			cancel()
+			proxyHTTPSRunning = false
+			proxyHTTPSServer = nil
+		}
+
+		// If www-ssl service is running, port 443 is already served by it
+		sslSvc, ok := servicesMgr.GetService("www-ssl")
+		if ok && !sslSvc.Disabled {
+			return nil
+		}
+
+		cfg := proxyMgr.GetConfig()
+		if !cfg.Enabled || cfg.HTTPSPort <= 0 {
+			return nil
+		}
+
+		tlsConfig := &tls.Config{
+			GetCertificate: proxyMgr.GetTLSCertificate,
+		}
+
+		listenAddr := fmt.Sprintf(":%d", cfg.HTTPSPort)
+		newSrv := &http.Server{
+			Addr:         listenAddr,
+			Handler:      srv,
+			TLSConfig:    tlsConfig,
+			ReadTimeout:  60 * time.Second,
+			WriteTimeout: 60 * time.Second,
+			IdleTimeout:  120 * time.Second,
+		}
+		listener, err := tls.Listen("tcp", listenAddr, tlsConfig)
+		if err != nil {
+			return err
+		}
+		proxyHTTPSServer = newSrv
+		proxyHTTPSRunning = true
+
+		go func() {
+			log.Printf("[Proxy-HTTPS] Standalone reverse proxy HTTPS server listening on %s", listenAddr)
+			if err := newSrv.Serve(listener); err != nil && err != http.ErrServerClosed {
+				log.Printf("[Proxy-HTTPS] Server error: %v", err)
+				proxyHTTPSMu.Lock()
+				proxyHTTPSRunning = false
+				proxyHTTPSMu.Unlock()
+			}
+		}()
+		return nil
+	}
+	defer func() {
+		proxyHTTPSMu.Lock()
+		if proxyHTTPSServer != nil {
+			ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
+			_ = proxyHTTPSServer.Shutdown(ctx)
+			cancel()
+		}
+		proxyHTTPSMu.Unlock()
+	}()
+
 	// HTTPS (www-ssl) Dynamic Server Listener
 	var (
 		httpsServer  *http.Server
@@ -207,6 +285,10 @@ func main() {
 		tlsConfig := &tls.Config{
 			Certificates: []tls.Certificate{tlsCert},
 			GetCertificate: func(hello *tls.ClientHelloInfo) (*tls.Certificate, error) {
+				// Priority: Check if reverse proxy has a rule matching this SNI domain with a certificate
+				if cert, err := proxyMgr.GetTLSCertificate(hello); err == nil {
+					return cert, nil
+				}
 				curSvc, ok := servicesMgr.GetService("www-ssl")
 				if ok && curSvc.Certificate != "" {
 					if c, err := certMgr.GetTLSCertificate(curSvc.Certificate); err == nil {
@@ -246,13 +328,20 @@ func main() {
 	}
 
 	servicesMgr.RegisterHook("www-ssl", func(serviceName string, port int, disabled bool) error {
-		return startOrRestartHTTPS(port, disabled)
+		err := startOrRestartHTTPS(port, disabled)
+		_ = startOrRestartProxyHTTPS()
+		return err
 	})
 
 	if sslSvc, ok := servicesMgr.GetService("www-ssl"); ok && !sslSvc.Disabled {
 		if err := startOrRestartHTTPS(sslSvc.Port, sslSvc.Disabled); err != nil {
 			log.Printf("HTTPS startup failed: %v", err)
 		}
+	} else {
+		// If www-ssl is disabled, start standalone proxy HTTPS listener
+		if err := startOrRestartProxyHTTPS(); err != nil {
+			log.Printf("Standalone HTTPS proxy startup note: %v", err)
+		}
 	}
 	defer func() {
 		httpsMu.Lock()

+ 121 - 0
docs/ReverseProxy.md

@@ -0,0 +1,121 @@
+# DebTik Reverse Proxy & Application Gateway
+
+DebTik features an enterprise-grade, high-performance **Reverse Proxy & Application Gateway** engine integrated natively into the appliance controller.
+
+It enables transparent reverse proxying of incoming HTTP and HTTPS traffic matching specific domain names, hostnames, IP addresses, or path prefixes, terminating SSL/TLS certificates issued or managed by the DebTik Certificate Manager, and forwarding requests to internal or external backend services while preserving complete subfolder hierarchies and query strings.
+
+---
+
+## Key Capabilities
+
+1. **Protocol Flexibility:**
+   - Terminate and route HTTP, HTTPS, or dual HTTP/HTTPS.
+   - Built-in HTTP-to-HTTPS redirect engine (301 Moved Permanently).
+   - WebSocket and HTTP Upgrade transparent tunneling.
+
+2. **Domain, Hostname & IP Matching:**
+   - Match exact domains (e.g., `app.example.com`, `nextcloud.home`).
+   - Match wildcard subdomains (e.g., `*.example.com`).
+   - Match router or virtual IP addresses (e.g., `192.168.1.1`, `10.99.1.1`).
+   - Global catch-all matching (`*`).
+
+3. **Path-Based Routing & Subfolder Preservation:**
+   - Match by path prefix (e.g., `/app1`, `/api/v1`, or root `/`).
+   - Automatically and seamlessly preserves deep nested subfolder paths:
+     - Request to `http://example.com/app1/module1/attribute1?filter=active`
+     - Forwarded to backend with complete subpath `/module1/attribute1` and query parameters.
+   - Supports both root and subpath backend targets:
+     - Target: `http://example.com/app1` (target has subpath)
+     - Target: `http://10.0.0.5:8080` (root target)
+
+4. **Strip Path Prefix:**
+   - Optional toggle (`strip_path: true`).
+   - When enabled, strips the incoming frontend path prefix before sending the request to the backend.
+   - Example: Frontend path `/service1`, Request `/service1/users` -> Backend receives `/users`.
+
+5. **Integrated SSL/TLS & Dynamic SNI:**
+   - Fully integrated with the DebTik Certificate Manager (`/certificate`).
+   - Select any certificate (Let's Encrypt, internal CA-signed, or imported PEM) per rule.
+   - Dynamic SNI (Server Name Indication) automatically serves the designated certificate for each domain on port 443.
+   - Configurable fallback default certificate for unmatched HTTPS handshakes.
+
+6. **Backend Insecure TLS & Host Preservation:**
+   - `insecure_tls`: Allows proxying to HTTPS backends using self-signed or internal certificates.
+   - `preserve_host`: Forwards original client `Host` header instead of target backend hostname when needed.
+
+---
+
+## Configuration Architecture
+
+Configuration is stored in `/etc/debtik/reverse_proxy.json`.
+
+```json
+{
+  "enabled": true,
+  "http_port": 80,
+  "https_port": 443,
+  "default_cert": "ns0-eparmak-eu",
+  "rules": [
+    {
+      "id": "proxy-1793627577980971773",
+      "name": "App1 Gateway",
+      "enabled": true,
+      "domains": ["example.com", "app.example.com"],
+      "path": "/app1",
+      "target_url": "http://example.com/app1",
+      "strip_path": false,
+      "preserve_host": false,
+      "protocol": "both",
+      "certificate": "ns0-eparmak-eu",
+      "http_to_https": true,
+      "insecure_tls": false,
+      "comment": "Production App1 backend forwarding"
+    }
+  ]
+}
+```
+
+---
+
+## RouterOS CLI Syntax
+
+DebTik provides full RouterOS CLI commands for the reverse proxy:
+
+### 1. View Rules & Engine Status
+```routeros
+/ip reverse-proxy print
+/ip reverse-proxy status
+```
+
+### 2. Add a Proxy Rule
+```routeros
+/ip reverse-proxy add name=Nextcloud domains=cloud.example.com path=/ target=http://10.0.0.25:8080 protocol=https certificate=cloud-cert http-to-https=yes
+/ip reverse-proxy add name=Microservice1 domains=example.com path=/app1 target=http://10.0.0.50:3000/app1 protocol=both
+```
+
+### 3. Toggle Rule State
+```routeros
+/ip reverse-proxy enable <id>
+/ip reverse-proxy disable <id>
+```
+
+### 4. Remove a Rule
+```routeros
+/ip reverse-proxy remove <id>
+```
+
+---
+
+## REST API Reference
+
+| Method | Endpoint | Description |
+|---|---|---|
+| `GET` | `/api/proxy/config` | Get global engine settings |
+| `POST` | `/api/proxy/config` | Update global engine settings |
+| `GET` | `/api/proxy/rules` | List all proxy rules |
+| `POST` | `/api/proxy/rules` | Create a new proxy rule |
+| `GET` | `/api/proxy/rules/{id}` | Get specific rule details |
+| `PUT` | `/api/proxy/rules/{id}` | Update existing rule |
+| `DELETE` | `/api/proxy/rules/{id}` | Delete proxy rule |
+| `POST` | `/api/proxy/rules/{id}/state` | Toggle rule enabled/disabled state |
+| `GET` | `/api/proxy/status` | Runtime status, ports, and telemetry |

+ 29 - 2
internal/api/api.go

@@ -18,6 +18,7 @@ import (
 	"debtik/internal/dynamicrouting"
 	"debtik/internal/firewall"
 	"debtik/internal/network"
+	"debtik/internal/reverseproxy"
 	"debtik/internal/routing"
 	"debtik/internal/services"
 	"debtik/internal/system"
@@ -46,11 +47,12 @@ type Server struct {
 	authMgr       *auth.Manager
 	servicesMgr   *services.Manager
 	certMgr       *certs.CertManager
+	proxyMgr      *reverseproxy.Manager
 	mux           *http.ServeMux
 	fsHandler     http.Handler
 }
 
-func NewServer(netMgr *network.Manager, persistMgr *network.PersistenceManager, metaMgr *network.MetadataManager, vpnMgr *vpn.Manager, fwMgr *firewall.FirewallManager, routingMgr *routing.RoutingManager, dynRoutingMgr *dynamicrouting.Manager, bridgeMgr *bridge.BridgeManager, toolsMgr *tools.Manager, dhcpMgr *dhcp.Manager, dhcpServerMgr *dhcp.ServerManager, vrrpMgr *vrrp.Manager, bwtestMgr *bwtest.Manager, dnsMgr *dns.Manager, authMgr *auth.Manager, servicesMgr *services.Manager, certMgr *certs.CertManager, fsHandler http.Handler) *Server {
+func NewServer(netMgr *network.Manager, persistMgr *network.PersistenceManager, metaMgr *network.MetadataManager, vpnMgr *vpn.Manager, fwMgr *firewall.FirewallManager, routingMgr *routing.RoutingManager, dynRoutingMgr *dynamicrouting.Manager, bridgeMgr *bridge.BridgeManager, toolsMgr *tools.Manager, dhcpMgr *dhcp.Manager, dhcpServerMgr *dhcp.ServerManager, vrrpMgr *vrrp.Manager, bwtestMgr *bwtest.Manager, dnsMgr *dns.Manager, authMgr *auth.Manager, servicesMgr *services.Manager, certMgr *certs.CertManager, proxyMgr *reverseproxy.Manager, fsHandler http.Handler) *Server {
 	s := &Server{
 		netMgr:        netMgr,
 		persistMgr:    persistMgr,
@@ -69,6 +71,7 @@ func NewServer(netMgr *network.Manager, persistMgr *network.PersistenceManager,
 		authMgr:       authMgr,
 		servicesMgr:   servicesMgr,
 		certMgr:       certMgr,
+		proxyMgr:      proxyMgr,
 		mux:           http.NewServeMux(),
 		fsHandler:     fsHandler,
 	}
@@ -446,6 +449,17 @@ func (s *Server) registerRoutes() {
 	s.mux.HandleFunc("GET /api/certificates/{name}/export", s.handleExportCertificate)
 	s.mux.HandleFunc("DELETE /api/certificates/{name}", s.handleDeleteCertificate)
 
+	// Reverse Proxy & Application Gateway (/reverse-proxy)
+	s.mux.HandleFunc("GET /api/proxy/config", s.handleGetProxyConfig)
+	s.mux.HandleFunc("POST /api/proxy/config", s.handleUpdateProxyConfig)
+	s.mux.HandleFunc("GET /api/proxy/rules", s.handleListProxyRules)
+	s.mux.HandleFunc("POST /api/proxy/rules", s.handleSaveProxyRule)
+	s.mux.HandleFunc("GET /api/proxy/rules/{id}", s.handleGetProxyRule)
+	s.mux.HandleFunc("PUT /api/proxy/rules/{id}", s.handleSaveProxyRule)
+	s.mux.HandleFunc("DELETE /api/proxy/rules/{id}", s.handleDeleteProxyRule)
+	s.mux.HandleFunc("POST /api/proxy/rules/{id}/state", s.handleSetProxyRuleState)
+	s.mux.HandleFunc("GET /api/proxy/status", s.handleGetProxyStatus)
+
 	// ACME HTTP-01 Challenge responder for Let's Encrypt
 	s.mux.HandleFunc("GET /.well-known/acme-challenge/{token}", s.handleACMEChallenge)
 
@@ -495,8 +509,21 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 		return
 	}
 
-	// Authentication protection for API routes
 	path := r.URL.Path
+
+	// Reverse Proxy interceptor (prior to authentication and static handlers)
+	if s.proxyMgr != nil && s.proxyMgr.IsEnabled() {
+		isACME := strings.HasPrefix(path, "/.well-known/acme-challenge/")
+		isMgmtAPI := strings.HasPrefix(path, "/api/")
+
+		if !isACME && !isMgmtAPI {
+			if s.proxyMgr.HandleHTTP(w, r) {
+				return
+			}
+		}
+	}
+
+	// Authentication protection for API routes
 	if strings.HasPrefix(path, "/api/") {
 		// Public endpoint: Login
 		if path != "/api/auth/login" {

+ 162 - 0
internal/api/proxy_handlers.go

@@ -0,0 +1,162 @@
+package api
+
+import (
+	"encoding/json"
+	"net/http"
+	"strings"
+
+	"debtik/internal/reverseproxy"
+)
+
+func (s *Server) handleGetProxyConfig(w http.ResponseWriter, r *http.Request) {
+	if s.proxyMgr == nil {
+		writeError(w, http.StatusServiceUnavailable, "reverse proxy manager not configured")
+		return
+	}
+	cfg := s.proxyMgr.GetConfig()
+	writeJSON(w, http.StatusOK, cfg)
+}
+
+func (s *Server) handleUpdateProxyConfig(w http.ResponseWriter, r *http.Request) {
+	if s.proxyMgr == nil {
+		writeError(w, http.StatusServiceUnavailable, "reverse proxy manager not configured")
+		return
+	}
+
+	var req reverseproxy.Config
+	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+		writeError(w, http.StatusBadRequest, "invalid request payload")
+		return
+	}
+
+	if err := s.proxyMgr.UpdateConfig(req); err != nil {
+		writeError(w, http.StatusInternalServerError, err.Error())
+		return
+	}
+
+	writeJSON(w, http.StatusOK, map[string]any{
+		"success": true,
+		"message": "Reverse proxy configuration updated",
+	})
+}
+
+func (s *Server) handleListProxyRules(w http.ResponseWriter, r *http.Request) {
+	if s.proxyMgr == nil {
+		writeError(w, http.StatusServiceUnavailable, "reverse proxy manager not configured")
+		return
+	}
+	rules := s.proxyMgr.ListRules()
+	writeJSON(w, http.StatusOK, rules)
+}
+
+func (s *Server) handleGetProxyRule(w http.ResponseWriter, r *http.Request) {
+	if s.proxyMgr == nil {
+		writeError(w, http.StatusServiceUnavailable, "reverse proxy manager not configured")
+		return
+	}
+	id := r.PathValue("id")
+	rule, err := s.proxyMgr.GetRule(id)
+	if err != nil {
+		writeError(w, http.StatusNotFound, err.Error())
+		return
+	}
+	writeJSON(w, http.StatusOK, rule)
+}
+
+func (s *Server) handleSaveProxyRule(w http.ResponseWriter, r *http.Request) {
+	if s.proxyMgr == nil {
+		writeError(w, http.StatusServiceUnavailable, "reverse proxy manager not configured")
+		return
+	}
+
+	var rule reverseproxy.Rule
+	if err := json.NewDecoder(r.Body).Decode(&rule); err != nil {
+		writeError(w, http.StatusBadRequest, "invalid request payload")
+		return
+	}
+
+	id := r.PathValue("id")
+	if id != "" {
+		rule.ID = id
+	}
+
+	saved, err := s.proxyMgr.SaveRule(&rule)
+	if err != nil {
+		writeError(w, http.StatusBadRequest, err.Error())
+		return
+	}
+
+	writeJSON(w, http.StatusOK, saved)
+}
+
+func (s *Server) handleDeleteProxyRule(w http.ResponseWriter, r *http.Request) {
+	if s.proxyMgr == nil {
+		writeError(w, http.StatusServiceUnavailable, "reverse proxy manager not configured")
+		return
+	}
+	id := r.PathValue("id")
+	if err := s.proxyMgr.DeleteRule(id); err != nil {
+		writeError(w, http.StatusNotFound, err.Error())
+		return
+	}
+	writeJSON(w, http.StatusOK, map[string]any{
+		"success": true,
+		"message": "Rule deleted successfully",
+	})
+}
+
+func (s *Server) handleSetProxyRuleState(w http.ResponseWriter, r *http.Request) {
+	if s.proxyMgr == nil {
+		writeError(w, http.StatusServiceUnavailable, "reverse proxy manager not configured")
+		return
+	}
+	id := r.PathValue("id")
+
+	var req struct {
+		Enabled bool `json:"enabled"`
+	}
+	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+		writeError(w, http.StatusBadRequest, "invalid request payload")
+		return
+	}
+
+	if err := s.proxyMgr.SetRuleState(id, req.Enabled); err != nil {
+		writeError(w, http.StatusBadRequest, err.Error())
+		return
+	}
+
+	writeJSON(w, http.StatusOK, map[string]any{
+		"success": true,
+		"enabled": req.Enabled,
+	})
+}
+
+func (s *Server) handleGetProxyStatus(w http.ResponseWriter, r *http.Request) {
+	if s.proxyMgr == nil {
+		writeError(w, http.StatusServiceUnavailable, "reverse proxy manager not configured")
+		return
+	}
+
+	cfg := s.proxyMgr.GetConfig()
+	var totalRequests uint64
+	var activeCerts int
+
+	certSet := make(map[string]bool)
+	for _, rule := range cfg.Rules {
+		totalRequests += rule.Stats.Requests
+		if rule.Enabled && rule.Certificate != "" {
+			certSet[strings.ToLower(rule.Certificate)] = true
+		}
+	}
+	activeCerts = len(certSet)
+
+	writeJSON(w, http.StatusOK, map[string]any{
+		"enabled":        cfg.Enabled,
+		"http_port":      cfg.HTTPPort,
+		"https_port":     cfg.HTTPSPort,
+		"rules_count":    len(cfg.Rules),
+		"total_requests": totalRequests,
+		"active_certs":   activeCerts,
+		"default_cert":   cfg.DefaultCert,
+	})
+}

+ 119 - 0
internal/api/reverse_proxy_handlers_test.go

@@ -0,0 +1,119 @@
+package api
+
+import (
+	"bytes"
+	"encoding/json"
+	"net/http"
+	"net/http/httptest"
+	"path/filepath"
+	"testing"
+
+	"debtik/internal/auth"
+	"debtik/internal/reverseproxy"
+)
+
+func setupTestProxyServer(t *testing.T) (*Server, *reverseproxy.Manager) {
+	tmpDir := t.TempDir()
+	cfgPath := filepath.Join(tmpDir, "reverse_proxy.json")
+	proxyMgr := reverseproxy.NewManager(cfgPath, nil)
+
+	authPath := filepath.Join(tmpDir, "users.json")
+	authMgr := auth.NewManager(authPath)
+
+	srv := &Server{
+		proxyMgr:  proxyMgr,
+		authMgr:   authMgr,
+		mux:       http.NewServeMux(),
+		fsHandler: http.NotFoundHandler(),
+	}
+	srv.registerRoutes()
+	return srv, proxyMgr
+}
+
+func TestProxyAPIEndpoints(t *testing.T) {
+	srv, _ := setupTestProxyServer(t)
+
+	// 1. GET /api/proxy/config
+	req := httptest.NewRequest(http.MethodGet, "/api/proxy/config", nil)
+	rec := httptest.NewRecorder()
+	srv.mux.ServeHTTP(rec, req)
+	if rec.Code != http.StatusOK {
+		t.Fatalf("GET /api/proxy/config status = %d; want 200", rec.Code)
+	}
+
+	var cfg reverseproxy.Config
+	if err := json.NewDecoder(rec.Body).Decode(&cfg); err != nil {
+		t.Fatalf("failed to decode config: %v", err)
+	}
+	if !cfg.Enabled || cfg.HTTPPort != 80 {
+		t.Errorf("unexpected config defaults: %+v", cfg)
+	}
+
+	// 2. POST /api/proxy/rules (Create Rule)
+	newRule := reverseproxy.Rule{
+		Name:      "Web App",
+		Enabled:   true,
+		Domains:   []string{"app.example.com"},
+		Path:      "/app1",
+		TargetURL: "http://10.0.0.10:8080/app1",
+		Protocol:  "both",
+	}
+	payload, _ := json.Marshal(newRule)
+	req = httptest.NewRequest(http.MethodPost, "/api/proxy/rules", bytes.NewReader(payload))
+	rec = httptest.NewRecorder()
+	srv.mux.ServeHTTP(rec, req)
+	if rec.Code != http.StatusOK {
+		t.Fatalf("POST /api/proxy/rules status = %d; want 200, body: %s", rec.Code, rec.Body.String())
+	}
+
+	var created reverseproxy.Rule
+	if err := json.NewDecoder(rec.Body).Decode(&created); err != nil {
+		t.Fatalf("failed to decode created rule: %v", err)
+	}
+	if created.ID == "" || created.Name != "Web App" {
+		t.Errorf("unexpected created rule: %+v", created)
+	}
+
+	// 3. GET /api/proxy/rules
+	req = httptest.NewRequest(http.MethodGet, "/api/proxy/rules", nil)
+	rec = httptest.NewRecorder()
+	srv.mux.ServeHTTP(rec, req)
+	if rec.Code != http.StatusOK {
+		t.Fatalf("GET /api/proxy/rules status = %d", rec.Code)
+	}
+	var rules []*reverseproxy.Rule
+	_ = json.NewDecoder(rec.Body).Decode(&rules)
+	if len(rules) != 1 {
+		t.Errorf("expected 1 rule, got %d", len(rules))
+	}
+
+	// 4. POST /api/proxy/rules/{id}/state (Toggle state)
+	statePayload := bytes.NewReader([]byte(`{"enabled":false}`))
+	req = httptest.NewRequest(http.MethodPost, "/api/proxy/rules/"+created.ID+"/state", statePayload)
+	rec = httptest.NewRecorder()
+	srv.mux.ServeHTTP(rec, req)
+	if rec.Code != http.StatusOK {
+		t.Fatalf("POST /api/proxy/rules/{id}/state status = %d", rec.Code)
+	}
+
+	// 5. GET /api/proxy/status
+	req = httptest.NewRequest(http.MethodGet, "/api/proxy/status", nil)
+	rec = httptest.NewRecorder()
+	srv.mux.ServeHTTP(rec, req)
+	if rec.Code != http.StatusOK {
+		t.Fatalf("GET /api/proxy/status status = %d", rec.Code)
+	}
+	var status map[string]any
+	_ = json.NewDecoder(rec.Body).Decode(&status)
+	if status["rules_count"].(float64) != 1 {
+		t.Errorf("expected rules_count 1, got %v", status["rules_count"])
+	}
+
+	// 6. DELETE /api/proxy/rules/{id}
+	req = httptest.NewRequest(http.MethodDelete, "/api/proxy/rules/"+created.ID, nil)
+	rec = httptest.NewRecorder()
+	srv.mux.ServeHTTP(rec, req)
+	if rec.Code != http.StatusOK {
+		t.Fatalf("DELETE /api/proxy/rules/{id} status = %d", rec.Code)
+	}
+}

+ 150 - 0
internal/cli/executor.go

@@ -20,6 +20,7 @@ import (
 	"debtik/internal/firewall"
 	"debtik/internal/logging"
 	"debtik/internal/network"
+	"debtik/internal/reverseproxy"
 	"debtik/internal/routing"
 	"debtik/internal/services"
 	"debtik/internal/system"
@@ -42,6 +43,7 @@ type Executor struct {
 	bwtestMgr     *bwtest.Manager
 	servicesMgr   *services.Manager
 	certMgr       *certs.CertManager
+	proxyMgr      *reverseproxy.Manager
 }
 
 func NewExecutor(
@@ -59,6 +61,7 @@ func NewExecutor(
 	bwtestMgr *bwtest.Manager,
 	servicesMgr *services.Manager,
 	certMgr *certs.CertManager,
+	proxyMgr *reverseproxy.Manager,
 ) *Executor {
 	return &Executor{
 		authMgr:       authMgr,
@@ -75,6 +78,7 @@ func NewExecutor(
 		bwtestMgr:     bwtestMgr,
 		servicesMgr:   servicesMgr,
 		certMgr:       certMgr,
+		proxyMgr:      proxyMgr,
 	}
 }
 
@@ -146,6 +150,9 @@ func (e *Executor) Execute(rawCmd string, user *auth.PublicUser) (output string,
 	case "certificate", "certificates":
 		return e.handleCertificate(parts[1:], user), false
 
+	case "reverse-proxy", "reverseproxy":
+		return e.handleIPReverseProxy(parts[1:], user), false
+
 	default:
 		return fmt.Sprintf(ColorRed+"Unknown command or syntax error: %q. Type 'help' or '?' for command list.\n"+ColorReset, cmd), false
 	}
@@ -1021,6 +1028,9 @@ func (e *Executor) handleIP(parts []string, user *auth.PublicUser) string {
 	case "service", "services":
 		return e.handleIPService(parts[1:], user)
 
+	case "reverse-proxy", "reverseproxy", "proxy":
+		return e.handleIPReverseProxy(parts[1:], user)
+
 	default:
 		return ColorRed + "Unknown IP command: " + sub + "\n" + ColorReset
 	}
@@ -2550,6 +2560,146 @@ func formatBytes(bytes int64) string {
 	return fmt.Sprintf("%.1f %ciB", float64(bytes)/float64(div), "KMGTPE"[exp])
 }
 
+func (e *Executor) handleIPReverseProxy(parts []string, user *auth.PublicUser) string {
+	if e.proxyMgr == nil {
+		return ColorRed + "Reverse proxy service not available\n" + ColorReset
+	}
+
+	if len(parts) == 0 || parts[0] == "print" || parts[0] == "pr" {
+		rules := e.proxyMgr.ListRules()
+		cfg := e.proxyMgr.GetConfig()
+
+		var out strings.Builder
+		stateStr := ColorGreen + "enabled" + ColorReset
+		if !cfg.Enabled {
+			stateStr = ColorRed + "disabled" + ColorReset
+		}
+		out.WriteString(fmt.Sprintf("Flags: X - disabled\nReverse Proxy Master: %s | HTTP Port: %d | HTTPS Port: %d\n", stateStr, cfg.HTTPPort, cfg.HTTPSPort))
+
+		tbl := NewTable("Flags", "Name", "Domains", "Path", "Target", "Proto", "SSL Cert", "Requests")
+		for _, r := range rules {
+			flag := " "
+			if !r.Enabled {
+				flag = "X"
+			}
+			doms := strings.Join(r.Domains, ",")
+			if doms == "" {
+				doms = "*"
+			}
+			cert := r.Certificate
+			if cert == "" {
+				cert = "none"
+			}
+			reqCount := fmt.Sprintf("%d", r.Stats.Requests)
+			tbl.AddRow(flag, r.Name, doms, r.Path, r.TargetURL, r.Protocol, cert, reqCount)
+		}
+		out.WriteString(tbl.String())
+		return out.String()
+	}
+
+	if parts[0] == "status" {
+		cfg := e.proxyMgr.GetConfig()
+		var totalReq uint64
+		for _, r := range cfg.Rules {
+			totalReq += r.Stats.Requests
+		}
+		return fmt.Sprintf("Reverse Proxy Status:\n  Enabled:        %v\n  HTTP Port:      %d\n  HTTPS Port:     %d\n  Rules Count:    %d\n  Total Requests: %d\n  Default Cert:   %s\n",
+			cfg.Enabled, cfg.HTTPPort, cfg.HTTPSPort, len(cfg.Rules), totalReq, cfg.DefaultCert)
+	}
+
+	if user.Role != auth.RoleFull {
+		return ColorRed + "Permission denied: admin role required\n" + ColorReset
+	}
+
+	sub := strings.ToLower(parts[0])
+	switch sub {
+	case "add":
+		_, params := parseArgs(parts[1:])
+		name := params["name"]
+		target := params["target"]
+		if target == "" {
+			target = params["target-url"]
+		}
+		if name == "" || target == "" {
+			return ColorRed + "Usage: /ip reverse-proxy add name=<name> target=<url> [domains=d1,d2] [path=<path>] [protocol=http|https|both] [certificate=<cert>] [strip-path=yes|no] [http-to-https=yes|no]\n" + ColorReset
+		}
+
+		var domains []string
+		if dStr, ok := params["domains"]; ok {
+			for _, d := range strings.Split(dStr, ",") {
+				if d = strings.TrimSpace(d); d != "" {
+					domains = append(domains, d)
+				}
+			}
+		}
+
+		pfx := params["path"]
+		if pfx == "" {
+			pfx = "/"
+		}
+
+		proto := params["protocol"]
+		if proto == "" {
+			proto = "both"
+		}
+
+		stripPath := (params["strip-path"] == "yes" || params["strip-path"] == "true")
+		httpToHttps := (params["http-to-https"] == "yes" || params["http-to-https"] == "true")
+		insecureTLS := (params["insecure-tls"] == "yes" || params["insecure-tls"] == "true")
+
+		rule, err := e.proxyMgr.SaveRule(&reverseproxy.Rule{
+			Name:        name,
+			Enabled:     true,
+			Domains:     domains,
+			Path:        pfx,
+			TargetURL:   target,
+			StripPath:   stripPath,
+			Protocol:    proto,
+			Certificate: params["certificate"],
+			HTTPToHTTPS: httpToHttps,
+			InsecureTLS: insecureTLS,
+			Comment:     params["comment"],
+		})
+		if err != nil {
+			return ColorRed + "Error: " + err.Error() + "\n" + ColorReset
+		}
+		return ColorGreen + fmt.Sprintf("Proxy rule %q (ID: %s) added successfully.\n", rule.Name, rule.ID) + ColorReset
+
+	case "remove", "delete":
+		if len(parts) < 2 {
+			return ColorRed + "Usage: /ip reverse-proxy remove <id>\n" + ColorReset
+		}
+		id := parts[1]
+		if err := e.proxyMgr.DeleteRule(id); err != nil {
+			return ColorRed + "Error: " + err.Error() + "\n" + ColorReset
+		}
+		return ColorGreen + fmt.Sprintf("Rule %q removed.\n", id) + ColorReset
+
+	case "enable":
+		if len(parts) < 2 {
+			return ColorRed + "Usage: /ip reverse-proxy enable <id>\n" + ColorReset
+		}
+		id := parts[1]
+		if err := e.proxyMgr.SetRuleState(id, true); err != nil {
+			return ColorRed + "Error: " + err.Error() + "\n" + ColorReset
+		}
+		return ColorGreen + fmt.Sprintf("Rule %q enabled.\n", id) + ColorReset
+
+	case "disable":
+		if len(parts) < 2 {
+			return ColorRed + "Usage: /ip reverse-proxy disable <id>\n" + ColorReset
+		}
+		id := parts[1]
+		if err := e.proxyMgr.SetRuleState(id, false); err != nil {
+			return ColorRed + "Error: " + err.Error() + "\n" + ColorReset
+		}
+		return ColorGreen + fmt.Sprintf("Rule %q disabled.\n", id) + ColorReset
+
+	default:
+		return ColorRed + "Usage: /ip reverse-proxy [print|status|add|set|remove|enable|disable]\n" + ColorReset
+	}
+}
+
 // Helpers for sorting/filtering
 func sortStrings(s []string) {
 	sort.Strings(s)

+ 574 - 0
internal/reverseproxy/manager.go

@@ -0,0 +1,574 @@
+package reverseproxy
+
+import (
+	"context"
+	"crypto/tls"
+	"encoding/json"
+	"fmt"
+	"net"
+	"net/http"
+	"net/url"
+	"os"
+	"path"
+	"path/filepath"
+	"strings"
+	"sync"
+	"sync/atomic"
+	"time"
+
+	"debtik/internal/certs"
+	"debtik/internal/logging"
+)
+
+type Manager struct {
+	filePath string
+	certMgr  *certs.CertManager
+
+	mu       sync.RWMutex
+	config   Config
+	handlers map[string]http.Handler
+
+	// Standalone listeners
+	httpsServer  *http.Server
+	httpsMu      sync.Mutex
+	httpsRunning bool
+
+	httpServer  *http.Server
+	httpMu      sync.Mutex
+	httpRunning bool
+}
+
+func NewManager(customPath string, certMgr *certs.CertManager) *Manager {
+	p := customPath
+	if p == "" {
+		p = DefaultConfigPath
+	}
+
+	m := &Manager{
+		filePath: p,
+		certMgr:  certMgr,
+		handlers: make(map[string]http.Handler),
+	}
+
+	m.initDefaults()
+	_ = m.load()
+	m.rebuildHandlersLocked()
+	return m
+}
+
+func (m *Manager) initDefaults() {
+	m.config = Config{
+		Enabled:   true,
+		HTTPPort:  80,
+		HTTPSPort: 443,
+		Rules:     []*Rule{},
+	}
+}
+
+func (m *Manager) load() error {
+	m.mu.Lock()
+	defer m.mu.Unlock()
+
+	data, err := os.ReadFile(m.filePath)
+	if err != nil {
+		if os.IsNotExist(err) {
+			return m.saveLocked()
+		}
+		return err
+	}
+
+	var cfg Config
+	if err := json.Unmarshal(data, &cfg); err != nil {
+		return err
+	}
+
+	if cfg.HTTPPort == 0 {
+		cfg.HTTPPort = 80
+	}
+	if cfg.HTTPSPort == 0 {
+		cfg.HTTPSPort = 443
+	}
+	if cfg.Rules == nil {
+		cfg.Rules = []*Rule{}
+	}
+
+	m.config = cfg
+	return nil
+}
+
+func (m *Manager) saveLocked() error {
+	dir := filepath.Dir(m.filePath)
+	if err := os.MkdirAll(dir, 0755); err != nil {
+		return fmt.Errorf("failed to create directory %s: %w", dir, err)
+	}
+
+	data, err := json.MarshalIndent(m.config, "", "  ")
+	if err != nil {
+		return fmt.Errorf("failed to marshal reverse proxy config: %w", err)
+	}
+
+	tmpFile := fmt.Sprintf("%s.tmp.%d", m.filePath, time.Now().UnixNano())
+	if err := os.WriteFile(tmpFile, data, 0644); err != nil {
+		return fmt.Errorf("failed to write tmp file: %w", err)
+	}
+	return os.Rename(tmpFile, m.filePath)
+}
+
+func (m *Manager) rebuildHandlersLocked() {
+	m.handlers = make(map[string]http.Handler)
+	for _, r := range m.config.Rules {
+		if !r.Enabled {
+			continue
+		}
+		ruleRef := r
+		h, err := NewProxyHandler(ruleRef, func(reqDelta, rxDelta, txDelta uint64) {
+			m.mu.Lock()
+			atomic.AddUint64(&ruleRef.Stats.Requests, reqDelta)
+			atomic.AddUint64(&ruleRef.Stats.BytesReceived, rxDelta)
+			atomic.AddUint64(&ruleRef.Stats.BytesSent, txDelta)
+			ruleRef.Stats.LastAccess = time.Now()
+			m.mu.Unlock()
+		})
+		if err != nil {
+			logging.Log("proxy,error", "Failed to build proxy handler for rule %s (%s): %v", r.ID, r.Name, err)
+			continue
+		}
+		m.handlers[r.ID] = h
+	}
+}
+
+// IsEnabled returns whether the reverse proxy is globally active.
+func (m *Manager) IsEnabled() bool {
+	m.mu.RLock()
+	defer m.mu.RUnlock()
+	return m.config.Enabled
+}
+
+// GetConfig returns a deep copy of the global configuration.
+func (m *Manager) GetConfig() Config {
+	m.mu.RLock()
+	defer m.mu.RUnlock()
+
+	cfgCopy := m.config
+	cfgCopy.Rules = make([]*Rule, len(m.config.Rules))
+	for i, r := range m.config.Rules {
+		rc := *r
+		if r.Domains != nil {
+			rc.Domains = append([]string(nil), r.Domains...)
+		}
+		if r.CustomHeaders != nil {
+			rc.CustomHeaders = make(map[string]string)
+			for k, v := range r.CustomHeaders {
+				rc.CustomHeaders[k] = v
+			}
+		}
+		cfgCopy.Rules[i] = &rc
+	}
+	return cfgCopy
+}
+
+// UpdateConfig updates the global configuration settings and persists them.
+func (m *Manager) UpdateConfig(newCfg Config) error {
+	m.mu.Lock()
+	defer m.mu.Unlock()
+
+	m.config.Enabled = newCfg.Enabled
+	if newCfg.HTTPPort > 0 {
+		m.config.HTTPPort = newCfg.HTTPPort
+	}
+	if newCfg.HTTPSPort > 0 {
+		m.config.HTTPSPort = newCfg.HTTPSPort
+	}
+	m.config.DefaultCert = newCfg.DefaultCert
+
+	if err := m.saveLocked(); err != nil {
+		return err
+	}
+	m.rebuildHandlersLocked()
+	return nil
+}
+
+// ListRules returns all configured proxy rules.
+func (m *Manager) ListRules() []*Rule {
+	m.mu.RLock()
+	defer m.mu.RUnlock()
+
+	result := make([]*Rule, len(m.config.Rules))
+	for i, r := range m.config.Rules {
+		rc := *r
+		if r.Domains != nil {
+			rc.Domains = append([]string(nil), r.Domains...)
+		}
+		if r.CustomHeaders != nil {
+			rc.CustomHeaders = make(map[string]string)
+			for k, v := range r.CustomHeaders {
+				rc.CustomHeaders[k] = v
+			}
+		}
+		result[i] = &rc
+	}
+	return result
+}
+
+// GetRule returns a specific rule by ID.
+func (m *Manager) GetRule(id string) (*Rule, error) {
+	m.mu.RLock()
+	defer m.mu.RUnlock()
+
+	for _, r := range m.config.Rules {
+		if r.ID == id {
+			rc := *r
+			if r.Domains != nil {
+				rc.Domains = append([]string(nil), r.Domains...)
+			}
+			return &rc, nil
+		}
+	}
+	return nil, fmt.Errorf("rule %q not found", id)
+}
+
+// SaveRule creates or updates a reverse proxy rule.
+func (m *Manager) SaveRule(r *Rule) (*Rule, error) {
+	if r == nil {
+		return nil, fmt.Errorf("rule cannot be nil")
+	}
+	if strings.TrimSpace(r.Name) == "" {
+		return nil, fmt.Errorf("rule name is required")
+	}
+	if strings.TrimSpace(r.TargetURL) == "" {
+		return nil, fmt.Errorf("target URL is required")
+	}
+
+	targetURL, err := url.Parse(r.TargetURL)
+	if err != nil || targetURL.Scheme == "" || targetURL.Host == "" {
+		return nil, fmt.Errorf("target URL must be a valid absolute HTTP or HTTPS URL (e.g. http://10.0.0.5:8080 or http://example.com/app1)")
+	}
+
+	// Normalize Path prefix
+	cleanPath := path.Clean("/" + strings.TrimPrefix(strings.TrimSpace(r.Path), "/"))
+	if r.Path == "" {
+		cleanPath = "/"
+	}
+	r.Path = cleanPath
+
+	// Normalize Protocol
+	r.Protocol = strings.ToLower(strings.TrimSpace(r.Protocol))
+	if r.Protocol == "" {
+		r.Protocol = ProtocolBoth
+	}
+
+	m.mu.Lock()
+	defer m.mu.Unlock()
+
+	now := time.Now()
+	var savedRule *Rule
+
+	if r.ID == "" {
+		r.ID = fmt.Sprintf("proxy-%d", now.UnixNano())
+		r.CreatedAt = now
+		r.UpdatedAt = now
+		m.config.Rules = append(m.config.Rules, r)
+		savedRule = r
+	} else {
+		found := false
+		for i, existing := range m.config.Rules {
+			if existing.ID == r.ID {
+				r.CreatedAt = existing.CreatedAt
+				r.UpdatedAt = now
+				r.Stats = existing.Stats
+				m.config.Rules[i] = r
+				savedRule = r
+				found = true
+				break
+			}
+		}
+		if !found {
+			r.CreatedAt = now
+			r.UpdatedAt = now
+			m.config.Rules = append(m.config.Rules, r)
+			savedRule = r
+		}
+	}
+
+	if err := m.saveLocked(); err != nil {
+		return nil, err
+	}
+	m.rebuildHandlersLocked()
+
+	rc := *savedRule
+	return &rc, nil
+}
+
+// DeleteRule removes a rule by ID.
+func (m *Manager) DeleteRule(id string) error {
+	m.mu.Lock()
+	defer m.mu.Unlock()
+
+	found := false
+	var updated []*Rule
+	for _, r := range m.config.Rules {
+		if r.ID == id {
+			found = true
+			continue
+		}
+		updated = append(updated, r)
+	}
+
+	if !found {
+		return fmt.Errorf("rule %q not found", id)
+	}
+
+	m.config.Rules = updated
+	if err := m.saveLocked(); err != nil {
+		return err
+	}
+	m.rebuildHandlersLocked()
+	return nil
+}
+
+// SetRuleState toggles enabled status for a rule.
+func (m *Manager) SetRuleState(id string, enabled bool) error {
+	m.mu.Lock()
+	defer m.mu.Unlock()
+
+	found := false
+	for _, r := range m.config.Rules {
+		if r.ID == id {
+			r.Enabled = enabled
+			r.UpdatedAt = time.Now()
+			found = true
+			break
+		}
+	}
+
+	if !found {
+		return fmt.Errorf("rule %q not found", id)
+	}
+
+	if err := m.saveLocked(); err != nil {
+		return err
+	}
+	m.rebuildHandlersLocked()
+	return nil
+}
+
+// FindMatchingRule matches an incoming request against all enabled rules and returns the best match.
+func (m *Manager) FindMatchingRule(r *http.Request) (*Rule, http.Handler) {
+	m.mu.RLock()
+	defer m.mu.RUnlock()
+
+	if !m.config.Enabled || len(m.config.Rules) == 0 {
+		return nil, nil
+	}
+
+	host := r.Host
+	if h, _, err := net.SplitHostPort(host); err == nil {
+		host = h
+	}
+	host = strings.ToLower(strings.TrimSpace(host))
+
+	isTLS := (r.TLS != nil)
+	reqPath := r.URL.Path
+
+	var bestRule *Rule
+	var bestScore int
+
+	for _, rule := range m.config.Rules {
+		if !rule.Enabled {
+			continue
+		}
+
+		// Protocol matching
+		switch rule.Protocol {
+		case ProtocolHTTP:
+			if isTLS {
+				continue
+			}
+		case ProtocolHTTPS:
+			if !isTLS && !rule.HTTPToHTTPS {
+				continue
+			}
+		}
+
+		// Domain matching & scoring
+		domainScore := 0
+		if len(rule.Domains) == 0 {
+			domainScore = 100 // default wildcard match
+		} else {
+			for _, d := range rule.Domains {
+				dClean := strings.ToLower(strings.TrimSpace(d))
+				if dClean == "*" {
+					if domainScore < 100 {
+						domainScore = 100
+					}
+				} else if strings.HasPrefix(dClean, "*.") {
+					if MatchDomain(dClean, host) && domainScore < 500 {
+						domainScore = 500
+					}
+				} else if MatchDomain(dClean, host) {
+					if domainScore < 1000 {
+						domainScore = 1000
+					}
+				}
+			}
+		}
+
+		if domainScore == 0 {
+			continue
+		}
+
+		// Path matching & scoring
+		if !MatchPath(rule.Path, reqPath) {
+			continue
+		}
+
+		cleanRulePath := path.Clean("/" + strings.TrimPrefix(strings.TrimSpace(rule.Path), "/"))
+		pathScore := len(cleanRulePath) * 10
+		if cleanRulePath == "/" {
+			pathScore = 1
+		}
+
+		totalScore := domainScore + pathScore
+		if totalScore > bestScore {
+			bestScore = totalScore
+			bestRule = rule
+		}
+	}
+
+	if bestRule == nil {
+		return nil, nil
+	}
+
+	handler := m.handlers[bestRule.ID]
+	return bestRule, handler
+}
+
+// HandleHTTP handles an incoming HTTP/HTTPS request if a matching proxy rule is found.
+// Returns true if the request was handled/proxied, or false if it should fall through to DebTik Web UI / API.
+func (m *Manager) HandleHTTP(w http.ResponseWriter, r *http.Request) bool {
+	rule, handler := m.FindMatchingRule(r)
+	if rule == nil {
+		return false
+	}
+
+	// Never intercept Let's Encrypt ACME challenges
+	if strings.HasPrefix(r.URL.Path, "/.well-known/acme-challenge/") {
+		return false
+	}
+
+	// Check HTTP-to-HTTPS redirect
+	if rule.HTTPToHTTPS && r.TLS == nil {
+		destHost := r.Host
+		if h, _, err := net.SplitHostPort(destHost); err == nil {
+			destHost = h
+		}
+		m.mu.RLock()
+		httpsPort := m.config.HTTPSPort
+		m.mu.RUnlock()
+
+		portSuffix := ""
+		if httpsPort != 443 && httpsPort > 0 {
+			portSuffix = fmt.Sprintf(":%d", httpsPort)
+		}
+		target := fmt.Sprintf("https://%s%s%s", destHost, portSuffix, r.URL.RequestURI())
+		http.Redirect(w, r, target, http.StatusMovedPermanently)
+		return true
+	}
+
+	if handler == nil {
+		return false
+	}
+
+	handler.ServeHTTP(w, r)
+	return true
+}
+
+// GetTLSCertificate provides dynamic SNI certificate resolution for TLS listeners.
+func (m *Manager) GetTLSCertificate(clientHello *tls.ClientHelloInfo) (*tls.Certificate, error) {
+	if m.certMgr == nil {
+		return nil, fmt.Errorf("certificate manager not configured")
+	}
+
+	sni := strings.ToLower(clientHello.ServerName)
+
+	m.mu.RLock()
+	defer m.mu.RUnlock()
+
+	// 1. Try to find an enabled rule matching this SNI host that specifies a certificate
+	for _, rule := range m.config.Rules {
+		if !rule.Enabled || rule.Certificate == "" {
+			continue
+		}
+		for _, d := range rule.Domains {
+			dClean := strings.ToLower(strings.TrimSpace(d))
+			if MatchDomain(dClean, sni) {
+				cert, err := m.certMgr.GetTLSCertificate(rule.Certificate)
+				if err == nil {
+					return &cert, nil
+				}
+			}
+		}
+	}
+
+	// 2. Try default certificate if configured
+	if m.config.DefaultCert != "" {
+		cert, err := m.certMgr.GetTLSCertificate(m.config.DefaultCert)
+		if err == nil {
+			return &cert, nil
+		}
+	}
+
+	// 3. Fallback to any certificate in certMgr matching SNI
+	certsList := m.certMgr.ListCertificates()
+	for _, c := range certsList {
+		if c.IsCA {
+			continue
+		}
+		if strings.EqualFold(c.CommonName, sni) {
+			if cert, err := m.certMgr.GetTLSCertificate(c.Name); err == nil {
+				return &cert, nil
+			}
+		}
+		for _, san := range c.SANs {
+			if MatchDomain(strings.ToLower(san), sni) {
+				if cert, err := m.certMgr.GetTLSCertificate(c.Name); err == nil {
+					return &cert, nil
+				}
+			}
+		}
+	}
+
+	// 4. Fallback to the first non-CA certificate
+	for _, c := range certsList {
+		if !c.IsCA {
+			if cert, err := m.certMgr.GetTLSCertificate(c.Name); err == nil {
+				return &cert, nil
+			}
+		}
+	}
+
+	return nil, fmt.Errorf("no TLS certificate found for server name %q", sni)
+}
+
+// Stop gracefully shuts down any standalone proxy listeners.
+func (m *Manager) Stop() {
+	m.httpsMu.Lock()
+	if m.httpsServer != nil {
+		ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
+		_ = m.httpsServer.Shutdown(ctx)
+		cancel()
+		m.httpsServer = nil
+		m.httpsRunning = false
+	}
+	m.httpsMu.Unlock()
+
+	m.httpMu.Lock()
+	if m.httpServer != nil {
+		ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
+		_ = m.httpServer.Shutdown(ctx)
+		cancel()
+		m.httpServer = nil
+		m.httpRunning = false
+	}
+	m.httpMu.Unlock()
+}

+ 140 - 0
internal/reverseproxy/manager_test.go

@@ -0,0 +1,140 @@
+package reverseproxy
+
+import (
+	"fmt"
+	"io"
+	"net/http"
+	"net/http/httptest"
+	"os"
+	"path/filepath"
+	"testing"
+)
+
+func TestReverseProxyManagerEndToEnd(t *testing.T) {
+	// 1. Setup mock backend server that echoes received request path and headers
+	backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		w.Header().Set("X-Echo-Path", r.URL.Path)
+		w.Header().Set("X-Echo-Query", r.URL.RawQuery)
+		w.Header().Set("X-Echo-Host", r.Host)
+		w.WriteHeader(http.StatusOK)
+		_, _ = fmt.Fprintf(w, "OK: %s", r.URL.Path)
+	}))
+	defer backend.Close()
+
+	// 2. Setup manager with temporary config file
+	tmpDir := t.TempDir()
+	cfgPath := filepath.Join(tmpDir, "reverse_proxy.json")
+
+	mgr := NewManager(cfgPath, nil)
+	defer mgr.Stop()
+
+	// 3. Add Rule for /app1 pointing to backend/app1
+	rule1, err := mgr.SaveRule(&Rule{
+		Name:      "App1 Rule",
+		Enabled:   true,
+		Domains:   []string{"example.com"},
+		Path:      "/app1",
+		TargetURL: backend.URL + "/app1",
+		StripPath: false,
+	})
+	if err != nil {
+		t.Fatalf("failed to save rule: %v", err)
+	}
+
+	// 4. Test request to /app1/module1/attribute1
+	req := httptest.NewRequest(http.MethodGet, "http://example.com/app1/module1/attribute1?page=2", nil)
+	rec := httptest.NewRecorder()
+
+	handled := mgr.HandleHTTP(rec, req)
+	if !handled {
+		t.Fatalf("expected request to be handled by reverse proxy")
+	}
+
+	res := rec.Result()
+	if res.StatusCode != http.StatusOK {
+		t.Fatalf("unexpected status code: %d", res.StatusCode)
+	}
+
+	if got := res.Header.Get("X-Echo-Path"); got != "/app1/module1/attribute1" {
+		t.Errorf("X-Echo-Path = %q; want %q", got, "/app1/module1/attribute1")
+	}
+
+	if got := res.Header.Get("X-Echo-Query"); got != "page=2" {
+		t.Errorf("X-Echo-Query = %q; want %q", got, "page=2")
+	}
+
+	body, _ := io.ReadAll(res.Body)
+	if string(body) != "OK: /app1/module1/attribute1" {
+		t.Errorf("body = %q; want %q", string(body), "OK: /app1/module1/attribute1")
+	}
+
+	// 5. Test Rule Stats
+	updatedRule, err := mgr.GetRule(rule1.ID)
+	if err != nil {
+		t.Fatalf("failed to get rule: %v", err)
+	}
+	if updatedRule.Stats.Requests != 1 {
+		t.Errorf("expected 1 request in stats, got %d", updatedRule.Stats.Requests)
+	}
+
+	// 6. Test Request to unmapped path /unmapped - should NOT be handled
+	req2 := httptest.NewRequest(http.MethodGet, "http://example.com/unmapped", nil)
+	rec2 := httptest.NewRecorder()
+	if mgr.HandleHTTP(rec2, req2) {
+		t.Errorf("expected unmapped request to NOT be handled by proxy")
+	}
+
+	// 7. Test StripPath option
+	rule2, err := mgr.SaveRule(&Rule{
+		Name:      "Strip Path Rule",
+		Enabled:   true,
+		Domains:   []string{"strip.example.com"},
+		Path:      "/service",
+		TargetURL: backend.URL,
+		StripPath: true,
+	})
+	if err != nil {
+		t.Fatalf("failed to save rule2: %v", err)
+	}
+
+	req3 := httptest.NewRequest(http.MethodGet, "http://strip.example.com/service/sub/item", nil)
+	rec3 := httptest.NewRecorder()
+	if !mgr.HandleHTTP(rec3, req3) {
+		t.Fatalf("expected strip path request to be handled")
+	}
+	res3 := rec3.Result()
+	if got := res3.Header.Get("X-Echo-Path"); got != "/sub/item" {
+		t.Errorf("X-Echo-Path with stripPath = %q; want %q", got, "/sub/item")
+	}
+
+	// 8. Test HTTP to HTTPS redirect
+	_, err = mgr.SaveRule(&Rule{
+		Name:        "HTTPS Only Rule",
+		Enabled:     true,
+		Domains:     []string{"secure.example.com"},
+		Path:        "/",
+		TargetURL:   backend.URL,
+		Protocol:    ProtocolHTTPS,
+		HTTPToHTTPS: true,
+	})
+	if err != nil {
+		t.Fatalf("failed to save https rule: %v", err)
+	}
+
+	req4 := httptest.NewRequest(http.MethodGet, "http://secure.example.com/login", nil)
+	rec4 := httptest.NewRecorder()
+	if !mgr.HandleHTTP(rec4, req4) {
+		t.Fatalf("expected HTTP to HTTPS redirect to be handled")
+	}
+	res4 := rec4.Result()
+	if res4.StatusCode != http.StatusMovedPermanently {
+		t.Errorf("expected status 301, got %d", res4.StatusCode)
+	}
+	if loc := res4.Header.Get("Location"); loc != "https://secure.example.com/login" {
+		t.Errorf("redirect Location = %q; want %q", loc, "https://secure.example.com/login")
+	}
+
+	// Clean up temp file
+	_ = os.Remove(cfgPath)
+	_ = rule2
+}

+ 238 - 0
internal/reverseproxy/proxy.go

@@ -0,0 +1,238 @@
+package reverseproxy
+
+import (
+	"crypto/tls"
+	"fmt"
+	"html"
+	"net"
+	"net/http"
+	"net/http/httputil"
+	"net/url"
+	"path"
+	"strings"
+	"time"
+
+	"debtik/internal/logging"
+)
+
+// MatchDomain checks if a given host matches a domain pattern.
+// Handles port stripping, wildcards (*.example.com), exact matching, and global wildcards (*).
+func MatchDomain(pattern, host string) bool {
+	pattern = strings.ToLower(strings.TrimSpace(pattern))
+	host = strings.ToLower(strings.TrimSpace(host))
+
+	// Strip port from host if present
+	if h, _, err := net.SplitHostPort(host); err == nil {
+		host = h
+	}
+
+	if pattern == "*" || pattern == "" {
+		return true
+	}
+
+	if pattern == host {
+		return true
+	}
+
+	// Wildcard domain: *.example.com matches sub.example.com
+	if strings.HasPrefix(pattern, "*.") {
+		root := pattern[2:]
+		if host == root || strings.HasSuffix(host, "."+root) {
+			return true
+		}
+	}
+
+	return false
+}
+
+// MatchPath checks if request path matches the rule's path prefix.
+// Normalized to avoid partial segment collisions (e.g. /app1 must not match /app100).
+func MatchPath(rulePath, reqPath string) bool {
+	cleanRule := path.Clean("/" + strings.TrimPrefix(strings.TrimSpace(rulePath), "/"))
+	cleanReq := path.Clean("/" + strings.TrimPrefix(strings.TrimSpace(reqPath), "/"))
+
+	if cleanRule == "/" {
+		return true
+	}
+
+	if cleanReq == cleanRule {
+		return true
+	}
+
+	return strings.HasPrefix(cleanReq, cleanRule+"/")
+}
+
+// BuildTargetURL calculates the destination URL based on targetBase, rulePath, reqPath, and stripPath.
+// Fully supports deep subfolder paths (e.g. /app1/module1/attribute1), trailing slashes, and query params.
+func BuildTargetURL(targetBase *url.URL, rulePath, reqPath, rawQuery string, stripPath bool) *url.URL {
+	u := *targetBase // shallow copy
+
+	cleanReq := path.Clean("/" + strings.TrimPrefix(strings.TrimSpace(reqPath), "/"))
+	cleanRule := path.Clean("/" + strings.TrimPrefix(strings.TrimSpace(rulePath), "/"))
+
+	targetBasePath := targetBase.Path
+	if targetBasePath == "" {
+		targetBasePath = "/"
+	}
+
+	var finalPath string
+	if stripPath {
+		// Strip rulePath from the beginning of reqPath
+		sub := strings.TrimPrefix(cleanReq, cleanRule)
+		if !strings.HasPrefix(sub, "/") {
+			sub = "/" + sub
+		}
+		finalPath = singleJoiningSlash(targetBasePath, sub)
+	} else {
+		// When not stripping, if target has a specific base path (not just "/"), append relative subpath
+		if targetBasePath == "/" {
+			finalPath = cleanReq
+		} else {
+			sub := strings.TrimPrefix(cleanReq, cleanRule)
+			if !strings.HasPrefix(sub, "/") {
+				sub = "/" + sub
+			}
+			finalPath = singleJoiningSlash(targetBasePath, sub)
+		}
+	}
+
+	// Preserve trailing slash if present in incoming request
+	if strings.HasSuffix(reqPath, "/") && !strings.HasSuffix(finalPath, "/") {
+		finalPath += "/"
+	}
+
+	u.Path = finalPath
+
+	// Merge query parameters
+	if targetBase.RawQuery == "" {
+		u.RawQuery = rawQuery
+	} else if rawQuery == "" {
+		u.RawQuery = targetBase.RawQuery
+	} else {
+		u.RawQuery = targetBase.RawQuery + "&" + rawQuery
+	}
+
+	return &u
+}
+
+func singleJoiningSlash(a, b string) string {
+	aslash := strings.HasSuffix(a, "/")
+	bslash := strings.HasPrefix(b, "/")
+	switch {
+	case aslash && bslash:
+		return a + b[1:]
+	case !aslash && !bslash:
+		return a + "/" + b
+	}
+	return a + b
+}
+
+type countingResponseWriter struct {
+	http.ResponseWriter
+	statusCode int
+	bytesSent  uint64
+}
+
+func (w *countingResponseWriter) WriteHeader(statusCode int) {
+	w.statusCode = statusCode
+	w.ResponseWriter.WriteHeader(statusCode)
+}
+
+func (w *countingResponseWriter) Write(b []byte) (int, error) {
+	n, err := w.ResponseWriter.Write(b)
+	w.bytesSent += uint64(n)
+	return n, err
+}
+
+// NewProxyHandler creates an http.Handler that reverses-proxies traffic according to the rule.
+func NewProxyHandler(rule *Rule, onStats func(reqDelta, rxDelta, txDelta uint64)) (http.Handler, error) {
+	targetURL, err := url.Parse(rule.TargetURL)
+	if err != nil {
+		return nil, fmt.Errorf("invalid target URL %q: %w", rule.TargetURL, err)
+	}
+
+	transport := &http.Transport{
+		Proxy: http.ProxyFromEnvironment,
+		DialContext: (&net.Dialer{
+			Timeout:   15 * time.Second,
+			KeepAlive: 30 * time.Second,
+		}).DialContext,
+		MaxIdleConns:          100,
+		IdleConnTimeout:       90 * time.Second,
+		TLSHandshakeTimeout:   10 * time.Second,
+		ExpectContinueTimeout: 1 * time.Second,
+	}
+
+	if rule.InsecureTLS || targetURL.Scheme == "https" {
+		transport.TLSClientConfig = &tls.Config{
+			InsecureSkipVerify: rule.InsecureTLS,
+		}
+	}
+
+	proxy := &httputil.ReverseProxy{
+		Transport: transport,
+		Director: func(req *http.Request) {
+			dest := BuildTargetURL(targetURL, rule.Path, req.URL.Path, req.URL.RawQuery, rule.StripPath)
+
+			req.URL.Scheme = dest.Scheme
+			req.URL.Host = dest.Host
+			req.URL.Path = dest.Path
+			req.URL.RawPath = ""
+			req.URL.RawQuery = dest.RawQuery
+
+			origHost := req.Host
+			if !rule.PreserveHost {
+				req.Host = targetURL.Host
+			}
+
+			// Forwarded headers
+			if clientIP, _, err := net.SplitHostPort(req.RemoteAddr); err == nil {
+				if prior := req.Header.Get("X-Forwarded-For"); prior != "" {
+					req.Header.Set("X-Forwarded-For", prior+", "+clientIP)
+				} else {
+					req.Header.Set("X-Forwarded-For", clientIP)
+				}
+				req.Header.Set("X-Real-IP", clientIP)
+			}
+
+			proto := "http"
+			if req.TLS != nil {
+				proto = "https"
+			}
+			req.Header.Set("X-Forwarded-Proto", proto)
+			req.Header.Set("X-Forwarded-Host", origHost)
+
+			// Custom headers
+			for k, v := range rule.CustomHeaders {
+				req.Header.Set(k, v)
+			}
+
+			// WebSocket support
+			if req.Header.Get("Upgrade") != "" {
+				req.Header.Set("Upgrade", req.Header.Get("Upgrade"))
+				req.Header.Set("Connection", "Upgrade")
+			}
+		},
+		ErrorHandler: func(w http.ResponseWriter, r *http.Request, err error) {
+			logging.Log("proxy,error", "Proxy error forwarding %s %s to %s: %v", r.Method, r.URL.Path, rule.TargetURL, err)
+			w.Header().Set("Content-Type", "text/html; charset=utf-8")
+			w.WriteHeader(http.StatusBadGateway)
+			_, _ = fmt.Fprintf(w, "<!DOCTYPE html><html><head><title>502 Bad Gateway - DebTik</title><style>body{font-family:system-ui,-apple-system,sans-serif;background:#0f172a;color:#f8fafc;display:flex;align-items:center;justify-content:center;height:100vh;margin:0;} .card{background:#1e293b;border:1px solid #334155;border-radius:12px;padding:32px;max-width:500px;text-align:center;box-shadow:0 10px 25px -5px rgba(0,0,0,0.5);} h1{color:#ef4444;margin-top:0;} p{color:#94a3b8;line-height:1.6;} code{background:#0f172a;padding:2px 6px;border-radius:4px;color:#cbd5e1;}</style></head><body><div class=\"card\"><h1>502 Bad Gateway</h1><p><strong>DebTik Reverse Proxy</strong> could not connect to the upstream backend:</p><p><code>%s</code></p><p><small style=\"color:#64748b;\">%s</small></p></div></body></html>", html.EscapeString(rule.TargetURL), html.EscapeString(err.Error()))
+		},
+	}
+
+	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		cw := &countingResponseWriter{ResponseWriter: w, statusCode: http.StatusOK}
+		
+		var rxBytes uint64
+		if r.ContentLength > 0 {
+			rxBytes = uint64(r.ContentLength)
+		}
+
+		proxy.ServeHTTP(cw, r)
+
+		if onStats != nil {
+			onStats(1, rxBytes, cw.bytesSent)
+		}
+	}), nil
+}

+ 143 - 0
internal/reverseproxy/proxy_test.go

@@ -0,0 +1,143 @@
+package reverseproxy
+
+import (
+	"net/url"
+	"testing"
+)
+
+func TestMatchDomain(t *testing.T) {
+	tests := []struct {
+		pattern string
+		host    string
+		matched bool
+	}{
+		{"example.com", "example.com", true},
+		{"example.com", "example.com:80", true},
+		{"example.com", "example.com:443", true},
+		{"example.com", "sub.example.com", false},
+		{"*.example.com", "sub.example.com", true},
+		{"*.example.com", "api.sub.example.com", true},
+		{"*.example.com", "example.com", true},
+		{"*.example.com", "other.org", false},
+		{"*", "anything.local", true},
+		{"*", "192.168.1.1:8080", true},
+		{"", "anything.local", true},
+		{"192.168.1.100", "192.168.1.100", true},
+		{"192.168.1.100", "192.168.1.100:80", true},
+		{"192.168.1.100", "192.168.1.101", false},
+	}
+
+	for _, tt := range tests {
+		got := MatchDomain(tt.pattern, tt.host)
+		if got != tt.matched {
+			t.Errorf("MatchDomain(%q, %q) = %v; want %v", tt.pattern, tt.host, got, tt.matched)
+		}
+	}
+}
+
+func TestMatchPath(t *testing.T) {
+	tests := []struct {
+		rulePath string
+		reqPath  string
+		matched  bool
+	}{
+		{"/", "/", true},
+		{"/", "/app1", true},
+		{"/", "/app1/module1", true},
+		{"/app1", "/app1", true},
+		{"/app1", "/app1/", true},
+		{"/app1", "/app1/module1", true},
+		{"/app1", "/app1/module1/attribute1", true},
+		{"/app1", "/app100", false},
+		{"/app1", "/other", false},
+		{"app1", "/app1", true},
+		{"/app1/", "/app1/module1", true},
+	}
+
+	for _, tt := range tests {
+		got := MatchPath(tt.rulePath, tt.reqPath)
+		if got != tt.matched {
+			t.Errorf("MatchPath(%q, %q) = %v; want %v", tt.rulePath, tt.reqPath, got, tt.matched)
+		}
+	}
+}
+
+func TestBuildTargetURL(t *testing.T) {
+	tests := []struct {
+		name      string
+		targetRaw string
+		rulePath  string
+		reqPath   string
+		rawQuery  string
+		stripPath bool
+		expected  string
+	}{
+		{
+			name:      "Root target preserving full path",
+			targetRaw: "http://example.com",
+			rulePath:  "/",
+			reqPath:   "/test/item",
+			rawQuery:  "a=1",
+			stripPath: false,
+			expected:  "http://example.com/test/item?a=1",
+		},
+		{
+			name:      "Target with path, forwarding subfolders",
+			targetRaw: "http://example.com/app1",
+			rulePath:  "/app1",
+			reqPath:   "/app1/module1/attribute1",
+			rawQuery:  "filter=all",
+			stripPath: false,
+			expected:  "http://example.com/app1/module1/attribute1?filter=all",
+		},
+		{
+			name:      "Target without path, stripPath true",
+			targetRaw: "http://10.0.0.5:8080",
+			rulePath:  "/app1",
+			reqPath:   "/app1/module1/attribute1",
+			rawQuery:  "",
+			stripPath: true,
+			expected:  "http://10.0.0.5:8080/module1/attribute1",
+		},
+		{
+			name:      "Target without path, stripPath false",
+			targetRaw: "http://10.0.0.5:8080",
+			rulePath:  "/app1",
+			reqPath:   "/app1/module1/attribute1",
+			rawQuery:  "",
+			stripPath: false,
+			expected:  "http://10.0.0.5:8080/app1/module1/attribute1",
+		},
+		{
+			name:      "Target with trailing slash, req root",
+			targetRaw: "http://example.com/app1/",
+			rulePath:  "/app1",
+			reqPath:   "/app1",
+			rawQuery:  "",
+			stripPath: false,
+			expected:  "http://example.com/app1/",
+		},
+		{
+			name:      "Merging backend query and incoming query",
+			targetRaw: "http://example.com/api?v=2",
+			rulePath:  "/api",
+			reqPath:   "/api/users",
+			rawQuery:  "page=1",
+			stripPath: false,
+			expected:  "http://example.com/api/users?v=2&page=1",
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			targetBase, err := url.Parse(tt.targetRaw)
+			if err != nil {
+				t.Fatalf("failed to parse target URL: %v", err)
+			}
+			dest := BuildTargetURL(targetBase, tt.rulePath, tt.reqPath, tt.rawQuery, tt.stripPath)
+			if dest.String() != tt.expected {
+				t.Errorf("BuildTargetURL() = %q; want %q", dest.String(), tt.expected)
+			}
+		})
+	}
+}

+ 51 - 0
internal/reverseproxy/types.go

@@ -0,0 +1,51 @@
+package reverseproxy
+
+import (
+	"time"
+)
+
+const (
+	DefaultConfigPath = "/etc/debtik/reverse_proxy.json"
+
+	ProtocolHTTP  = "http"
+	ProtocolHTTPS = "https"
+	ProtocolBoth  = "both"
+)
+
+// RuleStats holds runtime counters for a proxy rule.
+type RuleStats struct {
+	Requests      uint64    `json:"requests"`
+	BytesReceived uint64    `json:"bytes_received"`
+	BytesSent     uint64    `json:"bytes_sent"`
+	LastAccess    time.Time `json:"last_access,omitempty"`
+}
+
+// Rule represents a single routing rule for the reverse proxy.
+type Rule struct {
+	ID            string            `json:"id"`
+	Name          string            `json:"name"`
+	Enabled       bool              `json:"enabled"`
+	Domains       []string          `json:"domains"`                  // Hostnames or IPs to match (e.g. "example.com", "app.local", "*")
+	Path          string            `json:"path"`                     // Inbound path prefix to match (e.g. "/" or "/app1")
+	TargetURL     string            `json:"target_url"`               // Backend target URL (e.g. "http://example.com/app1" or "http://10.0.0.5:8080")
+	StripPath     bool              `json:"strip_path"`               // Whether to strip the inbound path prefix before forwarding
+	PreserveHost  bool              `json:"preserve_host"`            // If true, forward client Host header; else use TargetURL host
+	Protocol      string            `json:"protocol"`                 // "http", "https", "both"
+	Certificate   string            `json:"certificate,omitempty"`    // Certificate name from CertManager for TLS/HTTPS
+	HTTPToHTTPS   bool              `json:"http_to_https"`            // Auto-redirect HTTP to HTTPS for this rule
+	InsecureTLS   bool              `json:"insecure_tls"`             // Skip TLS verification for backend HTTPS targets
+	CustomHeaders map[string]string `json:"custom_headers,omitempty"` // Custom HTTP headers to inject
+	Comment       string            `json:"comment,omitempty"`        // User description
+	CreatedAt     time.Time         `json:"created_at"`
+	UpdatedAt     time.Time         `json:"updated_at"`
+	Stats         RuleStats         `json:"stats"`
+}
+
+// Config represents global reverse proxy configuration.
+type Config struct {
+	Enabled     bool    `json:"enabled"`
+	HTTPPort    int     `json:"http_port"`              // Inbound HTTP port (default: 80)
+	HTTPSPort   int     `json:"https_port"`             // Inbound HTTPS port (default: 443)
+	DefaultCert string  `json:"default_cert,omitempty"` // Fallback certificate name
+	Rules       []*Rule `json:"rules"`
+}

+ 416 - 0
internal/web/static/app.js

@@ -377,6 +377,7 @@ function navigate(tabName) {
     settings: { title: 'Router Settings & DNS', sub: 'System DNS forwarder, upstream resolvers, static host mappings, and appliance identity' },
     users: { title: 'Local User Manager', sub: 'Manage local system administrator credentials and access permissions' },
     certificates: { title: 'Certificates & SSL/TLS', sub: 'Root Certificate Authorities (CA), Server/Client certificates and automated Let\'s Encrypt' },
+    reverse_proxy: { title: 'Reverse Proxy & Gateway', sub: 'HTTP/HTTPS Path Routing, Domain Forwarding & SSL Termination' },
     services: { title: 'Management Services', sub: 'Enable or disable Web, SSH, and Telnet management listeners and listening ports' },
     updates: { title: 'Git Updates', sub: 'Check, install and restore appliance software' },
     logs: { title: 'System Activity Logs', sub: 'Real-time circular buffer log viewer and topic-based event monitoring' }
@@ -431,6 +432,7 @@ function navigate(tabName) {
   if (tabName === 'settings') loadSettingsData();
   if (tabName === 'users') loadUsers();
   if (tabName === 'certificates') loadCertificates();
+  if (tabName === 'reverse_proxy') loadReverseProxy();
   if (tabName === 'services') loadServices();
   if (tabName === 'updates') loadUpdateStatus();
   if (tabName === 'logs') loadLogs();
@@ -544,6 +546,7 @@ async function refreshCoreData(isManual = false) {
     if (state.activeTab === 'settings') await loadSettingsData(false);
     if (state.activeTab === 'users') await loadUsers(false);
     if (state.activeTab === 'certificates') await loadCertificates(false);
+    if (state.activeTab === 'reverse_proxy') await loadReverseProxy(false);
     if (state.activeTab === 'services') await loadServices(false);
     if (state.activeTab === 'updates') await loadUpdateStatus(false);
     if (state.activeTab === 'bwtest') await fetchBwTestServerStatus();
@@ -17798,3 +17801,416 @@ function setFirewallActionSelection(kind, value) {
  }
  select.value=value;
 }
+
+// ==========================================
+// REVERSE PROXY & APPLICATION GATEWAY
+// ==========================================
+state.proxyRules = [];
+state.proxyConfig = null;
+
+async function loadReverseProxy(manual = false) {
+  try {
+    const [cfgRes, rulesRes, certsRes] = await Promise.all([
+      fetch(`${API_BASE}/proxy/config`),
+      fetch(`${API_BASE}/proxy/rules`),
+      fetch(`${API_BASE}/certificates`).catch(() => null)
+    ]);
+
+    if (!cfgRes.ok) throw new Error('Failed to load proxy config');
+    if (!rulesRes.ok) throw new Error('Failed to load proxy rules');
+
+    state.proxyConfig = await cfgRes.json();
+    state.proxyRules = await rulesRes.json() || [];
+
+    if (certsRes && certsRes.ok) {
+      state.certificates = await certsRes.json() || [];
+    }
+
+    renderProxyRules(state.proxyRules);
+    updateProxyMetrics();
+
+    if (manual) showToast('Reverse Proxy', 'Proxy configuration and rules reloaded', 'success');
+  } catch (err) {
+    console.error('loadReverseProxy error:', err);
+    if (manual) showToast('Error', err.message, 'error');
+  }
+}
+
+function updateProxyMetrics() {
+  const badge = document.getElementById('badge-proxy-count');
+  if (badge) badge.textContent = (state.proxyRules || []).length;
+
+  const metricRules = document.getElementById('proxy-metric-rules');
+  if (metricRules) metricRules.textContent = (state.proxyRules || []).length;
+
+  const cfg = state.proxyConfig;
+  const statusEl = document.getElementById('proxy-metric-status');
+  const portsEl = document.getElementById('proxy-metric-ports');
+
+  if (cfg) {
+    if (statusEl) {
+      if (cfg.enabled) {
+        statusEl.innerHTML = '<span class="w-2.5 h-2.5 rounded-full bg-emerald-500 animate-pulse"></span><span class="text-white">Active</span>';
+      } else {
+        statusEl.innerHTML = '<span class="w-2.5 h-2.5 rounded-full bg-rose-500"></span><span class="text-rose-400">Disabled</span>';
+      }
+    }
+    if (portsEl) {
+      portsEl.textContent = `HTTP: ${cfg.http_port || 80} | HTTPS: ${cfg.https_port || 443}`;
+    }
+  }
+
+  let totalRequests = 0;
+  const certSet = new Set();
+  (state.proxyRules || []).forEach(r => {
+    totalRequests += (r.stats && r.stats.requests) || 0;
+    if (r.enabled && r.certificate) certSet.add(r.certificate.toLowerCase());
+  });
+
+  const metricRequests = document.getElementById('proxy-metric-requests');
+  if (metricRequests) metricRequests.textContent = totalRequests.toLocaleString();
+
+  const metricCerts = document.getElementById('proxy-metric-certs');
+  if (metricCerts) metricCerts.textContent = certSet.size;
+}
+
+function renderProxyRules(rules) {
+  const tbody = document.getElementById('proxy-rules-table-body');
+  if (!tbody) return;
+
+  if (!rules || rules.length === 0) {
+    tbody.innerHTML = '<tr><td colspan="9" class="py-8 text-center text-slate-500 text-xs font-sans">No reverse proxy rules configured yet. Click "Add Proxy Rule" to begin.</td></tr>';
+    return;
+  }
+
+  tbody.innerHTML = rules.map(r => {
+    const isEnabled = r.enabled !== false;
+    const domainsStr = (r.domains && r.domains.length > 0) ? r.domains.map(d => `<span class="px-1.5 py-0.5 rounded bg-slate-800 text-slate-200 border border-slate-700 font-mono text-[11px]">${escapeHtml(d)}</span>`).join(' ') : '<span class="px-1.5 py-0.5 rounded bg-violet-950/60 text-violet-300 border border-violet-800/40 font-mono text-[11px]">* (any)</span>';
+
+    const pathBadge = `<span class="px-2 py-0.5 rounded bg-cyan-950/60 text-cyan-300 border border-cyan-800/40 font-mono text-[11px] font-bold">${escapeHtml(r.path || '/')}</span>`;
+
+    let protoBadge = '<span class="px-1.5 py-0.5 rounded bg-slate-800 text-slate-300 text-[10px] font-mono">HTTP/S</span>';
+    if (r.protocol === 'https') protoBadge = '<span class="px-1.5 py-0.5 rounded bg-emerald-950 text-emerald-400 border border-emerald-800/40 text-[10px] font-mono font-bold">HTTPS</span>';
+    else if (r.protocol === 'http') protoBadge = '<span class="px-1.5 py-0.5 rounded bg-sky-950 text-sky-400 border border-sky-800/40 text-[10px] font-mono">HTTP</span>';
+
+    const certBadge = r.certificate ? `<span class="ml-1 text-[10px] bg-amber-950 text-amber-300 border border-amber-800/40 px-1 py-0.5 rounded font-mono truncate max-w-[120px] inline-block align-bottom" title="${escapeHtml(r.certificate)}">🔒 ${escapeHtml(r.certificate)}</span>` : '';
+
+    const stripBadge = r.strip_path ? '<span class="text-amber-400 text-[11px]">Yes</span>' : '<span class="text-slate-500 text-[11px]">No</span>';
+
+    const requests = (r.stats && r.stats.requests) ? r.stats.requests.toLocaleString() : '0';
+
+    return `
+      <tr class="hover:bg-slate-800/40 transition">
+        <td class="py-3 px-4">
+          <button onclick="toggleProxyRuleState('${escapeHtml(r.id)}', ${isEnabled})" class="relative inline-flex h-5 w-9 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none ${isEnabled ? 'bg-emerald-500' : 'bg-slate-700'}" title="${isEnabled ? 'Click to disable' : 'Click to enable'}">
+            <span class="inline-block h-4 w-4 transform rounded-full bg-white shadow transition duration-200 ease-in-out ${isEnabled ? 'translate-x-4' : 'translate-x-0'}"></span>
+          </button>
+        </td>
+        <td class="py-3 px-4">
+          <div class="font-bold text-white text-xs">${escapeHtml(r.name || 'Unnamed Rule')}</div>
+          ${r.comment ? `<div class="text-[10px] text-slate-400 font-sans truncate max-w-[200px]">${escapeHtml(r.comment)}</div>` : ''}
+        </td>
+        <td class="py-3 px-4 flex flex-wrap gap-1 items-center max-w-xs">
+          ${domainsStr}
+        </td>
+        <td class="py-3 px-4">
+          ${pathBadge}
+        </td>
+        <td class="py-3 px-4 font-mono text-slate-300 max-w-xs truncate" title="${escapeHtml(r.target_url || '')}">
+          <span class="text-emerald-400">→</span> ${escapeHtml(r.target_url || '')}
+        </td>
+        <td class="py-3 px-4 whitespace-nowrap">
+          ${protoBadge}${certBadge}
+        </td>
+        <td class="py-3 px-4 font-mono">
+          ${stripBadge}
+        </td>
+        <td class="py-3 px-4 font-mono text-slate-300">
+          ${requests}
+        </td>
+        <td class="py-3 px-4 text-right whitespace-nowrap">
+          <button onclick="openEditProxyRuleModal('${escapeHtml(r.id)}')" class="px-2.5 py-1 rounded-lg bg-slate-800 hover:bg-slate-700 text-slate-300 text-xs mr-1 transition font-sans">Edit</button>
+          <button onclick="deleteProxyRule('${escapeHtml(r.id)}')" class="px-2.5 py-1 rounded-lg bg-rose-950/40 hover:bg-rose-900/60 text-rose-300 border border-rose-800/40 text-xs transition font-sans">Delete</button>
+        </td>
+      </tr>
+    `;
+  }).join('');
+}
+
+function populateProxyCertDropdown(selectedVal = '') {
+  const select = document.getElementById('proxy-rule-cert');
+  if (!select) return;
+
+  const certs = state.certificates || [];
+  select.innerHTML = '<option value="">(None / Use Default Cert)</option>';
+
+  certs.filter(c => !c.is_ca).forEach(c => {
+    const opt = document.createElement('option');
+    opt.value = c.name;
+    const cn = c.common_name ? ` (${c.common_name})` : '';
+    opt.textContent = `${c.name}${cn}`;
+    if (selectedVal && selectedVal.toLowerCase() === c.name.toLowerCase()) {
+      opt.selected = true;
+    }
+    select.appendChild(opt);
+  });
+}
+
+function populateDefaultProxyCertDropdown(selectedVal = '') {
+  const select = document.getElementById('proxy-cfg-default-cert');
+  if (!select) return;
+
+  const certs = state.certificates || [];
+  select.innerHTML = '<option value="">(None / First Available Cert)</option>';
+
+  certs.filter(c => !c.is_ca).forEach(c => {
+    const opt = document.createElement('option');
+    opt.value = c.name;
+    const cn = c.common_name ? ` (${c.common_name})` : '';
+    opt.textContent = `${c.name}${cn}`;
+    if (selectedVal && selectedVal.toLowerCase() === c.name.toLowerCase()) {
+      opt.selected = true;
+    }
+    select.appendChild(opt);
+  });
+}
+
+function onProxyProtoChange() {
+  const proto = document.getElementById('proxy-rule-proto')?.value;
+  const certSelect = document.getElementById('proxy-rule-cert');
+  const redirectCheck = document.getElementById('proxy-rule-http-to-https');
+
+  if (proto === 'http') {
+    if (certSelect) certSelect.disabled = true;
+    if (redirectCheck) redirectCheck.disabled = true;
+  } else {
+    if (certSelect) certSelect.disabled = false;
+    if (redirectCheck) redirectCheck.disabled = false;
+  }
+}
+
+function openAddProxyRuleModal() {
+  const modal = document.getElementById('modal-proxy-rule');
+  if (!modal) return;
+
+  document.getElementById('proxy-rule-modal-title').innerHTML = `
+    <svg class="w-4 h-4 text-violet-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7h12m0 0l-4-4m4 4l-4 4m0 6H4m0 0l4 4m-4-4l4-4" /></svg>
+    <span>Add Reverse Proxy Rule</span>
+  `;
+  document.getElementById('proxy-rule-id').value = '';
+  document.getElementById('proxy-rule-name').value = '';
+  document.getElementById('proxy-rule-domains').value = '';
+  document.getElementById('proxy-rule-path').value = '/';
+  document.getElementById('proxy-rule-target').value = '';
+  document.getElementById('proxy-rule-proto').value = 'both';
+  document.getElementById('proxy-rule-strip-path').checked = false;
+  document.getElementById('proxy-rule-http-to-https').checked = false;
+  document.getElementById('proxy-rule-preserve-host').checked = false;
+  document.getElementById('proxy-rule-insecure-tls').checked = false;
+  document.getElementById('proxy-rule-enabled').checked = true;
+  document.getElementById('proxy-rule-comment').value = '';
+
+  populateProxyCertDropdown('');
+  onProxyProtoChange();
+
+  modal.classList.remove('hidden');
+}
+
+function openEditProxyRuleModal(id) {
+  const modal = document.getElementById('modal-proxy-rule');
+  if (!modal) return;
+
+  const rule = (state.proxyRules || []).find(r => r.id === id);
+  if (!rule) {
+    showToast('Error', 'Rule not found', 'error');
+    return;
+  }
+
+  document.getElementById('proxy-rule-modal-title').innerHTML = `
+    <svg class="w-4 h-4 text-violet-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" /></svg>
+    <span>Edit Reverse Proxy Rule</span>
+  `;
+  document.getElementById('proxy-rule-id').value = rule.id || '';
+  document.getElementById('proxy-rule-name').value = rule.name || '';
+  document.getElementById('proxy-rule-domains').value = (rule.domains || []).join(', ');
+  document.getElementById('proxy-rule-path').value = rule.path || '/';
+  document.getElementById('proxy-rule-target').value = rule.target_url || '';
+  document.getElementById('proxy-rule-proto').value = rule.protocol || 'both';
+  document.getElementById('proxy-rule-strip-path').checked = !!rule.strip_path;
+  document.getElementById('proxy-rule-http-to-https').checked = !!rule.http_to_https;
+  document.getElementById('proxy-rule-preserve-host').checked = !!rule.preserve_host;
+  document.getElementById('proxy-rule-insecure-tls').checked = !!rule.insecure_tls;
+  document.getElementById('proxy-rule-enabled').checked = rule.enabled !== false;
+  document.getElementById('proxy-rule-comment').value = rule.comment || '';
+
+  populateProxyCertDropdown(rule.certificate || '');
+  onProxyProtoChange();
+
+  modal.classList.remove('hidden');
+}
+
+function closeProxyRuleModal() {
+  const modal = document.getElementById('modal-proxy-rule');
+  if (modal) modal.classList.add('hidden');
+}
+
+async function saveProxyRule(event) {
+  event.preventDefault();
+
+  const id = document.getElementById('proxy-rule-id').value;
+  const name = document.getElementById('proxy-rule-name').value.trim();
+  const rawDomains = document.getElementById('proxy-rule-domains').value.trim();
+  const pathVal = document.getElementById('proxy-rule-path').value.trim() || '/';
+  const target = document.getElementById('proxy-rule-target').value.trim();
+  const proto = document.getElementById('proxy-rule-proto').value;
+  const cert = document.getElementById('proxy-rule-cert').value;
+  const stripPath = document.getElementById('proxy-rule-strip-path').checked;
+  const httpToHttps = document.getElementById('proxy-rule-http-to-https').checked;
+  const preserveHost = document.getElementById('proxy-rule-preserve-host').checked;
+  const insecureTLS = document.getElementById('proxy-rule-insecure-tls').checked;
+  const enabled = document.getElementById('proxy-rule-enabled').checked;
+  const comment = document.getElementById('proxy-rule-comment').value.trim();
+
+  let domains = [];
+  if (rawDomains && rawDomains !== '*') {
+    domains = rawDomains.split(',').map(d => d.trim()).filter(d => d.length > 0);
+  }
+
+  const payload = {
+    id: id || undefined,
+    name,
+    domains,
+    path: pathVal,
+    target_url: target,
+    protocol: proto,
+    certificate: cert,
+    strip_path: stripPath,
+    http_to_https: httpToHttps,
+    preserve_host: preserveHost,
+    insecure_tls: insecureTLS,
+    enabled,
+    comment
+  };
+
+  try {
+    const url = id ? `${API_BASE}/proxy/rules/${encodeURIComponent(id)}` : `${API_BASE}/proxy/rules`;
+    const method = id ? 'PUT' : 'POST';
+
+    const res = await fetch(url, {
+      method,
+      headers: { 'Content-Type': 'application/json' },
+      body: JSON.stringify(payload)
+    });
+
+    if (!res.ok) {
+      const errData = await res.json().catch(() => ({}));
+      throw new Error(errData.message || 'Failed to save proxy rule');
+    }
+
+    closeProxyRuleModal();
+    showToast('Success', `Proxy rule "${name}" saved successfully`, 'success');
+    await loadReverseProxy(false);
+  } catch (err) {
+    showToast('Error', err.message, 'error');
+  }
+}
+
+async function deleteProxyRule(id) {
+  const rule = (state.proxyRules || []).find(r => r.id === id);
+  const name = rule ? rule.name : id;
+
+  if (!confirm(`Are you sure you want to delete proxy rule "${name}"?`)) {
+    return;
+  }
+
+  try {
+    const res = await fetch(`${API_BASE}/proxy/rules/${encodeURIComponent(id)}`, {
+      method: 'DELETE'
+    });
+
+    if (!res.ok) {
+      const errData = await res.json().catch(() => ({}));
+      throw new Error(errData.message || 'Failed to delete rule');
+    }
+
+    showToast('Rule Deleted', `Proxy rule "${name}" removed`, 'success');
+    await loadReverseProxy(false);
+  } catch (err) {
+    showToast('Error', err.message, 'error');
+  }
+}
+
+async function toggleProxyRuleState(id, currentState) {
+  try {
+    const res = await fetch(`${API_BASE}/proxy/rules/${encodeURIComponent(id)}/state`, {
+      method: 'POST',
+      headers: { 'Content-Type': 'application/json' },
+      body: JSON.stringify({ enabled: !currentState })
+    });
+
+    if (!res.ok) {
+      const errData = await res.json().catch(() => ({}));
+      throw new Error(errData.message || 'Failed to toggle rule state');
+    }
+
+    await loadReverseProxy(false);
+  } catch (err) {
+    showToast('Error', err.message, 'error');
+  }
+}
+
+function openProxyConfigModal() {
+  const modal = document.getElementById('modal-proxy-config');
+  if (!modal) return;
+
+  const cfg = state.proxyConfig || { enabled: true, http_port: 80, https_port: 443, default_cert: '' };
+
+  document.getElementById('proxy-cfg-enabled').checked = cfg.enabled !== false;
+  document.getElementById('proxy-cfg-http-port').value = cfg.http_port || 80;
+  document.getElementById('proxy-cfg-https-port').value = cfg.https_port || 443;
+
+  populateDefaultProxyCertDropdown(cfg.default_cert || '');
+
+  modal.classList.remove('hidden');
+}
+
+function closeProxyConfigModal() {
+  const modal = document.getElementById('modal-proxy-config');
+  if (modal) modal.classList.add('hidden');
+}
+
+async function saveProxyConfig(event) {
+  event.preventDefault();
+
+  const enabled = document.getElementById('proxy-cfg-enabled').checked;
+  const httpPort = parseInt(document.getElementById('proxy-cfg-http-port').value, 10) || 80;
+  const httpsPort = parseInt(document.getElementById('proxy-cfg-https-port').value, 10) || 443;
+  const defaultCert = document.getElementById('proxy-cfg-default-cert').value;
+
+  const payload = {
+    enabled,
+    http_port: httpPort,
+    https_port: httpsPort,
+    default_cert: defaultCert
+  };
+
+  try {
+    const res = await fetch(`${API_BASE}/proxy/config`, {
+      method: 'POST',
+      headers: { 'Content-Type': 'application/json' },
+      body: JSON.stringify(payload)
+    });
+
+    if (!res.ok) {
+      const errData = await res.json().catch(() => ({}));
+      throw new Error(errData.message || 'Failed to update proxy settings');
+    }
+
+    closeProxyConfigModal();
+    showToast('Success', 'Reverse proxy settings updated', 'success');
+    await loadReverseProxy(false);
+  } catch (err) {
+    showToast('Error', err.message, 'error');
+  }
+}
+

+ 254 - 1
internal/web/static/index.html

@@ -446,6 +446,17 @@
           <span id="badge-certs-count" class="text-[10px] bg-amber-950 text-amber-300 border border-amber-800/40 px-1.5 py-0.2 rounded-full font-mono">0</span>
         </a>
 
+        <a href="#reverse_proxy" onclick="navigate('reverse_proxy')" id="nav-reverse_proxy"
+           class="nav-link flex items-center justify-between px-3 py-2 rounded-lg text-xs font-medium transition-all group text-slate-300 hover:bg-slate-800/80 hover:text-white">
+          <div class="flex items-center space-x-2.5">
+            <svg class="w-4 h-4 text-violet-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
+              <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7h12m0 0l-4-4m4 4l-4 4m0 6H4m0 0l4 4m-4-4l4-4" />
+            </svg>
+            <span>Reverse Proxy</span>
+          </div>
+          <span id="badge-proxy-count" class="text-[10px] bg-violet-950 text-violet-300 border border-violet-800/40 px-1.5 py-0.2 rounded-full font-mono">0</span>
+        </a>
+
         <a href="#services" onclick="navigate('services')" id="nav-services"
            class="nav-link flex items-center justify-between px-3 py-2 rounded-lg text-xs font-medium transition-all group text-slate-300 hover:bg-slate-800/80 hover:text-white">
           <div class="flex items-center space-x-2.5">
@@ -4620,6 +4631,96 @@
           </div>
         </section>
 
+        <!-- ================= SECTION: REVERSE PROXY ================= -->
+        <section id="section-reverse_proxy" class="hidden space-y-6">
+          <!-- Header and Stats Banner -->
+          <div class="bg-slate-900/80 border border-slate-800 rounded-2xl p-5 shadow-lg flex flex-wrap items-center justify-between gap-4">
+            <div>
+              <h3 class="text-base font-bold text-white flex items-center gap-2">
+                <svg class="w-5 h-5 text-violet-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
+                  <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7h12m0 0l-4-4m4 4l-4 4m0 6H4m0 0l4 4m-4-4l4-4" />
+                </svg>
+                <span>Reverse Proxy & Application Gateway</span>
+              </h3>
+              <p class="text-xs text-slate-400 mt-1">Route HTTP/HTTPS domains and IP path prefixes to backend targets with subfolder forwarding and SSL/TLS termination.</p>
+            </div>
+            <div class="flex items-center gap-2 flex-wrap">
+              <button onclick="openProxyConfigModal()" class="px-3.5 py-1.5 rounded-xl bg-slate-800 hover:bg-slate-700 text-slate-200 border border-slate-700 text-xs font-semibold flex items-center gap-1.5 transition">
+                <svg class="w-3.5 h-3.5 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" /><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" /></svg>
+                <span>Global Settings</span>
+              </button>
+              <button onclick="loadReverseProxy(true)" class="px-3.5 py-1.5 rounded-xl bg-slate-800 hover:bg-slate-700 text-slate-200 border border-slate-700 text-xs font-semibold flex items-center gap-1.5 transition">
+                <svg class="w-3.5 h-3.5 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" /></svg>
+                <span>Refresh</span>
+              </button>
+              <button onclick="openAddProxyRuleModal()" class="px-3.5 py-1.5 rounded-xl bg-violet-600 hover:bg-violet-500 text-white text-xs font-semibold flex items-center gap-1.5 shadow-lg shadow-violet-600/20 transition">
+                <svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" /></svg>
+                <span>Add Proxy Rule</span>
+              </button>
+            </div>
+          </div>
+
+          <!-- Quick Metrics Cards -->
+          <div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
+            <div class="bg-slate-900/80 border border-slate-800 rounded-2xl p-4 shadow-sm">
+              <div class="text-[11px] font-mono text-slate-400 uppercase tracking-wider">Engine Status</div>
+              <div class="text-xl font-bold mt-1.5 flex items-center gap-2" id="proxy-metric-status">
+                <span class="w-2.5 h-2.5 rounded-full bg-emerald-500 animate-pulse"></span>
+                <span class="text-white">Active</span>
+              </div>
+              <div class="text-[10px] text-slate-500 mt-1" id="proxy-metric-ports">HTTP: 80 | HTTPS: 443</div>
+            </div>
+
+            <div class="bg-slate-900/80 border border-slate-800 rounded-2xl p-4 shadow-sm">
+              <div class="text-[11px] font-mono text-slate-400 uppercase tracking-wider">Configured Rules</div>
+              <div class="text-2xl font-bold text-violet-400 mt-1" id="proxy-metric-rules">0</div>
+              <div class="text-[10px] text-slate-500 mt-1">Active proxy routes</div>
+            </div>
+
+            <div class="bg-slate-900/80 border border-slate-800 rounded-2xl p-4 shadow-sm">
+              <div class="text-[11px] font-mono text-slate-400 uppercase tracking-wider">SSL Certificates</div>
+              <div class="text-2xl font-bold text-amber-400 mt-1" id="proxy-metric-certs">0</div>
+              <div class="text-[10px] text-slate-500 mt-1">Bound to proxy routes</div>
+            </div>
+
+            <div class="bg-slate-900/80 border border-slate-800 rounded-2xl p-4 shadow-sm">
+              <div class="text-[11px] font-mono text-slate-400 uppercase tracking-wider">Total Requests Proxied</div>
+              <div class="text-2xl font-bold text-emerald-400 font-mono mt-1" id="proxy-metric-requests">0</div>
+              <div class="text-[10px] text-slate-500 mt-1">Cumulative HTTP/S hits</div>
+            </div>
+          </div>
+
+          <!-- Proxy Rules Table -->
+          <div class="bg-slate-900/80 border border-slate-800 rounded-2xl overflow-hidden shadow-xl">
+            <div class="p-4 border-b border-slate-800 flex items-center justify-between">
+              <h4 class="text-xs font-bold text-slate-300 uppercase tracking-wider font-mono">Proxy Routes & Rules</h4>
+              <span class="text-[11px] text-slate-500">Supports wildcards, subpath nesting and SSL termination</span>
+            </div>
+            <div class="overflow-x-auto">
+              <table class="w-full text-left text-xs">
+                <thead class="bg-slate-950/60 text-slate-400 uppercase font-mono text-[10px] tracking-wider border-b border-slate-800">
+                  <tr>
+                    <th class="py-3 px-4">State</th>
+                    <th class="py-3 px-4">Name / Comment</th>
+                    <th class="py-3 px-4">Inbound Domain / IP</th>
+                    <th class="py-3 px-4">Inbound Path</th>
+                    <th class="py-3 px-4">Target Backend</th>
+                    <th class="py-3 px-4">Protocol & SSL</th>
+                    <th class="py-3 px-4">Strip Path</th>
+                    <th class="py-3 px-4">Requests</th>
+                    <th class="py-3 px-4 text-right">Actions</th>
+                  </tr>
+                </thead>
+                <tbody id="proxy-rules-table-body" class="divide-y divide-slate-800 font-mono">
+                  <tr>
+                    <td colspan="9" class="py-8 text-center text-slate-500 text-xs font-sans">Loading proxy rules...</td>
+                  </tr>
+                </tbody>
+              </table>
+            </div>
+          </div>
+        </section>
+
         <!-- ================= SECTION: SERVICES ================= -->
         <section id="section-services" class="hidden space-y-6">
           <div class="bg-slate-900/80 border border-slate-800 rounded-2xl p-5 shadow-lg flex items-center justify-between">
@@ -11293,7 +11394,159 @@
     </div>
   </div>
 
+  <!-- ================= MODAL: ADD / EDIT REVERSE PROXY RULE ================= -->
+  <div id="modal-proxy-rule" class="fixed inset-0 bg-slate-950/80 backdrop-blur-sm z-50 hidden flex items-center justify-center p-4">
+    <div class="bg-slate-900 border border-slate-800 rounded-2xl w-full max-w-xl shadow-2xl overflow-hidden max-h-[90vh] flex flex-col">
+      <div class="px-5 py-4 border-b border-slate-800 flex items-center justify-between bg-slate-950/50">
+        <h3 id="proxy-rule-modal-title" class="text-sm font-bold text-white flex items-center gap-2">
+          <svg class="w-4 h-4 text-violet-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7h12m0 0l-4-4m4 4l-4 4m0 6H4m0 0l4 4m-4-4l4-4" /></svg>
+          <span>Add Reverse Proxy Rule</span>
+        </h3>
+        <button type="button" onclick="closeProxyRuleModal()" class="text-slate-400 hover:text-white p-1 rounded-lg">✕</button>
+      </div>
+      <form id="proxy-rule-form" onsubmit="saveProxyRule(event)" class="p-5 space-y-4 overflow-y-auto flex-1">
+        <input type="hidden" id="proxy-rule-id">
+
+        <div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
+          <div>
+            <label class="block text-xs font-semibold text-slate-300 mb-1">Rule Name *</label>
+            <input type="text" id="proxy-rule-name" placeholder="e.g. Nextcloud or WebApp" required class="w-full bg-slate-950 border border-slate-800 rounded-lg px-3 py-2 text-xs text-white font-mono focus:outline-none focus:border-violet-500">
+          </div>
+          <div>
+            <label class="block text-xs font-semibold text-slate-300 mb-1">Protocol</label>
+            <select id="proxy-rule-proto" onchange="onProxyProtoChange()" class="w-full bg-slate-950 border border-slate-800 rounded-lg px-3 py-2 text-xs text-white font-mono focus:outline-none focus:border-violet-500">
+              <option value="both">HTTP & HTTPS (Both)</option>
+              <option value="https">HTTPS Only</option>
+              <option value="http">HTTP Only</option>
+            </select>
+          </div>
+        </div>
+
+        <div>
+          <label class="block text-xs font-semibold text-slate-300 mb-1">Inbound Domain / Hostname / IP</label>
+          <input type="text" id="proxy-rule-domains" placeholder="e.g. example.com, app.example.com, 192.168.1.100, or * for any" class="w-full bg-slate-950 border border-slate-800 rounded-lg px-3 py-2 text-xs text-white font-mono focus:outline-none focus:border-violet-500">
+          <p class="text-[10px] text-slate-500 mt-1">Leave empty or specify * to match all hostnames and IPs on the router.</p>
+        </div>
+
+        <div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
+          <div>
+            <label class="block text-xs font-semibold text-slate-300 mb-1">Inbound Path Prefix *</label>
+            <input type="text" id="proxy-rule-path" value="/" required placeholder="e.g. /app1 or /" class="w-full bg-slate-950 border border-slate-800 rounded-lg px-3 py-2 text-xs text-white font-mono focus:outline-none focus:border-violet-500">
+            <p class="text-[10px] text-slate-500 mt-1">Subfolders (e.g. /app1/module1/attribute1) are automatically routed.</p>
+          </div>
+          <div>
+            <label class="block text-xs font-semibold text-slate-300 mb-1">SSL/TLS Certificate</label>
+            <select id="proxy-rule-cert" class="w-full bg-slate-950 border border-slate-800 rounded-lg px-3 py-2 text-xs text-white font-mono focus:outline-none focus:border-violet-500">
+              <option value="">(None / Use Default Cert)</option>
+            </select>
+            <p class="text-[10px] text-slate-500 mt-1">Loaded dynamically from Certificates module.</p>
+          </div>
+        </div>
+
+        <div>
+          <label class="block text-xs font-semibold text-slate-300 mb-1">Target Backend URL *</label>
+          <input type="text" id="proxy-rule-target" placeholder="e.g. http://10.0.0.5:8080 or http://example.com/app1" required class="w-full bg-slate-950 border border-slate-800 rounded-lg px-3 py-2 text-xs text-white font-mono focus:outline-none focus:border-violet-500">
+          <p class="text-[10px] text-slate-500 mt-1">Can be internal IP or external domain, with or without subfolder.</p>
+        </div>
+
+        <div class="grid grid-cols-1 sm:grid-cols-2 gap-3 pt-1">
+          <label class="flex items-center gap-2 cursor-pointer bg-slate-950/60 border border-slate-800/80 p-2.5 rounded-xl">
+            <input type="checkbox" id="proxy-rule-strip-path" class="rounded bg-slate-800 border-slate-700 text-violet-600 focus:ring-0">
+            <div>
+              <span class="text-xs font-semibold text-slate-300 block">Strip Path Prefix</span>
+              <span class="text-[10px] text-slate-500">Strip inbound path prefix before sending to target</span>
+            </div>
+          </label>
+
+          <label class="flex items-center gap-2 cursor-pointer bg-slate-950/60 border border-slate-800/80 p-2.5 rounded-xl">
+            <input type="checkbox" id="proxy-rule-http-to-https" class="rounded bg-slate-800 border-slate-700 text-violet-600 focus:ring-0">
+            <div>
+              <span class="text-xs font-semibold text-slate-300 block">Force HTTPS</span>
+              <span class="text-[10px] text-slate-500">Redirect plain HTTP requests to HTTPS (301)</span>
+            </div>
+          </label>
+
+          <label class="flex items-center gap-2 cursor-pointer bg-slate-950/60 border border-slate-800/80 p-2.5 rounded-xl">
+            <input type="checkbox" id="proxy-rule-preserve-host" class="rounded bg-slate-800 border-slate-700 text-violet-600 focus:ring-0">
+            <div>
+              <span class="text-xs font-semibold text-slate-300 block">Preserve Host Header</span>
+              <span class="text-[10px] text-slate-500">Pass original client Host instead of target Host</span>
+            </div>
+          </label>
+
+          <label class="flex items-center gap-2 cursor-pointer bg-slate-950/60 border border-slate-800/80 p-2.5 rounded-xl">
+            <input type="checkbox" id="proxy-rule-insecure-tls" class="rounded bg-slate-800 border-slate-700 text-violet-600 focus:ring-0">
+            <div>
+              <span class="text-xs font-semibold text-slate-300 block">Insecure Backend TLS</span>
+              <span class="text-[10px] text-slate-500">Allow self-signed certificates on backend</span>
+            </div>
+          </label>
+        </div>
+
+        <div>
+          <label class="block text-xs font-semibold text-slate-300 mb-1">Description / Comment</label>
+          <input type="text" id="proxy-rule-comment" placeholder="Optional notes for this route" class="w-full bg-slate-950 border border-slate-800 rounded-lg px-3 py-2 text-xs text-white focus:outline-none focus:border-violet-500">
+        </div>
+
+        <div class="flex items-center gap-2 pt-1">
+          <input type="checkbox" id="proxy-rule-enabled" checked class="rounded bg-slate-800 border-slate-700 text-emerald-500 focus:ring-0">
+          <label for="proxy-rule-enabled" class="text-xs font-semibold text-slate-300 cursor-pointer">Enable this rule immediately</label>
+        </div>
+
+        <div class="flex justify-end gap-2 pt-3 border-t border-slate-800">
+          <button type="button" onclick="closeProxyRuleModal()" class="px-4 py-2 rounded-lg bg-slate-800 hover:bg-slate-700 text-xs font-medium text-slate-300 transition">Cancel</button>
+          <button type="submit" class="px-4 py-2 rounded-lg bg-violet-600 hover:bg-violet-500 text-xs font-semibold text-white shadow-lg shadow-violet-600/30 transition">Save Rule</button>
+        </div>
+      </form>
+    </div>
+  </div>
+
+  <!-- ================= MODAL: REVERSE PROXY GLOBAL CONFIG ================= -->
+  <div id="modal-proxy-config" class="fixed inset-0 bg-slate-950/80 backdrop-blur-sm z-50 hidden flex items-center justify-center p-4">
+    <div class="bg-slate-900 border border-slate-800 rounded-2xl w-full max-w-md shadow-2xl overflow-hidden">
+      <div class="px-5 py-4 border-b border-slate-800 flex items-center justify-between bg-slate-950/50">
+        <h3 class="text-sm font-bold text-white flex items-center gap-2">
+          <svg class="w-4 h-4 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" /><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" /></svg>
+          <span>Reverse Proxy Engine Settings</span>
+        </h3>
+        <button type="button" onclick="closeProxyConfigModal()" class="text-slate-400 hover:text-white p-1 rounded-lg">✕</button>
+      </div>
+      <form id="proxy-config-form" onsubmit="saveProxyConfig(event)" class="p-5 space-y-4">
+        <div class="flex items-center gap-2 p-3 rounded-xl bg-slate-950/60 border border-slate-800">
+          <input type="checkbox" id="proxy-cfg-enabled" class="rounded bg-slate-800 border-slate-700 text-emerald-500 focus:ring-0">
+          <label for="proxy-cfg-enabled" class="text-xs font-semibold text-white cursor-pointer">
+            Master Switch: Enable Reverse Proxy Engine
+          </label>
+        </div>
+
+        <div class="grid grid-cols-2 gap-3">
+          <div>
+            <label class="block text-xs font-semibold text-slate-300 mb-1">HTTP Port</label>
+            <input type="number" id="proxy-cfg-http-port" min="1" max="65535" value="80" required class="w-full bg-slate-950 border border-slate-800 rounded-lg px-3 py-2 text-xs text-white font-mono focus:outline-none focus:border-violet-500">
+          </div>
+          <div>
+            <label class="block text-xs font-semibold text-slate-300 mb-1">HTTPS Port</label>
+            <input type="number" id="proxy-cfg-https-port" min="1" max="65535" value="443" required class="w-full bg-slate-950 border border-slate-800 rounded-lg px-3 py-2 text-xs text-white font-mono focus:outline-none focus:border-violet-500">
+          </div>
+        </div>
+
+        <div>
+          <label class="block text-xs font-semibold text-slate-300 mb-1">Default Fallback SSL Certificate</label>
+          <select id="proxy-cfg-default-cert" class="w-full bg-slate-950 border border-slate-800 rounded-lg px-3 py-2 text-xs text-white font-mono focus:outline-none focus:border-violet-500">
+            <option value="">(None / First Available Cert)</option>
+          </select>
+          <p class="text-[10px] text-slate-500 mt-1">Used for HTTPS clients when no rule-specific certificate matches.</p>
+        </div>
+
+        <div class="flex justify-end gap-2 pt-3 border-t border-slate-800">
+          <button type="button" onclick="closeProxyConfigModal()" class="px-4 py-2 rounded-lg bg-slate-800 hover:bg-slate-700 text-xs font-medium text-slate-300 transition">Cancel</button>
+          <button type="submit" class="px-4 py-2 rounded-lg bg-violet-600 hover:bg-violet-500 text-xs font-semibold text-white shadow-lg shadow-violet-600/30 transition">Save Settings</button>
+        </div>
+      </form>
+    </div>
+  </div>
+
   <datalist id="fw-addrlist-datalist"></datalist>
-  <script src="app.js?v=20.0"></script>
+  <script src="app.js?v=21.0"></script>
 </body>
 </html>