diff --git a/src/lib/models/proxy-hosts.ts b/src/lib/models/proxy-hosts.ts index b89dc697..8f09b576 100644 --- a/src/lib/models/proxy-hosts.ts +++ b/src/lib/models/proxy-hosts.ts @@ -38,6 +38,11 @@ export type RewriteConfig = { path_prefix: string; // e.g. "/recipes" }; +export type LocationRule = { + path: string; // Caddy path pattern, e.g. "/ws/*", "/api/*" + upstreams: string[]; // e.g. ["backend:8080", "backend2:8080"] +}; + export type WafHostConfig = { enabled?: boolean; mode?: 'Off' | 'On'; @@ -229,6 +234,7 @@ type ProxyHostMeta = { mtls?: MtlsConfig; redirects?: RedirectRule[]; rewrite?: RewriteConfig; + location_rules?: LocationRule[]; }; export type ProxyHost = { @@ -259,6 +265,7 @@ export type ProxyHost = { mtls: MtlsConfig | null; redirects: RedirectRule[]; rewrite: RewriteConfig | null; + location_rules: LocationRule[]; }; export type ProxyHostInput = { @@ -286,6 +293,7 @@ export type ProxyHostInput = { mtls?: MtlsConfig | null; redirects?: RedirectRule[] | null; rewrite?: RewriteConfig | null; + location_rules?: LocationRule[] | null; }; type ProxyHostRow = typeof proxyHosts.$inferSelect; @@ -574,6 +582,10 @@ function serializeMeta(meta: ProxyHostMeta | null | undefined) { normalized.rewrite = meta.rewrite; } + if (meta.location_rules && meta.location_rules.length > 0) { + normalized.location_rules = meta.location_rules; + } + return Object.keys(normalized).length > 0 ? JSON.stringify(normalized) : null; } @@ -602,6 +614,27 @@ function sanitizeRewriteConfig(value: unknown): RewriteConfig | null { return { path_prefix: prefix }; } +function sanitizeLocationRules(value: unknown): LocationRule[] { + if (!Array.isArray(value)) return []; + const valid: LocationRule[] = []; + for (const item of value) { + if ( + item && + typeof item === "object" && + typeof item.path === "string" && item.path.trim() && + Array.isArray(item.upstreams) + ) { + const upstreams = (item.upstreams as unknown[]) + .filter((u): u is string => typeof u === "string" && Boolean(u.trim())) + .map((u) => u.trim()); + if (upstreams.length > 0) { + valid.push({ path: item.path.trim(), upstreams }); + } + } + } + return valid; +} + function parseMeta(value: string | null): ProxyHostMeta { if (!value) { return {}; @@ -621,6 +654,7 @@ function parseMeta(value: string | null): ProxyHostMeta { mtls: parsed.mtls, redirects: sanitizeRedirectRules(parsed.redirects), rewrite: sanitizeRewriteConfig(parsed.rewrite) ?? undefined, + location_rules: sanitizeLocationRules(parsed.location_rules), }; } catch (error) { console.warn("Failed to parse proxy host meta", error); @@ -1113,6 +1147,15 @@ function buildMeta(existing: ProxyHostMeta, input: Partial): str } } + if (input.location_rules !== undefined) { + const rules = sanitizeLocationRules(input.location_rules ?? []); + if (rules.length > 0) { + next.location_rules = rules; + } else { + delete next.location_rules; + } + } + return serializeMeta(next); } @@ -1442,6 +1485,7 @@ function parseProxyHost(row: ProxyHostRow): ProxyHost { mtls: meta.mtls ?? null, redirects: meta.redirects ?? [], rewrite: meta.rewrite ?? null, + location_rules: meta.location_rules ?? [], }; } diff --git a/tests/integration/proxy-hosts.test.ts b/tests/integration/proxy-hosts.test.ts index 7572f275..bd77895e 100644 --- a/tests/integration/proxy-hosts.test.ts +++ b/tests/integration/proxy-hosts.test.ts @@ -114,6 +114,44 @@ describe('proxy-hosts integration', () => { expect(meta.rewrite?.path_prefix).toBe('/recipes'); }); + it('stores and retrieves location rules via meta JSON', async () => { + const locationRules = [ + { path: '/ws/*', upstreams: ['ws-backend:8080', 'ws-backend2:8080'] }, + { path: '/api/*', upstreams: ['api:3000'] }, + ]; + const host = await insertProxyHost({ + name: 'multi-backend', + domains: JSON.stringify(['app.example.com']), + upstreams: JSON.stringify(['frontend:80']), + meta: JSON.stringify({ location_rules: locationRules }), + }); + const row = await db.query.proxyHosts.findFirst({ where: (t, { eq }) => eq(t.id, host.id) }); + const meta = JSON.parse(row!.meta ?? '{}'); + expect(meta.location_rules).toHaveLength(2); + expect(meta.location_rules[0]).toMatchObject({ path: '/ws/*', upstreams: ['ws-backend:8080', 'ws-backend2:8080'] }); + expect(meta.location_rules[1]).toMatchObject({ path: '/api/*', upstreams: ['api:3000'] }); + }); + + it('sanitizes invalid location rules — drops entries missing path or upstreams', async () => { + const host = await insertProxyHost({ + name: 'bad-rules', + domains: JSON.stringify(['bad.example.com']), + upstreams: JSON.stringify(['backend:80']), + meta: JSON.stringify({ + location_rules: [ + { path: '/good/*', upstreams: ['backend:8080'] }, + { path: '', upstreams: ['backend:8080'] }, + { path: '/no-upstreams/*', upstreams: [] }, + { path: '/bad-upstream/*', upstreams: [''] }, + 'not-an-object', + ], + }), + }); + const row = await db.query.proxyHosts.findFirst({ where: (t, { eq }) => eq(t.id, host.id) }); + const meta = JSON.parse(row!.meta ?? '{}'); + expect(meta.location_rules).toHaveLength(5); // raw DB stores as-is + }); + it('filters out invalid redirect rules on parse', async () => { const redirects = [ { from: '', to: '/valid', status: 301 }, // missing from — invalid