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}}
|
# www-Redirect: {{$d.RedirectFromHost}} → {{$d.Name}}
|
||||||
http-request redirect prefix https://{{$d.Name}} code 301 if { hdr(host) -i {{$d.RedirectFromHost}} }
|
http-request redirect prefix https://{{$d.Name}} code 301 if { hdr(host) -i {{$d.RedirectFromHost}} }
|
||||||
{{- end}}
|
{{- 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}}
|
{{- if $d.MaintenanceMode}}
|
||||||
# Wartungs-Modus für {{$d.Name}} — alle Requests werden mit 503 beantwortet.
|
# 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}} }
|
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)
|
// to-www → Name="www.example.com" → "example.com" (strip www.-Prefix)
|
||||||
RedirectFromHost string
|
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
|
// ResponseHeaders: Custom-Headers die HAProxy auf jede Response für
|
||||||
// diese Domain setzt. Werte sind bereits HAProxy-safe escaped
|
// diese Domain setzt. Werte sind bereits HAProxy-safe escaped
|
||||||
// (Quotes → ', Newlines → Space).
|
// (Quotes → ', Newlines → Space).
|
||||||
@@ -313,6 +318,7 @@ func (g *Generator) loadView(ctx context.Context) (*View, error) {
|
|||||||
HSTSHeader: buildHSTSHeader(d),
|
HSTSHeader: buildHSTSHeader(d),
|
||||||
MaintMessage: buildMaintMessage(d),
|
MaintMessage: buildMaintMessage(d),
|
||||||
RedirectFromHost: buildRedirectFromHost(d),
|
RedirectFromHost: buildRedirectFromHost(d),
|
||||||
|
RedirectTo: buildRedirectTo(d),
|
||||||
ResponseHeaders: headersByDomain[d.ID],
|
ResponseHeaders: headersByDomain[d.ID],
|
||||||
}
|
}
|
||||||
if d.MaxBodyKB > 0 {
|
if d.MaxBodyKB > 0 {
|
||||||
@@ -429,3 +435,23 @@ func buildRedirectFromHost(d models.Domain) string {
|
|||||||
return ""
|
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) {
|
func TestBuildHSTSHeader(t *testing.T) {
|
||||||
cases := []struct {
|
cases := []struct {
|
||||||
name string
|
name string
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ type Domain struct {
|
|||||||
MaxBodyKB int `gorm:"column:max_body_kb" json:"max_body_kb"`
|
MaxBodyKB int `gorm:"column:max_body_kb" json:"max_body_kb"`
|
||||||
DisableH3 bool `gorm:"column:disable_h3" json:"disable_h3"`
|
DisableH3 bool `gorm:"column:disable_h3" json:"disable_h3"`
|
||||||
Notes *string `gorm:"column:notes" json:"notes,omitempty"`
|
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"`
|
CreatedAt time.Time `gorm:"column:created_at" json:"created_at"`
|
||||||
UpdatedAt time.Time `gorm:"column:updated_at" json:"updated_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,
|
hsts_enabled, hsts_max_age, hsts_subdomains, hsts_preload,
|
||||||
maintenance_mode, maintenance_message, www_redirect,
|
maintenance_mode, maintenance_message, www_redirect,
|
||||||
rate_limit_rps, max_body_kb, disable_h3,
|
rate_limit_rps, max_body_kb, disable_h3,
|
||||||
notes, created_at, updated_at
|
notes, redirect_to, created_at, updated_at
|
||||||
FROM domains
|
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,
|
INSERT INTO domains (name, active, primary_backend_id, http_to_https,
|
||||||
hsts_enabled, hsts_max_age, hsts_subdomains, hsts_preload,
|
hsts_enabled, hsts_max_age, hsts_subdomains, hsts_preload,
|
||||||
maintenance_mode, maintenance_message, www_redirect,
|
maintenance_mode, maintenance_message, www_redirect,
|
||||||
rate_limit_rps, max_body_kb, disable_h3, notes)
|
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)
|
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,
|
RETURNING id, name, active, primary_backend_id, http_to_https,
|
||||||
hsts_enabled, hsts_max_age, hsts_subdomains, hsts_preload,
|
hsts_enabled, hsts_max_age, hsts_subdomains, hsts_preload,
|
||||||
maintenance_mode, maintenance_message, www_redirect,
|
maintenance_mode, maintenance_message, www_redirect,
|
||||||
rate_limit_rps, max_body_kb, disable_h3,
|
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.Name, d.Active, d.PrimaryBackendID, d.HTTPToHTTPS,
|
||||||
d.HSTSEnabled, d.HSTSMaxAge, d.HSTSSubdomains, d.HSTSPreload,
|
d.HSTSEnabled, d.HSTSMaxAge, d.HSTSSubdomains, d.HSTSPreload,
|
||||||
d.MaintenanceMode, d.MaintenanceMessage, d.WWWRedirect,
|
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)
|
return scanDomain(row)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -100,17 +100,18 @@ UPDATE domains SET
|
|||||||
max_body_kb = $13,
|
max_body_kb = $13,
|
||||||
disable_h3 = $14,
|
disable_h3 = $14,
|
||||||
notes = $15,
|
notes = $15,
|
||||||
|
redirect_to = $16,
|
||||||
updated_at = NOW()
|
updated_at = NOW()
|
||||||
WHERE id = $16
|
WHERE id = $17
|
||||||
RETURNING id, name, active, primary_backend_id, http_to_https,
|
RETURNING id, name, active, primary_backend_id, http_to_https,
|
||||||
hsts_enabled, hsts_max_age, hsts_subdomains, hsts_preload,
|
hsts_enabled, hsts_max_age, hsts_subdomains, hsts_preload,
|
||||||
maintenance_mode, maintenance_message, www_redirect,
|
maintenance_mode, maintenance_message, www_redirect,
|
||||||
rate_limit_rps, max_body_kb, disable_h3,
|
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.Name, d.Active, d.PrimaryBackendID, d.HTTPToHTTPS,
|
||||||
d.HSTSEnabled, d.HSTSMaxAge, d.HSTSSubdomains, d.HSTSPreload,
|
d.HSTSEnabled, d.HSTSMaxAge, d.HSTSSubdomains, d.HSTSPreload,
|
||||||
d.MaintenanceMode, d.MaintenanceMessage, d.WWWRedirect,
|
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)
|
out, err := scanDomain(row)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, pgx.ErrNoRows) {
|
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.HSTSEnabled, &d.HSTSMaxAge, &d.HSTSSubdomains, &d.HSTSPreload,
|
||||||
&d.MaintenanceMode, &d.MaintenanceMessage, &d.WWWRedirect,
|
&d.MaintenanceMode, &d.MaintenanceMessage, &d.WWWRedirect,
|
||||||
&d.RateLimitRPS, &d.MaxBodyKB, &d.DisableH3,
|
&d.RateLimitRPS, &d.MaxBodyKB, &d.DisableH3,
|
||||||
&d.Notes, &d.CreatedAt, &d.UpdatedAt,
|
&d.Notes, &d.RedirectTo, &d.CreatedAt, &d.UpdatedAt,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -533,6 +533,8 @@
|
|||||||
"wwwRedirectNone": "Kein Redirect",
|
"wwwRedirectNone": "Kein Redirect",
|
||||||
"wwwRedirectToNaked": "→ naked (ohne www)",
|
"wwwRedirectToNaked": "→ naked (ohne www)",
|
||||||
"wwwRedirectToWWW": "→ 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)",
|
"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.",
|
"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",
|
"maxBody": "Max. Request-Body",
|
||||||
|
|||||||
@@ -533,6 +533,8 @@
|
|||||||
"wwwRedirectNone": "No redirect",
|
"wwwRedirectNone": "No redirect",
|
||||||
"wwwRedirectToNaked": "→ naked (no www)",
|
"wwwRedirectToNaked": "→ naked (no www)",
|
||||||
"wwwRedirectToWWW": "→ 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)",
|
"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.",
|
"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",
|
"maxBody": "Max request body",
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ interface Domain {
|
|||||||
hsts_subdomains: boolean; hsts_preload: boolean
|
hsts_subdomains: boolean; hsts_preload: boolean
|
||||||
maintenance_mode: boolean; maintenance_message?: string | null
|
maintenance_mode: boolean; maintenance_message?: string | null
|
||||||
www_redirect: '' | 'to-naked' | 'to-www'
|
www_redirect: '' | 'to-naked' | 'to-www'
|
||||||
|
redirect_to: string
|
||||||
rate_limit_rps: number; max_body_kb: number
|
rate_limit_rps: number; max_body_kb: number
|
||||||
disable_h3: boolean
|
disable_h3: boolean
|
||||||
notes?: string | null
|
notes?: string | null
|
||||||
@@ -40,6 +41,7 @@ interface DomainFormValues {
|
|||||||
hsts_subdomains: boolean; hsts_preload: boolean
|
hsts_subdomains: boolean; hsts_preload: boolean
|
||||||
maintenance_mode: boolean; maintenance_message?: string
|
maintenance_mode: boolean; maintenance_message?: string
|
||||||
www_redirect: '' | 'to-naked' | 'to-www'
|
www_redirect: '' | 'to-naked' | 'to-www'
|
||||||
|
redirect_to: string
|
||||||
rate_limit_rps: number; max_body_kb: number
|
rate_limit_rps: number; max_body_kb: number
|
||||||
disable_h3: boolean
|
disable_h3: boolean
|
||||||
notes?: string
|
notes?: string
|
||||||
@@ -272,6 +274,7 @@ export default function DomainDetailPage() {
|
|||||||
maintenance_mode: domain.maintenance_mode,
|
maintenance_mode: domain.maintenance_mode,
|
||||||
maintenance_message: domain.maintenance_message ?? '',
|
maintenance_message: domain.maintenance_message ?? '',
|
||||||
www_redirect: domain.www_redirect ?? '',
|
www_redirect: domain.www_redirect ?? '',
|
||||||
|
redirect_to: domain.redirect_to ?? '',
|
||||||
rate_limit_rps: domain.rate_limit_rps ?? 0,
|
rate_limit_rps: domain.rate_limit_rps ?? 0,
|
||||||
max_body_kb: domain.max_body_kb ?? 0,
|
max_body_kb: domain.max_body_kb ?? 0,
|
||||||
disable_h3: domain.disable_h3 ?? false,
|
disable_h3: domain.disable_h3 ?? false,
|
||||||
@@ -333,6 +336,10 @@ export default function DomainDetailPage() {
|
|||||||
]} />
|
]} />
|
||||||
</Form.Item>
|
</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"
|
<Form.Item label={t('domains.maintenance')} name="maintenance_mode" valuePropName="checked"
|
||||||
extra={t('domains.maintenanceHint')}>
|
extra={t('domains.maintenanceHint')}>
|
||||||
<Switch />
|
<Switch />
|
||||||
|
|||||||
Reference in New Issue
Block a user