diff --git a/app/(dashboard)/proxy-hosts/actions.ts b/app/(dashboard)/proxy-hosts/actions.ts
index 49893977..8e3f7f46 100644
--- a/app/(dashboard)/proxy-hosts/actions.ts
+++ b/app/(dashboard)/proxy-hosts/actions.ts
@@ -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
);
diff --git a/src/components/proxy-hosts/HostDialogs.tsx b/src/components/proxy-hosts/HostDialogs.tsx
index f6a58bca..62d77aa2 100644
--- a/src/components/proxy-hosts/HostDialogs.tsx
+++ b/src/components/proxy-hosts/HostDialogs.tsx
@@ -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({
))}
+
+
))}
+
+
(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 (
+
+
+ Redirects
+
+
+ {rules.length > 0 && (
+
+
+
+ From Path
+ To URL / Path
+ Status
+
+
+
+
+ {rules.map((rule, i) => (
+
+
+ updateRule(i, "from", e.target.value)}
+ fullWidth
+ />
+
+
+ updateRule(i, "to", e.target.value)}
+ fullWidth
+ />
+
+
+
+
+
+ removeRule(i)}>
+
+
+
+
+ ))}
+
+
+ )}
+ } onClick={addRule}>
+ Add Redirect
+
+
+ );
+}
diff --git a/src/components/proxy-hosts/RewriteFields.tsx b/src/components/proxy-hosts/RewriteFields.tsx
new file mode 100644
index 00000000..0c8ee84c
--- /dev/null
+++ b/src/components/proxy-hosts/RewriteFields.tsx
@@ -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 (
+
+ );
+}
diff --git a/src/lib/caddy.ts b/src/lib/caddy.ts
index d6399ec5..e77c7914 100644
--- a/src/lib/caddy.ts
+++ b/src/lib/caddy.ts
@@ -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);
diff --git a/src/lib/models/proxy-hosts.ts b/src/lib/models/proxy-hosts.ts
index 3a74c5c2..712537ff 100644
--- a/src/lib/models/proxy-hosts.ts
+++ b/src/lib/models/proxy-hosts.ts
@@ -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;
+ 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): 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,
};
}
diff --git a/tests/docker-compose.test.yml b/tests/docker-compose.test.yml
index 815872b1..2afefb48 100644
--- a/tests/docker-compose.test.yml
+++ b/tests/docker-compose.test.yml
@@ -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
diff --git a/tests/e2e/functional/path-prefix-rewrite.spec.ts b/tests/e2e/functional/path-prefix-rewrite.spec.ts
new file mode 100644
index 00000000..64ff74e2
--- /dev/null
+++ b/tests/e2e/functional/path-prefix-rewrite.spec.ts
@@ -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);
+ });
+});
diff --git a/tests/e2e/functional/redirects-advanced.spec.ts b/tests/e2e/functional/redirects-advanced.spec.ts
new file mode 100644
index 00000000..843882f0
--- /dev/null
+++ b/tests/e2e/functional/redirects-advanced.spec.ts
@@ -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>): 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');
+ });
+});
diff --git a/tests/e2e/functional/redirects.spec.ts b/tests/e2e/functional/redirects.spec.ts
new file mode 100644
index 00000000..940eb2fe
--- /dev/null
+++ b/tests/e2e/functional/redirects.spec.ts
@@ -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');
+ });
+});
diff --git a/tests/integration/proxy-hosts.test.ts b/tests/integration/proxy-hosts.test.ts
index 5f4ccd02..7572f275 100644
--- a/tests/integration/proxy-hosts.test.ts
+++ b/tests/integration/proxy-hosts.test.ts
@@ -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');
+ });
});