package proxy import ( "context" "crypto/tls" "encoding/base64" "fmt" "io" "log/slog" "net" "net/http" "net/http/httptest" "net/http/httputil" "net/url" "os" "path/filepath" "strconv" "strings" "sync" "time" "helix-proxy/internal/config" "helix-proxy/internal/store" "helix-proxy/internal/www" ) // Engine manages the live HTTP reverse proxy (and later streams) based on the store. // It replaces all nginx config generation / reload. type Engine struct { mu sync.RWMutex hosts map[string]*Host // domain (or wildcard key) -> config + handler st store.Store srv *http.Server // the :80 / :443 listener(s) started bool // streams streamMu sync.Mutex streamHandles map[int]*streamHandle // stream id -> live listeners streamRuntimeMu sync.RWMutex streamRuntime map[int]*streamRuntime // incoming port -> live listener state // TLS certs per host (from certificates) certMu sync.RWMutex hostCerts map[string]*tls.Certificate certByID map[int]*tls.Certificate // for streams SSL termination etc. // redirection hosts redirMu sync.RWMutex redirHosts map[string]store.RedirectionHost // dead hosts deadMu sync.RWMutex deadHosts map[string]store.DeadHost // simple response cache for cachingEnabled cacheMu sync.Mutex cache map[string]struct { body []byte headers http.Header status int expires time.Time } } // Host holds the live config for one (or more) domain(s). type Host struct { ID int Domains []string ForwardURL string // default backend Enabled bool ForwardScheme string SslForced bool BlockExploits bool Locations []store.Location // Access list (resolved at reload) AccessClients []store.AccessClient AccessSatisfyAny bool AccessItems []store.AccessItem AccessPassAuth bool // Additional flags from ProxyHost (applied in Handler) HstsEnabled bool HstsSubdomains bool AllowWebsocketUpgrade bool TrustForwardedProto bool ReqHeaderSets map[string]string RespHeaderAdds map[string]string RespHeaderHides []string CachingEnabled bool Http2Support bool // noted for future http2 backend config } func NewEngine(st store.Store) *Engine { return &Engine{ hosts: make(map[string]*Host), st: st, streamHandles: make(map[int]*streamHandle), streamRuntime: make(map[int]*streamRuntime), hostCerts: make(map[string]*tls.Certificate), certByID: make(map[int]*tls.Certificate), redirHosts: make(map[string]store.RedirectionHost), deadHosts: make(map[string]store.DeadHost), cache: make(map[string]struct { body []byte headers http.Header status int expires time.Time }), } } // ReloadFromStore rebuilds the in-memory routing table from the store. // Called on startup and after any host mutation. func (e *Engine) ReloadFromStore() { e.mu.Lock() defer e.mu.Unlock() e.hosts = make(map[string]*Host) proxyHosts := e.st.GetProxyHosts() slog.Info("store root for reload", "proxyHosts", len(proxyHosts), "accessLists", len(e.st.GetAccessLists())) for _, ph := range proxyHosts { if !ph.Enabled || len(ph.DomainNames) == 0 { continue } scheme := ph.ForwardScheme if scheme == "" { scheme = "http" } u := &url.URL{Scheme: scheme, Host: net.JoinHostPort(ph.ForwardHost, "80")} if ph.ForwardPort > 0 { u.Host = net.JoinHostPort(ph.ForwardHost, itoa(ph.ForwardPort)) } h := &Host{ ID: ph.ID, Domains: append([]string(nil), ph.DomainNames...), ForwardURL: u.String(), Enabled: ph.Enabled, ForwardScheme: scheme, SslForced: ph.SslForced, BlockExploits: ph.BlockExploits, Locations: append([]store.Location(nil), ph.Locations...), // access list AccessSatisfyAny: false, // additional flags HstsEnabled: ph.HstsEnabled, HstsSubdomains: ph.HstsSubdomains, AllowWebsocketUpgrade: ph.AllowWebsocketUpgrade, TrustForwardedProto: ph.TrustForwardedProto, CachingEnabled: ph.CachingEnabled, Http2Support: ph.Http2Support, } h.ReqHeaderSets, h.RespHeaderAdds, h.RespHeaderHides = resolveProxyHeaders(ph) if ph.AccessListID > 0 { if al, ok := e.st.GetAccessList(ph.AccessListID); ok { h.AccessClients = append([]store.AccessClient(nil), al.Clients...) h.AccessSatisfyAny = al.SatisfyAny h.AccessItems = append([]store.AccessItem(nil), al.Items...) h.AccessPassAuth = al.PassAuth slog.Info("loaded access list for host", "hostID", ph.ID, "listID", ph.AccessListID, "clients", len(h.AccessClients), "authItems", len(h.AccessItems)) } else { slog.Warn("access list not found for host", "hostID", ph.ID, "listID", ph.AccessListID) } } for _, d := range h.Domains { e.hosts[strings.ToLower(d)] = h // later: support *.example.com wildcard matching } } slog.Info("proxy engine reloaded", "hosts", len(e.hosts)) e.reloadStreams() e.loadCerts() e.loadRedirs() e.loadDeads() e.cacheMu.Lock() e.cache = make(map[string]struct { body []byte headers http.Header status int expires time.Time }) e.cacheMu.Unlock() } func itoa(i int) string { return strconv.Itoa(i) } // Handler returns the http.Handler for the main proxy listener(s). // It does SNI/Host based lookup and reverse proxy. func (e *Engine) Handler() http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // Serve ACME challenges for LE (before host lookup) // lego webroot provider writes tokens to /.well-known/acme-challenge/ // so our serve must look there (standard layout). if strings.HasPrefix(r.URL.Path, "/.well-known/acme-challenge/") { challengeDir := config.Resolve("letsencrypt-acme-challenge") acmeDir := filepath.Join(challengeDir, ".well-known", "acme-challenge") file := filepath.Join(acmeDir, filepath.Base(r.URL.Path)) if data, err := os.ReadFile(file); err == nil { w.Header().Set("Content-Type", "text/plain") w.Write(data) return } http.NotFound(w, r) return } host := r.Host if h, _, err := net.SplitHostPort(host); err == nil { host = h } host = strings.ToLower(host) // Check redirection hosts first (independent) e.redirMu.RLock() rh, isRedir := e.redirHosts[host] e.redirMu.RUnlock() slog.Debug("redir check", "host", host, "isRedir", isRedir, "numRedirs", len(e.redirHosts)) if isRedir { code := rh.ForwardHTTPCode if code == 0 { code = 302 } target := rh.ForwardDomainName scheme := rh.ForwardScheme if scheme == "" || scheme == "auto" { scheme = "http" } target = scheme + "://" + target if rh.PreservePath { target += r.URL.RequestURI() } http.Redirect(w, r, target, code) return } // Check dead hosts (blocks access, serves 404 or custom) e.deadMu.RLock() dh, isDead := e.deadHosts[host] e.deadMu.RUnlock() if isDead { // per-dead custom html via meta.html (for full parity/custom content) if dh.Meta != nil { if html, ok := dh.Meta["html"].(string); ok && html != "" { w.Header().Set("Content-Type", "text/html; charset=utf-8") w.WriteHeader(http.StatusNotFound) w.Write([]byte(html)) return } } // Serve 404 page (data/www/404.html overrides embedded default). if data := www.LoadPage("404.html", map[string]string{ "MESSAGE": fmt.Sprintf("This host (%s) is configured as dead/blocked.", host), }); data != nil { w.Header().Set("Content-Type", "text/html; charset=utf-8") w.WriteHeader(http.StatusNotFound) w.Write(data) return } w.WriteHeader(http.StatusNotFound) w.Write([]byte("404 Not Found (this host is configured as dead/blocked)")) return } e.mu.RLock() hcfg, ok := e.hosts[host] e.mu.RUnlock() if !ok || !hcfg.Enabled { // default site behavior driven by settings ds := "congratulations" if settings := e.st.GetSettings(); settings != nil { if s, ok := settings["default_site"].(string); ok && s != "" { ds = s } } switch ds { case "404": if data := www.LoadPage("404.html", map[string]string{ "MESSAGE": fmt.Sprintf("No virtual host configured for %s.", host), }); data != nil { w.Header().Set("Content-Type", "text/html; charset=utf-8") w.WriteHeader(http.StatusNotFound) w.Write(data) return } w.WriteHeader(http.StatusNotFound) w.Write([]byte("404 Not Found")) return case "444": w.WriteHeader(444) // close like nginx return case "redirect": tgt := "https://example.com" if settings := e.st.GetSettings(); settings != nil { if r, ok := settings["default_site_redirect"].(string); ok && r != "" { tgt = r } } http.Redirect(w, r, tgt, http.StatusFound) return default: // congratulations if data := www.LoadPage("index.html", map[string]string{"HOST": host}); data != nil { w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Write(data) return } w.Header().Set("Content-Type", "text/html; charset=utf-8") fmt.Fprintf(w, `

Welcome to Helix Proxy

No virtual host for %s. Use the admin UI on :81 to configure hosts.

`, host) return } } slog.Debug("host matched", "host", host, "hasAccessClients", len(hcfg.AccessClients)) // Access list IP check (before other processing) // Reuses getRealClientIP (defined below) with TrustForwardedProto gating to avoid dupe of XFF-trust logic (see also injection path at ~431). if len(hcfg.AccessClients) > 0 { clientIP := getRealClientIP(r, hcfg.TrustForwardedProto) slog.Debug("access check", "host", host, "clients", len(hcfg.AccessClients), "satisfyAny", hcfg.AccessSatisfyAny, "ip", clientIP) if !checkAccess(hcfg.AccessClients, hcfg.AccessSatisfyAny, clientIP) { w.WriteHeader(http.StatusForbidden) w.Write([]byte("access denied by access list")) return } } // Basic auth if access list has items if len(hcfg.AccessItems) > 0 { authHeader := r.Header.Get("Authorization") if authHeader == "" || !strings.HasPrefix(authHeader, "Basic ") { w.Header().Set("WWW-Authenticate", `Basic realm="Authorization required"`) w.WriteHeader(http.StatusUnauthorized) return } payload, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(authHeader, "Basic ")) if err != nil { w.Header().Set("WWW-Authenticate", `Basic realm="Authorization required"`) w.WriteHeader(http.StatusUnauthorized) return } creds := strings.SplitN(string(payload), ":", 2) if len(creds) != 2 { w.Header().Set("WWW-Authenticate", `Basic realm="Authorization required"`) w.WriteHeader(http.StatusUnauthorized) return } user, pass := creds[0], creds[1] authed := false for _, item := range hcfg.AccessItems { if item.Username == user && item.Password == pass { // demo: plain compare; real would hash authed = true break } } if !authed { w.Header().Set("WWW-Authenticate", `Basic realm="Authorization required"`) w.WriteHeader(http.StatusUnauthorized) return } // if not pass_auth, strip the header so backend doesn't see the creds if !hcfg.AccessPassAuth { r.Header.Del("Authorization") } } // Basic ssl_forced: if configured and request looks http, redirect to https if hcfg.SslForced { proto := r.Header.Get("X-Forwarded-Proto") if proto == "http" || r.TLS == nil { httpsURL := "https://" + r.Host + r.URL.RequestURI() http.Redirect(w, r, httpsURL, http.StatusMovedPermanently) return } } // Basic block_exploits if hcfg.BlockExploits { badPaths := []string{".env", "wp-admin", ".git/", "config.php", "phpinfo", "shell.php"} for _, bad := range badPaths { if strings.Contains(r.URL.Path, bad) { w.WriteHeader(http.StatusForbidden) w.Write([]byte("blocked by exploits rule")) return } } } // HSTS (set on responses for this vhost if enabled; typically observed on https) if hcfg.HstsEnabled { hsts := "max-age=31536000" if hcfg.HstsSubdomains { hsts += "; includeSubDomains" } w.Header().Set("Strict-Transport-Security", hsts) } // Support custom locations (path prefix routing to different backends) targetURL := hcfg.ForwardURL reqPath := r.URL.Path for _, loc := range hcfg.Locations { if loc.Path != "" && strings.HasPrefix(reqPath, loc.Path) { scheme := loc.ForwardScheme if scheme == "" { scheme = hcfg.ForwardScheme if scheme == "" { scheme = "http" } } port := loc.ForwardPort if port == 0 { port = 80 } u := &url.URL{ Scheme: scheme, Host: net.JoinHostPort(loc.ForwardHost, strconv.Itoa(port)), } if loc.ForwardPath != "" { // simple path rewrite (mutate request path only; target URL stays host-level // so ReverseProxy joinURLPath does not duplicate the prefix) rest := strings.TrimPrefix(reqPath, loc.Path) r.URL.Path = loc.ForwardPath + rest } targetURL = u.String() break } } // Websocket upgrade support (if enabled for this host) if hcfg.AllowWebsocketUpgrade && isWebsocketRequest(r) { r.Header.Set("Connection", "Upgrade") r.Header.Set("Upgrade", "websocket") // ReverseProxy will handle hijacking the conn for the upgrade } // Compute real client info (respect trust-forwarded if configured) clientIP := getRealClientIP(r, hcfg.TrustForwardedProto) forwardedProto := "http" if r.TLS != nil { forwardedProto = "https" } else if hcfg.TrustForwardedProto { if p := r.Header.Get("X-Forwarded-Proto"); p != "" { forwardedProto = strings.ToLower(strings.Split(p, ",")[0]) } } // Apply configured request/response header overrides. for k, v := range hcfg.ReqHeaderSets { r.Header.Set(k, v) } target, _ := url.Parse(targetURL) proxy := httputil.NewSingleHostReverseProxy(target) // Director to set X-Forwarded* and any request header overrides (after default director) origDirector := proxy.Director proxy.Director = func(req *http.Request) { origDirector(req) req.Header.Set("X-Forwarded-Host", r.Host) req.Header.Set("X-Forwarded-Proto", forwardedProto) req.Header.Set("X-Forwarded-For", clientIP) req.Header.Set("X-Real-IP", clientIP) for k, v := range hcfg.ReqHeaderSets { req.Header.Set(k, v) } } // ModifyResponse to apply response header adds/hides proxy.ModifyResponse = func(resp *http.Response) error { for k, v := range hcfg.RespHeaderAdds { resp.Header.Set(k, v) } for _, k := range hcfg.RespHeaderHides { resp.Header.Del(k) } return nil } // Basic caching support if enabled (for GET; simple in-mem with 1h TTL + HIT/MISS + no-store skip) if hcfg.CachingEnabled && r.Method == "GET" { key := host + r.URL.RequestURI() e.cacheMu.Lock() if c, ok := e.cache[key]; ok && time.Now().Before(c.expires) { for k, vs := range c.headers { for _, v := range vs { w.Header().Add(k, v) } } w.Header().Set("X-Proxy-Cache", "HIT") status := c.status if status == 0 { status = 200 } w.WriteHeader(status) w.Write(c.body) e.cacheMu.Unlock() return } e.cacheMu.Unlock() // capture response for cache (note: buffers full body in RAM; no size cap yet, see review) rec := httptest.NewRecorder() proxy.ServeHTTP(rec, r) // decide to cache: skip if no-store/private in resp doCache := true if cc := rec.Header().Get("Cache-Control"); cc != "" { lcc := strings.ToLower(cc) if strings.Contains(lcc, "no-store") || strings.Contains(lcc, "private") { doCache = false } } if doCache { e.cacheMu.Lock() e.cache[key] = struct { body []byte headers http.Header status int expires time.Time }{ body: rec.Body.Bytes(), headers: rec.Header(), status: rec.Code, expires: time.Now().Add(1 * time.Hour), } e.cacheMu.Unlock() } // write to client (MISS only set for actual cacheable paths; omitted/BYPASS for no-store) for k, vs := range rec.Header() { for _, v := range vs { w.Header().Add(k, v) } } if doCache { w.Header().Set("X-Proxy-Cache", "MISS") } w.WriteHeader(rec.Code) w.Write(rec.Body.Bytes()) return } proxy.ServeHTTP(w, r) }) } // Start begins listening on the proxy ports (80/443 in real; for dev we can use high ports or require root). // In production docker this will be able to bind because of how the image runs. // Respects PROXY_HTTP_PORT env (e.g. 80 or 8080). func (e *Engine) Start(ctx context.Context) error { e.ReloadFromStore() port := "8080" if p := os.Getenv("PROXY_HTTP_PORT"); p != "" { port = p } addr := net.JoinHostPort("", port) if os.Getenv("DISABLE_IPV6") == "1" { addr = "0.0.0.0:" + port } e.srv = &http.Server{ Addr: addr, Handler: e.Handler(), } ln, err := net.Listen("tcp", e.srv.Addr) if err != nil { return err } slog.Info("pure-Go proxy HTTP listening (no nginx). Use PROXY_HTTP_PORT=80 (and privileges) or in docker for real low port", "addr", e.srv.Addr) go func() { <-ctx.Done() _ = e.srv.Shutdown(context.Background()) _ = ln.Close() }() e.started = true return e.srv.Serve(ln) } // StreamStatus is the live listener state for an enabled stream (not persisted). type StreamStatus struct { Listening bool `json:"listening"` ListenError string `json:"listenError,omitempty"` } type streamRuntime struct { wantTCP bool wantUDP bool tcpOK bool udpOK bool tcpErr string udpErr string tcpDone bool udpDone bool } func (r *streamRuntime) status() StreamStatus { if r.wantTCP && !r.tcpDone { return StreamStatus{} } if r.wantUDP && !r.udpDone { return StreamStatus{} } var errs []string if r.wantTCP && !r.tcpOK && r.tcpErr != "" { errs = append(errs, r.tcpErr) } if r.wantUDP && !r.udpOK && r.udpErr != "" { errs = append(errs, r.udpErr) } if len(errs) > 0 { return StreamStatus{ListenError: strings.Join(errs, "; ")} } return StreamStatus{Listening: r.wantTCP || r.wantUDP} } func (e *Engine) StreamStatus(port int) StreamStatus { e.streamRuntimeMu.RLock() rt := e.streamRuntime[port] e.streamRuntimeMu.RUnlock() if rt == nil { return StreamStatus{} } return rt.status() } func (e *Engine) initStreamRuntime(s store.Stream) { e.streamRuntimeMu.Lock() defer e.streamRuntimeMu.Unlock() e.streamRuntime[s.IncomingPort] = &streamRuntime{ wantTCP: s.TCPForwarding, wantUDP: s.UDPForwarding, } } func (e *Engine) markStreamTCPResult(port int, ok bool, err error) { e.streamRuntimeMu.Lock() defer e.streamRuntimeMu.Unlock() rt := e.streamRuntime[port] if rt == nil { return } rt.tcpDone = true rt.tcpOK = ok if err != nil { rt.tcpErr = err.Error() } } func (e *Engine) markStreamUDPResult(port int, ok bool, err error) { e.streamRuntimeMu.Lock() defer e.streamRuntimeMu.Unlock() rt := e.streamRuntime[port] if rt == nil { return } rt.udpDone = true rt.udpOK = ok if err != nil { rt.udpErr = err.Error() } } type streamHandle struct { cancel context.CancelFunc running store.Stream tcpLn net.Listener udpConn *net.UDPConn wg sync.WaitGroup } func streamListenerConfigEqual(a, b store.Stream) bool { return a.IncomingPort == b.IncomingPort && a.ForwardingHost == b.ForwardingHost && a.ForwardingPort == b.ForwardingPort && a.TCPForwarding == b.TCPForwarding && a.UDPForwarding == b.UDPForwarding && a.CertificateID == b.CertificateID } func duplicateStreamListenPorts(streams map[int]store.Stream) map[int]string { byPort := make(map[int]int, len(streams)) errs := make(map[int]string) for id, s := range streams { if owner, ok := byPort[s.IncomingPort]; ok { msg := fmt.Sprintf("incoming port %d already assigned to stream %d", s.IncomingPort, owner) if _, has := errs[id]; !has { errs[id] = msg } if _, has := errs[owner]; !has { errs[owner] = fmt.Sprintf("incoming port %d conflict with stream %d", s.IncomingPort, id) } continue } byPort[s.IncomingPort] = id } return errs } func (e *Engine) markStreamFailed(s store.Stream, err error) { if s.TCPForwarding { e.markStreamTCPResult(s.IncomingPort, false, err) } if s.UDPForwarding { e.markStreamUDPResult(s.IncomingPort, false, err) } } func (e *Engine) streamPortHeldByOther(streamID, port int) (int, bool) { e.streamMu.Lock() defer e.streamMu.Unlock() for id, h := range e.streamHandles { if id != streamID && h.running.IncomingPort == port { return id, true } } return 0, false } func (e *Engine) clearStreamRuntime(port int) { e.streamRuntimeMu.Lock() delete(e.streamRuntime, port) e.streamRuntimeMu.Unlock() } func (e *Engine) stopStreamByID(id int) { e.streamMu.Lock() h := e.streamHandles[id] delete(e.streamHandles, id) e.streamMu.Unlock() if h == nil { return } if h.tcpLn != nil { _ = h.tcpLn.Close() } if h.udpConn != nil { _ = h.udpConn.Close() } h.cancel() h.wg.Wait() e.clearStreamRuntime(h.running.IncomingPort) } func (e *Engine) reloadStreams() { all := e.st.GetStreams() desired := make(map[int]store.Stream, len(all)) for _, s := range all { if s.Enabled && (s.TCPForwarding || s.UDPForwarding) { desired[s.ID] = s } } e.streamMu.Lock() running := make(map[int]*streamHandle, len(e.streamHandles)) for id, h := range e.streamHandles { running[id] = h } e.streamMu.Unlock() portConflicts := duplicateStreamListenPorts(desired) for id, msg := range portConflicts { s := desired[id] e.initStreamRuntime(s) e.markStreamFailed(s, fmt.Errorf("%s", msg)) delete(desired, id) slog.Error("stream port conflict", "streamID", id, "port", s.IncomingPort, "err", msg) } stopIDs := make(map[int]struct{}) for id, h := range running { want, ok := desired[id] if !ok || !streamListenerConfigEqual(want, h.running) { stopIDs[id] = struct{}{} } } for id := range stopIDs { e.stopStreamByID(id) } for id, want := range desired { e.streamMu.Lock() h := e.streamHandles[id] e.streamMu.Unlock() if h != nil && streamListenerConfigEqual(want, h.running) { continue } e.startStream(want) } e.streamRuntimeMu.Lock() activePorts := make(map[int]struct{}) e.streamMu.Lock() for _, h := range e.streamHandles { activePorts[h.running.IncomingPort] = struct{}{} } e.streamMu.Unlock() for port := range e.streamRuntime { if _, ok := activePorts[port]; ok { continue } keep := false for _, s := range desired { if s.IncomingPort == port { keep = true break } } if !keep { delete(e.streamRuntime, port) } } e.streamRuntimeMu.Unlock() } func (e *Engine) startStream(s store.Stream) { if !s.TCPForwarding && !s.UDPForwarding { return } ctx, cancel := context.WithCancel(context.Background()) h := &streamHandle{cancel: cancel, running: s} e.streamMu.Lock() e.streamHandles[s.ID] = h e.streamMu.Unlock() e.initStreamRuntime(s) if holderID, held := e.streamPortHeldByOther(s.ID, s.IncomingPort); held { err := fmt.Errorf("incoming port %d already used by stream %d", s.IncomingPort, holderID) slog.Error("stream listen blocked", "streamID", s.ID, "port", s.IncomingPort, "holder", holderID) e.markStreamFailed(s, err) return } if s.TCPForwarding { listenAddr := ":" + itoa(s.IncomingPort) if os.Getenv("DISABLE_IPV6") == "1" { listenAddr = "0.0.0.0:" + itoa(s.IncomingPort) } if s.CertificateID > 0 { cert := e.getStreamCert(s.CertificateID) if cert == nil { err := fmt.Errorf("no certificate for stream TLS (cert id %d)", s.CertificateID) slog.Error("tcp stream tls listen failed", "port", s.IncomingPort, "err", err) e.markStreamTCPResult(s.IncomingPort, false, err) } else { tlsCfg := &tls.Config{Certificates: []tls.Certificate{*cert}} ln, err := tls.Listen("tcp", listenAddr, tlsCfg) if err != nil { slog.Error("tcp stream tls listen failed", "port", s.IncomingPort, "err", err) e.markStreamTCPResult(s.IncomingPort, false, err) } else { h.tcpLn = ln e.markStreamTCPResult(s.IncomingPort, true, nil) h.wg.Add(1) go func() { defer h.wg.Done() e.serveTCPStream(ctx, s, ln) }() } } } else { ln, err := net.Listen("tcp", listenAddr) if err != nil { slog.Error("tcp stream listen failed", "port", s.IncomingPort, "err", err) e.markStreamTCPResult(s.IncomingPort, false, err) } else { h.tcpLn = ln e.markStreamTCPResult(s.IncomingPort, true, nil) h.wg.Add(1) go func() { defer h.wg.Done() e.serveTCPStream(ctx, s, ln) }() } } } if s.UDPForwarding { udpAddr := ":" + itoa(s.IncomingPort) if os.Getenv("DISABLE_IPV6") == "1" { udpAddr = "0.0.0.0:" + itoa(s.IncomingPort) } addr, _ := net.ResolveUDPAddr("udp", udpAddr) conn, err := net.ListenUDP("udp", addr) if err != nil { slog.Error("udp stream listen failed", "port", s.IncomingPort, "err", err) e.markStreamUDPResult(s.IncomingPort, false, err) } else { h.udpConn = conn e.markStreamUDPResult(s.IncomingPort, true, nil) h.wg.Add(1) go func() { defer h.wg.Done() e.serveUDPStream(ctx, s, conn) }() } } st := e.StreamStatus(s.IncomingPort) if st.Listening { slog.Info("started stream listener", "port", s.IncomingPort, "tcp", s.TCPForwarding, "udp", s.UDPForwarding, "ssl", s.CertificateID > 0) } else if st.ListenError != "" { slog.Warn("stream listener failed to start", "port", s.IncomingPort, "tcp", s.TCPForwarding, "udp", s.UDPForwarding, "ssl", s.CertificateID > 0, "err", st.ListenError) if h.tcpLn == nil && h.udpConn == nil { e.streamMu.Lock() delete(e.streamHandles, s.ID) e.streamMu.Unlock() h.cancel() } } } func (e *Engine) serveTCPStream(ctx context.Context, s store.Stream, ln net.Listener) { for { conn, err := ln.Accept() if err != nil { if ctx.Err() != nil { return } continue } go func(c net.Conn) { defer c.Close() target, err := net.Dial("tcp", net.JoinHostPort(s.ForwardingHost, itoa(s.ForwardingPort))) if err != nil { return } defer target.Close() done := make(chan struct{}, 2) go func() { io.Copy(target, c); done <- struct{}{} }() go func() { io.Copy(c, target); done <- struct{}{} }() <-done }(conn) } } func (e *Engine) getStreamCert(id int) *tls.Certificate { e.certMu.RLock() defer e.certMu.RUnlock() return e.certByID[id] } func (e *Engine) serveUDPStream(ctx context.Context, s store.Stream, conn *net.UDPConn) { buf := make([]byte, 65535) for { n, clientAddr, err := conn.ReadFromUDP(buf) if err != nil { if ctx.Err() != nil { return } continue } go func(data []byte, caddr *net.UDPAddr) { taddr, _ := net.ResolveUDPAddr("udp", net.JoinHostPort(s.ForwardingHost, itoa(s.ForwardingPort))) tconn, err := net.DialUDP("udp", nil, taddr) if err != nil { return } defer tconn.Close() tconn.Write(data) tconn.SetReadDeadline(time.Now().Add(5 * time.Second)) resp := make([]byte, 65535) m, _, _ := tconn.ReadFromUDP(resp) if m > 0 { conn.WriteToUDP(resp[:m], caddr) } }(append([]byte{}, buf[:n]...), clientAddr) } } func (e *Engine) loadCerts() { e.certMu.Lock() defer e.certMu.Unlock() e.hostCerts = make(map[string]*tls.Certificate) e.certByID = make(map[int]*tls.Certificate) certs := e.st.GetCertificates() certByID := map[int]store.Certificate{} for _, c := range certs { certByID[c.ID] = c } for id, c := range certByID { certPEM, _ := c.Meta["certificate"].(string) if certPEM == "" { certPEM, _ = c.Meta["cert"].(string) // compat our old + UI } keyPEM, _ := c.Meta["certificate_key"].(string) if keyPEM == "" { keyPEM, _ = c.Meta["key"].(string) } if certPEM != "" && keyPEM != "" { tcert, err := tls.X509KeyPair([]byte(certPEM), []byte(keyPEM)) if err == nil { e.certByID[id] = &tcert // also index by domains for http proxy hosts for _, d := range c.DomainNames { e.hostCerts[strings.ToLower(d)] = &tcert } } } } // legacy per-host for proxy (now covered above, but keep for compat) // re-attaches using ph.CertificateID even if cert's DomainNames differ; supports hosts referencing cert by id only proxyHosts := e.st.GetProxyHosts() for _, ph := range proxyHosts { if ph.CertificateID > 0 { if tcert, ok := e.certByID[ph.CertificateID]; ok { for _, d := range ph.DomainNames { e.hostCerts[strings.ToLower(d)] = tcert } } } } } func (e *Engine) GetCertificate(hello *tls.ClientHelloInfo) (*tls.Certificate, error) { e.certMu.RLock() defer e.certMu.RUnlock() if cert, ok := e.hostCerts[hello.ServerName]; ok { return cert, nil } // fallback to any for _, c := range e.hostCerts { return c, nil } return nil, fmt.Errorf("no certificate for %s", hello.ServerName) } func (e *Engine) loadRedirs() { e.redirMu.Lock() defer e.redirMu.Unlock() e.redirHosts = make(map[string]store.RedirectionHost) redirs := e.st.GetRedirectionHosts() keys := []string{} for _, rh := range redirs { if rh.Enabled { for _, d := range rh.DomainNames { lower := strings.ToLower(d) e.redirHosts[lower] = rh keys = append(keys, lower) } } } slog.Info("loadRedirs done", "num", len(e.redirHosts), "keys", keys) } func (e *Engine) loadDeads() { e.deadMu.Lock() defer e.deadMu.Unlock() e.deadHosts = make(map[string]store.DeadHost) deads := e.st.GetDeadHosts() keys := []string{} for _, dh := range deads { if dh.Enabled { for _, d := range dh.DomainNames { e.deadHosts[strings.ToLower(d)] = dh keys = append(keys, d) } } } slog.Info("loadDeads done", "num", len(e.deadHosts), "keys", keys) } func (e *Engine) TLSConfig() *tls.Config { return &tls.Config{ GetCertificate: e.GetCertificate, } } // TLS + streams: https listener (with SNI via GetCertificate) and dynamic stream mgmt // (reload + per-port listeners/cancels) are implemented in cmd/helix-proxy/main.go // (using eng.TLSConfig()/Handler() and reloadStreams on every ReloadFromStore). // checkAccess implements basic allow/deny from access list clients. // satisfyAny means any allow is enough (after denies). func checkAccess(clients []store.AccessClient, satisfyAny bool, ipStr string) bool { if len(clients) == 0 { return true } ip := ipStr if h, _, err := net.SplitHostPort(ipStr); err == nil { ip = h } parsed := net.ParseIP(ip) if parsed == nil { slog.Info("access check bad ip", "ip", ipStr) return false } hasAllow := false for _, c := range clients { addr := c.Address if addr == "all" { if c.Directive == "deny" { slog.Info("access deny all", "ip", ipStr) return false } hasAllow = true continue } // try CIDR if _, cidr, err := net.ParseCIDR(addr); err == nil { if cidr.Contains(parsed) { if c.Directive == "deny" { slog.Info("access deny cidr", "ip", ipStr, "cidr", addr) return false } hasAllow = true } continue } // exact IP if net.ParseIP(addr).Equal(parsed) { if c.Directive == "deny" { slog.Info("access deny exact", "ip", ipStr, "addr", addr) return false } hasAllow = true } } if satisfyAny { slog.Info("access satisfyAny result", "ip", ipStr, "hasAllow", hasAllow) return hasAllow } // satisfy all: if we didn't hit any deny, and there were allows or no rules, allow slog.Info("access satisfyAll result", "ip", ipStr, "hasAllow", hasAllow) return true } // (no more config ref needed here) // --- helpers for new engine features (hsts/ws/headers/advanced) --- func isWebsocketRequest(r *http.Request) bool { return strings.EqualFold(r.Header.Get("Upgrade"), "websocket") && strings.Contains(strings.ToLower(r.Header.Get("Connection")), "upgrade") } func getRealClientIP(r *http.Request, trustForwarded bool) string { // X-Forwarded-For may be comma list; first is original client (when trusted) // gated on host's TrustForwardedProto (also controls proto; used for IP trust per security review) if trustForwarded { if xf := r.Header.Get("X-Forwarded-For"); xf != "" { parts := strings.Split(xf, ",") return strings.TrimSpace(parts[0]) } } ip := r.RemoteAddr if h, _, err := net.SplitHostPort(ip); err == nil { return h } return ip } // resolveProxyHeaders returns header overrides from structured fields, falling back to legacy advancedConfig. func resolveProxyHeaders(ph store.ProxyHost) (reqSets map[string]string, respAdds map[string]string, respHides []string) { hasStructured := len(ph.RequestHeaders) > 0 || len(ph.ResponseHeaders) > 0 || len(ph.HideHeaders) > 0 if hasStructured { reqSets = map[string]string{} for _, h := range ph.RequestHeaders { name := strings.TrimSpace(h.Name) if name != "" { reqSets[name] = h.Value } } respAdds = map[string]string{} for _, h := range ph.ResponseHeaders { name := strings.TrimSpace(h.Name) if name != "" { respAdds[name] = h.Value } } for _, name := range ph.HideHeaders { name = strings.TrimSpace(name) if name != "" { respHides = append(respHides, name) } } return reqSets, respAdds, respHides } if ph.AdvancedConfig != "" { return parseAdvancedConfig(ph.AdvancedConfig) } return map[string]string{}, map[string]string{}, nil } // parseAdvancedConfig does a fuller (but still best-effort) parse of the advanced_config string // (in the spirit of advanced config / snippets) to support common cases: // - proxy_set_header Foo Bar // - add_header X-Resp baz; // - proxy_hide_header X-Foo // - proxy_redirect (parsed, no-op for now as complex) // - comments, ; or \n sep, set $var ignored. // Returns maps for application via Director/ModifyResponse so headers survive proxy. func parseAdvancedConfig(adv string) (reqSets map[string]string, respAdds map[string]string, respHides []string) { reqSets = map[string]string{} respAdds = map[string]string{} respHides = []string{} if adv == "" { return } // normalize separators adv = strings.ReplaceAll(adv, ";", "\n") lines := strings.Split(adv, "\n") for _, line := range lines { line = strings.TrimSpace(line) if line == "" || strings.HasPrefix(line, "#") { continue } lower := strings.ToLower(line) switch { case strings.HasPrefix(lower, "proxy_set_header "): // proxy_set_header X-Foo bar baz parts := strings.Fields(line) if len(parts) >= 3 { name := parts[1] val := strings.Join(parts[2:], " ") reqSets[name] = val } case strings.HasPrefix(lower, "add_header "): // add_header X-Response-Header "value" always; parts := strings.Fields(line) if len(parts) >= 3 { name := parts[1] // take until ; or end, strip quotes val := strings.Join(parts[2:], " ") val = strings.Trim(val, `"; `) respAdds[name] = val } case strings.HasPrefix(lower, "proxy_hide_header "): parts := strings.Fields(line) if len(parts) >= 2 { name := parts[1] respHides = append(respHides, name) } case strings.HasPrefix(lower, "proxy_redirect "): // best-effort: no-op here (would require Location rewrite in ModifyResponse for full) default: // ignore set $foo, etc. } } return }