R-753: the box tells visitors apart — cloudflared at a fixed address, traefik trusts only it, the controller reads the hop traefik saw
- felhom-tunnel network 172.16.253.0/29 (ip-range .4/30): cloudflared alone at .2, traefik at .3; traefik's websecure trusts forwarded headers from 172.16.253.2/32 only, and every request passes felhom-forwarded@file, which removes the client-writable host/path/address headers (X-Forwarded-Host/-Uri/-Method/-Prefix, Forwarded, True-Client-Ip, …) and fixes X-Forwarded-Port to 443 (measured: Cloudflare passes a client's X-Forwarded-Host/-Port). - EnsureBaseStack reconciles a RUNNING traefik/cloudflared whose rendered files changed (recreate), refuses a rewrite that would drop a certificate resolver, and moves cloudflared only once traefik is on the tunnel network. - clientIP: believed only when the TCP peer is traefik; the rightmost X-Forwarded-For entry (the hop traefik saw); the tunnel hop → CF-Connecting-IP (the edge refuses a client-sent one, measured 403). rateKey: IPv6 per /64. Dashboard login, claim, share and escrow counters key on it; the setup gate logs it. - Dashboard login messages: keys, informal voice, both languages. Red-proofs: RP-A1 (leftmost hop), RP-A2 (shared tunnel key), RP-A3 (no reconcile) — felhom.eu audits/visitors-2026-10-01/A. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
This commit is contained in:
@@ -2505,5 +2505,8 @@
|
|||||||
"backups_offsite.fit_quota": "storage limit:",
|
"backups_offsite.fit_quota": "storage limit:",
|
||||||
"backups_offsite.fit_largest": "The largest:",
|
"backups_offsite.fit_largest": "The largest:",
|
||||||
"backups_offsite.fit_choose": "Turn off the off-site copy for the apps that do not need one outside the house. Until then the largest get only their settings and database uploaded, and once the limit is full no new off-site backup is made. The box does not delete old backups because of this.",
|
"backups_offsite.fit_choose": "Turn off the off-site copy for the apps that do not need one outside the house. Until then the largest get only their settings and database uploaded, and once the limit is full no new off-site backup is made. The box does not delete old backups because of this.",
|
||||||
"flash.offbox.enabled_all": "Off-site backup is on for every app."
|
"flash.offbox.enabled_all": "Off-site backup is on for every app.",
|
||||||
|
"login.msg.empty_password": "Enter your password.",
|
||||||
|
"login.msg.rate_limited": "Too many wrong tries from this address. Try again in a minute.",
|
||||||
|
"login.msg.wrong_password": "Wrong password."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1600,7 +1600,7 @@
|
|||||||
"layout.uzenet": "Üzenet",
|
"layout.uzenet": "Üzenet",
|
||||||
"layout.vezerlopult": "Vezérlőpult",
|
"layout.vezerlopult": "Vezérlőpult",
|
||||||
"layout.visszaallitas": "Visszaállítás",
|
"layout.visszaallitas": "Visszaállítás",
|
||||||
"login.adja_meg_a_jelszavat": "Adja meg a jelszavát",
|
"login.adja_meg_a_jelszavat": "Add meg a jelszavad",
|
||||||
"login.bejelentkezes": "Bejelentkezés",
|
"login.bejelentkezes": "Bejelentkezés",
|
||||||
"login.bejelentkezes_felhom": "Bejelentkezés — Felhom",
|
"login.bejelentkezes_felhom": "Bejelentkezés — Felhom",
|
||||||
"login.elfelejtett_jelszo": "Elfelejtett jelszó",
|
"login.elfelejtett_jelszo": "Elfelejtett jelszó",
|
||||||
@@ -2493,5 +2493,8 @@
|
|||||||
"backups_offsite.fit_quota": "tárhelykeret:",
|
"backups_offsite.fit_quota": "tárhelykeret:",
|
||||||
"backups_offsite.fit_largest": "A legnagyobbak:",
|
"backups_offsite.fit_largest": "A legnagyobbak:",
|
||||||
"backups_offsite.fit_choose": "Kapcsold ki a távoli mentést annál, amelyiknek nem kell a házon kívül is lennie. Addig a legnagyobbaknak csak a beállításai és az adatbázisa kerül fel, és ha a keret betelik, új távoli mentés nem készül. Régi mentést a doboz ezért nem töröl.",
|
"backups_offsite.fit_choose": "Kapcsold ki a távoli mentést annál, amelyiknek nem kell a házon kívül is lennie. Addig a legnagyobbaknak csak a beállításai és az adatbázisa kerül fel, és ha a keret betelik, új távoli mentés nem készül. Régi mentést a doboz ezért nem töröl.",
|
||||||
"flash.offbox.enabled_all": "A távoli mentés be van kapcsolva minden alkalmazásra."
|
"flash.offbox.enabled_all": "A távoli mentés be van kapcsolva minden alkalmazásra.",
|
||||||
|
"login.msg.empty_password": "Add meg a jelszavad.",
|
||||||
|
"login.msg.rate_limited": "Túl sok hibás próbálkozás erről a címről. Próbáld újra egy perc múlva.",
|
||||||
|
"login.msg.wrong_password": "Hibás jelszó."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -71,6 +71,25 @@ type FileSpec struct {
|
|||||||
Mode uint32 // os.FileMode bits (e.g. 0o600); uint32 keeps this package IO-free
|
Mode uint32 // os.FileMode bits (e.g. 0o600); uint32 keeps this package IO-free
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The tunnel's own network (R-753, `09` §3 decision 63 Part A). cloudflared sits ALONE on it at a FIXED address, so
|
||||||
|
// traefik can trust forwarded headers from that one address and from nothing else; traefik joins it as the second
|
||||||
|
// member. 172.16.0.0/16 is private (RFC 1918), so apps that count private addresses as proxies (Tomcat's
|
||||||
|
// RemoteIpValve, for one) skip it, and it is OUTSIDE docker's default address pools (they begin at 172.17), so docker
|
||||||
|
// never hands it to an app network. A /29 holds the gateway, cloudflared and traefik. BOTH members take FIXED addresses
|
||||||
|
// and docker's own allocation is confined to TunnelIPRange: measured 2026-10-01 on 9202, traefik joining first was given
|
||||||
|
// .2 — cloudflared's address — by docker's allocator.
|
||||||
|
// Pinned by TestTunnelConstantsAgree and TestRenderTraefik_TrustsOnlyTheTunnel.
|
||||||
|
const (
|
||||||
|
TunnelNetwork = "felhom-tunnel"
|
||||||
|
TunnelSubnet = "172.16.253.0/29"
|
||||||
|
TunnelGateway = "172.16.253.1"
|
||||||
|
TunnelAddr = "172.16.253.2" // cloudflared — the ONLY address traefik believes forwarded headers from
|
||||||
|
TunnelTraefikAddr = "172.16.253.3" // traefik's own place on the tunnel network
|
||||||
|
TunnelIPRange = "172.16.253.4/30" // where docker may put anything else: never .2 or .3
|
||||||
|
// ForwardedMiddleware is the entrypoint middleware every websecure request passes (RenderForwardedHeaders).
|
||||||
|
ForwardedMiddleware = "felhom-forwarded"
|
||||||
|
)
|
||||||
|
|
||||||
// TraefikData is the per-customer input for the traefik stack. ACMEEmail empty → no Let's Encrypt
|
// TraefikData is the per-customer input for the traefik stack. ACMEEmail empty → no Let's Encrypt
|
||||||
// (traefik serves self-signed); CFAPIToken empty → HTTP-01 instead of Cloudflare DNS-01, and no .env.
|
// (traefik serves self-signed); CFAPIToken empty → HTTP-01 instead of Cloudflare DNS-01, and no .env.
|
||||||
// (Wildcard proactive issuance is driven by the controller route, NOT here — see RenderControllerRoute:
|
// (Wildcard proactive issuance is driven by the controller route, NOT here — see RenderControllerRoute:
|
||||||
@@ -79,21 +98,34 @@ type FileSpec struct {
|
|||||||
type TraefikData struct {
|
type TraefikData struct {
|
||||||
ACMEEmail string
|
ACMEEmail string
|
||||||
CFAPIToken string
|
CFAPIToken string
|
||||||
|
// Tunnel: the felhom-tunnel network exists with its fixed subnet — traefik joins it and trusts forwarded headers
|
||||||
|
// from TunnelAddr only. False keeps the old shape (trusts nothing), so a box whose network could not be made still
|
||||||
|
// routes (a compose naming an absent external network would not start at all).
|
||||||
|
Tunnel bool
|
||||||
}
|
}
|
||||||
|
|
||||||
type traefikTmpl struct {
|
type traefikTmpl struct {
|
||||||
TraefikData
|
TraefikData
|
||||||
Image string
|
Image string
|
||||||
|
TunnelNetwork string
|
||||||
|
TunnelAddr string
|
||||||
|
TunnelTraefikAddr string
|
||||||
|
ForwardedMiddleware string
|
||||||
}
|
}
|
||||||
|
|
||||||
// CloudflaredData is the per-customer input for the cloudflared stack (just the tunnel token).
|
// CloudflaredData is the per-customer input for the cloudflared stack (just the tunnel token).
|
||||||
type CloudflaredData struct {
|
type CloudflaredData struct {
|
||||||
CFTunnelToken string
|
CFTunnelToken string
|
||||||
|
// Tunnel: put cloudflared on felhom-tunnel at TunnelAddr (and on nothing else). The caller sets it only once traefik
|
||||||
|
// is on that network too — otherwise cloudflared could not reach "traefik" and the tunnel would be down.
|
||||||
|
Tunnel bool
|
||||||
}
|
}
|
||||||
|
|
||||||
type cloudflaredTmpl struct {
|
type cloudflaredTmpl struct {
|
||||||
CloudflaredData
|
CloudflaredData
|
||||||
Image string
|
Image string
|
||||||
|
TunnelNetwork string
|
||||||
|
TunnelAddr string
|
||||||
}
|
}
|
||||||
|
|
||||||
func render(name string, data any) (string, error) {
|
func render(name string, data any) (string, error) {
|
||||||
@@ -108,7 +140,8 @@ func render(name string, data any) (string, error) {
|
|||||||
// — only when a Cloudflare API token is set — a 0600 .env carrying CF_DNS_API_TOKEN (kept out of the
|
// — only when a Cloudflare API token is set — a 0600 .env carrying CF_DNS_API_TOKEN (kept out of the
|
||||||
// compose file). The orchestrator additionally creates dynamic/, certs/ and an empty 0600 acme.json.
|
// compose file). The orchestrator additionally creates dynamic/, certs/ and an empty 0600 acme.json.
|
||||||
func RenderTraefik(d TraefikData) (map[string]FileSpec, error) {
|
func RenderTraefik(d TraefikData) (map[string]FileSpec, error) {
|
||||||
td := traefikTmpl{TraefikData: d, Image: TraefikImage}
|
td := traefikTmpl{TraefikData: d, Image: TraefikImage, TunnelNetwork: TunnelNetwork, TunnelAddr: TunnelAddr,
|
||||||
|
TunnelTraefikAddr: TunnelTraefikAddr, ForwardedMiddleware: ForwardedMiddleware}
|
||||||
yml, err := render("traefik.yml.tmpl", td)
|
yml, err := render("traefik.yml.tmpl", td)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -132,7 +165,7 @@ func RenderTraefik(d TraefikData) (map[string]FileSpec, error) {
|
|||||||
// RenderCloudflared returns the cloudflared stack files (compose only — no bind mounts; the tunnel
|
// RenderCloudflared returns the cloudflared stack files (compose only — no bind mounts; the tunnel
|
||||||
// token is the entire config). Caller deploys this only when a tunnel token is configured.
|
// token is the entire config). Caller deploys this only when a tunnel token is configured.
|
||||||
func RenderCloudflared(d CloudflaredData) (map[string]FileSpec, error) {
|
func RenderCloudflared(d CloudflaredData) (map[string]FileSpec, error) {
|
||||||
cd := cloudflaredTmpl{CloudflaredData: d, Image: CloudflaredImage}
|
cd := cloudflaredTmpl{CloudflaredData: d, Image: CloudflaredImage, TunnelNetwork: TunnelNetwork, TunnelAddr: TunnelAddr}
|
||||||
compose, err := render("cloudflared-compose.yml.tmpl", cd)
|
compose, err := render("cloudflared-compose.yml.tmpl", cd)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -275,6 +308,41 @@ http:
|
|||||||
`, ServersTransportInsecure, ServersTransportInsecure)
|
`, ServersTransportInsecure, ServersTransportInsecure)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RenderForwardedHeaders returns the dynamic file defining the entrypoint middleware every websecure request passes
|
||||||
|
// (traefik.yml names it). Once traefik trusts the tunnel's address it KEEPS the forwarded headers that hop carries,
|
||||||
|
// and Cloudflare passes a client's own X-Forwarded-Host and X-Forwarded-Port through unchanged (measured,
|
||||||
|
// audits/visitors-2026-10-01/A/M2) — so this removes every header in which a client could write a host, a path or an
|
||||||
|
// address, and fixes the port: both paths reach traefik on 443. X-Forwarded-For stays (traefik appends the hop it saw;
|
||||||
|
// readers take it from the RIGHT), X-Real-Ip stays (traefik's peer — Cloudflare strips a client's, measured M2),
|
||||||
|
// CF-Connecting-IP stays (the controller believes it only when the hop is the tunnel). A request's Host header still
|
||||||
|
// says which app it is for, so an app that falls back from X-Forwarded-Host to Host gets the same name.
|
||||||
|
// Static — no per-customer input. Pinned by TestRenderForwardedHeaders_RemovesClientWritableHeaders.
|
||||||
|
func RenderForwardedHeaders() string {
|
||||||
|
return `# Traefik dynamic config — the forwarded-header clean-up every websecure request passes. Managed by felhom-controller.
|
||||||
|
# WARNING: auto-generated at base-infra bring-up. Manual edits are overwritten. traefik.yml names this middleware on its
|
||||||
|
# websecure entrypoint, so a missing file would break every route: it is written before traefik.yml.
|
||||||
|
# An empty value REMOVES the header (traefik headers middleware).
|
||||||
|
http:
|
||||||
|
middlewares:
|
||||||
|
` + ForwardedMiddleware + `:
|
||||||
|
headers:
|
||||||
|
customRequestHeaders:
|
||||||
|
X-Forwarded-Port: "443"
|
||||||
|
X-Forwarded-Host: ""
|
||||||
|
X-Forwarded-Uri: ""
|
||||||
|
X-Forwarded-Method: ""
|
||||||
|
X-Forwarded-Prefix: ""
|
||||||
|
X-Forwarded-Tls-Client-Cert: ""
|
||||||
|
X-Forwarded-Tls-Client-Cert-Info: ""
|
||||||
|
Forwarded: ""
|
||||||
|
True-Client-Ip: ""
|
||||||
|
X-Client-Ip: ""
|
||||||
|
X-Cluster-Client-Ip: ""
|
||||||
|
Client-Ip: ""
|
||||||
|
X-Original-Forwarded-For: ""
|
||||||
|
`
|
||||||
|
}
|
||||||
|
|
||||||
// RenderFileBrowserConfig returns a FileBrowser Quantum config.yaml with one source per registered
|
// RenderFileBrowserConfig returns a FileBrowser Quantum config.yaml with one source per registered
|
||||||
// storage path (each a named sidebar entry). Empty paths → a single default /srv source. Ported
|
// storage path (each a named sidebar entry). Empty paths → a single default /srv source. Ported
|
||||||
// verbatim from internal/web/handlers.go.
|
// verbatim from internal/web/handlers.go.
|
||||||
|
|||||||
@@ -14,9 +14,20 @@ services:
|
|||||||
- 8.8.8.8
|
- 8.8.8.8
|
||||||
security_opt:
|
security_opt:
|
||||||
- no-new-privileges:true
|
- no-new-privileges:true
|
||||||
|
{{- if .Tunnel}}
|
||||||
|
# R-753: alone on felhom-tunnel at a fixed address — the one peer traefik believes forwarded headers from.
|
||||||
|
networks:
|
||||||
|
{{.TunnelNetwork}}:
|
||||||
|
ipv4_address: {{.TunnelAddr}}
|
||||||
|
|
||||||
|
networks:
|
||||||
|
{{.TunnelNetwork}}:
|
||||||
|
external: true
|
||||||
|
{{- else}}
|
||||||
networks:
|
networks:
|
||||||
- traefik-public
|
- traefik-public
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
traefik-public:
|
traefik-public:
|
||||||
external: true
|
external: true
|
||||||
|
{{- end}}
|
||||||
|
|||||||
@@ -22,9 +22,20 @@ services:
|
|||||||
- ./dynamic:/etc/traefik/dynamic:ro
|
- ./dynamic:/etc/traefik/dynamic:ro
|
||||||
- ./acme.json:/etc/traefik/acme.json
|
- ./acme.json:/etc/traefik/acme.json
|
||||||
- ./certs:/etc/traefik/certs:ro
|
- ./certs:/etc/traefik/certs:ro
|
||||||
|
{{- if .Tunnel}}
|
||||||
|
networks:
|
||||||
|
traefik-public: {}
|
||||||
|
{{.TunnelNetwork}}:
|
||||||
|
ipv4_address: {{.TunnelTraefikAddr}}
|
||||||
|
{{- else}}
|
||||||
networks:
|
networks:
|
||||||
- traefik-public
|
- traefik-public
|
||||||
|
{{- end}}
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
traefik-public:
|
traefik-public:
|
||||||
external: true
|
external: true
|
||||||
|
{{- if .Tunnel}}
|
||||||
|
{{.TunnelNetwork}}:
|
||||||
|
external: true
|
||||||
|
{{- end}}
|
||||||
|
|||||||
@@ -15,8 +15,16 @@ entryPoints:
|
|||||||
scheme: https
|
scheme: https
|
||||||
websecure:
|
websecure:
|
||||||
address: ":443"
|
address: ":443"
|
||||||
{{- if .ACMEEmail}}
|
{{- if .Tunnel}}
|
||||||
|
# R-753: believe X-Forwarded-* only from cloudflared's fixed address on felhom-tunnel; every other peer's are dropped.
|
||||||
|
forwardedHeaders:
|
||||||
|
trustedIPs:
|
||||||
|
- "{{.TunnelAddr}}/32"
|
||||||
|
{{- end}}
|
||||||
http:
|
http:
|
||||||
|
middlewares:
|
||||||
|
- {{.ForwardedMiddleware}}@file
|
||||||
|
{{- if .ACMEEmail}}
|
||||||
tls:
|
tls:
|
||||||
certResolver: letsencrypt
|
certResolver: letsencrypt
|
||||||
{{- end}}
|
{{- end}}
|
||||||
|
|||||||
@@ -0,0 +1,137 @@
|
|||||||
|
package infra
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
// R-753: the fixed tunnel address must sit inside its subnet, apart from the gateway, inside 172.16.0.0/12 (so apps that
|
||||||
|
// count private addresses as proxies skip it) and OUTSIDE docker's default pools (172.17.0.0/16 … 172.31.0.0/16,
|
||||||
|
// 192.168.0.0/16), so docker never gives it to an app network.
|
||||||
|
func TestTunnelConstantsAgree(t *testing.T) {
|
||||||
|
_, sub, err := net.ParseCIDR(TunnelSubnet)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
addr, gw, tr := net.ParseIP(TunnelAddr), net.ParseIP(TunnelGateway), net.ParseIP(TunnelTraefikAddr)
|
||||||
|
if !sub.Contains(addr) || !sub.Contains(gw) || !sub.Contains(tr) || addr.Equal(gw) || addr.Equal(tr) || tr.Equal(gw) {
|
||||||
|
t.Fatalf("cloudflared %s / traefik %s / gateway %s must be inside %s and differ", TunnelAddr, TunnelTraefikAddr, TunnelGateway, TunnelSubnet)
|
||||||
|
}
|
||||||
|
// docker's own allocation (TunnelIPRange) must never reach the two fixed addresses — measured: traefik joining
|
||||||
|
// first was given .2 by the allocator when no range was set.
|
||||||
|
_, rng, err := net.ParseCIDR(TunnelIPRange)
|
||||||
|
if err != nil || rng.Contains(addr) || rng.Contains(tr) || !sub.Contains(rng.IP) {
|
||||||
|
t.Fatalf("ip-range %s must lie in %s and exclude %s and %s", TunnelIPRange, TunnelSubnet, TunnelAddr, TunnelTraefikAddr)
|
||||||
|
}
|
||||||
|
_, private, _ := net.ParseCIDR("172.16.0.0/12")
|
||||||
|
if !private.Contains(addr) {
|
||||||
|
t.Fatalf("%s is not in 172.16.0.0/12", TunnelAddr)
|
||||||
|
}
|
||||||
|
for _, pool := range []string{"172.17.0.0/16", "172.18.0.0/16", "172.24.0.0/16", "172.31.0.0/16", "192.168.0.0/16"} {
|
||||||
|
_, p, _ := net.ParseCIDR(pool)
|
||||||
|
if p.Contains(sub.IP) {
|
||||||
|
t.Fatalf("%s overlaps docker's default pool %s", TunnelSubnet, pool)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func staticEntry(t *testing.T, yml string) map[string]any {
|
||||||
|
t.Helper()
|
||||||
|
var doc map[string]any
|
||||||
|
if err := yaml.Unmarshal([]byte(yml), &doc); err != nil {
|
||||||
|
t.Fatalf("traefik.yml is not YAML: %v\n%s", err, yml)
|
||||||
|
}
|
||||||
|
return doc["entryPoints"].(map[string]any)["websecure"].(map[string]any)
|
||||||
|
}
|
||||||
|
|
||||||
|
// traefik believes forwarded headers from EXACTLY the tunnel address — never `insecure`, never a range — and every
|
||||||
|
// websecure request passes the clean-up middleware. Without the network (Tunnel false) it trusts nobody.
|
||||||
|
func TestRenderTraefik_TrustsOnlyTheTunnel(t *testing.T) {
|
||||||
|
for _, email := range []string{"", "owner@example.com"} {
|
||||||
|
on, err := RenderTraefik(TraefikData{ACMEEmail: email, Tunnel: true})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
ws := staticEntry(t, on["traefik.yml"].Content)
|
||||||
|
fh, ok := ws["forwardedHeaders"].(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("Tunnel: no forwardedHeaders on websecure:\n%s", on["traefik.yml"].Content)
|
||||||
|
}
|
||||||
|
ips, _ := fh["trustedIPs"].([]any)
|
||||||
|
if len(ips) != 1 || ips[0] != TunnelAddr+"/32" || fh["insecure"] != nil {
|
||||||
|
t.Fatalf("Tunnel: trust must be exactly [%s/32], got %v (insecure %v)", TunnelAddr, ips, fh["insecure"])
|
||||||
|
}
|
||||||
|
mw := ws["http"].(map[string]any)["middlewares"].([]any)
|
||||||
|
if len(mw) != 1 || mw[0] != ForwardedMiddleware+"@file" {
|
||||||
|
t.Fatalf("websecure middlewares = %v", mw)
|
||||||
|
}
|
||||||
|
if (email != "") != strings.Contains(on["traefik.yml"].Content, "certResolver: letsencrypt") {
|
||||||
|
t.Fatalf("the resolver must follow the e-mail (%q)", email)
|
||||||
|
}
|
||||||
|
var cdoc map[string]any
|
||||||
|
if err := yaml.Unmarshal([]byte(on["docker-compose.yml"].Content), &cdoc); err != nil {
|
||||||
|
t.Fatalf("traefik compose is not YAML: %v", err)
|
||||||
|
}
|
||||||
|
tn := cdoc["services"].(map[string]any)["traefik"].(map[string]any)["networks"].(map[string]any)
|
||||||
|
if _, ok := tn["traefik-public"]; !ok || tn[TunnelNetwork].(map[string]any)["ipv4_address"] != TunnelTraefikAddr {
|
||||||
|
t.Fatalf("Tunnel: traefik must keep traefik-public and sit at %s on %s, got %v", TunnelTraefikAddr, TunnelNetwork, tn)
|
||||||
|
}
|
||||||
|
if cdoc["networks"].(map[string]any)[TunnelNetwork].(map[string]any)["external"] != true {
|
||||||
|
t.Fatalf("Tunnel: %s must be external", TunnelNetwork)
|
||||||
|
}
|
||||||
|
|
||||||
|
off, _ := RenderTraefik(TraefikData{ACMEEmail: email})
|
||||||
|
ws = staticEntry(t, off["traefik.yml"].Content)
|
||||||
|
if ws["forwardedHeaders"] != nil || strings.Contains(off["docker-compose.yml"].Content, TunnelNetwork) {
|
||||||
|
t.Fatalf("no tunnel network → no trust and no network:\n%s", off["traefik.yml"].Content)
|
||||||
|
}
|
||||||
|
if mw := ws["http"].(map[string]any)["middlewares"].([]any); len(mw) != 1 {
|
||||||
|
t.Fatalf("the clean-up middleware applies without the tunnel too, got %v", mw)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRenderCloudflared_AloneOnTheTunnel(t *testing.T) {
|
||||||
|
on, _ := RenderCloudflared(CloudflaredData{CFTunnelToken: "t", Tunnel: true})
|
||||||
|
c := on["docker-compose.yml"].Content
|
||||||
|
var doc map[string]any
|
||||||
|
if err := yaml.Unmarshal([]byte(c), &doc); err != nil {
|
||||||
|
t.Fatalf("compose is not YAML: %v\n%s", err, c)
|
||||||
|
}
|
||||||
|
nets := doc["services"].(map[string]any)["cloudflared"].(map[string]any)["networks"].(map[string]any)
|
||||||
|
if len(nets) != 1 || nets[TunnelNetwork].(map[string]any)["ipv4_address"] != TunnelAddr {
|
||||||
|
t.Fatalf("cloudflared must be ONLY on %s at %s, got %v", TunnelNetwork, TunnelAddr, nets)
|
||||||
|
}
|
||||||
|
off, _ := RenderCloudflared(CloudflaredData{CFTunnelToken: "t"})
|
||||||
|
if strings.Contains(off["docker-compose.yml"].Content, TunnelNetwork) {
|
||||||
|
t.Fatal("without Tunnel the old shape (traefik-public) must be kept")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The clean-up removes every header a client could write a host, a path or an address into, fixes the port, and leaves
|
||||||
|
// alone the three the readers need: X-Forwarded-For (read from the right), X-Real-Ip (traefik's peer) and
|
||||||
|
// CF-Connecting-IP (read only when the hop is the tunnel).
|
||||||
|
func TestRenderForwardedHeaders_RemovesClientWritableHeaders(t *testing.T) {
|
||||||
|
var doc map[string]any
|
||||||
|
if err := yaml.Unmarshal([]byte(RenderForwardedHeaders()), &doc); err != nil {
|
||||||
|
t.Fatalf("not YAML: %v", err)
|
||||||
|
}
|
||||||
|
h := doc["http"].(map[string]any)["middlewares"].(map[string]any)[ForwardedMiddleware].(map[string]any)["headers"].(map[string]any)["customRequestHeaders"].(map[string]any)
|
||||||
|
for _, name := range []string{"X-Forwarded-Host", "X-Forwarded-Uri", "X-Forwarded-Method", "X-Forwarded-Prefix",
|
||||||
|
"X-Forwarded-Tls-Client-Cert", "X-Forwarded-Tls-Client-Cert-Info", "Forwarded", "True-Client-Ip", "X-Client-Ip"} {
|
||||||
|
if v, ok := h[name]; !ok || v != "" {
|
||||||
|
t.Errorf("%s must be removed (empty value), got %v present=%v", name, v, ok)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if h["X-Forwarded-Port"] != "443" {
|
||||||
|
t.Errorf("X-Forwarded-Port must be fixed to 443, got %v", h["X-Forwarded-Port"])
|
||||||
|
}
|
||||||
|
for _, keep := range []string{"X-Forwarded-For", "X-Real-Ip", "CF-Connecting-IP", "Cf-Connecting-Ip", "X-Forwarded-Proto"} {
|
||||||
|
if _, ok := h[keep]; ok {
|
||||||
|
t.Errorf("%s must NOT be touched", keep)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"gitea.dooplex.hu/admin/felhom-controller/internal/dockerexec"
|
"gitea.dooplex.hu/admin/felhom-controller/internal/dockerexec"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"gitea.dooplex.hu/admin/felhom-controller/internal/infra"
|
"gitea.dooplex.hu/admin/felhom-controller/internal/infra"
|
||||||
@@ -39,7 +40,24 @@ func (m *Manager) EnsureBaseStack() error {
|
|||||||
traefikDir := filepath.Join(base, "traefik")
|
traefikDir := filepath.Join(base, "traefik")
|
||||||
var errs []string
|
var errs []string
|
||||||
|
|
||||||
if err := m.ensureTraefik(traefikDir); err != nil {
|
// R-753: the tunnel's own network, with cloudflared at a fixed address. Without it traefik and cloudflared keep the
|
||||||
|
// old shape (traefik trusts nothing) — the box still routes; it just cannot tell tunnel visitors apart.
|
||||||
|
tunnelOK := true
|
||||||
|
if err := m.ensureTunnelNetwork(); err != nil {
|
||||||
|
tunnelOK = false
|
||||||
|
errs = append(errs, fmt.Sprintf("tunnel network: %v", err))
|
||||||
|
}
|
||||||
|
// The forwarded-header clean-up BEFORE traefik.yml: the entrypoint names it, and a missing middleware would break
|
||||||
|
// every route on the box.
|
||||||
|
fwdOK := true
|
||||||
|
if err := m.ensureForwardedHeaders(traefikDir); err != nil {
|
||||||
|
fwdOK = false
|
||||||
|
errs = append(errs, fmt.Sprintf("forwarded headers: %v", err))
|
||||||
|
}
|
||||||
|
|
||||||
|
if !fwdOK {
|
||||||
|
m.logger.Printf("[WARN] [infra] traefik left as it is — its forwarded-header file could not be written")
|
||||||
|
} else if err := m.ensureTraefik(traefikDir, tunnelOK); err != nil {
|
||||||
errs = append(errs, fmt.Sprintf("traefik: %v", err))
|
errs = append(errs, fmt.Sprintf("traefik: %v", err))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -60,7 +78,13 @@ func (m *Manager) EnsureBaseStack() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if m.cfg.Infrastructure.CFTunnelToken != "" {
|
if m.cfg.Infrastructure.CFTunnelToken != "" {
|
||||||
if err := m.ensureCloudflared(filepath.Join(base, "cloudflared")); err != nil {
|
// cloudflared moves to the tunnel network only once traefik is on it (it reaches traefik by name there); while
|
||||||
|
// traefik is down a running cloudflared is left alone, so an outage never flips it back and forth.
|
||||||
|
traefikUp := containerRunning("traefik")
|
||||||
|
cfTunnel := tunnelOK && traefikUp && containerOnNetwork("traefik", infra.TunnelNetwork)
|
||||||
|
if !traefikUp && containerRunning("cloudflared") {
|
||||||
|
m.logger.Printf("[INFO] [infra] cloudflared left as it is while traefik is not running")
|
||||||
|
} else if err := m.ensureCloudflared(filepath.Join(base, "cloudflared"), cfTunnel); err != nil {
|
||||||
errs = append(errs, fmt.Sprintf("cloudflared: %v", err))
|
errs = append(errs, fmt.Sprintf("cloudflared: %v", err))
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -89,9 +113,36 @@ func (m *Manager) EnsureBaseStack() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Manager) ensureTraefik(dir string) error {
|
// ensureTraefik deploys traefik when it is not running, and RECONCILES a running one: when the rendered files differ
|
||||||
|
// from the ones on disk (a release changed the template — R-753 added the tunnel trust), it rewrites them and recreates
|
||||||
|
// the container, because traefik reads its static file only at start. Equal files → nothing (the healthy tick).
|
||||||
|
// A rewrite that would DROP the certificate resolver the running file has is refused (logged): the inputs that produce
|
||||||
|
// it (the customer's e-mail) are missing, and dropping it would cost the box its certificates.
|
||||||
|
// Pinned by TestEnsureTraefik_* (internal/stacks/infra_test.go).
|
||||||
|
func (m *Manager) ensureTraefik(dir string, tunnel bool) error {
|
||||||
|
files, err := infra.RenderTraefik(infra.TraefikData{
|
||||||
|
ACMEEmail: m.cfg.Customer.Email,
|
||||||
|
CFAPIToken: m.cfg.Infrastructure.CFAPIToken,
|
||||||
|
Tunnel: tunnel,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
if containerRunning("traefik") {
|
if containerRunning("traefik") {
|
||||||
return nil
|
changed := changedInfraFiles(dir, files)
|
||||||
|
if len(changed) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if cur, err := os.ReadFile(filepath.Join(dir, "traefik.yml")); err == nil &&
|
||||||
|
strings.Contains(string(cur), "certResolver") && !strings.Contains(files["traefik.yml"].Content, "certResolver") {
|
||||||
|
m.logger.Printf("[WARN] [infra] traefik NOT reconciled: the new traefik.yml would drop the running certificate resolver (no customer e-mail in the config) — left as it is")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
m.logger.Printf("[INFO] [infra] traefik config changed (%s, tunnel trust %v) — rewriting and recreating traefik (routing pauses a few seconds)", strings.Join(changed, ", "), tunnel)
|
||||||
|
if err := writeInfraFiles(dir, files); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return m.composeUp(dir, "--force-recreate")
|
||||||
}
|
}
|
||||||
m.logger.Printf("[INFO] [infra] deploying traefik → %s", dir)
|
m.logger.Printf("[INFO] [infra] deploying traefik → %s", dir)
|
||||||
if err := os.MkdirAll(filepath.Join(dir, "dynamic"), 0o755); err != nil {
|
if err := os.MkdirAll(filepath.Join(dir, "dynamic"), 0o755); err != nil {
|
||||||
@@ -110,32 +161,91 @@ func (m *Manager) ensureTraefik(dir string) error {
|
|||||||
if err := os.Chmod(acme, 0o600); err != nil {
|
if err := os.Chmod(acme, 0o600); err != nil {
|
||||||
return fmt.Errorf("chmod acme.json: %w", err)
|
return fmt.Errorf("chmod acme.json: %w", err)
|
||||||
}
|
}
|
||||||
files, err := infra.RenderTraefik(infra.TraefikData{
|
if err := writeInfraFiles(dir, files); err != nil {
|
||||||
ACMEEmail: m.cfg.Customer.Email,
|
return err
|
||||||
CFAPIToken: m.cfg.Infrastructure.CFAPIToken,
|
}
|
||||||
})
|
return m.composeUp(dir)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ensureCloudflared deploys cloudflared, or reconciles a running one whose compose changed (R-753 moved it to the tunnel
|
||||||
|
// network at a fixed address); compose recreates the container when its networks change. The tunnel reconnects in a
|
||||||
|
// few seconds. Pinned by TestEnsureCloudflared_*.
|
||||||
|
func (m *Manager) ensureCloudflared(dir string, tunnel bool) error {
|
||||||
|
files, err := infra.RenderCloudflared(infra.CloudflaredData{CFTunnelToken: m.cfg.Infrastructure.CFTunnelToken, Tunnel: tunnel})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if containerRunning("cloudflared") {
|
||||||
|
if len(changedInfraFiles(dir, files)) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
m.logger.Printf("[INFO] [infra] cloudflared compose changed (tunnel network %v) — recreating cloudflared (the tunnel reconnects in seconds)", tunnel)
|
||||||
|
} else {
|
||||||
|
m.logger.Printf("[INFO] [infra] deploying cloudflared → %s (tunnel network %v)", dir, tunnel)
|
||||||
|
}
|
||||||
if err := writeInfraFiles(dir, files); err != nil {
|
if err := writeInfraFiles(dir, files); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return m.composeUp(dir)
|
return m.composeUp(dir)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Manager) ensureCloudflared(dir string) error {
|
// ensureTunnelNetwork makes felhom-tunnel with its FIXED subnet (infra.TunnelSubnet). A network of that name with any
|
||||||
if containerRunning("cloudflared") {
|
// other subnet is an error and is left alone: cloudflared could not take its address there, and traefik's trust would
|
||||||
return nil
|
// name an address nobody holds. Pinned by TestEnsureTunnelNetwork_*.
|
||||||
|
func (m *Manager) ensureTunnelNetwork() error {
|
||||||
|
inspect := func() (string, error) {
|
||||||
|
out, err := dockerexec.Command("docker", "network", "inspect", "--format",
|
||||||
|
"{{range .IPAM.Config}}{{.Subnet}} {{end}}", infra.TunnelNetwork).Output()
|
||||||
|
return strings.TrimSpace(string(out)), err
|
||||||
}
|
}
|
||||||
m.logger.Printf("[INFO] [infra] deploying cloudflared → %s", dir)
|
if got, err := inspect(); err == nil {
|
||||||
files, err := infra.RenderCloudflared(infra.CloudflaredData{CFTunnelToken: m.cfg.Infrastructure.CFTunnelToken})
|
if got == infra.TunnelSubnet {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return fmt.Errorf("docker network %s exists with subnet %q, want %s — left alone; the tunnel keeps its old shape", infra.TunnelNetwork, got, infra.TunnelSubnet)
|
||||||
|
}
|
||||||
|
m.logger.Printf("[INFO] [infra] creating docker network %s (%s)", infra.TunnelNetwork, infra.TunnelSubnet)
|
||||||
|
out, err := dockerexec.Command("docker", "network", "create", "--driver", "bridge",
|
||||||
|
"--subnet", infra.TunnelSubnet, "--ip-range", infra.TunnelIPRange, "--gateway", infra.TunnelGateway,
|
||||||
|
infra.TunnelNetwork).CombinedOutput()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
if got, ierr := inspect(); ierr == nil && got == infra.TunnelSubnet {
|
||||||
|
return nil // created by a concurrent actor
|
||||||
|
}
|
||||||
|
return fmt.Errorf("network create %s: %s: %w", infra.TunnelNetwork, strings.TrimSpace(string(out)), err)
|
||||||
}
|
}
|
||||||
if err := writeInfraFiles(dir, files); err != nil {
|
return nil
|
||||||
return err
|
}
|
||||||
|
|
||||||
|
// ensureForwardedHeaders writes the forwarded-header clean-up middleware (infra.RenderForwardedHeaders) when its content
|
||||||
|
// changes. traefik.yml names it on the websecure entrypoint, so EnsureBaseStack writes it BEFORE traefik.yml.
|
||||||
|
func (m *Manager) ensureForwardedHeaders(traefikDir string) error {
|
||||||
|
dynDir := filepath.Join(traefikDir, "dynamic")
|
||||||
|
if err := os.MkdirAll(dynDir, 0o755); err != nil {
|
||||||
|
return fmt.Errorf("mkdir dynamic: %w", err)
|
||||||
}
|
}
|
||||||
return m.composeUp(dir)
|
path := filepath.Join(dynDir, "forwarded.yml")
|
||||||
|
want := infra.RenderForwardedHeaders()
|
||||||
|
if cur, err := os.ReadFile(path); err != nil || string(cur) != want {
|
||||||
|
if err := os.WriteFile(path, []byte(want), 0o644); err != nil {
|
||||||
|
return fmt.Errorf("write forwarded headers: %w", err)
|
||||||
|
}
|
||||||
|
m.logger.Printf("[INFO] [infra] wrote the forwarded-header clean-up → %s (%s)", path, infra.ForwardedMiddleware)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// changedInfraFiles names the rendered files whose content differs from the file on disk (absent counts as different).
|
||||||
|
func changedInfraFiles(dir string, files map[string]infra.FileSpec) []string {
|
||||||
|
var changed []string
|
||||||
|
for name, spec := range files {
|
||||||
|
cur, err := os.ReadFile(filepath.Join(dir, name))
|
||||||
|
if err != nil || string(cur) != spec.Content {
|
||||||
|
changed = append(changed, name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Strings(changed)
|
||||||
|
return changed
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Manager) ensureFileBrowser(dir string) error {
|
func (m *Manager) ensureFileBrowser(dir string) error {
|
||||||
@@ -265,9 +375,9 @@ func (m *Manager) ensureTraefikNetwork() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// composeUp runs `docker compose up -d` in dir (DOMAIN injected by composeExecWithEnv).
|
// composeUp runs `docker compose up -d [extra…]` in dir (DOMAIN injected by composeExecWithEnv).
|
||||||
func (m *Manager) composeUp(dir string) error {
|
func (m *Manager) composeUp(dir string, extra ...string) error {
|
||||||
out, err := m.composeExecWithEnv(dir, nil, "up", "-d")
|
out, err := m.composeExecWithEnv(dir, nil, append([]string{"up", "-d"}, extra...)...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("compose up: %s: %w", truncateStr(strings.TrimSpace(out), 300), err)
|
return fmt.Errorf("compose up: %s: %w", truncateStr(strings.TrimSpace(out), 300), err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,257 @@
|
|||||||
|
package stacks
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gitea.dooplex.hu/admin/felhom-controller/internal/config"
|
||||||
|
"gitea.dooplex.hu/admin/felhom-controller/internal/infra"
|
||||||
|
)
|
||||||
|
|
||||||
|
// R-753 (`09` §3 decision 63, Part A): the base stack moves cloudflared onto its own network at a fixed address and
|
||||||
|
// makes traefik trust forwarded headers from that address only. These tests drive EnsureBaseStack's pieces against a
|
||||||
|
// STUB docker on PATH (never the real one — R-650) and a recorded compose seam, and assert the consequence on disk and
|
||||||
|
// in the commands run.
|
||||||
|
|
||||||
|
// stubDocker writes a fake `docker` into a temp dir on PATH. State lives in files under state/:
|
||||||
|
//
|
||||||
|
// running-<name> → `docker inspect --format {{.State.Running}} <name>` prints true
|
||||||
|
// nets-<name> → the networks `containerOnNetwork` sees (one per line)
|
||||||
|
// net-<network> → `docker network inspect --format … <network>` prints the file (else exit 1)
|
||||||
|
//
|
||||||
|
// `docker network create … --subnet S … <network>` writes net-<network> = S unless state/create-fails exists.
|
||||||
|
// Every call is appended to state/calls.
|
||||||
|
func stubDocker(t *testing.T) (state string) {
|
||||||
|
t.Helper()
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
t.Skip("the stub docker is a shell script")
|
||||||
|
}
|
||||||
|
dir := t.TempDir()
|
||||||
|
state = filepath.Join(dir, "state")
|
||||||
|
if err := os.MkdirAll(state, 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
script := `#!/bin/sh
|
||||||
|
S="` + state + `"
|
||||||
|
echo "$*" >> "$S/calls"
|
||||||
|
case "$1" in
|
||||||
|
inspect)
|
||||||
|
last=""; for a in "$@"; do last="$a"; done
|
||||||
|
case "$*" in
|
||||||
|
*State.Running*) if [ -f "$S/running-$last" ]; then echo true; else echo false; fi; exit 0;;
|
||||||
|
*NetworkSettings.Networks*) [ -f "$S/nets-$last" ] && cat "$S/nets-$last"; exit 0;;
|
||||||
|
esac;;
|
||||||
|
network)
|
||||||
|
last=""; for a in "$@"; do last="$a"; done
|
||||||
|
case "$2" in
|
||||||
|
inspect) if [ -f "$S/net-$last" ]; then cat "$S/net-$last"; exit 0; fi; echo "Error: no such network: $last" >&2; exit 1;;
|
||||||
|
create)
|
||||||
|
if [ -f "$S/create-fails" ]; then echo "Error response from daemon: Pool overlaps with other one on this address space" >&2; exit 1; fi
|
||||||
|
sub=""; prev=""; for a in "$@"; do [ "$prev" = "--subnet" ] && sub="$a"; prev="$a"; done
|
||||||
|
if [ -n "$sub" ]; then echo "$sub" > "$S/net-$last"; else echo "auto" > "$S/net-$last"; fi; exit 0;;
|
||||||
|
connect) exit 0;;
|
||||||
|
esac;;
|
||||||
|
esac
|
||||||
|
exit 0
|
||||||
|
`
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, "docker"), []byte(script), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH"))
|
||||||
|
return state
|
||||||
|
}
|
||||||
|
|
||||||
|
func touch(t *testing.T, path, content string) {
|
||||||
|
t.Helper()
|
||||||
|
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type composeCall struct {
|
||||||
|
dir string
|
||||||
|
args string
|
||||||
|
}
|
||||||
|
|
||||||
|
func tunnelTestManager(t *testing.T, email string) (*Manager, *[]composeCall) {
|
||||||
|
t.Helper()
|
||||||
|
cfg := &config.Config{}
|
||||||
|
cfg.Customer.Email = email
|
||||||
|
cfg.Infrastructure.CFTunnelToken = "tok-test"
|
||||||
|
m := &Manager{cfg: cfg, logger: log.New(io.Discard, "", 0)}
|
||||||
|
var calls []composeCall
|
||||||
|
m.composeExecFn = func(dir string, env map[string]string, args ...string) (string, error) {
|
||||||
|
calls = append(calls, composeCall{dir: dir, args: strings.Join(args, " ")})
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
return m, &calls
|
||||||
|
}
|
||||||
|
|
||||||
|
// The network is created with its FIXED subnet — the address traefik trusts must be one docker cannot hand elsewhere.
|
||||||
|
func TestEnsureTunnelNetwork_CreatesFixedSubnet(t *testing.T) {
|
||||||
|
state := stubDocker(t)
|
||||||
|
m, _ := tunnelTestManager(t, "")
|
||||||
|
if err := m.ensureTunnelNetwork(); err != nil {
|
||||||
|
t.Fatalf("ensureTunnelNetwork: %v", err)
|
||||||
|
}
|
||||||
|
got, _ := os.ReadFile(filepath.Join(state, "net-"+infra.TunnelNetwork))
|
||||||
|
if strings.TrimSpace(string(got)) != infra.TunnelSubnet {
|
||||||
|
t.Fatalf("network created with subnet %q, want %s", got, infra.TunnelSubnet)
|
||||||
|
}
|
||||||
|
calls, _ := os.ReadFile(filepath.Join(state, "calls"))
|
||||||
|
if !strings.Contains(string(calls), "--gateway "+infra.TunnelGateway) || !strings.Contains(string(calls), "--ip-range "+infra.TunnelIPRange) {
|
||||||
|
t.Fatalf("create did not fix the gateway: %s", calls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A network of that name with another subnet is NOT trusted and NOT touched: an error, and the caller keeps the old shape.
|
||||||
|
func TestEnsureTunnelNetwork_WrongSubnetIsAnError(t *testing.T) {
|
||||||
|
state := stubDocker(t)
|
||||||
|
touch(t, filepath.Join(state, "net-"+infra.TunnelNetwork), "172.30.0.0/16\n")
|
||||||
|
m, _ := tunnelTestManager(t, "")
|
||||||
|
err := m.ensureTunnelNetwork()
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "172.30.0.0/16") {
|
||||||
|
t.Fatalf("want an error naming the wrong subnet, got %v", err)
|
||||||
|
}
|
||||||
|
calls, _ := os.ReadFile(filepath.Join(state, "calls"))
|
||||||
|
if strings.Contains(string(calls), "network create") || strings.Contains(string(calls), " rm ") {
|
||||||
|
t.Fatalf("a wrong-subnet network must be left alone; calls: %s", calls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// THE CONSEQUENCE on a box that already runs traefik (every installed box): the new release rewrites traefik.yml with the
|
||||||
|
// tunnel trust and RECREATES traefik (static config is read only at start). A second tick with nothing changed does nothing.
|
||||||
|
func TestEnsureTraefik_ReconcilesARunningTraefik(t *testing.T) {
|
||||||
|
state := stubDocker(t)
|
||||||
|
touch(t, filepath.Join(state, "running-traefik"), "")
|
||||||
|
m, calls := tunnelTestManager(t, "owner@example.com")
|
||||||
|
dir := t.TempDir()
|
||||||
|
old, err := infra.RenderTraefik(infra.TraefikData{ACMEEmail: "owner@example.com"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// the pre-R-753 file: no trust, no middleware
|
||||||
|
oldYML := strings.Replace(old["traefik.yml"].Content, " http:\n middlewares:\n - "+infra.ForwardedMiddleware+"@file\n", " http:\n", 1)
|
||||||
|
touch(t, filepath.Join(dir, "traefik.yml"), oldYML)
|
||||||
|
touch(t, filepath.Join(dir, "docker-compose.yml"), old["docker-compose.yml"].Content)
|
||||||
|
|
||||||
|
if err := m.ensureTraefik(dir, true); err != nil {
|
||||||
|
t.Fatalf("ensureTraefik: %v", err)
|
||||||
|
}
|
||||||
|
yml, _ := os.ReadFile(filepath.Join(dir, "traefik.yml"))
|
||||||
|
if !strings.Contains(string(yml), `- "`+infra.TunnelAddr+`/32"`) {
|
||||||
|
t.Fatalf("traefik.yml was not rewritten with the tunnel trust:\n%s", yml)
|
||||||
|
}
|
||||||
|
cmp, _ := os.ReadFile(filepath.Join(dir, "docker-compose.yml"))
|
||||||
|
if !strings.Contains(string(cmp), "ipv4_address: "+infra.TunnelTraefikAddr) {
|
||||||
|
t.Fatalf("traefik's compose does not join %s:\n%s", infra.TunnelNetwork, cmp)
|
||||||
|
}
|
||||||
|
if len(*calls) != 1 || (*calls)[0].args != "up -d --force-recreate" {
|
||||||
|
t.Fatalf("want ONE `up -d --force-recreate`, got %+v", *calls)
|
||||||
|
}
|
||||||
|
if err := m.ensureTraefik(dir, true); err != nil {
|
||||||
|
t.Fatalf("second ensureTraefik: %v", err)
|
||||||
|
}
|
||||||
|
if len(*calls) != 1 {
|
||||||
|
t.Fatalf("an unchanged config must not recreate traefik again; calls %+v", *calls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A rewrite that would DROP the certificate resolver the running file has is refused (no e-mail in the config).
|
||||||
|
func TestEnsureTraefik_RefusesToDropTheCertResolver(t *testing.T) {
|
||||||
|
state := stubDocker(t)
|
||||||
|
touch(t, filepath.Join(state, "running-traefik"), "")
|
||||||
|
m, calls := tunnelTestManager(t, "") // no customer e-mail → the render has no resolver
|
||||||
|
dir := t.TempDir()
|
||||||
|
withACME, _ := infra.RenderTraefik(infra.TraefikData{ACMEEmail: "owner@example.com"})
|
||||||
|
touch(t, filepath.Join(dir, "traefik.yml"), withACME["traefik.yml"].Content)
|
||||||
|
if err := m.ensureTraefik(dir, true); err != nil {
|
||||||
|
t.Fatalf("ensureTraefik: %v", err)
|
||||||
|
}
|
||||||
|
yml, _ := os.ReadFile(filepath.Join(dir, "traefik.yml"))
|
||||||
|
if string(yml) != withACME["traefik.yml"].Content || len(*calls) != 0 {
|
||||||
|
t.Fatalf("the running file with a resolver must be left alone; calls %+v", *calls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// cloudflared moves to the tunnel network at the fixed address — and is recreated by compose — only when asked.
|
||||||
|
func TestEnsureCloudflared_MovesToTheTunnelNetwork(t *testing.T) {
|
||||||
|
state := stubDocker(t)
|
||||||
|
touch(t, filepath.Join(state, "running-cloudflared"), "")
|
||||||
|
m, calls := tunnelTestManager(t, "")
|
||||||
|
dir := t.TempDir()
|
||||||
|
old, _ := infra.RenderCloudflared(infra.CloudflaredData{CFTunnelToken: "tok-test"})
|
||||||
|
touch(t, filepath.Join(dir, "docker-compose.yml"), old["docker-compose.yml"].Content)
|
||||||
|
|
||||||
|
if err := m.ensureCloudflared(dir, false); err != nil || len(*calls) != 0 {
|
||||||
|
t.Fatalf("unchanged old shape must do nothing; err %v calls %+v", err, *calls)
|
||||||
|
}
|
||||||
|
if err := m.ensureCloudflared(dir, true); err != nil {
|
||||||
|
t.Fatalf("ensureCloudflared: %v", err)
|
||||||
|
}
|
||||||
|
cmp, _ := os.ReadFile(filepath.Join(dir, "docker-compose.yml"))
|
||||||
|
if !strings.Contains(string(cmp), "ipv4_address: "+infra.TunnelAddr) || strings.Contains(string(cmp), "traefik-public") {
|
||||||
|
t.Fatalf("cloudflared must be ALONE on %s at %s:\n%s", infra.TunnelNetwork, infra.TunnelAddr, cmp)
|
||||||
|
}
|
||||||
|
if len(*calls) != 1 || (*calls)[0].args != "up -d" {
|
||||||
|
t.Fatalf("want one `up -d`, got %+v", *calls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The whole bring-up, in order: network → the header clean-up file → traefik (recreated with the trust) → cloudflared
|
||||||
|
// moved only because traefik is on the tunnel network. And when the network cannot be made, NOTHING moves and traefik
|
||||||
|
// keeps trusting nobody.
|
||||||
|
func TestEnsureBaseStack_TunnelOrder(t *testing.T) {
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
createFail bool
|
||||||
|
}{{"network made", false}, {"network refused", true}} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
state := stubDocker(t)
|
||||||
|
touch(t, filepath.Join(state, "net-traefik-public"), "172.18.0.0/16\n")
|
||||||
|
for _, c := range []string{"traefik", "cloudflared", "filebrowser", "felhom-controller"} {
|
||||||
|
touch(t, filepath.Join(state, "running-"+c), "")
|
||||||
|
}
|
||||||
|
if tc.createFail {
|
||||||
|
touch(t, filepath.Join(state, "create-fails"), "")
|
||||||
|
touch(t, filepath.Join(state, "nets-traefik"), "traefik-public\n")
|
||||||
|
} else {
|
||||||
|
touch(t, filepath.Join(state, "nets-traefik"), "traefik-public\n"+infra.TunnelNetwork+"\n")
|
||||||
|
}
|
||||||
|
touch(t, filepath.Join(state, "nets-felhom-controller"), "traefik-public\n")
|
||||||
|
m, calls := tunnelTestManager(t, "owner@example.com")
|
||||||
|
m.cfg.Paths.StacksDir = t.TempDir()
|
||||||
|
_ = m.EnsureBaseStack()
|
||||||
|
|
||||||
|
traefikDir := filepath.Join(m.cfg.Paths.StacksDir, "traefik")
|
||||||
|
if _, err := os.Stat(filepath.Join(traefikDir, "dynamic", "forwarded.yml")); err != nil {
|
||||||
|
t.Fatalf("the forwarded-header file must be written in either case: %v", err)
|
||||||
|
}
|
||||||
|
yml, _ := os.ReadFile(filepath.Join(traefikDir, "traefik.yml"))
|
||||||
|
cf, _ := os.ReadFile(filepath.Join(m.cfg.Paths.StacksDir, "cloudflared", "docker-compose.yml"))
|
||||||
|
trusts := strings.Contains(string(yml), "trustedIPs")
|
||||||
|
moved := strings.Contains(string(cf), "ipv4_address: "+infra.TunnelAddr)
|
||||||
|
if tc.createFail {
|
||||||
|
if trusts || moved {
|
||||||
|
t.Fatalf("no network → no trust and no move; trust %v moved %v", trusts, moved)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !trusts || !moved {
|
||||||
|
t.Fatalf("network made → traefik trusts the tunnel and cloudflared moved; trust %v moved %v", trusts, moved)
|
||||||
|
}
|
||||||
|
var order []string
|
||||||
|
for _, c := range *calls {
|
||||||
|
order = append(order, filepath.Base(c.dir)+":"+c.args)
|
||||||
|
}
|
||||||
|
if len(order) < 2 || order[0] != "traefik:up -d --force-recreate" || order[1] != "cloudflared:up -d" {
|
||||||
|
t.Fatalf("traefik must be recreated BEFORE cloudflared moves; compose calls %v", order)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -150,20 +150,20 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
|
|||||||
password := r.FormValue("password")
|
password := r.FormValue("password")
|
||||||
nextURL := r.FormValue("next")
|
nextURL := r.FormValue("next")
|
||||||
|
|
||||||
|
// The counter's key is the VISITOR (clientaddr.go, R-753): through the tunnel each visitor has its own address, so a
|
||||||
|
// stranger's wrong passwords lock only the stranger. Before v0.286.0 every tunnel visitor shared cloudflared's
|
||||||
|
// address here, and five wrong tries locked the whole household out of its own dashboard for a minute.
|
||||||
|
ip := rateKey(r)
|
||||||
if s.isDebug() {
|
if s.isDebug() {
|
||||||
s.logger.Printf("[DEBUG] [web] login attempt from %s (X-Forwarded-For: %s)", r.RemoteAddr, r.Header.Get("X-Forwarded-For"))
|
s.logger.Printf("[DEBUG] [web] login attempt: visitor %s (peer %s, X-Forwarded-For %q, CF-Connecting-IP %q)",
|
||||||
|
ip, r.RemoteAddr, r.Header.Get("X-Forwarded-For"), r.Header.Get("CF-Connecting-IP"))
|
||||||
}
|
}
|
||||||
|
|
||||||
if password == "" {
|
if password == "" {
|
||||||
s.renderLogin(w, r, "Kérjük adja meg a jelszót", "")
|
s.renderLogin(w, r, s.msg(r, "login.msg.empty_password"), "")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Rate limit: check failed attempts from this host. clientIP strips the ephemeral port
|
|
||||||
// (CAMPAIGN-4 F-B) so distinct direct connections from one host share a key and the counter
|
|
||||||
// actually accrues; XFF first-hop still wins for proxied clients.
|
|
||||||
ip := clientIP(r)
|
|
||||||
|
|
||||||
s.loginAttemptMu.Lock()
|
s.loginAttemptMu.Lock()
|
||||||
attempt := s.loginAttempts[ip]
|
attempt := s.loginAttempts[ip]
|
||||||
if attempt != nil && time.Since(attempt.lastFail) > loginWindowDuration {
|
if attempt != nil && time.Since(attempt.lastFail) > loginWindowDuration {
|
||||||
@@ -174,14 +174,14 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
|
|||||||
if attempt != nil && attempt.count >= loginMaxAttempts {
|
if attempt != nil && attempt.count >= loginMaxAttempts {
|
||||||
s.loginAttemptMu.Unlock()
|
s.loginAttemptMu.Unlock()
|
||||||
s.logger.Printf("[WARN] [web] Login rate limited for %s (%d attempts)", ip, attempt.count)
|
s.logger.Printf("[WARN] [web] Login rate limited for %s (%d attempts)", ip, attempt.count)
|
||||||
s.renderLogin(w, r, "Túl sok sikertelen próbálkozás, próbálja újra 1 perc múlva", "")
|
s.renderLogin(w, r, s.msg(r, "login.msg.rate_limited"), "")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
s.loginAttemptMu.Unlock()
|
s.loginAttemptMu.Unlock()
|
||||||
|
|
||||||
effectiveHash := s.effectivePasswordHash()
|
effectiveHash := s.effectivePasswordHash()
|
||||||
if err := bcrypt.CompareHashAndPassword([]byte(effectiveHash), []byte(password)); err != nil {
|
if err := bcrypt.CompareHashAndPassword([]byte(effectiveHash), []byte(password)); err != nil {
|
||||||
s.logger.Printf("[WARN] [web] Failed login from %s", r.RemoteAddr)
|
s.logger.Printf("[WARN] [web] Failed login from %s", ip)
|
||||||
s.loginAttemptMu.Lock()
|
s.loginAttemptMu.Lock()
|
||||||
if s.loginAttempts[ip] == nil {
|
if s.loginAttempts[ip] == nil {
|
||||||
s.loginAttempts[ip] = &loginAttempt{}
|
s.loginAttempts[ip] = &loginAttempt{}
|
||||||
@@ -189,7 +189,7 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
|
|||||||
s.loginAttempts[ip].count++
|
s.loginAttempts[ip].count++
|
||||||
s.loginAttempts[ip].lastFail = time.Now()
|
s.loginAttempts[ip].lastFail = time.Now()
|
||||||
s.loginAttemptMu.Unlock()
|
s.loginAttemptMu.Unlock()
|
||||||
s.renderLogin(w, r, "Hibás jelszó", "")
|
s.renderLogin(w, r, s.msg(r, "login.msg.wrong_password"), "")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -219,7 +219,7 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
|
|||||||
// browser is closed". The durable opt-out is a separate, explicit choice and is untouched here.
|
// browser is closed". The durable opt-out is a separate, explicit choice and is untouched here.
|
||||||
http.SetCookie(w, &http.Cookie{Name: recoveryBannerCookie, Value: "", Path: "/", MaxAge: -1})
|
http.SetCookie(w, &http.Cookie{Name: recoveryBannerCookie, Value: "", Path: "/", MaxAge: -1})
|
||||||
|
|
||||||
s.logger.Printf("[INFO] [web] Login from %s", r.RemoteAddr)
|
s.logger.Printf("[INFO] [web] Login from %s", ip)
|
||||||
|
|
||||||
// Redirect to ?next= target if provided, otherwise to dashboard
|
// Redirect to ?next= target if provided, otherwise to dashboard
|
||||||
redirectTo := "/"
|
redirectTo := "/"
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"gitea.dooplex.hu/admin/felhom-controller/internal/i18n"
|
"gitea.dooplex.hu/admin/felhom-controller/internal/i18n"
|
||||||
"io"
|
"io"
|
||||||
"net"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -217,27 +216,7 @@ func (s *Server) claimNow() time.Time {
|
|||||||
return time.Now()
|
return time.Now()
|
||||||
}
|
}
|
||||||
|
|
||||||
// clientIP returns the client IP used as the rate-limiter key. Order: the X-Forwarded-For first
|
// clientIP and rateKey live in clientaddr.go (R-753).
|
||||||
// hop (set by the traefik/Cloudflare proxy) wins; otherwise the HOST portion of RemoteAddr with the
|
|
||||||
// ephemeral PORT stripped (net.SplitHostPort). This is the CAMPAIGN-4 F-B fix: keying on the raw
|
|
||||||
// RemoteAddr (IP:PORT) meant every fresh direct connection from one host got a distinct ephemeral
|
|
||||||
// port → a distinct key → the failed-attempt counter never accrued, so a direct-to-controller
|
|
||||||
// (LAN/guest, non-proxied) path had NO brute-force protection. A RemoteAddr with no port
|
|
||||||
// (tests/edge) or an IPv6 form is handled by SplitHostPort, falling back to the raw value.
|
|
||||||
//
|
|
||||||
// Accepted limitation (out of scope here): X-Forwarded-For is attacker-controlled on a direct path,
|
|
||||||
// so a client rotating the first hop still evades the per-IP counter. This fix only closes the
|
|
||||||
// port-in-key bug so the proxied / stable-source-IP case — the real deployment — works; it does NOT
|
|
||||||
// attempt to establish XFF trust.
|
|
||||||
func clientIP(r *http.Request) string {
|
|
||||||
if fwd := r.Header.Get("X-Forwarded-For"); fwd != "" {
|
|
||||||
return strings.TrimSpace(strings.Split(fwd, ",")[0])
|
|
||||||
}
|
|
||||||
if host, _, err := net.SplitHostPort(r.RemoteAddr); err == nil {
|
|
||||||
return host
|
|
||||||
}
|
|
||||||
return strings.TrimSpace(r.RemoteAddr)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── the pages ────────────────────────────────────────────────────────────────────────────────
|
// ── the pages ────────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -316,7 +295,7 @@ func (s *Server) handleClaimSubmit(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
wasReset := s.authEnabled() // a password already set → this is a reset, not a first-claim
|
wasReset := s.authEnabled() // a password already set → this is a reset, not a first-claim
|
||||||
ip := clientIP(r)
|
ip := rateKey(r)
|
||||||
|
|
||||||
if locked, _ := s.claimRateLocked(); locked {
|
if locked, _ := s.claimRateLocked(); locked {
|
||||||
s.handleClaimPage(w, r, s.msg(r, "claim.msg.too_many"), "")
|
s.handleClaimPage(w, r, s.msg(r, "claim.msg.too_many"), "")
|
||||||
|
|||||||
@@ -0,0 +1,129 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.dooplex.hu/admin/felhom-controller/internal/infra"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── The visitor's address (R-753, `09` §3 decision 63, Part A) ─────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// The rule: NEVER BELIEVE AN ADDRESS A CLIENT CAN WRITE.
|
||||||
|
//
|
||||||
|
// Two paths reach the controller, both through traefik:
|
||||||
|
// tunnel: browser → Cloudflare's edge → cloudflared (felhom-tunnel, fixed infra.TunnelAddr) → traefik → controller
|
||||||
|
// LAN: browser → traefik → controller
|
||||||
|
// traefik APPENDS the address it saw to X-Forwarded-For, and drops any chain written by a peer it does not trust — so the
|
||||||
|
// RIGHTMOST X-Forwarded-For entry is the address traefik itself saw, on either path. That entry, and only that entry,
|
||||||
|
// is believed — and only when the request's own TCP peer IS traefik (anything else that can open a connection to the
|
||||||
|
// controller can write any header it likes).
|
||||||
|
//
|
||||||
|
// - The hop is cloudflared's fixed address → the visitor is CF-Connecting-IP. Cloudflare's edge sets it on every
|
||||||
|
// request and refuses a request that carries its own (measured: HTTP 403 at the edge,
|
||||||
|
// felhom.eu/documentation/audits/visitors-2026-10-01/A/M1-status-quo.txt). On the LAN the hop is the LAN client
|
||||||
|
// itself, so a CF-Connecting-IP forged on the LAN (it does arrive — M4) is never read.
|
||||||
|
// - Any other hop → the visitor is that hop.
|
||||||
|
//
|
||||||
|
// Everything else — the LEFTMOST X-Forwarded-For entry above all (Cloudflare APPENDS to a client-sent chain, measured
|
||||||
|
// M2: "6.6.6.6,37.191.56.193, 172.18.0.5") — is client-written and ignored. The rule holds whether or not traefik
|
||||||
|
// trusts the tunnel: the setup gate's forwardAuth request carries only traefik's own hop, and the dashboard request
|
||||||
|
// carries the whole chain; both end in the hop traefik saw.
|
||||||
|
//
|
||||||
|
// If cloudflared is NOT at its fixed address (a box whose tunnel network could not be made), the hop is cloudflared's
|
||||||
|
// docker-assigned address: the visitor is that address — every tunnel visitor shares one key, as before R-753. Never a
|
||||||
|
// client-written one.
|
||||||
|
//
|
||||||
|
// Pinned by internal/web/clientaddr_test.go (TestClientIP_*), red-proven against the leftmost-hop shape.
|
||||||
|
|
||||||
|
// traefikHost is the name the controller resolves traefik by on traefik-public (docker's embedded DNS).
|
||||||
|
const traefikHost = "traefik"
|
||||||
|
|
||||||
|
// isTraefikPeer reports whether ip is traefik's address. A variable so tests can name traefik without docker DNS.
|
||||||
|
var isTraefikPeer = func(ip string) bool { return traefikPeers.has(ip) }
|
||||||
|
|
||||||
|
var traefikPeers = &peerResolver{host: traefikHost, ttl: 30 * time.Second, lookup: net.DefaultResolver.LookupHost}
|
||||||
|
|
||||||
|
// peerResolver caches the addresses a container name resolves to. A failed lookup believes nobody (fail closed: the
|
||||||
|
// TCP peer is then the visitor, which can never be client-written).
|
||||||
|
type peerResolver struct {
|
||||||
|
host string
|
||||||
|
ttl time.Duration
|
||||||
|
lookup func(ctx context.Context, host string) ([]string, error)
|
||||||
|
|
||||||
|
mu sync.Mutex
|
||||||
|
at time.Time
|
||||||
|
addrs []string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *peerResolver) has(ip string) bool {
|
||||||
|
p.mu.Lock()
|
||||||
|
defer p.mu.Unlock()
|
||||||
|
if time.Since(p.at) > p.ttl {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
|
||||||
|
addrs, err := p.lookup(ctx, p.host)
|
||||||
|
cancel()
|
||||||
|
if err != nil {
|
||||||
|
addrs = nil
|
||||||
|
}
|
||||||
|
p.addrs, p.at = addrs, time.Now()
|
||||||
|
}
|
||||||
|
for _, a := range p.addrs {
|
||||||
|
if a == ip {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// peerHost is RemoteAddr without its port (CAMPAIGN-4 F-B: a key with the ephemeral port never accrues).
|
||||||
|
func peerHost(r *http.Request) string {
|
||||||
|
if host, _, err := net.SplitHostPort(r.RemoteAddr); err == nil {
|
||||||
|
return host
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(r.RemoteAddr)
|
||||||
|
}
|
||||||
|
|
||||||
|
// clientIP is the visitor's address — logged, and the key of every per-visitor counter (dashboard login, claim code,
|
||||||
|
// share password, escrow re-auth). See the block comment above for the rule.
|
||||||
|
func clientIP(r *http.Request) string {
|
||||||
|
peer := peerHost(r)
|
||||||
|
if !isTraefikPeer(peer) {
|
||||||
|
return peer
|
||||||
|
}
|
||||||
|
var hops []string
|
||||||
|
for _, v := range r.Header.Values("X-Forwarded-For") {
|
||||||
|
for _, h := range strings.Split(v, ",") {
|
||||||
|
if h = strings.TrimSpace(h); h != "" {
|
||||||
|
hops = append(hops, h)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(hops) == 0 {
|
||||||
|
return peer
|
||||||
|
}
|
||||||
|
hop := hops[len(hops)-1] // the address traefik saw
|
||||||
|
if net.ParseIP(hop) == nil {
|
||||||
|
return peer
|
||||||
|
}
|
||||||
|
if hop == infra.TunnelAddr {
|
||||||
|
if cf := strings.TrimSpace(r.Header.Get("CF-Connecting-IP")); net.ParseIP(cf) != nil {
|
||||||
|
return cf
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return hop
|
||||||
|
}
|
||||||
|
|
||||||
|
// rateKey is clientIP as a counter key. An IPv6 visitor is counted per /64: one household or one attacker usually holds
|
||||||
|
// a whole /64, and per-/128 keys would let one machine step around a counter by changing its own address.
|
||||||
|
func rateKey(r *http.Request) string {
|
||||||
|
ip := clientIP(r)
|
||||||
|
if p := net.ParseIP(ip); p != nil && p.To4() == nil {
|
||||||
|
return p.Mask(net.CIDRMask(64, 128)).String() + "/64"
|
||||||
|
}
|
||||||
|
return ip
|
||||||
|
}
|
||||||
@@ -0,0 +1,206 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.dooplex.hu/admin/felhom-controller/internal/infra"
|
||||||
|
)
|
||||||
|
|
||||||
|
// R-753 (`09` §3 decision 63, Part A) — "never believe an address a client can write". The shapes below are the
|
||||||
|
// MEASURED ones (felhom.eu/documentation/audits/visitors-2026-10-01/A): Cloudflare appends the real visitor to a
|
||||||
|
// client-sent X-Forwarded-For, traefik appends the hop it saw, and a CF-Connecting-IP forged on the LAN arrives.
|
||||||
|
|
||||||
|
const traefikAddr = "172.18.0.3"
|
||||||
|
|
||||||
|
func withTraefikAt(t *testing.T, addrs ...string) {
|
||||||
|
t.Helper()
|
||||||
|
old := isTraefikPeer
|
||||||
|
isTraefikPeer = func(ip string) bool {
|
||||||
|
for _, a := range addrs {
|
||||||
|
if a == ip {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { isTraefikPeer = old })
|
||||||
|
}
|
||||||
|
|
||||||
|
func addrReq(remote, xff, cf string) *http.Request {
|
||||||
|
r := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||||
|
r.RemoteAddr = remote
|
||||||
|
if xff != "" {
|
||||||
|
r.Header.Set("X-Forwarded-For", xff)
|
||||||
|
}
|
||||||
|
if cf != "" {
|
||||||
|
r.Header.Set("CF-Connecting-IP", cf)
|
||||||
|
}
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClientIP_Paths(t *testing.T) {
|
||||||
|
withTraefikAt(t, traefikAddr)
|
||||||
|
tun := infra.TunnelAddr
|
||||||
|
cases := []struct {
|
||||||
|
name, remote, xff, cf, want string
|
||||||
|
}{
|
||||||
|
// tunnel, traefik trusting the tunnel: "<client-written>, <real>, <cloudflared>" — the forged LEFTMOST is not believed
|
||||||
|
{"tunnel, forged leftmost", traefikAddr + ":5000", "6.6.6.6,37.191.56.193, " + tun, "37.191.56.193", "37.191.56.193"},
|
||||||
|
{"tunnel, plain", traefikAddr + ":5000", "37.191.56.193, " + tun, "37.191.56.193", "37.191.56.193"},
|
||||||
|
// the setup gate's forwardAuth request: traefik writes only the hop it saw
|
||||||
|
{"gate request through the tunnel", traefikAddr + ":5000", tun, "203.0.113.50", "203.0.113.50"},
|
||||||
|
// LAN: traefik replaced the chain with the LAN client; a CF-Connecting-IP forged on the LAN is never read
|
||||||
|
{"LAN, forged CF-Connecting-IP", traefikAddr + ":5000", "192.168.0.180", "7.7.7.7", "192.168.0.180"},
|
||||||
|
// a peer that is NOT traefik wrote every header itself: only the TCP peer counts
|
||||||
|
{"direct, forged headers", "192.168.0.50:4000", "1.2.3.4", "5.6.7.8", "192.168.0.50"},
|
||||||
|
{"direct, no headers", "127.0.0.1:5001", "", "", "127.0.0.1"},
|
||||||
|
{"direct, no port", "192.168.0.5", "", "", "192.168.0.5"},
|
||||||
|
{"direct IPv6", "[::1]:443", "", "", "::1"},
|
||||||
|
// cloudflared not (yet) at its fixed address: the old shared address, never a client-written one
|
||||||
|
{"old cloudflared address", traefikAddr + ":5000", "6.6.6.6, 172.18.0.5", "9.9.9.9", "172.18.0.5"},
|
||||||
|
{"tunnel hop without CF-Connecting-IP", traefikAddr + ":5000", tun, "", tun},
|
||||||
|
{"tunnel hop, garbage CF-Connecting-IP", traefikAddr + ":5000", tun, "not-an-ip", tun},
|
||||||
|
{"traefik, garbage hop", traefikAddr + ":5000", "1.2.3.4, garbage", "", traefikAddr},
|
||||||
|
{"traefik, no XFF", traefikAddr + ":5000", "", "", traefikAddr},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
if got := clientIP(addrReq(c.remote, c.xff, c.cf)); got != c.want {
|
||||||
|
t.Errorf("%s: clientIP(remote=%q xff=%q cf=%q) = %q, want %q", c.name, c.remote, c.xff, c.cf, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Two X-Forwarded-For header LINES are one chain; the last entry of the last line is the hop.
|
||||||
|
func TestClientIP_MultipleXFFLines(t *testing.T) {
|
||||||
|
withTraefikAt(t, traefikAddr)
|
||||||
|
r := addrReq(traefikAddr+":1", "", "203.0.113.7")
|
||||||
|
r.Header.Add("X-Forwarded-For", "6.6.6.6")
|
||||||
|
r.Header.Add("X-Forwarded-For", "203.0.113.7, "+infra.TunnelAddr)
|
||||||
|
if got := clientIP(r); got != "203.0.113.7" {
|
||||||
|
t.Fatalf("got %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRateKey_IPv6Per64(t *testing.T) {
|
||||||
|
withTraefikAt(t, traefikAddr)
|
||||||
|
a := rateKey(addrReq(traefikAddr+":1", infra.TunnelAddr, "2001:db8:1:2:aaaa::1"))
|
||||||
|
b := rateKey(addrReq(traefikAddr+":1", infra.TunnelAddr, "2001:db8:1:2:bbbb::9"))
|
||||||
|
c := rateKey(addrReq(traefikAddr+":1", infra.TunnelAddr, "2001:db8:1:3::1"))
|
||||||
|
if a != b || a != "2001:db8:1:2::/64" || a == c {
|
||||||
|
t.Fatalf("one /64 must be one key: %q %q %q", a, b, c)
|
||||||
|
}
|
||||||
|
if k := rateKey(addrReq(traefikAddr+":1", infra.TunnelAddr, "203.0.113.9")); k != "203.0.113.9" {
|
||||||
|
t.Fatalf("IPv4 key = %q", k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The production resolver believes nobody when docker's DNS does not answer (fail closed), and caches an answer.
|
||||||
|
func TestPeerResolver_FailsClosedAndCaches(t *testing.T) {
|
||||||
|
n := 0
|
||||||
|
p := &peerResolver{host: "traefik", ttl: time.Minute, lookup: func(context.Context, string) ([]string, error) {
|
||||||
|
n++
|
||||||
|
return nil, errors.New("no such host")
|
||||||
|
}}
|
||||||
|
if p.has("172.18.0.3") {
|
||||||
|
t.Fatal("a failed lookup must believe nobody")
|
||||||
|
}
|
||||||
|
ok := &peerResolver{host: "traefik", ttl: time.Minute, lookup: func(context.Context, string) ([]string, error) {
|
||||||
|
n++
|
||||||
|
return []string{"172.18.0.3"}, nil
|
||||||
|
}}
|
||||||
|
if !ok.has("172.18.0.3") || ok.has("172.18.0.4") || n != 2 {
|
||||||
|
t.Fatalf("resolver answer not used or not cached (lookups %d)", n)
|
||||||
|
}
|
||||||
|
if isTraefikPeer == nil || traefikPeers.host != traefikHost {
|
||||||
|
t.Fatal("the production seam must resolve the traefik container by name")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func tunnelLogin(s *Server, visitor, forgedLeft, password string) *httptest.ResponseRecorder {
|
||||||
|
form := url.Values{"password": {password}}
|
||||||
|
r := httptest.NewRequest(http.MethodPost, "/login", strings.NewReader(form.Encode()))
|
||||||
|
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
r.RemoteAddr = traefikAddr + ":44321"
|
||||||
|
xff := visitor + ", " + infra.TunnelAddr
|
||||||
|
if forgedLeft != "" {
|
||||||
|
xff = forgedLeft + "," + xff
|
||||||
|
}
|
||||||
|
r.Header.Set("X-Forwarded-For", xff)
|
||||||
|
r.Header.Set("CF-Connecting-IP", visitor)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
s.handleLogin(w, r)
|
||||||
|
return w
|
||||||
|
}
|
||||||
|
|
||||||
|
// THE CONSEQUENCE (R-753): through the tunnel, a stranger's wrong passwords lock only the stranger — rotating a forged
|
||||||
|
// leftmost address does not get him out — and the household, from another address, signs in at once.
|
||||||
|
// Red-proof: with the pre-R-753 clientIP (leftmost X-Forwarded-For hop) the stranger's rotation is never locked, and
|
||||||
|
// with a key of cloudflared's address the household is refused.
|
||||||
|
func TestLogin_StrangerThroughTheTunnelLocksOnlyHimself(t *testing.T) {
|
||||||
|
withTraefikAt(t, traefikAddr)
|
||||||
|
s := rateLimitTestServer(t)
|
||||||
|
stranger, household := "198.51.100.66", "203.0.113.10"
|
||||||
|
var last string
|
||||||
|
for i := 1; i <= 7; i++ {
|
||||||
|
last = tunnelLogin(s, stranger, fmt.Sprintf("10.0.0.%d", i), "wrong").Body.String()
|
||||||
|
}
|
||||||
|
if !strings.Contains(last, "Túl sok hibás próbálkozás") {
|
||||||
|
t.Fatalf("the stranger rotating a forged leftmost address must be locked after 5 tries; got: %s", ex(last))
|
||||||
|
}
|
||||||
|
w := tunnelLogin(s, household, "", "correct-pass")
|
||||||
|
if w.Code != http.StatusFound || !strings.Contains(w.Header().Get("Set-Cookie"), sessionCookieName+"=") {
|
||||||
|
t.Fatalf("the household must sign in at once from its own address; got %d %s", w.Code, ex(w.Body.String()))
|
||||||
|
}
|
||||||
|
if s.loginAttempts[stranger] == nil || s.loginAttempts[stranger].count != loginMaxAttempts {
|
||||||
|
t.Fatalf("the stranger's own counter must hold the tries; got %+v", s.loginAttempts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The dashboard login's three messages are keys (informal voice, v0.286.0) and follow the reader's language — the
|
||||||
|
// sign-in page is met with no session, so the language cookie decides. Asserted both ways: the English page carries the
|
||||||
|
// English and NOT the Hungarian.
|
||||||
|
func TestLoginMessagesFollowTheReader(t *testing.T) {
|
||||||
|
withTraefikAt(t, traefikAddr)
|
||||||
|
s := rateLimitTestServer(t)
|
||||||
|
post := func(lang, visitor, password string) string {
|
||||||
|
form := url.Values{"password": {password}}
|
||||||
|
r := httptest.NewRequest(http.MethodPost, "/login", strings.NewReader(form.Encode()))
|
||||||
|
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
r.RemoteAddr = "192.168.0." + visitor + ":4000"
|
||||||
|
r.AddCookie(&http.Cookie{Name: langCookieName, Value: lang})
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
s.handleLogin(w, r)
|
||||||
|
return w.Body.String()
|
||||||
|
}
|
||||||
|
type msg struct{ en, hu string }
|
||||||
|
wrong := msg{"Wrong password.", "Hibás jelszó."}
|
||||||
|
empty := msg{"Enter your password.", "Add meg a jelszavad."}
|
||||||
|
locked := msg{"Too many wrong tries from this address. Try again in a minute.", "Túl sok hibás próbálkozás erről a címről. Próbáld újra egy perc múlva."}
|
||||||
|
for _, c := range []struct {
|
||||||
|
lang, visitor string
|
||||||
|
m msg
|
||||||
|
tries int
|
||||||
|
pw string
|
||||||
|
}{{"en", "11", wrong, 1, "x"}, {"hu", "12", wrong, 1, "x"}, {"en", "13", empty, 1, ""}, {"hu", "14", empty, 1, ""},
|
||||||
|
{"en", "15", locked, 6, "x"}, {"hu", "16", locked, 6, "x"}} {
|
||||||
|
var out string
|
||||||
|
for i := 0; i < c.tries; i++ {
|
||||||
|
out = post(c.lang, c.visitor, c.pw)
|
||||||
|
}
|
||||||
|
want, not := c.m.hu, c.m.en
|
||||||
|
if c.lang == "en" {
|
||||||
|
want, not = c.m.en, c.m.hu
|
||||||
|
}
|
||||||
|
if !strings.Contains(out, want) || strings.Contains(out, not) {
|
||||||
|
t.Errorf("%s: want %q and not %q", c.lang, want, not)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -184,7 +184,7 @@ func (s *Server) escrowStartAPIHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
escrowJSON(w, http.StatusForbidden, nil, "A vezérlőpult jelszava nincs beállítva — előbb állítson be jelszót.")
|
escrowJSON(w, http.StatusForbidden, nil, "A vezérlőpult jelszava nincs beállítva — előbb állítson be jelszót.")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
ip := clientIP(r)
|
ip := rateKey(r)
|
||||||
if s.escrowRateLimited(ip) {
|
if s.escrowRateLimited(ip) {
|
||||||
s.logger.Printf("[WARN] [web] escrow start rate limited for %s", ip)
|
s.logger.Printf("[WARN] [web] escrow start rate limited for %s", ip)
|
||||||
escrowJSON(w, http.StatusTooManyRequests, nil, "Túl sok sikertelen próbálkozás, próbálja újra 1 perc múlva.")
|
escrowJSON(w, http.StatusTooManyRequests, nil, "Túl sok sikertelen próbálkozás, próbálja újra 1 perc múlva.")
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ func TestLoginRateLimit_DirectDistinctPorts_Limited(t *testing.T) {
|
|||||||
t.Fatalf("attempt %d expected Hibás jelszó, got: %s", i, ex(last))
|
t.Fatalf("attempt %d expected Hibás jelszó, got: %s", i, ex(last))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !strings.Contains(last, "Túl sok sikertelen") {
|
if !strings.Contains(last, "Túl sok hibás próbálkozás") {
|
||||||
t.Fatalf("attempt 6 (distinct ports, no XFF) MUST be rate-limited; got: %s", ex(last))
|
t.Fatalf("attempt 6 (distinct ports, no XFF) MUST be rate-limited; got: %s", ex(last))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -72,25 +72,22 @@ func TestLoginRateLimit_StableXFF_Limited(t *testing.T) {
|
|||||||
for i := 1; i <= 6; i++ {
|
for i := 1; i <= 6; i++ {
|
||||||
last = doLogin(s, fmt.Sprintf("10.9.9.9:%d", 5000+i), "203.0.113.9", "wrong").Body.String()
|
last = doLogin(s, fmt.Sprintf("10.9.9.9:%d", 5000+i), "203.0.113.9", "wrong").Body.String()
|
||||||
}
|
}
|
||||||
if !strings.Contains(last, "Túl sok sikertelen") {
|
if !strings.Contains(last, "Túl sok hibás próbálkozás") {
|
||||||
t.Fatalf("attempt 6 with a stable XFF MUST be rate-limited; got: %s", ex(last))
|
t.Fatalf("attempt 6 with a stable XFF MUST be rate-limited; got: %s", ex(last))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Scenario C (documented accepted limitation): rotating the X-Forwarded-For first hop evades the
|
// Scenario C (R-753, reversed on purpose): a peer that is NOT traefik wrote its X-Forwarded-For itself, so rotating it
|
||||||
// per-IP counter. This is NOT what the fix targets (XFF is attacker-controlled on a direct path);
|
// no longer evades the counter — the key is the TCP peer. (Before v0.286.0 this test pinned the evasion as "a future
|
||||||
// the test pins the known behavior so a future XFF-trust change is a conscious decision.
|
// XFF-trust change is a conscious decision"; this is that decision, `09` §3 decision 63.)
|
||||||
func TestLoginRateLimit_RotatingXFF_NotLimited(t *testing.T) {
|
func TestLoginRateLimit_RotatingXFF_Limited(t *testing.T) {
|
||||||
s := rateLimitTestServer(t)
|
s := rateLimitTestServer(t)
|
||||||
var last string
|
var last string
|
||||||
for i := 1; i <= 6; i++ {
|
for i := 1; i <= 6; i++ {
|
||||||
last = doLogin(s, "10.9.9.9:5000", fmt.Sprintf("203.0.113.%d", i), "wrong").Body.String()
|
last = doLogin(s, "10.9.9.9:5000", fmt.Sprintf("203.0.113.%d", i), "wrong").Body.String()
|
||||||
}
|
}
|
||||||
if strings.Contains(last, "Túl sok sikertelen") {
|
if !strings.Contains(last, "Túl sok hibás próbálkozás") {
|
||||||
t.Fatalf("rotating XFF is a known evasion (out of scope) — expected NOT limited")
|
t.Fatalf("a rotating X-Forwarded-For from a direct peer must NOT evade the counter; got: %s", ex(last))
|
||||||
}
|
|
||||||
if !strings.Contains(last, "Hibás jelszó") {
|
|
||||||
t.Fatalf("expected Hibás jelszó on rotating XFF; got: %s", ex(last))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -129,14 +126,15 @@ func TestLoginRateLimit_SuccessClearsCounter(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// clientIP unit: port stripped; XFF first-hop wins; no-port and IPv6 handled.
|
// clientIP unit (direct peers — the forwarded paths are TestClientIP_Paths in clientaddr_test.go): port stripped; a
|
||||||
|
// direct peer's own X-Forwarded-For is never believed (R-753).
|
||||||
func TestClientIP_StripsPort(t *testing.T) {
|
func TestClientIP_StripsPort(t *testing.T) {
|
||||||
cases := []struct{ remote, xff, want string }{
|
cases := []struct{ remote, xff, want string }{
|
||||||
{"127.0.0.1:5001", "", "127.0.0.1"},
|
{"127.0.0.1:5001", "", "127.0.0.1"},
|
||||||
{"127.0.0.1:5002", "203.0.113.9", "203.0.113.9"},
|
{"127.0.0.1:5002", "203.0.113.9", "127.0.0.1"},
|
||||||
{"[::1]:443", "", "::1"},
|
{"[::1]:443", "", "::1"},
|
||||||
{"192.168.0.5", "", "192.168.0.5"}, // no port → raw
|
{"192.168.0.5", "", "192.168.0.5"}, // no port → raw
|
||||||
{"10.0.0.1:80", "198.51.100.7, 203.0.113.9", "198.51.100.7"},
|
{"10.0.0.1:80", "198.51.100.7, 203.0.113.9", "10.0.0.1"},
|
||||||
}
|
}
|
||||||
for _, c := range cases {
|
for _, c := range cases {
|
||||||
r := httptest.NewRequest(http.MethodGet, "/", nil)
|
r := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||||
|
|||||||
@@ -220,7 +220,7 @@ func (s *Server) ServeGateAuth(w http.ResponseWriter, r *http.Request) {
|
|||||||
if u, err := url.Parse(uri); err == nil && u.Path == gateCallbackURI {
|
if u, err := url.Parse(uri); err == nil && u.Path == gateCallbackURI {
|
||||||
rd, err := s.takeGateToken(u.Query().Get("t"), host)
|
rd, err := s.takeGateToken(u.Query().Get("t"), host)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.logger.Printf("[WARN] [web] setup gate %s: a sign-in token was refused (%v)", app, err)
|
s.logger.Printf("[WARN] [web] setup gate %s: a sign-in token was refused (%v) — visitor %s", app, err, clientIP(r))
|
||||||
gateRefuse(w, http.StatusForbidden)
|
gateRefuse(w, http.StatusForbidden)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -229,7 +229,7 @@ func (s *Server) ServeGateAuth(w http.ResponseWriter, r *http.Request) {
|
|||||||
Name: gateCookieName, Value: exp + "." + s.gateMAC("cookie", host, exp), Path: "/",
|
Name: gateCookieName, Value: exp + "." + s.gateMAC("cookie", host, exp), Path: "/",
|
||||||
MaxAge: int(gateCookieLife.Seconds()), HttpOnly: true, Secure: true, SameSite: http.SameSiteLaxMode,
|
MaxAge: int(gateCookieLife.Seconds()), HttpOnly: true, Secure: true, SameSite: http.SameSiteLaxMode,
|
||||||
})
|
})
|
||||||
s.logger.Printf("[INFO] [web] setup gate %s: the household passed (a dashboard session vouched for this browser)", app)
|
s.logger.Printf("[INFO] [web] setup gate %s: the household passed (a dashboard session vouched for this browser) — visitor %s", app, clientIP(r))
|
||||||
w.Header().Set("Cache-Control", "no-store")
|
w.Header().Set("Cache-Control", "no-store")
|
||||||
http.Redirect(w, r, rd, http.StatusFound)
|
http.Redirect(w, r, rd, http.StatusFound)
|
||||||
return
|
return
|
||||||
@@ -245,7 +245,7 @@ func (s *Server) ServeGateAuth(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if s.isDebug() {
|
if s.isDebug() {
|
||||||
s.logger.Printf("[DEBUG] [web] setup gate %s: %s %s without a pass — 401", app, method, uri)
|
s.logger.Printf("[DEBUG] [web] setup gate %s: %s %s without a pass — 401 (visitor %s)", app, method, uri, clientIP(r))
|
||||||
}
|
}
|
||||||
gateRefuse(w, http.StatusUnauthorized)
|
gateRefuse(w, http.StatusUnauthorized)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -113,7 +113,7 @@ func (s *Server) shareGuestPasswordHandler(w http.ResponseWriter, r *http.Reques
|
|||||||
s.renderSharePasswordPage(w, r, "Érvénytelen űrlap — töltse újra az oldalt.")
|
s.renderSharePasswordPage(w, r, "Érvénytelen űrlap — töltse újra az oldalt.")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
ip := clientIP(r)
|
ip := rateKey(r)
|
||||||
if s.shareRateLimited(ip) {
|
if s.shareRateLimited(ip) {
|
||||||
s.logger.Printf("[WARN] [web] share password rate limited for %s", ip)
|
s.logger.Printf("[WARN] [web] share password rate limited for %s", ip)
|
||||||
s.renderSharePasswordPage(w, r, "Túl sok sikertelen próbálkozás, próbálja újra 1 perc múlva")
|
s.renderSharePasswordPage(w, r, "Túl sok sikertelen próbálkozás, próbálja újra 1 perc múlva")
|
||||||
|
|||||||
+1
-1
@@ -19,7 +19,7 @@
|
|||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="password">Jelszó</label>
|
<label for="password">Jelszó</label>
|
||||||
<input type="password" id="password" name="password" required autofocus
|
<input type="password" id="password" name="password" required autofocus
|
||||||
placeholder="Adja meg a jelszavát" class="form-control">
|
placeholder="Add meg a jelszavad" class="form-control">
|
||||||
</div>
|
</div>
|
||||||
<button type="submit" class="btn btn-primary btn-full">Bejelentkezés</button>
|
<button type="submit" class="btn btn-primary btn-full">Bejelentkezés</button>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -129,7 +129,10 @@
|
|||||||
"err.backup.unit_versions_mixed": "BORN AS A KEY, v0.275.0 (R-696, `07` §6.6 which version a restore brings back) -- a NEW sentence, never a Go literal. Pinned by internal/backup/a_version_travel_test.go / internal/web/a_version_travel_test.go.",
|
"err.backup.unit_versions_mixed": "BORN AS A KEY, v0.275.0 (R-696, `07` §6.6 which version a restore brings back) -- a NEW sentence, never a Go literal. Pinned by internal/backup/a_version_travel_test.go / internal/web/a_version_travel_test.go.",
|
||||||
"err.backup.unit_version_mismatch": "BORN AS A KEY, v0.275.0 (R-696, `07` §6.6 which version a restore brings back) -- a NEW sentence, never a Go literal. Pinned by internal/backup/a_version_travel_test.go / internal/web/a_version_travel_test.go.",
|
"err.backup.unit_version_mismatch": "BORN AS A KEY, v0.275.0 (R-696, `07` §6.6 which version a restore brings back) -- a NEW sentence, never a Go literal. Pinned by internal/backup/a_version_travel_test.go / internal/web/a_version_travel_test.go.",
|
||||||
"deploy.login_from_backup": "BORN AS A KEY, v0.275.0 (R-694) -- a NEW sentence, never a Go literal. Pinned by internal/web/r694_restored_login_test.go.",
|
"deploy.login_from_backup": "BORN AS A KEY, v0.275.0 (R-694) -- a NEW sentence, never a Go literal. Pinned by internal/web/r694_restored_login_test.go.",
|
||||||
"flash.offbox.enabled_all": "v0.283.0 decision 50: born as a key (the one-press off-site offer)"
|
"flash.offbox.enabled_all": "v0.283.0 decision 50: born as a key (the one-press off-site offer)",
|
||||||
|
"login.msg.empty_password": "R-753 (v0.286.0) -- the dashboard login's messages moved to the informal voice on purpose (brief 2026-10-01: formal „Kérjük adja meg …\" → informal), so byte parity with the base literal cannot hold. Pinned in both languages by TestLoginMessagesFollowTheReader.",
|
||||||
|
"login.msg.rate_limited": "R-753 (v0.286.0) -- the dashboard login's messages moved to the informal voice on purpose (brief 2026-10-01: formal „Kérjük adja meg …\" → informal), so byte parity with the base literal cannot hold. Pinned in both languages by TestLoginMessagesFollowTheReader.",
|
||||||
|
"login.msg.wrong_password": "R-753 (v0.286.0) -- the dashboard login's messages moved to the informal voice on purpose (brief 2026-10-01: formal „Kérjük adja meg …\" → informal), so byte parity with the base literal cannot hold. Pinned in both languages by TestLoginMessagesFollowTheReader."
|
||||||
},
|
},
|
||||||
"flash.share.already_on": "A megosztás már be van kapcsolva.",
|
"flash.share.already_on": "A megosztás már be van kapcsolva.",
|
||||||
"flash.share.enable_failed": "A megosztás bekapcsolása nem sikerült.",
|
"flash.share.enable_failed": "A megosztás bekapcsolása nem sikerült.",
|
||||||
|
|||||||
Reference in New Issue
Block a user