From 79cd68e4601d4c5deb260f36b2abb43c791b1573 Mon Sep 17 00:00:00 2001 From: Debian Date: Mon, 15 Jun 2026 19:23:37 +0200 Subject: [PATCH] =?UTF-8?q?feat(domains):=20301-Weiterleitung=20Domain?= =?UTF-8?q?=E2=86=92Domain=20(redirect=5Fto)=20=E2=80=94=20v1.2.108?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Neue Domain kann per 301 auf eine andere Domain/URL umgeleitet werden, statt auf ein Backend zu routen (Use-Case: kvs.netcell-it.de → https://zkm.netcell-it.de, inkl. HTTPS). Variante (a): immer auf Ziel-Root (`redirect location`), pfad- unabhängig. - Migration 0043: domains.redirect_to text NOT NULL DEFAULT '' - Model + domains-Service (SELECT/INSERT/UPDATE/scan) - HAProxy-Generator: buildRedirectTo() sanitisiert (nur http(s), kein Whitespace/Quotes → sonst kein Redirect statt kaputter Config); Template emittiert `http-request redirect location code 301 if hdr(host)`. Terminiert vor use_backend → Redirect-Domain routet auf kein Backend. http→https läuft über den vorhandenen :80-Redirect (zwei Hops, inkl. TLS). - UI: Feld „Weiterleitung (301) nach" im Domain-Formular (de/en) - Tests: Render-Zeile + buildRedirectTo-Sanitisierung Hinweis: Die Redirect-Domain braucht weiterhin ein eigenes TLS-Zert (ACME). Co-Authored-By: Claude Opus 4.8 (1M context) --- VERSION | 2 +- .../migrations/0043_domains_redirect_to.sql | 8 ++++ internal/haproxy/haproxy.cfg.tpl | 5 +++ internal/haproxy/haproxy.go | 26 +++++++++++++ internal/haproxy/haproxy_test.go | 39 +++++++++++++++++++ internal/models/domain.go | 1 + internal/services/domains/domains.go | 19 ++++----- management-ui/src/i18n/locales/de/common.json | 2 + management-ui/src/i18n/locales/en/common.json | 2 + management-ui/src/pages/Domains/Detail.tsx | 7 ++++ 10 files changed, 101 insertions(+), 10 deletions(-) create mode 100644 internal/database/migrations/0043_domains_redirect_to.sql diff --git a/VERSION b/VERSION index 349813b..34b910f 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.2.107 \ No newline at end of file +1.2.108 \ No newline at end of file diff --git a/internal/database/migrations/0043_domains_redirect_to.sql b/internal/database/migrations/0043_domains_redirect_to.sql new file mode 100644 index 0000000..7ce421a --- /dev/null +++ b/internal/database/migrations/0043_domains_redirect_to.sql @@ -0,0 +1,8 @@ +-- +goose Up +-- redirect_to: wenn gesetzt, liefert HAProxy für diese Domain einen 301 auf +-- die angegebene Ziel-URL (Domain-zu-Domain-Weiterleitung) statt sie auf ein +-- Backend zu routen. Leerstring = keine Weiterleitung (Normalbetrieb). +ALTER TABLE domains ADD COLUMN IF NOT EXISTS redirect_to text NOT NULL DEFAULT ''; + +-- +goose Down +ALTER TABLE domains DROP COLUMN IF EXISTS redirect_to; diff --git a/internal/haproxy/haproxy.cfg.tpl b/internal/haproxy/haproxy.cfg.tpl index b68ab76..a114ed1 100644 --- a/internal/haproxy/haproxy.cfg.tpl +++ b/internal/haproxy/haproxy.cfg.tpl @@ -113,6 +113,11 @@ frontend public_https # www-Redirect: {{$d.RedirectFromHost}} → {{$d.Name}} http-request redirect prefix https://{{$d.Name}} code 301 if { hdr(host) -i {{$d.RedirectFromHost}} } {{- end}} + {{- if $d.RedirectTo}} + # Domain-Redirect (301): {{$d.Name}} → {{$d.RedirectTo}} (immer auf Ziel-Root, + # terminiert vor use_backend → diese Domain routet auf kein Backend). + http-request redirect location {{$d.RedirectTo}} code 301 if { hdr(host) -i {{$d.Name}} } + {{- end}} {{- if $d.MaintenanceMode}} # Wartungs-Modus für {{$d.Name}} — alle Requests werden mit 503 beantwortet. http-request return status 503 content-type "text/plain; charset=utf-8" string "{{$d.MaintMessage}}" if { hdr(host) -i {{$d.Name}} } diff --git a/internal/haproxy/haproxy.go b/internal/haproxy/haproxy.go index b5acba1..dfbb10d 100644 --- a/internal/haproxy/haproxy.go +++ b/internal/haproxy/haproxy.go @@ -212,6 +212,11 @@ type DomainView struct { // to-www → Name="www.example.com" → "example.com" (strip www.-Prefix) RedirectFromHost string + // RedirectTo: HAProxy-safe 301-Ziel-URL für eine Domain→Domain-Weiterleitung + // (z. B. "https://zkm.netcell-it.de"). Leer = kein Redirect. Schattet das + // gleichnamige Feld aus dem eingebetteten models.Domain (sanitisiert). + RedirectTo string + // ResponseHeaders: Custom-Headers die HAProxy auf jede Response für // diese Domain setzt. Werte sind bereits HAProxy-safe escaped // (Quotes → ', Newlines → Space). @@ -313,6 +318,7 @@ func (g *Generator) loadView(ctx context.Context) (*View, error) { HSTSHeader: buildHSTSHeader(d), MaintMessage: buildMaintMessage(d), RedirectFromHost: buildRedirectFromHost(d), + RedirectTo: buildRedirectTo(d), ResponseHeaders: headersByDomain[d.ID], } if d.MaxBodyKB > 0 { @@ -429,3 +435,23 @@ func buildRedirectFromHost(d models.Domain) string { return "" } } + +// buildRedirectTo liefert die 301-Ziel-URL HAProxy-safe, oder "" wenn kein +// Redirect gesetzt ist bzw. die URL ungültig erscheint. Defensiv: nur +// http(s)-URLs ohne Whitespace/Steuerzeichen/Quotes — sonst würde die +// `redirect location `-Zeile die HAProxy-Config sprengen. Im Zweifel +// lieber KEIN Redirect rendern als eine kaputte Config auszuliefern. +func buildRedirectTo(d models.Domain) string { + u := strings.TrimSpace(d.RedirectTo) + if u == "" { + return "" + } + lower := strings.ToLower(u) + if !strings.HasPrefix(lower, "http://") && !strings.HasPrefix(lower, "https://") { + return "" + } + if strings.ContainsAny(u, " \t\r\n\"'`\\{}") { + return "" + } + return u +} diff --git a/internal/haproxy/haproxy_test.go b/internal/haproxy/haproxy_test.go index e6a9847..0d0b294 100644 --- a/internal/haproxy/haproxy_test.go +++ b/internal/haproxy/haproxy_test.go @@ -170,6 +170,45 @@ func TestRender_WWWRedirectToWWW(t *testing.T) { } } +func TestRender_RedirectTo(t *testing.T) { + v := View{ + Domains: []DomainView{ + { + Domain: models.Domain{ + ID: 1, Name: "kvs.netcell-it.de", Active: true, + RedirectTo: "https://zkm.netcell-it.de", + }, + RedirectTo: "https://zkm.netcell-it.de", + }, + }, + } + out := renderView(t, v) + want := `http-request redirect location https://zkm.netcell-it.de code 301 if { hdr(host) -i kvs.netcell-it.de }` + if !strings.Contains(out, want) { + t.Errorf("missing domain→domain 301 redirect line:\n%s", out) + } +} + +func TestBuildRedirectTo(t *testing.T) { + cases := []struct{ in, want string }{ + {"https://zkm.netcell-it.de", "https://zkm.netcell-it.de"}, + {" https://zkm.netcell-it.de ", "https://zkm.netcell-it.de"}, // getrimmt + {"http://x.de", "http://x.de"}, + {"", ""}, + {"zkm.netcell-it.de", ""}, // kein Schema + {"ftp://x.de", ""}, // falsches Schema + {"https://x .de", ""}, // Whitespace → unsafe + {"https://x\"de", ""}, // Quote → unsafe + {"javascript:alert(1)", ""}, // kein http(s) + } + for _, c := range cases { + got := buildRedirectTo(models.Domain{RedirectTo: c.in}) + if got != c.want { + t.Errorf("buildRedirectTo(%q) = %q, want %q", c.in, got, c.want) + } + } +} + func TestBuildHSTSHeader(t *testing.T) { cases := []struct { name string diff --git a/internal/models/domain.go b/internal/models/domain.go index e8572d8..8e175b6 100644 --- a/internal/models/domain.go +++ b/internal/models/domain.go @@ -19,6 +19,7 @@ type Domain struct { MaxBodyKB int `gorm:"column:max_body_kb" json:"max_body_kb"` DisableH3 bool `gorm:"column:disable_h3" json:"disable_h3"` Notes *string `gorm:"column:notes" json:"notes,omitempty"` + RedirectTo string `gorm:"column:redirect_to" json:"redirect_to"` // ""=aus; sonst 301-Ziel-URL (Domain→Domain) CreatedAt time.Time `gorm:"column:created_at" json:"created_at"` UpdatedAt time.Time `gorm:"column:updated_at" json:"updated_at"` } diff --git a/internal/services/domains/domains.go b/internal/services/domains/domains.go index 6a149bf..758ab98 100644 --- a/internal/services/domains/domains.go +++ b/internal/services/domains/domains.go @@ -24,7 +24,7 @@ SELECT id, name, active, primary_backend_id, http_to_https, hsts_enabled, hsts_max_age, hsts_subdomains, hsts_preload, maintenance_mode, maintenance_message, www_redirect, rate_limit_rps, max_body_kb, disable_h3, - notes, created_at, updated_at + notes, redirect_to, created_at, updated_at FROM domains ` @@ -65,17 +65,17 @@ func (r *Repo) Create(ctx context.Context, d models.Domain) (*models.Domain, err INSERT INTO domains (name, active, primary_backend_id, http_to_https, hsts_enabled, hsts_max_age, hsts_subdomains, hsts_preload, maintenance_mode, maintenance_message, www_redirect, - rate_limit_rps, max_body_kb, disable_h3, notes) -VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15) + rate_limit_rps, max_body_kb, disable_h3, notes, redirect_to) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16) RETURNING id, name, active, primary_backend_id, http_to_https, hsts_enabled, hsts_max_age, hsts_subdomains, hsts_preload, maintenance_mode, maintenance_message, www_redirect, rate_limit_rps, max_body_kb, disable_h3, - notes, created_at, updated_at`, + notes, redirect_to, created_at, updated_at`, d.Name, d.Active, d.PrimaryBackendID, d.HTTPToHTTPS, d.HSTSEnabled, d.HSTSMaxAge, d.HSTSSubdomains, d.HSTSPreload, d.MaintenanceMode, d.MaintenanceMessage, d.WWWRedirect, - d.RateLimitRPS, d.MaxBodyKB, d.DisableH3, d.Notes) + d.RateLimitRPS, d.MaxBodyKB, d.DisableH3, d.Notes, d.RedirectTo) return scanDomain(row) } @@ -100,17 +100,18 @@ UPDATE domains SET max_body_kb = $13, disable_h3 = $14, notes = $15, + redirect_to = $16, updated_at = NOW() -WHERE id = $16 +WHERE id = $17 RETURNING id, name, active, primary_backend_id, http_to_https, hsts_enabled, hsts_max_age, hsts_subdomains, hsts_preload, maintenance_mode, maintenance_message, www_redirect, rate_limit_rps, max_body_kb, disable_h3, - notes, created_at, updated_at`, + notes, redirect_to, created_at, updated_at`, d.Name, d.Active, d.PrimaryBackendID, d.HTTPToHTTPS, d.HSTSEnabled, d.HSTSMaxAge, d.HSTSSubdomains, d.HSTSPreload, d.MaintenanceMode, d.MaintenanceMessage, d.WWWRedirect, - d.RateLimitRPS, d.MaxBodyKB, d.DisableH3, d.Notes, id) + d.RateLimitRPS, d.MaxBodyKB, d.DisableH3, d.Notes, d.RedirectTo, id) out, err := scanDomain(row) if err != nil { if errors.Is(err, pgx.ErrNoRows) { @@ -139,7 +140,7 @@ func scanDomain(row interface{ Scan(...any) error }) (*models.Domain, error) { &d.HSTSEnabled, &d.HSTSMaxAge, &d.HSTSSubdomains, &d.HSTSPreload, &d.MaintenanceMode, &d.MaintenanceMessage, &d.WWWRedirect, &d.RateLimitRPS, &d.MaxBodyKB, &d.DisableH3, - &d.Notes, &d.CreatedAt, &d.UpdatedAt, + &d.Notes, &d.RedirectTo, &d.CreatedAt, &d.UpdatedAt, ); err != nil { return nil, err } diff --git a/management-ui/src/i18n/locales/de/common.json b/management-ui/src/i18n/locales/de/common.json index 66437de..528588d 100644 --- a/management-ui/src/i18n/locales/de/common.json +++ b/management-ui/src/i18n/locales/de/common.json @@ -533,6 +533,8 @@ "wwwRedirectNone": "Kein Redirect", "wwwRedirectToNaked": "→ naked (ohne www)", "wwwRedirectToWWW": "→ www", + "redirectTo": "Weiterleitung (301) nach", + "redirectToHint": "Leitet ALLE Anfragen dieser Domain per 301 auf die Ziel-URL um (z. B. https://zkm.netcell-it.de) — immer auf die Ziel-Root. Ist es gesetzt, routet die Domain auf kein Backend. Die Domain braucht trotzdem ein eigenes TLS-Zertifikat. Leer = aus.", "rateLimit": "Rate-Limit (pro Client-IP)", "rateLimitHint": "Max. Requests pro Sekunde je Client-IP. HAProxy zählt über ein 10-Sekunden-Fenster pro Stick-Table (max. 100k IPs). 0 = aus.", "maxBody": "Max. Request-Body", diff --git a/management-ui/src/i18n/locales/en/common.json b/management-ui/src/i18n/locales/en/common.json index d5b7376..57fd94b 100644 --- a/management-ui/src/i18n/locales/en/common.json +++ b/management-ui/src/i18n/locales/en/common.json @@ -533,6 +533,8 @@ "wwwRedirectNone": "No redirect", "wwwRedirectToNaked": "→ naked (no www)", "wwwRedirectToWWW": "→ www", + "redirectTo": "Redirect (301) to", + "redirectToHint": "Redirects ALL requests for this domain with a 301 to the target URL (e.g. https://zkm.netcell-it.de) — always to the target root. When set, the domain routes to no backend. The domain still needs its own TLS certificate. Empty = off.", "rateLimit": "Rate limit (per client IP)", "rateLimitHint": "Max requests per second per client IP. HAProxy counts over a 10-second window per stick table (max. 100k IPs). 0 = off.", "maxBody": "Max request body", diff --git a/management-ui/src/pages/Domains/Detail.tsx b/management-ui/src/pages/Domains/Detail.tsx index b351446..32a5ec4 100644 --- a/management-ui/src/pages/Domains/Detail.tsx +++ b/management-ui/src/pages/Domains/Detail.tsx @@ -27,6 +27,7 @@ interface Domain { hsts_subdomains: boolean; hsts_preload: boolean maintenance_mode: boolean; maintenance_message?: string | null www_redirect: '' | 'to-naked' | 'to-www' + redirect_to: string rate_limit_rps: number; max_body_kb: number disable_h3: boolean notes?: string | null @@ -40,6 +41,7 @@ interface DomainFormValues { hsts_subdomains: boolean; hsts_preload: boolean maintenance_mode: boolean; maintenance_message?: string www_redirect: '' | 'to-naked' | 'to-www' + redirect_to: string rate_limit_rps: number; max_body_kb: number disable_h3: boolean notes?: string @@ -272,6 +274,7 @@ export default function DomainDetailPage() { maintenance_mode: domain.maintenance_mode, maintenance_message: domain.maintenance_message ?? '', www_redirect: domain.www_redirect ?? '', + redirect_to: domain.redirect_to ?? '', rate_limit_rps: domain.rate_limit_rps ?? 0, max_body_kb: domain.max_body_kb ?? 0, disable_h3: domain.disable_h3 ?? false, @@ -333,6 +336,10 @@ export default function DomainDetailPage() { ]} /> + + + +