1253 lines
34 KiB
Go
1253 lines
34 KiB
Go
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 <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 && 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
|
|
}
|