feat(domains): 301-Weiterleitung Domain→Domain (redirect_to) — v1.2.108

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 <url> 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) <noreply@anthropic.com>
This commit is contained in:
Debian
2026-06-15 19:23:37 +02:00
parent f3c76f6d18
commit 79cd68e460
10 changed files with 101 additions and 10 deletions

View File

@@ -1 +1 @@
1.2.107
1.2.108

View File

@@ -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;

View File

@@ -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}} }

View File

@@ -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 <url>`-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
}

View File

@@ -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

View File

@@ -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"`
}

View File

@@ -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
}

View File

@@ -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",

View File

@@ -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",

View File

@@ -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() {
]} />
</Form.Item>
<Form.Item label={t('domains.redirectTo')} name="redirect_to" extra={t('domains.redirectToHint')}>
<Input placeholder="https://ziel-domain.de" allowClear />
</Form.Item>
<Form.Item label={t('domains.maintenance')} name="maintenance_mode" valuePropName="checked"
extra={t('domains.maintenanceHint')}>
<Switch />