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() }