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:
@@ -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;
|
||||
@@ -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}} }
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 />
|
||||
|
||||
Reference in New Issue
Block a user