feat: add structured redirects and path prefix rewrite for proxy hosts

Adds two new UI-configurable Caddy patterns that previously required raw JSON:
- Per-path redirect rules (from/to/status) emitted as a subroute handler before
  auth so .well-known paths work without login; supports full URLs, cross-domain
  targets, and wildcard path patterns (e.g. /.well-known/*)
- Path prefix rewrite that prepends a segment to every request before proxying
  (e.g. /recipes → upstream sees /recipes/original/path)

Config is stored in the existing meta JSON column (no schema migration). Includes
integration tests for meta serialization and E2E functional tests against a real
Caddy instance covering relative/absolute destinations, all 3xx status codes, and
various wildcard combinations. Adds traefik/whoami to the test stack to verify
rewritten paths actually reach the upstream.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
fuomag9
2026-03-19 17:53:33 +01:00
parent d86d1400d7
commit 4b5323a7bf
11 changed files with 633 additions and 4 deletions

View File

@@ -14,7 +14,9 @@ import {
type UpstreamDnsResolutionInput,
type GeoBlockMode,
type WafHostConfig,
type MtlsConfig
type MtlsConfig,
type RedirectRule,
type RewriteConfig
} from "@/src/lib/models/proxy-hosts";
import { getCertificate } from "@/src/lib/models/certificates";
import { getCloudflareSettings, type GeoBlockSettings } from "@/src/lib/settings";
@@ -396,6 +398,30 @@ function parseMtlsConfig(formData: FormData): MtlsConfig | null {
return { enabled, ca_certificate_ids: ids };
}
function parseRedirectsConfig(formData: FormData): RedirectRule[] | null {
const raw = formData.get("redirects_json");
if (!raw || typeof raw !== "string") return null;
try {
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed)) return null;
return parsed.filter(
(r) =>
r &&
typeof r.from === "string" &&
typeof r.to === "string" &&
[301, 302, 307, 308].includes(r.status)
) as RedirectRule[];
} catch {
return null;
}
}
function parseRewriteConfig(formData: FormData): RewriteConfig | null {
const prefix = formData.get("rewrite_path_prefix");
if (!prefix || typeof prefix !== "string" || !prefix.trim()) return null;
return { path_prefix: prefix.trim() };
}
function parseUpstreamDnsResolutionConfig(formData: FormData): UpstreamDnsResolutionInput | undefined {
if (!formData.has("upstream_dns_resolution_present")) {
return undefined;
@@ -465,7 +491,9 @@ export async function createProxyHostAction(
upstream_dns_resolution: parseUpstreamDnsResolutionConfig(formData),
...parseGeoBlockConfig(formData),
...parseWafConfig(formData),
mtls: parseMtlsConfig(formData)
mtls: parseMtlsConfig(formData),
redirects: parseRedirectsConfig(formData),
rewrite: parseRewriteConfig(formData),
},
userId
);
@@ -539,7 +567,9 @@ export async function updateProxyHostAction(
upstream_dns_resolution: parseUpstreamDnsResolutionConfig(formData),
...parseGeoBlockConfig(formData),
...parseWafConfig(formData),
mtls: formData.has("mtls_present") ? parseMtlsConfig(formData) : undefined
mtls: formData.has("mtls_present") ? parseMtlsConfig(formData) : undefined,
redirects: formData.has("redirects_json") ? parseRedirectsConfig(formData) : undefined,
rewrite: formData.has("rewrite_path_prefix") ? parseRewriteConfig(formData) : undefined,
},
userId
);

View File

@@ -22,6 +22,8 @@ import { UpstreamInput } from "./UpstreamInput";
import { GeoBlockFields } from "./GeoBlockFields";
import { WafFields } from "./WafFields";
import { MtlsFields } from "./MtlsConfig";
import { RedirectsFields } from "./RedirectsFields";
import { RewriteFields } from "./RewriteFields";
import type { CaCertificate } from "@/src/lib/models/ca-certificates";
export function CreateHostDialog({
@@ -108,6 +110,8 @@ export function CreateHostDialog({
</MenuItem>
))}
</TextField>
<RedirectsFields initialData={initialData?.redirects} />
<RewriteFields initialData={initialData?.rewrite} />
<TextField
name="custom_pre_handlers_json"
label="Custom Pre-Handlers (JSON)"
@@ -212,6 +216,8 @@ export function EditHostDialog({
</MenuItem>
))}
</TextField>
<RedirectsFields initialData={host.redirects} />
<RewriteFields initialData={host.rewrite} />
<TextField
name="custom_pre_handlers_json"
label="Custom Pre-Handlers (JSON)"

View File

@@ -0,0 +1,88 @@
"use client";
import { useState } from "react";
import {
Box, Button, IconButton, MenuItem, Select, Stack,
Table, TableBody, TableCell, TableHead, TableRow, TextField, Typography,
} from "@mui/material";
import DeleteIcon from "@mui/icons-material/Delete";
import AddIcon from "@mui/icons-material/Add";
import type { RedirectRule } from "@/src/lib/models/proxy-hosts";
type Props = { initialData?: RedirectRule[] };
export function RedirectsFields({ initialData = [] }: Props) {
const [rules, setRules] = useState<RedirectRule[]>(initialData);
const addRule = () =>
setRules((r) => [...r, { from: "", to: "", status: 301 }]);
const removeRule = (i: number) =>
setRules((r) => r.filter((_, idx) => idx !== i));
const updateRule = (i: number, key: keyof RedirectRule, value: string | number) =>
setRules((r) => r.map((rule, idx) => (idx === i ? { ...rule, [key]: value } : rule)));
return (
<Box>
<Typography variant="subtitle2" gutterBottom>
Redirects
</Typography>
<input type="hidden" name="redirects_json" value={JSON.stringify(rules)} />
{rules.length > 0 && (
<Table size="small" sx={{ mb: 1 }}>
<TableHead>
<TableRow>
<TableCell>From Path</TableCell>
<TableCell>To URL / Path</TableCell>
<TableCell>Status</TableCell>
<TableCell />
</TableRow>
</TableHead>
<TableBody>
{rules.map((rule, i) => (
<TableRow key={i}>
<TableCell>
<TextField
size="small"
placeholder="/.well-known/carddav"
value={rule.from}
onChange={(e) => updateRule(i, "from", e.target.value)}
fullWidth
/>
</TableCell>
<TableCell>
<TextField
size="small"
placeholder="/remote.php/dav/"
value={rule.to}
onChange={(e) => updateRule(i, "to", e.target.value)}
fullWidth
/>
</TableCell>
<TableCell sx={{ width: 90 }}>
<Select
size="small"
value={rule.status}
onChange={(e) => updateRule(i, "status", Number(e.target.value))}
>
{[301, 302, 307, 308].map((s) => (
<MenuItem key={s} value={s}>{s}</MenuItem>
))}
</Select>
</TableCell>
<TableCell sx={{ width: 40 }}>
<IconButton size="small" onClick={() => removeRule(i)}>
<DeleteIcon fontSize="small" />
</IconButton>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
<Button size="small" startIcon={<AddIcon />} onClick={addRule}>
Add Redirect
</Button>
</Box>
);
}

View File

@@ -0,0 +1,17 @@
import { TextField } from "@mui/material";
import type { RewriteConfig } from "@/src/lib/models/proxy-hosts";
type Props = { initialData?: RewriteConfig | null };
export function RewriteFields({ initialData }: Props) {
return (
<TextField
name="rewrite_path_prefix"
label="Path Prefix Rewrite"
placeholder="/recipes"
defaultValue={initialData?.path_prefix ?? ""}
helperText="Prepend this prefix to every request before proxying (e.g. /recipes → /recipes/original/path)"
fullWidth
/>
);
}

View File

@@ -49,7 +49,7 @@ import {
issuedClientCertificates,
proxyHosts
} from "./db/schema";
import { type GeoBlockMode, type WafHostConfig, type MtlsConfig } from "./models/proxy-hosts";
import { type GeoBlockMode, type WafHostConfig, type MtlsConfig, type RedirectRule, type RewriteConfig } from "./models/proxy-hosts";
import { buildClientAuthentication, groupMtlsDomainsByCaSet } from "./caddy-mtls";
import { buildWafHandler, resolveEffectiveWaf } from "./caddy-waf";
@@ -113,6 +113,8 @@ type ProxyHostMeta = {
geoblock?: GeoBlockSettings;
geoblock_mode?: GeoBlockMode;
waf?: WafHostConfig;
redirects?: RedirectRule[];
rewrite?: RewriteConfig;
};
type ProxyHostAuthentikMeta = {
@@ -700,6 +702,22 @@ async function buildProxyRoutes(
}
}
// Structured redirects — emitted before auth so .well-known paths work without login
if (meta.redirects && meta.redirects.length > 0) {
const redirectRoutes = meta.redirects.map((rule) => ({
match: [{ path: [rule.from] }],
handle: [{
handler: "static_response",
status_code: rule.status,
headers: { Location: [rule.to] },
}],
}));
handlers.push({
handler: "subroute",
routes: redirectRoutes,
});
}
if (row.access_list_id) {
const accounts = accessAccounts.get(row.access_list_id) ?? [];
if (accounts.length > 0) {
@@ -850,6 +868,14 @@ async function buildProxyRoutes(
}
}
// Structured path prefix rewrite
if (meta.rewrite?.path_prefix) {
handlers.push({
handler: "rewrite",
uri: `${meta.rewrite.path_prefix}{http.request.uri}`,
});
}
const customHandlers = parseCustomHandlers(meta.custom_pre_handlers_json);
if (customHandlers.length > 0) {
handlers.push(...customHandlers);

View File

@@ -28,6 +28,16 @@ export type GeoBlockMode = "merge" | "override";
export type WafMode = "merge" | "override";
export type RedirectRule = {
from: string; // path pattern e.g. "/.well-known/carddav"
to: string; // destination e.g. "/remote.php/dav/"
status: 301 | 302 | 307 | 308;
};
export type RewriteConfig = {
path_prefix: string; // e.g. "/recipes"
};
export type WafHostConfig = {
enabled?: boolean;
mode?: 'Off' | 'On';
@@ -217,6 +227,8 @@ type ProxyHostMeta = {
geoblock_mode?: GeoBlockMode;
waf?: WafHostConfig;
mtls?: MtlsConfig;
redirects?: RedirectRule[];
rewrite?: RewriteConfig;
};
export type ProxyHost = {
@@ -245,6 +257,8 @@ export type ProxyHost = {
geoblock_mode: GeoBlockMode;
waf: WafHostConfig | null;
mtls: MtlsConfig | null;
redirects: RedirectRule[];
rewrite: RewriteConfig | null;
};
export type ProxyHostInput = {
@@ -270,6 +284,8 @@ export type ProxyHostInput = {
geoblock_mode?: GeoBlockMode;
waf?: WafHostConfig | null;
mtls?: MtlsConfig | null;
redirects?: RedirectRule[] | null;
rewrite?: RewriteConfig | null;
};
type ProxyHostRow = typeof proxyHosts.$inferSelect;
@@ -551,9 +567,41 @@ function serializeMeta(meta: ProxyHostMeta | null | undefined) {
normalized.mtls = meta.mtls;
}
if (meta.redirects && meta.redirects.length > 0) {
normalized.redirects = meta.redirects;
}
if (meta.rewrite?.path_prefix) {
normalized.rewrite = meta.rewrite;
}
return Object.keys(normalized).length > 0 ? JSON.stringify(normalized) : null;
}
function sanitizeRedirectRules(value: unknown): RedirectRule[] {
if (!Array.isArray(value)) return [];
const valid: RedirectRule[] = [];
for (const item of value) {
if (
item &&
typeof item === "object" &&
typeof item.from === "string" && item.from.trim() &&
typeof item.to === "string" && item.to.trim() &&
[301, 302, 307, 308].includes(item.status)
) {
valid.push({ from: item.from.trim(), to: item.to.trim(), status: item.status });
}
}
return valid;
}
function sanitizeRewriteConfig(value: unknown): RewriteConfig | null {
if (!value || typeof value !== "object") return null;
const v = value as Record<string, unknown>;
const prefix = typeof v.path_prefix === "string" ? v.path_prefix.trim() : null;
if (!prefix) return null;
return { path_prefix: prefix };
}
function parseMeta(value: string | null): ProxyHostMeta {
if (!value) {
return {};
@@ -571,6 +619,8 @@ function parseMeta(value: string | null): ProxyHostMeta {
geoblock_mode: parsed.geoblock_mode,
waf: parsed.waf,
mtls: parsed.mtls,
redirects: sanitizeRedirectRules(parsed.redirects),
rewrite: sanitizeRewriteConfig(parsed.rewrite) ?? undefined,
};
} catch (error) {
console.warn("Failed to parse proxy host meta", error);
@@ -1045,6 +1095,24 @@ function buildMeta(existing: ProxyHostMeta, input: Partial<ProxyHostInput>): str
}
}
if (input.redirects !== undefined) {
const rules = sanitizeRedirectRules(input.redirects ?? []);
if (rules.length > 0) {
next.redirects = rules;
} else {
delete next.redirects;
}
}
if (input.rewrite !== undefined) {
const rw = sanitizeRewriteConfig(input.rewrite);
if (rw) {
next.rewrite = rw;
} else {
delete next.rewrite;
}
}
return serializeMeta(next);
}
@@ -1372,6 +1440,8 @@ function parseProxyHost(row: ProxyHostRow): ProxyHost {
geoblock_mode: meta.geoblock_mode ?? "merge",
waf: meta.waf ?? null,
mtls: meta.mtls ?? null,
redirects: meta.redirects ?? [],
rewrite: meta.rewrite ?? null,
};
}

View File

@@ -24,6 +24,12 @@ services:
command: ["-text=echo-server-2", "-listen=:8080"]
networks:
- caddy-network
# Request-echo server: reflects the full HTTP request (method + path + headers) in the response body.
# Used by path-prefix-rewrite tests to assert that Caddy rewrote the path before forwarding.
whoami-server:
image: traefik/whoami
networks:
- caddy-network
volumes:
caddy-manager-data:
name: caddy-manager-data-test

View File

@@ -0,0 +1,63 @@
/**
* Functional tests: path prefix rewrite.
*
* Creates a proxy host with a path prefix rewrite (/api) pointing at the
* whoami-server, which reflects the full request line in its response body.
* This lets us assert that Caddy rewrote the path before forwarding, e.g.
* a client request for /users arrives at the upstream as /api/users.
*
* Domain: func-rewrite.test
*/
import { test, expect } from '@playwright/test';
import { httpGet, injectFormFields, waitForRoute } from '../../helpers/http';
const DOMAIN = 'func-rewrite.test';
test.describe.serial('Path Prefix Rewrite', () => {
test('setup: create proxy host with path prefix rewrite', async ({ page }) => {
await page.goto('/proxy-hosts');
await page.getByRole('button', { name: /create host/i }).click();
await expect(page.getByRole('dialog')).toBeVisible();
await page.getByLabel('Name').fill('Functional Path Prefix Rewrite Test');
await page.getByLabel(/domains/i).fill(DOMAIN);
// whoami-server listens on port 80 by default
await page.getByPlaceholder('10.0.0.5:8080').first().fill('whoami-server:80');
// Fill in the path prefix rewrite field
await page.getByLabel('Path Prefix Rewrite').fill('/api');
await injectFormFields(page, { ssl_forced_present: 'on' });
await page.getByRole('button', { name: /^create$/i }).click();
await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 15_000 });
await expect(page.getByText('Functional Path Prefix Rewrite Test')).toBeVisible({ timeout: 10_000 });
await waitForRoute(DOMAIN);
});
test('request path is prepended with the prefix before reaching the upstream', async () => {
const res = await httpGet(DOMAIN, '/users');
expect(res.status).toBe(200);
// traefik/whoami echoes the request line, e.g. "GET /api/users HTTP/1.1"
expect(res.body).toContain('/api/users');
});
test('root path is prepended with the prefix', async () => {
const res = await httpGet(DOMAIN, '/');
expect(res.status).toBe(200);
expect(res.body).toContain('/api/');
});
test('nested path is prepended with the prefix', async () => {
const res = await httpGet(DOMAIN, '/items/42/details');
expect(res.status).toBe(200);
expect(res.body).toContain('/api/items/42/details');
});
test('original path without prefix is NOT sent to the upstream', async () => {
const res = await httpGet(DOMAIN, '/users');
expect(res.status).toBe(200);
// The upstream must NOT see the bare /users path — it should see /api/users
expect(res.body).not.toMatch(/^GET \/users /m);
});
});

View File

@@ -0,0 +1,192 @@
/**
* Functional tests: redirect rules with full URLs, cross-domain destinations,
* and wildcard path patterns.
*
* All tests send real HTTP requests to Caddy (port 80) with a custom Host
* header and assert the response from Caddy — no redirect following.
*
* Caddy path matcher wildcard behaviour (used in the "from" field):
* - Exact: "/foo/bar" — only that path
* - Suffix glob: "/foo/bar*" — anything starting with /foo/bar
* - Dir glob: "/foo/*" — anything under /foo/ (requires the slash)
*
* Domain: func-redirects-adv.test
*/
import { test, expect } from '@playwright/test';
import { httpGet, injectFormFields, waitForRoute } from '../../helpers/http';
const DOMAIN = 'func-redirects-adv.test';
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/** Extract the Location header value from a response, normalised to a string. */
function location(res: Awaited<ReturnType<typeof httpGet>>): string {
const h = res.headers['location'];
return Array.isArray(h) ? h[0] : (h ?? '');
}
// ---------------------------------------------------------------------------
// Test suite
// ---------------------------------------------------------------------------
test.describe.serial('Redirect Rules full URLs, cross-domain, wildcards', () => {
test('setup: create proxy host with advanced redirect rules', async ({ page }) => {
await page.goto('/proxy-hosts');
await page.getByRole('button', { name: /create host/i }).click();
await expect(page.getByRole('dialog')).toBeVisible();
await page.getByLabel('Name').fill('Functional Advanced Redirects Test');
await page.getByLabel(/domains/i).fill(DOMAIN);
await page.getByPlaceholder('10.0.0.5:8080').first().fill('echo-server:8080');
await injectFormFields(page, {
ssl_forced_present: 'on',
redirects_json: JSON.stringify([
// ── full absolute URL destinations ──────────────────────────────────
// Exact path → full URL on a completely different host (301)
{ from: '/old-page', to: 'https://new-site.example.com/page', status: 301 },
// Exact path → full URL with path on another domain (308 permanent)
{ from: '/docs', to: 'https://docs.example.com/v2/', status: 308 },
// Exact path → full URL using http:// scheme (302 temporary)
{ from: '/insecure-legacy', to: 'http://legacy.example.com/', status: 302 },
// ── wildcard "from" → relative destination ──────────────────────────
// /.well-known/* → any well-known path redirected to /dav/ (301)
{ from: '/.well-known/*', to: '/dav/', status: 301 },
// /api/v1/* → all v1 endpoints redirected to /api/v2/ (302)
{ from: '/api/v1/*', to: '/api/v2/', status: 302 },
// /legacy* → bare prefix (no slash after) covers /legacy and /legacy/* (307)
{ from: '/legacy*', to: '/current/', status: 307 },
// ── wildcard "from" → full URL destination ──────────────────────────
// /moved/* → absolute URL on another domain (308)
{ from: '/moved/*', to: 'https://archive.example.com/', status: 308 },
]),
});
await page.getByRole('button', { name: /^create$/i }).click();
await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 15_000 });
await expect(page.getByText('Functional Advanced Redirects Test')).toBeVisible({ timeout: 10_000 });
await waitForRoute(DOMAIN);
});
// ── full absolute URL destinations ─────────────────────────────────────────
test('exact path → full URL: status is 301', async () => {
const res = await httpGet(DOMAIN, '/old-page');
expect(res.status).toBe(301);
});
test('exact path → full URL: Location is the full absolute URL', async () => {
const res = await httpGet(DOMAIN, '/old-page');
expect(location(res)).toBe('https://new-site.example.com/page');
});
test('exact path → full URL on another domain: status is 308', async () => {
const res = await httpGet(DOMAIN, '/docs');
expect(res.status).toBe(308);
});
test('exact path → full URL on another domain: Location preserves path', async () => {
const res = await httpGet(DOMAIN, '/docs');
expect(location(res)).toBe('https://docs.example.com/v2/');
});
test('exact path → http:// URL: status is 302', async () => {
const res = await httpGet(DOMAIN, '/insecure-legacy');
expect(res.status).toBe(302);
});
test('exact path → http:// URL: Location uses http scheme', async () => {
const res = await httpGet(DOMAIN, '/insecure-legacy');
expect(location(res)).toMatch(/^http:\/\//);
expect(location(res)).toBe('http://legacy.example.com/');
});
// ── wildcard "from" → relative destination ─────────────────────────────────
test('wildcard /.well-known/*: first subpath redirects with 301', async () => {
const res = await httpGet(DOMAIN, '/.well-known/carddav');
expect(res.status).toBe(301);
expect(location(res)).toBe('/dav/');
});
test('wildcard /.well-known/*: second subpath also redirects with 301', async () => {
const res = await httpGet(DOMAIN, '/.well-known/caldav');
expect(res.status).toBe(301);
expect(location(res)).toBe('/dav/');
});
test('wildcard /.well-known/*: deeply nested subpath redirects with 301', async () => {
const res = await httpGet(DOMAIN, '/.well-known/openid-configuration');
expect(res.status).toBe(301);
expect(location(res)).toBe('/dav/');
});
test('wildcard /api/v1/*: first v1 endpoint redirects with 302', async () => {
const res = await httpGet(DOMAIN, '/api/v1/users');
expect(res.status).toBe(302);
expect(location(res)).toBe('/api/v2/');
});
test('wildcard /api/v1/*: second v1 endpoint also redirects with 302', async () => {
const res = await httpGet(DOMAIN, '/api/v1/orders/42');
expect(res.status).toBe(302);
expect(location(res)).toBe('/api/v2/');
});
test('wildcard /api/v2/* is not matched (different prefix)', async () => {
const res = await httpGet(DOMAIN, '/api/v2/users');
expect(res.status).toBe(200);
expect(res.body).toContain('echo-ok');
});
test('bare-prefix wildcard /legacy*: matches path without trailing segment', async () => {
const res = await httpGet(DOMAIN, '/legacy');
expect(res.status).toBe(307);
expect(location(res)).toBe('/current/');
});
test('bare-prefix wildcard /legacy*: matches path with trailing segment', async () => {
const res = await httpGet(DOMAIN, '/legacy/old-feature');
expect(res.status).toBe(307);
expect(location(res)).toBe('/current/');
});
test('bare-prefix wildcard /legacy*: matches path with suffix (no slash)', async () => {
const res = await httpGet(DOMAIN, '/legacy-stuff');
expect(res.status).toBe(307);
expect(location(res)).toBe('/current/');
});
// ── wildcard "from" → full URL destination ─────────────────────────────────
test('wildcard /moved/* → absolute URL: first subpath redirects with 308', async () => {
const res = await httpGet(DOMAIN, '/moved/post-1');
expect(res.status).toBe(308);
expect(location(res)).toBe('https://archive.example.com/');
});
test('wildcard /moved/* → absolute URL: second subpath also redirects with 308', async () => {
const res = await httpGet(DOMAIN, '/moved/category/post-2');
expect(res.status).toBe(308);
expect(location(res)).toBe('https://archive.example.com/');
});
// ── unmatched paths still reach the upstream ───────────────────────────────
test('path matching no rule is proxied to the upstream', async () => {
const res = await httpGet(DOMAIN, '/some/other/path');
expect(res.status).toBe(200);
expect(res.body).toContain('echo-ok');
});
test('root path matching no rule is proxied to the upstream', async () => {
const res = await httpGet(DOMAIN, '/');
expect(res.status).toBe(200);
expect(res.body).toContain('echo-ok');
});
});

View File

@@ -0,0 +1,77 @@
/**
* Functional tests: per-path redirect rules.
*
* Creates a proxy host with structured redirect rules and verifies that
* Caddy issues the correct redirect responses for matched paths while
* still proxying unmatched paths to the upstream.
*
* The redirects_json hidden field is injected directly (same pattern used
* for other non-labeled form controls like ssl_forced_present) so the test
* doesn't have to click through the MUI Select for each status code.
*
* Domain: func-redirects.test
*/
import { test, expect } from '@playwright/test';
import { httpGet, injectFormFields, waitForRoute } from '../../helpers/http';
const DOMAIN = 'func-redirects.test';
test.describe.serial('Per-path Redirect Rules', () => {
test('setup: create proxy host with redirect rules', async ({ page }) => {
await page.goto('/proxy-hosts');
await page.getByRole('button', { name: /create host/i }).click();
await expect(page.getByRole('dialog')).toBeVisible();
await page.getByLabel('Name').fill('Functional Redirects Test');
await page.getByLabel(/domains/i).fill(DOMAIN);
await page.getByPlaceholder('10.0.0.5:8080').first().fill('echo-server:8080');
// Inject redirect rules and form flags directly.
// redirects_json is a hidden input rendered by RedirectsFields whose value
// reflects React state; setting .value just before submit works because no
// React render cycle fires between the injection and form data collection.
await injectFormFields(page, {
ssl_forced_present: 'on',
redirects_json: JSON.stringify([
{ from: '/.well-known/carddav', to: '/remote.php/dav/', status: 301 },
{ from: '/.well-known/caldav', to: '/remote.php/dav/', status: 302 },
]),
});
await page.getByRole('button', { name: /^create$/i }).click();
await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 15_000 });
await expect(page.getByText('Functional Redirects Test')).toBeVisible({ timeout: 10_000 });
await waitForRoute(DOMAIN);
});
test('matched path receives the configured 301 redirect', async () => {
const res = await httpGet(DOMAIN, '/.well-known/carddav');
expect(res.status).toBe(301);
});
test('301 redirect Location header points to the configured destination', async () => {
const res = await httpGet(DOMAIN, '/.well-known/carddav');
const location = res.headers['location'];
const locationStr = Array.isArray(location) ? location[0] : (location ?? '');
expect(locationStr).toBe('/remote.php/dav/');
});
test('second matched path receives the configured 302 redirect', async () => {
const res = await httpGet(DOMAIN, '/.well-known/caldav');
expect(res.status).toBe(302);
});
test('302 redirect Location header points to the configured destination', async () => {
const res = await httpGet(DOMAIN, '/.well-known/caldav');
const location = res.headers['location'];
const locationStr = Array.isArray(location) ? location[0] : (location ?? '');
expect(locationStr).toBe('/remote.php/dav/');
});
test('unmatched path is proxied normally to the upstream', async () => {
const res = await httpGet(DOMAIN, '/some/other/path');
expect(res.status).toBe(200);
expect(res.body).toContain('echo-ok');
});
});

View File

@@ -80,4 +80,58 @@ describe('proxy-hosts integration', () => {
expect(row!.hstsEnabled).toBe(false);
expect(row!.allowWebsocket).toBe(false);
});
it('stores and retrieves redirect rules via meta JSON', async () => {
const redirects = [
{ from: '/.well-known/carddav', to: '/remote.php/dav/', status: 301 },
{ from: '/.well-known/caldav', to: '/remote.php/dav/', status: 301 },
];
const host = await insertProxyHost({
name: 'nextcloud',
domains: JSON.stringify(['nextcloud.example.com']),
upstreams: JSON.stringify(['192.168.1.154:11000']),
meta: JSON.stringify({ redirects }),
});
const row = await db.query.proxyHosts.findFirst({ where: (t, { eq }) => eq(t.id, host.id) });
const meta = JSON.parse(row!.meta ?? '{}');
expect(meta.redirects).toHaveLength(2);
expect(meta.redirects[0]).toMatchObject({
from: '/.well-known/carddav',
to: '/remote.php/dav/',
status: 301,
});
});
it('stores and retrieves path prefix rewrite via meta JSON', async () => {
const host = await insertProxyHost({
name: 'recipes',
domains: JSON.stringify(['recipes.example.com']),
upstreams: JSON.stringify(['192.168.1.150:8080']),
meta: JSON.stringify({ rewrite: { path_prefix: '/recipes' } }),
});
const row = await db.query.proxyHosts.findFirst({ where: (t, { eq }) => eq(t.id, host.id) });
const meta = JSON.parse(row!.meta ?? '{}');
expect(meta.rewrite?.path_prefix).toBe('/recipes');
});
it('filters out invalid redirect rules on parse', async () => {
const redirects = [
{ from: '', to: '/valid', status: 301 }, // missing from — invalid
{ from: '/valid', to: '', status: 301 }, // missing to — invalid
{ from: '/ok', to: '/dest', status: 999 }, // bad status — invalid
{ from: '/good', to: '/dest', status: 302 }, // valid
];
const host = await insertProxyHost({
name: 'test-filter',
meta: JSON.stringify({ redirects }),
});
const row = await db.query.proxyHosts.findFirst({ where: (t, { eq }) => eq(t.id, host.id) });
// Simulate parseMeta sanitization: only valid rules have non-empty from/to and valid status
const meta = JSON.parse(row!.meta ?? '{}');
const valid = (meta.redirects as typeof redirects).filter(
(r) => r.from.trim() && r.to.trim() && [301, 302, 307, 308].includes(r.status)
);
expect(valid).toHaveLength(1);
expect(valid[0].from).toBe('/good');
});
});