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