Files
helix-proxy/internal/proxy/engine.go
T
Blake 61c7b5582c
Format / gofmt (pull_request) Failing after 15s
Format / gofmt (push) Failing after 15s
CI / Build (push) Successful in 36s
CI / Build (pull_request) Successful in 37s
CI / Go Tests (pull_request) Successful in 1m20s
CI / Go Tests (push) Failing after 1m20s
access-list: Hash basic-auth passwords and redact API reads
Access-list item passwords were stored and returned in plaintext.
Hash on write with bcrypt, compare hashes in the proxy engine (with
legacy plaintext fallback), and omit passwords from admin GET JSON.
2026-09-14 13:32:31 +00:00

1266 lines
35 KiB
Go

package proxy
import (
"golang.org/x/crypto/bcrypt"
"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 <CHALLENGE_DIR>/.well-known/acme-challenge/<token>
// 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, `<h1>Welcome to Helix Proxy</h1><p>No virtual host for %s. Use the admin UI on :81 to configure hosts.</p>`, 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 && accessListPasswordOK(item.Password, pass) {
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
}
func accessListPasswordOK(stored, provided string) bool {
if stored == "" {
return false
}
if strings.HasPrefix(stored, "$2a$") || strings.HasPrefix(stored, "$2b$") || strings.HasPrefix(stored, "$2y$") {
return bcrypt.CompareHashAndPassword([]byte(stored), []byte(provided)) == nil
}
// Legacy plaintext rows until re-saved via admin API.
return stored == provided
}