Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 3 additions & 3 deletions deploy/docker-compose.lite.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down
8 changes: 4 additions & 4 deletions deploy/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion deploy/node-agent/docker-compose.example.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
6 changes: 3 additions & 3 deletions deploy/portainer-external-db.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion deploy/remote-node/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion internal/httpserver/handlers/admin.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
6 changes: 3 additions & 3 deletions internal/httpserver/handlers/admin_captcha_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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},
Expand Down
18 changes: 17 additions & 1 deletion internal/httpserver/handlers/admin_hosts.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 -
Expand Down Expand Up @@ -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 " +
Expand Down
14 changes: 14 additions & 0 deletions internal/httpserver/handlers/admin_hosts_resolve_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
}
10 changes: 8 additions & 2 deletions internal/httpserver/handlers/admin_reseller_plans.go
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand All @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion internal/httpserver/handlers/admin_resellers.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion internal/httpserver/handlers/api_docs.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion internal/httpserver/handlers/api_v1.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
1 change: 0 additions & 1 deletion internal/httpserver/handlers/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -2446,4 +2446,3 @@ func (h *AuthHandlers) renderEmailOTP(w http.ResponseWriter, status int, d email
w.WriteHeader(status)
_, _ = w.Write(buf.Bytes())
}

41 changes: 20 additions & 21 deletions internal/httpserver/handlers/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -1827,4 +1827,3 @@ func (h *ClientHandlers) RouteLogsCSV(w http.ResponseWriter, r *http.Request) {
}
cw.Flush()
}

6 changes: 3 additions & 3 deletions internal/httpserver/handlers/portal.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion internal/view/admin/hosts_edit.html.tmpl
Original file line number Diff line number Diff line change
Expand Up @@ -226,7 +226,7 @@
<span></span>
<div class="ml-2">
<span class="t-caption font-medium cursor-pointer">Backend is resolved on the node</span>
<p class="t-caption text-muted mt-0.5">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.</p>
<p class="t-caption text-muted mt-0.5">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.</p>
</div>
</label>
<label class="r-toggle">
Expand Down
2 changes: 1 addition & 1 deletion internal/view/admin/hosts_new.html.tmpl
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@
<span></span>
<div class="ml-2">
<span class="t-caption font-medium cursor-pointer">Backend is resolved on the node</span>
<p class="t-caption text-muted mt-0.5">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.</p>
<p class="t-caption text-muted mt-0.5">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.</p>
</div>
</label>
</div>
Expand Down
Loading