diff --git a/CHANGELOG.md b/CHANGELOG.md index 6bf057e..7d06614 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -47,6 +47,18 @@ is deliberately left as published. (`caddyapi.ScreenDialTarget`): Caddy also accepts socket and file-descriptor upstream forms, which no address-based screen covers. +## [1.7.1] - 2026-09-21 + +### Security + +- **The per-route "backend is resolved on the node" switch was available to + scope-restricted accounts.** That switch waives panel-side resolution, and + with it the screen that checks where a backend actually points - so an + account limited to its own routes could turn its own screening off. It now + requires super_admin, the same bar as skipping upstream TLS verification. + Existing routes are unaffected: tunnel-bound routes were marked + automatically and keep working, and nothing already saved is re-evaluated. + ## [1.7.0] - 2026-09-21 Follow-up to the 1.6.0 remediation: an independent review of that work found diff --git a/README.md b/README.md index 259bb75..c4c88e9 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ yourself, with WireGuard tunnels to origin and per-node failover. The control plane configures every node over WireGuard, drives Let's Encrypt issuance, runs a WAF + GeoIP, and surfaces traffic stats. -**Status:** v1.7.0. Stack: Go 1.26.3, chi, MariaDB/MySQL or SQLite, Redis, Caddy 2.11. +**Status:** v1.7.1. Stack: Go 1.26.3, chi, MariaDB/MySQL or SQLite, Redis, Caddy 2.11. Single binary ~21 MB image, ~28 MB idle RAM. ## Use cases diff --git a/deploy/docker-compose.lite.yml b/deploy/docker-compose.lite.yml index 351de37..4ce40d6 100644 --- a/deploy/docker-compose.lite.yml +++ b/deploy/docker-compose.lite.yml @@ -22,7 +22,7 @@ services: app: - image: ${IMAGE_APP:-ghcr.io/host-yt/caddy-proxy-manager:1.7.0} + image: ${IMAGE_APP:-ghcr.io/host-yt/caddy-proxy-manager:1.7.1} pull_policy: ${IMAGE_PULL_POLICY:-if_not_present} restart: unless-stopped healthcheck: @@ -170,7 +170,7 @@ services: # access-log dashboard + analytics rollups) and runs the customer tunnel. # No WAF audit log in lite (WAF is off); the access log is enough. hpg-node-agent: - image: ${IMAGE_NODE_AGENT:-ghcr.io/host-yt/caddy-proxy-manager-node-agent:1.7.0} + image: ${IMAGE_NODE_AGENT:-ghcr.io/host-yt/caddy-proxy-manager-node-agent:1.7.1} pull_policy: ${IMAGE_PULL_POLICY:-if_not_present} restart: unless-stopped network_mode: host @@ -202,7 +202,7 @@ services: - node wireguard: - image: ${IMAGE_WG:-ghcr.io/host-yt/caddy-proxy-manager-wg:1.7.0} + image: ${IMAGE_WG:-ghcr.io/host-yt/caddy-proxy-manager-wg:1.7.1} pull_policy: ${IMAGE_PULL_POLICY:-if_not_present} restart: unless-stopped cap_add: diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index 4a7ac57..322696d 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -32,7 +32,7 @@ services: - internal app: - image: ${IMAGE_APP:-ghcr.io/host-yt/caddy-proxy-manager:1.7.0} + image: ${IMAGE_APP:-ghcr.io/host-yt/caddy-proxy-manager:1.7.1} pull_policy: ${IMAGE_PULL_POLICY:-if_not_present} restart: unless-stopped # App runs as distroless nonroot; block any setuid privilege escalation. @@ -166,7 +166,7 @@ services: # can flip CACHE_HANDLER_AVAILABLE=1 and per-route cache_enabled does # real origin caching. CI pushes deploy/caddy/Dockerfile to ghcr; local # dev falls back to `build:` when the image isn't pullable. - image: ${IMAGE_CADDY:-ghcr.io/host-yt/caddy-proxy-manager-edge:1.7.0} + image: ${IMAGE_CADDY:-ghcr.io/host-yt/caddy-proxy-manager-edge:1.7.1} build: context: ./caddy dockerfile: Dockerfile @@ -227,7 +227,7 @@ services: # file is written but nobody ships it - dashboard stays empty. # Needs a one-shot node token + WG key from the panel tunnel-enable flash. hpg-node-agent: - image: ${IMAGE_NODE_AGENT:-ghcr.io/host-yt/caddy-proxy-manager-node-agent:1.7.0} + image: ${IMAGE_NODE_AGENT:-ghcr.io/host-yt/caddy-proxy-manager-node-agent:1.7.1} pull_policy: ${IMAGE_PULL_POLICY:-if_not_present} restart: unless-stopped network_mode: host @@ -274,7 +274,7 @@ services: # the kernel module path may not exist; this service is intended for # the Linux VPS where the manager actually runs in production. wireguard: - image: ${IMAGE_WG:-ghcr.io/host-yt/caddy-proxy-manager-wg:1.7.0} + image: ${IMAGE_WG:-ghcr.io/host-yt/caddy-proxy-manager-wg:1.7.1} pull_policy: ${IMAGE_PULL_POLICY:-if_not_present} restart: unless-stopped cap_add: diff --git a/deploy/node-agent/docker-compose.example.yml b/deploy/node-agent/docker-compose.example.yml index 072240f..1fd5bae 100644 --- a/deploy/node-agent/docker-compose.example.yml +++ b/deploy/node-agent/docker-compose.example.yml @@ -12,7 +12,7 @@ version: "3.9" services: hpg-node-agent: - image: ghcr.io/host-yt/caddy-proxy-manager-node-agent:1.7.0 + image: ghcr.io/host-yt/caddy-proxy-manager-node-agent:1.7.1 restart: unless-stopped network_mode: host # needed for wg-tun0 + nftables + UDP listen cap_add: diff --git a/deploy/portainer-external-db.yml b/deploy/portainer-external-db.yml index c1eca42..c4a6e99 100644 --- a/deploy/portainer-external-db.yml +++ b/deploy/portainer-external-db.yml @@ -27,7 +27,7 @@ services: - internal app: - image: ghcr.io/host-yt/caddy-proxy-manager:1.7.0 + image: ghcr.io/host-yt/caddy-proxy-manager:1.7.1 pull_policy: if_not_present restart: unless-stopped healthcheck: @@ -103,7 +103,7 @@ services: retries: 5 caddy: - image: ghcr.io/host-yt/caddy-proxy-manager-edge:1.7.0 + image: ghcr.io/host-yt/caddy-proxy-manager-edge:1.7.1 pull_policy: if_not_present restart: unless-stopped # Falls back to the socket file when the admin endpoint has no TCP port: @@ -141,7 +141,7 @@ services: - internal hpg-node-agent: - image: ghcr.io/host-yt/caddy-proxy-manager-node-agent:1.7.0 + image: ghcr.io/host-yt/caddy-proxy-manager-node-agent:1.7.1 pull_policy: if_not_present restart: unless-stopped network_mode: host diff --git a/deploy/remote-node/docker-compose.yml b/deploy/remote-node/docker-compose.yml index f4b91e3..6ca1500 100644 --- a/deploy/remote-node/docker-compose.yml +++ b/deploy/remote-node/docker-compose.yml @@ -28,7 +28,7 @@ services: # no cache-handler, coraza, caddy-l4, rate_limit or maxmind-geolocation, # so cache_enabled / WAF / streams / GeoIP routes are rejected or silently # absent there. Override with IMAGE_CADDY=caddy:2.11.4 for a plain node. - image: ${IMAGE_CADDY:-ghcr.io/host-yt/caddy-proxy-manager-edge:1.7.0} + image: ${IMAGE_CADDY:-ghcr.io/host-yt/caddy-proxy-manager-edge:1.7.1} restart: unless-stopped # SEC-002: :2019 is Caddy's admin API and it authenticates NOTHING, so it # is published on host loopback only. The host-networked hpg-node-agent diff --git a/internal/httpserver/handlers/admin.go b/internal/httpserver/handlers/admin.go index ebd113f..7239c14 100644 --- a/internal/httpserver/handlers/admin.go +++ b/internal/httpserver/handlers/admin.go @@ -2232,7 +2232,7 @@ func (h *AdminHandlers) ClientsList(w http.ResponseWriter, r *http.Request) { JOIN routes r ON r.id = lr.route_id JOIN services s ON s.id = r.service_id WHERE s.client_id IN (` + strings.Join(ph, ",") + `) - AND lr.bucket_start >= `+store.DateSub(30, "DAY")+` + AND lr.bucket_start >= ` + store.DateSub(30, "DAY") + ` GROUP BY s.client_id` bwRows, bwErr := db.QueryContext(ctx, bwSQL, ids...) if bwErr == nil { diff --git a/internal/httpserver/handlers/admin_captcha_test.go b/internal/httpserver/handlers/admin_captcha_test.go index b699478..16e5165 100644 --- a/internal/httpserver/handlers/admin_captcha_test.go +++ b/internal/httpserver/handlers/admin_captcha_test.go @@ -7,10 +7,10 @@ import "testing" // a secret already exists. func TestCaptchaSecretRequired(t *testing.T) { cases := []struct { - name string - newProvider, curProvider string + name string + newProvider, curProvider string hasSecret, secretProvided bool - want bool + want bool }{ {"fresh secret always ok", "hcaptcha", "turnstile", true, true, false}, {"switch provider, no new secret -> required", "hcaptcha", "turnstile", true, false, true}, diff --git a/internal/httpserver/handlers/admin_hosts.go b/internal/httpserver/handlers/admin_hosts.go index e16bbae..d318f07 100644 --- a/internal/httpserver/handlers/admin_hosts.go +++ b/internal/httpserver/handlers/admin_hosts.go @@ -715,9 +715,15 @@ func (h *AdminHandlers) HostsCreate(w http.ResponseWriter, r *http.Request) { WildcardEnabled: r.FormValue("wildcard_enabled") == "1", WildcardZone: strings.ToLower(strings.TrimSpace(r.FormValue("wildcard_zone"))), ViaWGPeerID: strings.TrimSpace(r.FormValue("via_wg_peer_id")), - ResolveNodeSide: r.FormValue("backend_resolve_node_side") == "1", + ResolveNodeSide: r.FormValue("backend_resolve_node_side") == "1" && canWaivePanelResolution(sess.Role), RequireClientCert: r.FormValue("require_client_cert") == "1", } + // Waiving panel-side resolution means the target is never screened against + // a resolved address, so it stays with the unrestricted role. + if r.FormValue("backend_resolve_node_side") == "1" && !canWaivePanelResolution(sess.Role) { + h.renderHostsNewErr(w, r, form, "resolving the backend on the node requires super_admin") + return + } form.MTLSCAID, _ = strconv.ParseInt(r.FormValue("mtls_ca_id"), 10, 64) if !form.RequireClientCert { form.MTLSCAID = 0 // no enforcement, no anchor - mirrors the edit path @@ -3669,6 +3675,11 @@ func (h *AdminHandlers) HostsUpdate(w http.ResponseWriter, r *http.Request) { } viaPeerID, _ := strconv.ParseInt(r.FormValue("via_wg_peer_id"), 10, 64) resolveNodeSide := r.FormValue("backend_resolve_node_side") == "1" + if resolveNodeSide && (sess == nil || !canWaivePanelResolution(sess.Role)) { + editPath := "/admin/hosts/" + strconv.FormatInt(id, 10) + "/edit" + redirectWithFlash(w, r, editPath, "", "resolving the backend on the node requires super_admin") + return + } if external { editPath := "/admin/hosts/" + strconv.FormatInt(id, 10) + "/edit" // MUTUAL EXCLUSION: an external route must NOT be bound to a WG peer - @@ -5013,6 +5024,11 @@ func screenBackendWith(ctx context.Context, infra *streamguard.InfraTargets, hos // unresolvedHint turns a panel-side resolution failure into the one action // that actually unblocks the operator, instead of a dead end. +// canWaivePanelResolution gates the "backend is resolved on the node" switch. +// Waiving panel-side resolution means no resolved address is ever screened, so +// it stays with the unrestricted role rather than any scoped admin. +func canWaivePanelResolution(role string) bool { return role == "super_admin" } + func unresolvedHint(err error, fallback string) string { if errors.Is(err, streamguard.ErrUnresolved) { return "backend hostname does not resolve from the panel - fix DNS, or tick " + diff --git a/internal/httpserver/handlers/admin_hosts_resolve_test.go b/internal/httpserver/handlers/admin_hosts_resolve_test.go index 0dd8c0d..e2820e4 100644 --- a/internal/httpserver/handlers/admin_hosts_resolve_test.go +++ b/internal/httpserver/handlers/admin_hosts_resolve_test.go @@ -54,3 +54,17 @@ func TestUnresolvedHint(t *testing.T) { t.Errorf("other errors keep their message, got %q", got) } } + +// The node-side resolve switch waives the resolved-address screen, so it must +// stay with the unrestricted role. A scoped admin granting it to themselves +// would put an unscreened name back in the dial path. +func TestCanWaivePanelResolution(t *testing.T) { + if !canWaivePanelResolution("super_admin") { + t.Error("super_admin must be able to set it") + } + for _, role := range []string{"admin", "reseller", "client", "viewer", "", "SUPER_ADMIN"} { + if canWaivePanelResolution(role) { + t.Errorf("role %q must not be able to waive panel-side resolution", role) + } + } +} diff --git a/internal/httpserver/handlers/admin_reseller_plans.go b/internal/httpserver/handlers/admin_reseller_plans.go index f5f0bb1..b614004 100644 --- a/internal/httpserver/handlers/admin_reseller_plans.go +++ b/internal/httpserver/handlers/admin_reseller_plans.go @@ -8,8 +8,8 @@ import ( "strconv" "strings" - "github.com/host-yt/caddy-proxy-manager/internal/reseller" "github.com/go-chi/chi/v5" + "github.com/host-yt/caddy-proxy-manager/internal/reseller" ) // ResellerPlanSave handles POST /admin/reseller-plans (id=0 creates). @@ -23,7 +23,13 @@ func (h *AdminHandlers) ResellerPlanSave(w http.ResponseWriter, r *http.Request) return } _ = r.ParseForm() - atoi := func(k string) int { n, _ := strconv.Atoi(r.FormValue(k)); if n < 0 { n = 0 }; return n } + atoi := func(k string) int { + n, _ := strconv.Atoi(r.FormValue(k)) + if n < 0 { + n = 0 + } + return n + } id, _ := strconv.ParseInt(r.FormValue("id"), 10, 64) p := reseller.Plan{ ID: id, diff --git a/internal/httpserver/handlers/admin_resellers.go b/internal/httpserver/handlers/admin_resellers.go index b04dacd..3b7713a 100644 --- a/internal/httpserver/handlers/admin_resellers.go +++ b/internal/httpserver/handlers/admin_resellers.go @@ -10,12 +10,12 @@ import ( "strings" "time" + "github.com/go-chi/chi/v5" "github.com/host-yt/caddy-proxy-manager/internal/audit" "github.com/host-yt/caddy-proxy-manager/internal/auth" "github.com/host-yt/caddy-proxy-manager/internal/httpserver/middleware" "github.com/host-yt/caddy-proxy-manager/internal/quota" "github.com/host-yt/caddy-proxy-manager/internal/reseller" - "github.com/go-chi/chi/v5" ) // slugRe restricts reseller slugs to url-safe lowercase tokens (used in future diff --git a/internal/httpserver/handlers/api_docs.go b/internal/httpserver/handlers/api_docs.go index eb8220b..5ce45b0 100644 --- a/internal/httpserver/handlers/api_docs.go +++ b/internal/httpserver/handlers/api_docs.go @@ -9,8 +9,8 @@ import ( "strings" "time" - "github.com/host-yt/caddy-proxy-manager/internal/installstate" mw "github.com/host-yt/caddy-proxy-manager/internal/httpserver/middleware" + "github.com/host-yt/caddy-proxy-manager/internal/installstate" ) //go:embed openapi.json diff --git a/internal/httpserver/handlers/api_v1.go b/internal/httpserver/handlers/api_v1.go index d7304d6..de62dc6 100644 --- a/internal/httpserver/handlers/api_v1.go +++ b/internal/httpserver/handlers/api_v1.go @@ -16,8 +16,8 @@ import ( "github.com/go-chi/chi/v5" "github.com/host-yt/caddy-proxy-manager/internal/adminscope" - "github.com/host-yt/caddy-proxy-manager/internal/auth" "github.com/host-yt/caddy-proxy-manager/internal/audit" + "github.com/host-yt/caddy-proxy-manager/internal/auth" "github.com/host-yt/caddy-proxy-manager/internal/domain/routes" "github.com/host-yt/caddy-proxy-manager/internal/httpserver/middleware" "github.com/host-yt/caddy-proxy-manager/internal/quota" diff --git a/internal/httpserver/handlers/auth.go b/internal/httpserver/handlers/auth.go index 10f08ef..ff24f20 100644 --- a/internal/httpserver/handlers/auth.go +++ b/internal/httpserver/handlers/auth.go @@ -2446,4 +2446,3 @@ func (h *AuthHandlers) renderEmailOTP(w http.ResponseWriter, status int, d email w.WriteHeader(status) _, _ = w.Write(buf.Bytes()) } - diff --git a/internal/httpserver/handlers/client.go b/internal/httpserver/handlers/client.go index f0e38b4..4ebe02c 100644 --- a/internal/httpserver/handlers/client.go +++ b/internal/httpserver/handlers/client.go @@ -163,12 +163,12 @@ type clientDashboardData struct { ActiveRoutes int PendingRoutes int FailedRoutes int - TotalBandwidth7d string // formatted bytes for last 7 days - Requests24h int64 // total requests in last 24h across all client routes - Errors24h int64 // 4xx+5xx in last 24h + TotalBandwidth7d string // formatted bytes for last 7 days + Requests24h int64 // total requests in last 24h across all client routes + Errors24h int64 // 4xx+5xx in last 24h Bandwidth30dDays []accesslog.BandwidthDayBucket // 30-day daily totals - Bandwidth30dTotal int64 // sum across Bandwidth30dDays - MaxDay30dBytes int64 // max bucket for bar scaling + Bandwidth30dTotal int64 // sum across Bandwidth30dDays + MaxDay30dBytes int64 // max bucket for bar scaling } func (h *ClientHandlers) Dashboard(w http.ResponseWriter, r *http.Request) { @@ -1634,21 +1634,21 @@ func (h *ClientHandlers) loadClientAPIKeys(ctx context.Context) []clientAPIKeyRo type clientRouteLogsData struct { baseAppData - RouteID int64 - Domain string - Error string - Entries []accesslog.Entry - AnalyticsTotal int64 - StatusBuckets []accesslog.StatusBucket - ProtoBreakdown []accesslog.ProtoHit - BytesSummary accesslog.BytesSummary - TopPaths []accesslog.PathHit - TopCountries []accesslog.CountryHit - TopRemoteIPs []accesslog.RemoteIPHit - TopASNOrgs []accesslog.ASNOrgHit - BandwidthDays []accesslog.BandwidthDayBucket // 7-day daily totals - BandwidthTotal7d int64 // sum across BandwidthDays - MaxDayBytes int64 // max bucket for bar scaling + RouteID int64 + Domain string + Error string + Entries []accesslog.Entry + AnalyticsTotal int64 + StatusBuckets []accesslog.StatusBucket + ProtoBreakdown []accesslog.ProtoHit + BytesSummary accesslog.BytesSummary + TopPaths []accesslog.PathHit + TopCountries []accesslog.CountryHit + TopRemoteIPs []accesslog.RemoteIPHit + TopASNOrgs []accesslog.ASNOrgHit + BandwidthDays []accesslog.BandwidthDayBucket // 7-day daily totals + BandwidthTotal7d int64 // sum across BandwidthDays + MaxDayBytes int64 // max bucket for bar scaling Bandwidth30dDays []accesslog.BandwidthDayBucket // 30-day daily totals Bandwidth30dTotal int64 // sum across Bandwidth30dDays MaxDay30dBytes int64 // max bucket for 30-day bar scaling @@ -1827,4 +1827,3 @@ func (h *ClientHandlers) RouteLogsCSV(w http.ResponseWriter, r *http.Request) { } cw.Flush() } - diff --git a/internal/httpserver/handlers/portal.go b/internal/httpserver/handlers/portal.go index ebec7ac..46f55ca 100644 --- a/internal/httpserver/handlers/portal.go +++ b/internal/httpserver/handlers/portal.go @@ -39,9 +39,9 @@ type PortalHandlers struct { Logger *slog.Logger Portal *portal.Service Metrics metricsLoginEmitter - Secure bool // cookie Secure flag, mirrors the panel auth cookie - SameSite http.SameSite // mirrors the panel auth cookie SameSite - TTL time.Duration // portal session lifetime + Secure bool // cookie Secure flag, mirrors the panel auth cookie + SameSite http.SameSite // mirrors the panel auth cookie SameSite + TTL time.Duration // portal session lifetime State *installstate.Manager // for decrypting totp_secret_enc OAuth2X *oauth2x.Service // social login for the portal; nil disables buttons AppURL string // panel base URL, used to build OAuth callback URLs diff --git a/internal/view/admin/hosts_edit.html.tmpl b/internal/view/admin/hosts_edit.html.tmpl index cb5097c..b2934d6 100644 --- a/internal/view/admin/hosts_edit.html.tmpl +++ b/internal/view/admin/hosts_edit.html.tmpl @@ -226,7 +226,7 @@
Tick only for names nothing but the node can look up (container names, tunnel peers). The panel then cannot verify where this backend points, so the node dials whatever the name gives it. A name the panel cannot resolve is refused unless this is ticked. The panel otherwise resolves the name itself and the node dials the address the panel screened.
+Tick only for names nothing but the node can look up (container names, tunnel peers). The panel then cannot verify where this backend points, so the node dials whatever the name gives it. A name the panel cannot resolve is refused unless this is ticked. The panel otherwise resolves the name itself and the node dials the address the panel screened. Requires super_admin.