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:
@@ -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
|
||||
);
|
||||
|
||||
@@ -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)"
|
||||
|
||||
88
src/components/proxy-hosts/RedirectsFields.tsx
Normal file
88
src/components/proxy-hosts/RedirectsFields.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
17
src/components/proxy-hosts/RewriteFields.tsx
Normal file
17
src/components/proxy-hosts/RewriteFields.tsx
Normal 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
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
63
tests/e2e/functional/path-prefix-rewrite.spec.ts
Normal file
63
tests/e2e/functional/path-prefix-rewrite.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
192
tests/e2e/functional/redirects-advanced.spec.ts
Normal file
192
tests/e2e/functional/redirects-advanced.spec.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
77
tests/e2e/functional/redirects.spec.ts
Normal file
77
tests/e2e/functional/redirects.spec.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user