Compare commits

..

9 Commits

Author SHA1 Message Date
jkunz a97c4963d6 v14.1.0
Docker (tags) / release (push) Failing after 1s
Release / build-and-release (push) Successful in 6m40s
2026-06-05 14:39:09 +00:00
jkunz 62271c1819 feat(workapp-mail): add shared WorkApp mail address binding APIs 2026-06-05 14:34:32 +00:00
jkunz e6b3625256 v14.0.1
Docker (tags) / release (push) Failing after 1s
Release / build-and-release (push) Successful in 7m42s
2026-06-05 03:28:38 +00:00
jkunz 103680a3a0 fix(proxy-protocol): apply inbound PROXY protocol policies per listener 2026-06-05 03:17:37 +00:00
jkunz ba67e0d208 v14.0.0
Docker (tags) / release (push) Failing after 1s
Release / build-and-release (push) Successful in 6m13s
2026-06-04 16:01:33 +00:00
jkunz e86fe0df7a BREAKING CHANGE(config): remove legacy config seeding and route reprovisioning 2026-06-04 15:51:09 +00:00
jkunz 71ee2133e4 v13.45.0
Docker (tags) / release (push) Failing after 1s
Release / build-and-release (push) Successful in 6m20s
2026-06-04 13:39:39 +00:00
jkunz 6c8073b91a feat(network-routes): add route source policy editor 2026-06-04 13:36:02 +00:00
jkunz 17bb63f129 fix(email): relay server-first SMTP banners for generated email routes 2026-06-04 12:06:09 +00:00
27 changed files with 1402 additions and 561 deletions
+48 -1
View File
@@ -1,12 +1,59 @@
# Changelog
## Pending
## 2026-06-05 - 14.1.0
### Features
- add shared WorkApp mail address binding APIs (workapp-mail)
- Adds list, sync, and delete support for shared mail address bindings.
- Maps shared mail address bindings to stored WorkApp mail identities and grouped WorkApp mail bindings.
- Enforces gateway client ownership and allowed mail forward targets for gateway-scoped tokens.
- Updates interface dependencies for shared mail binding request types.
## 2026-06-05 - 14.0.1
### Fixes
- apply inbound PROXY protocol policies per listener (proxy-protocol)
- Apply inbound PROXY protocol policies across prepared and runtime routes that share the same listener.
- Require PROXY protocol for remote ingress SMTP and submission ports while using optional mode for other remote ingress and VPN listeners.
- Trust localhost for remote ingress and VPN forwarding without globally enabling PROXY protocol.
- Bump @push.rocks/smartproxy to ^27.12.8.
## 2026-06-04 - 14.0.0
### Breaking Changes
- remove legacy config seeding and route-based certificate reprovisioning (config)
- Make ACME configuration DB-backed only and report DB-backed ACME state in the OpsServer config response.
- Stop seeding DNS domains and records from constructor config at runtime.
- Remove the route-name certificate reprovision typed request; domain-based reprovisioning remains available.
- Remove legacy string email-domain normalization from runtime email startup.
### Fixes
- bump @push.rocks/smartproxy to ^27.12.7 (deps)
- Consumes the upstream SmartProxy socket-handler relay fix for server-first SMTP banners.
- Updates the lockfile to resolve @push.rocks/smartproxy 27.12.7.
- use exact SmartData collection names in DNS migrations (migrations)
- Updates DNS source rename migrations to use `DomainDoc` and `DnsRecordDoc` collection names.
- Adds migration coverage for exact SmartData collection names.
## 2026-06-04 - 13.45.0
### Fixes
- relay server-first SMTP banners for generated email routes (email)
- Convert generated plaintext email forward routes to runtime socket handlers for SmartProxy bootstrap.
- Hydrate DB-backed generated email routes to the same runtime handlers when their email system keys match.
- Add bidirectional socket proxy cleanup and tests for route hydration and SMTP banner relay.
### Features
- add route source policy editor (network-routes)
- Replace fixed source binding dropdown rows with the catalog route source policy input in route create and edit dialogs.
- Add source profile normalization, path class options, Gitea source policy presets, and validation for route source policies.
- Bump catalog UI dependencies and update pnpm built dependency configuration.
## 2026-06-04 - 13.44.1
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@serve.zone/dcrouter",
"version": "13.44.1",
"version": "14.1.0",
"exports": "./binary/dcrouter.ts",
"compile": {
"include": [
+6 -6
View File
@@ -1,7 +1,7 @@
{
"name": "@serve.zone/dcrouter",
"private": false,
"version": "13.44.1",
"version": "14.1.0",
"description": "A multifaceted routing service handling mail and SMS delivery functions.",
"type": "module",
"bin": {
@@ -29,7 +29,7 @@
"@git.zone/tsbuild": "^4.4.2",
"@git.zone/tsbundle": "^2.10.4",
"@git.zone/tsdeno": "^1.5.0",
"@git.zone/tsdocker": "^2.4.2",
"@git.zone/tsdocker": "^2.4.3",
"@git.zone/tsrun": "^2.0.4",
"@git.zone/tstest": "^3.6.6",
"@git.zone/tswatch": "^3.3.5",
@@ -41,7 +41,7 @@
"@api.global/typedserver": "^8.4.7",
"@api.global/typedsocket": "^4.1.4",
"@apiclient.xyz/cloudflare": "^7.1.0",
"@design.estate/dees-catalog": "^3.83.0",
"@design.estate/dees-catalog": "^3.84.0",
"@design.estate/dees-element": "^2.2.4",
"@idp.global/sdk": "^1.4.0",
"@push.rocks/lik": "^6.4.1",
@@ -61,7 +61,7 @@
"@push.rocks/smartnetwork": "^4.7.2",
"@push.rocks/smartpath": "^6.0.0",
"@push.rocks/smartpromise": "^4.2.4",
"@push.rocks/smartproxy": "^27.12.6",
"@push.rocks/smartproxy": "^27.12.8",
"@push.rocks/smartradius": "^1.3.0",
"@push.rocks/smartrequest": "^5.0.3",
"@push.rocks/smartrx": "^3.0.10",
@@ -69,8 +69,8 @@
"@push.rocks/smartunique": "^3.0.9",
"@push.rocks/smartvpn": "1.20.0",
"@push.rocks/taskbuffer": "^8.0.2",
"@serve.zone/catalog": "^2.12.8",
"@serve.zone/interfaces": "^6.2.1",
"@serve.zone/catalog": "^2.13.0",
"@serve.zone/interfaces": "^6.3.0",
"@serve.zone/remoteingress": "^4.23.0",
"@tsclass/tsclass": "^9.5.1",
"@types/qrcode": "^1.5.6",
+27 -27
View File
@@ -24,8 +24,8 @@ importers:
specifier: ^7.1.0
version: 7.1.0
'@design.estate/dees-catalog':
specifier: ^3.83.0
version: 3.83.0(@tiptap/pm@2.27.2)
specifier: ^3.84.0
version: 3.84.0(@tiptap/pm@2.27.2)
'@design.estate/dees-element':
specifier: ^2.2.4
version: 2.2.4
@@ -84,8 +84,8 @@ importers:
specifier: ^4.2.4
version: 4.2.4
'@push.rocks/smartproxy':
specifier: ^27.12.6
version: 27.12.6
specifier: ^27.12.8
version: 27.12.8
'@push.rocks/smartradius':
specifier: ^1.3.0
version: 1.3.0
@@ -108,11 +108,11 @@ importers:
specifier: ^8.0.2
version: 8.0.2
'@serve.zone/catalog':
specifier: ^2.12.8
version: 2.12.8(@tiptap/pm@2.27.2)
specifier: ^2.13.0
version: 2.13.0(@tiptap/pm@2.27.2)
'@serve.zone/interfaces':
specifier: ^6.2.1
version: 6.2.1
specifier: ^6.3.0
version: 6.3.0
'@serve.zone/remoteingress':
specifier: ^4.23.0
version: 4.23.0
@@ -142,8 +142,8 @@ importers:
specifier: ^1.5.0
version: 1.5.0
'@git.zone/tsdocker':
specifier: ^2.4.2
version: 2.4.2
specifier: ^2.4.3
version: 2.4.3
'@git.zone/tsrun':
specifier: ^2.0.4
version: 2.0.4
@@ -365,8 +365,8 @@ packages:
'@configvault.io/interfaces@1.0.17':
resolution: {integrity: sha512-bEcCUR2VBDJsTin8HQh8Uw/mlYl2v8A3jMIaQ+MTB9Hrqd6CZL2dL7iJdWyFl/3EIX+LDxWFR+Oq7liIq7w+1Q==}
'@design.estate/dees-catalog@3.83.0':
resolution: {integrity: sha512-Ia4fwZ5ndziJkSE000nCro83rD8Rujki7ASHBQhL6ZDflZRJRlfuc13azVnQC2sazKlo/bWSgiiLcpc3V2IYrw==}
'@design.estate/dees-catalog@3.84.0':
resolution: {integrity: sha512-CYNsKwOcu3FvkA+G3fli4P9fVfDcMK3my5AbhN6jLNM0JPMlKyKV8s3q6bAqQPc9QGAtm+XhY2zLI4Cgurs2HA==}
'@design.estate/dees-comms@1.0.30':
resolution: {integrity: sha512-KchMlklJfKAjQiJiR0xmofXtQ27VgZtBIxcMwPE9d+h3jJRv+lPZxzBQVOM0eyM0uS44S5vJMZ11IeV4uDXSHg==}
@@ -733,8 +733,8 @@ packages:
resolution: {integrity: sha512-OdGPhnBz6v92OkKKWyswpyGman3m3FOXin+9WRzEBvvwyLAAkc2mKUGViPAIxYkrak4GiglzqjTkSyReDU0QOw==}
hasBin: true
'@git.zone/tsdocker@2.4.2':
resolution: {integrity: sha512-cLG/o1TL7P62td/MUiJgnKYm0HjOFuPzh1YvVo8PwC+754QuW/bFGFnWQvbmoulzfkpzVoP2wwQwk4ECe+XBlQ==}
'@git.zone/tsdocker@2.4.3':
resolution: {integrity: sha512-ufLp7lyMj4tCpERd/xIE+z9II6o7qp+T7OZUexbLkIB8ZHzBGj/+zB9r2hFfMiUXcznd1vwAF3VNwADYwWut3Q==}
hasBin: true
'@git.zone/tspublish@1.11.6':
@@ -1429,8 +1429,8 @@ packages:
'@push.rocks/smartpromise@4.2.4':
resolution: {integrity: sha512-8FUyYt94hOIY9mqHjitn4h69u0jbEtTF2RKKw2DpiTVFjpDTk9gXbVHZ/V+xEcBrN4mrzdQES0OiDmkNPoddEQ==}
'@push.rocks/smartproxy@27.12.6':
resolution: {integrity: sha512-vGUMbv0vwJS2kQ6SqAlhSGsWRoPs4Zk/sELUtNFNpnHWKHlqXeu64FNgiF5mgA9Nz1dfgiFMqErXArzTm8ccOA==}
'@push.rocks/smartproxy@27.12.8':
resolution: {integrity: sha512-d1sbo2avzFO9PUXpb2FuBKwSDoackxNPFOHvR8q0DBMMoAmxRVf0mmhVxWrvqbGMk2N9rtORve2g3TsMJRTZYQ==}
'@push.rocks/smartpuppeteer@2.0.6':
resolution: {integrity: sha512-G+8cyDERvbXQcb9Sd8lnYdWYz8b3Mv2LfFf1ULmucDqQhcRHvxrWX/dKsvBZrwKPR4Wg+795Dyd+E1iOOh3tHw==}
@@ -1689,11 +1689,11 @@ packages:
'@selderee/plugin-htmlparser2@0.11.0':
resolution: {integrity: sha512-P33hHGdldxGabLFjPPpaTxVolMrzrcegejx+0GxjrIb9Zv48D8yAIA/QTDR2dFl7Uz7urX8aX6+5bCZslr+gWQ==}
'@serve.zone/catalog@2.12.8':
resolution: {integrity: sha512-TBclzYbDH3OJlbLkWpLrBij2MU4eFpBs5MIJU7njBMZZaQ37IVYftG+vn6N4W2E2WfuTxaPVshN7MV3A/oR81g==}
'@serve.zone/catalog@2.13.0':
resolution: {integrity: sha512-w2zfbcbJLR1jbwJQkeLNCOW/WB71FyMBfVb+uiIO5XTVK+7zTD0cFozySjBDOrueCFDcL7GcoO8Ohgs9jCfuhQ==}
'@serve.zone/interfaces@6.2.1':
resolution: {integrity: sha512-t2wrpBmd8zDdnyeeY/LG2hfjCXdm/uTHB6oovJ/xHgOws1E2VimYJPFiN7zqs1aEJAmFukfgOq79+eZeq3hfWw==}
'@serve.zone/interfaces@6.3.0':
resolution: {integrity: sha512-TjpOLCejOcwyMvkBoqwjdptVGwaNMAru+6jC3Lw2Em3+7Z0xVNU/du0DQtoLbaLqTb0vHbuxx6urqw12uR9/2A==}
'@serve.zone/remoteingress@4.23.0':
resolution: {integrity: sha512-ddF7k3ZfgpPn9rwfprDMWZR2CNzwQlmiETpST2obJPN5VrAZMLj2aT7yQYGiqLgVqUQBSXeSz9St2ygYyQ8PSQ==}
@@ -4276,7 +4276,7 @@ snapshots:
'@api.global/typedrequest-interfaces': 3.0.19
'@api.global/typedsocket': 4.1.4(@push.rocks/smartserve@2.0.4)
'@cloudflare/workers-types': 4.20260602.1
'@design.estate/dees-catalog': 3.83.0(@tiptap/pm@2.27.2)
'@design.estate/dees-catalog': 3.84.0(@tiptap/pm@2.27.2)
'@design.estate/dees-comms': 1.0.30
'@push.rocks/lik': 6.4.1
'@push.rocks/smartdelay': 3.1.0
@@ -4809,7 +4809,7 @@ snapshots:
dependencies:
'@api.global/typedrequest-interfaces': 3.0.19
'@design.estate/dees-catalog@3.83.0(@tiptap/pm@2.27.2)':
'@design.estate/dees-catalog@3.84.0(@tiptap/pm@2.27.2)':
dependencies:
'@design.estate/dees-domtools': 2.5.6
'@design.estate/dees-element': 2.2.4
@@ -5155,7 +5155,7 @@ snapshots:
- supports-color
- vue
'@git.zone/tsdocker@2.4.2':
'@git.zone/tsdocker@2.4.3':
dependencies:
'@push.rocks/lik': 6.4.1
'@push.rocks/projectinfo': 5.1.0
@@ -6581,7 +6581,7 @@ snapshots:
'@push.rocks/smartpromise@4.2.4': {}
'@push.rocks/smartproxy@27.12.6':
'@push.rocks/smartproxy@27.12.8':
dependencies:
'@push.rocks/smartcrypto': 2.0.4
'@push.rocks/smartlog': 3.2.2
@@ -6915,9 +6915,9 @@ snapshots:
domhandler: 5.0.3
selderee: 0.11.0
'@serve.zone/catalog@2.12.8(@tiptap/pm@2.27.2)':
'@serve.zone/catalog@2.13.0(@tiptap/pm@2.27.2)':
dependencies:
'@design.estate/dees-catalog': 3.83.0(@tiptap/pm@2.27.2)
'@design.estate/dees-catalog': 3.84.0(@tiptap/pm@2.27.2)
'@design.estate/dees-domtools': 2.5.6
'@design.estate/dees-element': 2.2.4
'@design.estate/dees-wcctools': 3.9.0
@@ -6928,7 +6928,7 @@ snapshots:
- supports-color
- vue
'@serve.zone/interfaces@6.2.1':
'@serve.zone/interfaces@6.3.0':
dependencies:
'@api.global/typedrequest-interfaces': 3.0.19
'@push.rocks/smartlog-interfaces': 3.0.2
+5 -4
View File
@@ -1,4 +1,5 @@
allowBuilds:
esbuild: true
mongodb-memory-server: true
puppeteer: true
onlyBuiltDependencies:
- '@design.estate/dees-catalog'
- esbuild
- mongodb-memory-server
- puppeteer
+218
View File
@@ -2,6 +2,7 @@ import { tap, expect } from '@git.zone/tstest/tapbundle';
import * as plugins from '../ts/plugins.js';
import * as path from 'path';
import * as fs from 'fs';
import { Buffer } from 'node:buffer';
import * as net from 'node:net';
import { DcRouter, type IDcRouterOptions } from '../ts/classes.dcrouter.js';
import type { IUnifiedEmailServerOptions } from '@push.rocks/smartmta';
@@ -18,6 +19,68 @@ async function getFreePort(): Promise<number> {
});
}
async function listen(server: net.Server, port: number = 0): Promise<number> {
return await new Promise<number>((resolve, reject) => {
server.once('error', reject);
server.listen(port, '127.0.0.1', () => {
server.off('error', reject);
const address = server.address();
resolve(typeof address === 'object' && address ? address.port : port);
});
});
}
function trackSocket(sockets: Set<net.Socket>, socket: net.Socket): void {
sockets.add(socket);
socket.once('close', () => sockets.delete(socket));
}
async function closeServer(server: net.Server, sockets?: Set<net.Socket>): Promise<void> {
for (const socket of sockets || []) {
socket.destroy();
}
if (!server.listening) {
return;
}
await new Promise<void>((resolve) => {
server.close(() => resolve());
});
}
async function readFirstSocketData(port: number): Promise<string> {
return await new Promise<string>((resolve, reject) => {
const socket = net.connect({ host: '127.0.0.1', port });
let settled = false;
let timeout: ReturnType<typeof setTimeout> & { unref?: () => void };
const cleanup = () => {
clearTimeout(timeout);
socket.removeListener('data', onData);
socket.removeListener('error', onError);
socket.removeListener('end', onEnd);
socket.removeListener('close', onClose);
};
const settle = (callback: () => void) => {
if (settled) return;
settled = true;
cleanup();
socket.destroy();
callback();
};
timeout = setTimeout(() => {
settle(() => reject(new Error('Timed out waiting for socket data')));
}, 5000) as ReturnType<typeof setTimeout> & { unref?: () => void };
timeout.unref?.();
const onData = (data: Buffer) => settle(() => resolve(data.toString('utf8')));
const onError = (error: Error) => settle(() => reject(error));
const onEnd = () => settle(() => reject(new Error('Socket ended before data')));
const onClose = () => settle(() => reject(new Error('Socket closed before data')));
socket.once('data', onData);
socket.once('error', onError);
socket.once('end', onEnd);
socket.once('close', onClose);
});
}
tap.test('DcRouter class - Custom email port configuration', async () => {
// Define custom port mapping
const customPortMapping: Record<number, number> = {
@@ -109,6 +172,8 @@ tap.test('DcRouter class - Custom email port configuration', async () => {
});
expect(customPortRoute).toBeTruthy();
expect(customPortRoute?.name).toEqual('custom-smtp-route');
expect(customPortRoute?.action.type).toEqual('forward');
expect(customPortRoute?.action.targets[0].host).toEqual('localhost');
expect(customPortRoute?.action.targets[0].port).toEqual(12525);
expect(customPortRoute?.remoteIngress).toBeUndefined();
@@ -127,6 +192,150 @@ tap.test('DcRouter class - Custom email port configuration', async () => {
}
});
tap.test('DcRouter class - Generated plaintext email routes hydrate to server-first socket handlers', async () => {
const emailConfig: IUnifiedEmailServerOptions = {
ports: [25, 587, 465],
hostname: 'mail.example.com',
domains: [],
routes: [],
};
const router = new DcRouter({ emailConfig });
const routes = (router as any)['generateEmailRoutes'](emailConfig);
const smtpRoute = routes.find((route: any) => route.name === 'smtp-route');
const submissionRoute = routes.find((route: any) => route.name === 'submission-route');
const smtpsRoute = routes.find((route: any) => route.name === 'smtps-route');
const hydrate = (routerArg: DcRouter, route: any, origin = 'email') => (routerArg as any)['hydrateStoredRouteForRuntime']({
id: `${origin}-${route.name}`,
route,
enabled: true,
createdAt: Date.now(),
updatedAt: Date.now(),
createdBy: 'system',
origin,
systemKey: `${origin}:${route.name}`,
});
const runtimeSmtpRoute = hydrate(router, smtpRoute);
expect(runtimeSmtpRoute?.action.type).toEqual('socket-handler');
expect(typeof runtimeSmtpRoute?.action.socketHandler).toEqual('function');
const runtimeSubmissionRoute = hydrate(router, submissionRoute);
expect(runtimeSubmissionRoute?.action.type).toEqual('socket-handler');
expect(typeof runtimeSubmissionRoute?.action.socketHandler).toEqual('function');
expect(hydrate(router, smtpsRoute)).toBeUndefined();
expect(hydrate(router, smtpRoute, 'api')).toBeUndefined();
const remoteIngressRouter = new DcRouter({
emailConfig,
remoteIngressConfig: {
enabled: true,
tunnelPort: 8443,
hubDomain: 'ingress.example.com',
},
});
const staleSmtpRoute = {
...smtpRoute,
match: {
...smtpRoute.match,
inboundProxyProtocol: undefined,
},
};
const runtimeRemoteSmtpRoute = hydrate(remoteIngressRouter, staleSmtpRoute);
expect(runtimeRemoteSmtpRoute?.match.inboundProxyProtocol).toEqual({ mode: 'required' });
});
tap.test('DcRouter class - Inbound PROXY policies are applied per listener', async () => {
const router = new DcRouter({
remoteIngressConfig: {
enabled: true,
tunnelPort: 8443,
hubDomain: 'ingress.example.com',
},
});
const routes = (router as any)['applyInboundProxyProtocolPolicies']([{
name: 'remote-route',
match: { ports: [443], domains: ['remote.example.com'] },
remoteIngress: { enabled: true },
action: {
type: 'forward',
targets: [{ host: '127.0.0.1', port: 8443 }],
},
}, {
name: 'same-listener-direct-route',
match: { ports: [443], domains: ['direct.example.com'] },
action: {
type: 'forward',
targets: [{ host: '127.0.0.1', port: 9443 }],
},
}]);
expect(routes[0].match.inboundProxyProtocol).toEqual({ mode: 'optional' });
expect(routes[1].match.inboundProxyProtocol).toEqual({ mode: 'optional' });
const vpnRouter = new DcRouter({
vpnConfig: { enabled: true },
});
const vpnRoutes = (vpnRouter as any)['applyInboundProxyProtocolPolicies']([{
name: 'vpn-route',
match: { ports: [9443] },
action: {
type: 'forward',
targets: [{ host: '127.0.0.1', port: 9443 }],
},
}]);
expect(vpnRoutes[0].match.inboundProxyProtocol).toEqual({ mode: 'optional' });
});
tap.test('DcRouter class - Email socket handler relays server-first SMTP banners', async () => {
const backendSockets = new Set<net.Socket>();
const backend = net.createServer((socket) => {
trackSocket(backendSockets, socket);
socket.write('220 test.example ESMTP Service Ready\r\n');
});
const backendPort = await listen(backend);
const emailConfig: IUnifiedEmailServerOptions = {
ports: [2525],
hostname: 'mail.example.com',
domains: [],
routes: [],
};
const router = new DcRouter({
emailConfig,
emailPortConfig: {
portMapping: { 2525: backendPort },
},
});
const routes = (router as any)['generateEmailRoutes'](emailConfig);
const route = routes.find((routeArg: any) => routeArg.name === 'email-port-2525-route');
const runtimeRoute = (router as any)['createServerFirstEmailRuntimeRoute'](route);
expect(runtimeRoute?.action.type).toEqual('socket-handler');
const frontendSockets = new Set<net.Socket>();
const frontend = net.createServer((socket) => {
trackSocket(frontendSockets, socket);
runtimeRoute.action.socketHandler(socket, {
port: 2525,
clientIp: '127.0.0.1',
serverIp: '127.0.0.1',
routeName: route.name,
timestamp: Date.now(),
connectionId: 'test-email-proxy',
});
});
const frontendPort = await listen(frontend);
try {
const banner = await readFirstSocketData(frontendPort);
expect(banner).toEqual('220 test.example ESMTP Service Ready\r\n');
} finally {
await closeServer(frontend, frontendSockets);
await closeServer(backend, backendSockets);
}
});
tap.test('DcRouter class - Email routes are exposed through RemoteIngress when enabled', async () => {
const emailConfig: IUnifiedEmailServerOptions = {
ports: [25, 587, 465],
@@ -149,6 +358,15 @@ tap.test('DcRouter class - Email routes are exposed through RemoteIngress when e
for (const route of routes) {
expect(route.remoteIngress).toEqual({ enabled: true });
}
const smtpRoute = routes.find((route: any) => route.name === 'smtp-route');
const submissionRoute = routes.find((route: any) => route.name === 'submission-route');
const smtpsRoute = routes.find((route: any) => route.name === 'smtps-route');
expect(smtpRoute?.match.transport).toEqual('tcp');
expect(smtpRoute?.match.inboundProxyProtocol).toEqual({ mode: 'required' });
expect(submissionRoute?.match.transport).toEqual('tcp');
expect(submissionRoute?.match.inboundProxyProtocol).toEqual({ mode: 'required' });
expect(smtpsRoute?.action.type).toEqual('forward');
expect(smtpsRoute?.match.inboundProxyProtocol).toEqual({ mode: 'optional' });
});
tap.test('DcRouter class - Email config with domains and routes', async () => {
+10 -43
View File
@@ -3,7 +3,6 @@ import { DcRouter } from '../ts/classes.dcrouter.js';
import { ReferenceResolver, RouteConfigManager } from '../ts/config/index.js';
import { DcRouterDb, DnsRecordDoc, DomainDoc, RouteDoc } from '../ts/db/index.js';
import { DnsManager } from '../ts/dns/manager.dns.js';
import { logger } from '../ts/logger.js';
import * as plugins from '../ts/plugins.js';
const createTestDb = async () => {
@@ -411,53 +410,21 @@ tap.test('RouteConfigManager clears remote ingress config when route patch sets
expect(appliedRoutes[appliedRoutes.length - 1][0].remoteIngress).toBeUndefined();
});
tap.test('DnsManager warning keeps dnsNsDomains in scope', async () => {
tap.test('DnsManager start does not seed constructor DNS config into DB', async () => {
await testDbPromise;
await clearTestState();
const originalLog = logger.log.bind(logger);
const warningMessages: string[] = [];
(logger as any).log = (level: 'error' | 'warn' | 'info' | 'success' | 'debug', message: string, context?: Record<string, any>) => {
if (level === 'warn') {
warningMessages.push(message);
}
return originalLog(level, message, context || {});
};
const dnsManager = new DnsManager({
dnsNsDomains: ['ns1.example.com'],
dnsScopes: ['example.com'],
dnsRecords: [{ name: 'www.example.com', type: 'A', value: '127.0.0.1' }],
smartProxyConfig: { routes: [] },
});
try {
const existingDomain = new DomainDoc();
existingDomain.id = 'existing-domain';
existingDomain.name = 'example.com';
existingDomain.source = 'dcrouter';
existingDomain.authoritative = true;
existingDomain.createdAt = Date.now();
existingDomain.updatedAt = Date.now();
existingDomain.createdBy = 'test';
await existingDomain.save();
await dnsManager.start();
const dnsManager = new DnsManager({
dnsNsDomains: ['ns1.example.com'],
dnsScopes: ['example.com'],
dnsRecords: [{ name: 'www.example.com', type: 'A', value: '127.0.0.1' }],
smartProxyConfig: { routes: [] },
});
await dnsManager.start();
expect(
warningMessages.some((message) =>
message.includes('ignoring legacy dnsScopes/dnsRecords constructor config')
&& message.includes('dnsNsDomains is still required for nameserver and DoH bootstrap'),
),
).toEqual(true);
expect(
warningMessages.some((message) =>
message.includes('ignoring legacy dnsScopes/dnsRecords/dnsNsDomains constructor config'),
),
).toEqual(false);
} finally {
(logger as any).log = originalLog;
}
expect(await DomainDoc.findAll()).toHaveLength(0);
expect(await DnsRecordDoc.findAll()).toHaveLength(0);
});
tap.test('cleanup test db', async () => {
+18
View File
@@ -165,6 +165,24 @@ tap.test('migration runner applies schema steps through the current target', asy
expect(sourceProfiles.map((profile) => profile.name)).toContain('PUBLIC');
});
tap.test('migration runner uses exact SmartData collection names for DNS source renames', async () => {
const domains: Array<Record<string, any>> = [{ _id: 'domain-1', source: 'manual' }];
const records: Array<Record<string, any>> = [{ _id: 'record-1', source: 'manual' }];
const runner = await createMigrationRunner(
createFakeDb('13.1.0', {
DomainDoc: domains,
DnsRecordDoc: records,
}),
'13.8.2',
);
const result = await runner.run();
expect(result.stepsApplied).toHaveLength(2);
expect(domains[0].source).toEqual('dcrouter');
expect(records[0].source).toEqual('local');
});
tap.test('migration runner rematerializes source-profile-backed route security', async () => {
const profiles: Array<Record<string, any>> = [
{
+54
View File
@@ -172,4 +172,58 @@ tap.test('WorkAppMailManager applies persisted identities to startup email confi
expect(startupConfig.auth?.users?.some((user) => user.username.startsWith('workapp-'))).toEqual(true);
});
tap.test('WorkAppMailManager maps shared mail address bindings to WorkApp identities', async () => {
const { dcRouterRef } = createDcRouterStub();
const manager = new WorkAppMailManager(dcRouterRef);
const syncResult = await manager.syncMailAddressBinding({
owner: {
gatewayClientType: 'onebox',
gatewayClientId: 'box-1',
appInstanceId: 'app-1',
},
address: 'hello@example.com',
localPart: 'hello',
domain: 'example.com',
enabled: true,
inboundTarget: {
type: 'smtpForward',
smtpForward: {
host: '10.0.0.4',
port: 2527,
},
},
}, 'tester');
expect(syncResult.success).toEqual(true);
expect(syncResult.binding?.owner).toEqual({
gatewayClientType: 'onebox',
gatewayClientId: 'box-1',
appInstanceId: 'app-1',
});
expect(syncResult.binding?.inboundTarget?.smtpForward?.host).toEqual('10.0.0.4');
expect(syncResult.binding?.outboundIdentityId?.startsWith('workapp-')).toEqual(true);
const addressBindings = await manager.listMailAddressBindings({
owner: { appInstanceId: 'app-1' },
domain: 'example.com',
});
expect(addressBindings.length).toEqual(1);
expect(addressBindings[0].address).toEqual('hello@example.com');
expect(addressBindings[0].recipientPolicy?.staticRecipients).toEqual(['hello@example.com']);
const workAppBindings = await manager.listWorkAppMailBindings({
gatewayClientId: 'box-1',
});
expect(workAppBindings.length).toEqual(1);
expect(workAppBindings[0].addressBindingIds).toEqual([syncResult.binding!.id]);
const generatedRoute = dcRouterRef.options.emailConfig.routes.find((route: any) => route.name.startsWith('workapp-mail-'));
expect(generatedRoute.action.forward.host).toEqual('10.0.0.4');
const deleteResult = await manager.deleteMailAddressBinding(syncResult.binding!.id, 'tester');
expect(deleteResult.success).toEqual(true);
expect(dcRouterRef.options.emailConfig.routes.some((route: any) => route.name.startsWith('workapp-mail-'))).toEqual(false);
});
export default tap.start();
+249 -2
View File
@@ -54,13 +54,13 @@ const makeApiTokenManager = (
for (const policyScope of storedToken.policy?.scopes || []) {
scopes.add(policyScope);
}
const compatibilityAliases: Partial<Record<TScope, TScope[]>> = {
const equivalentScopes: Partial<Record<TScope, TScope[]>> = {
'gateway-clients:read': ['workhosters:read'],
'gateway-clients:write': ['workhosters:write'],
'workhosters:read': ['gateway-clients:read'],
'workhosters:write': ['gateway-clients:write'],
};
return scopes.has(scope) || Boolean(compatibilityAliases[scope]?.some((alias) => scopes.has(alias)));
return scopes.has(scope) || Boolean(equivalentScopes[scope]?.some((alias) => scopes.has(alias)));
},
};
};
@@ -587,4 +587,251 @@ tap.test('WorkHosterHandler rejects WorkApp mail sync without workhosters:write'
expect(result.error?.text).toEqual('insufficient scope');
});
tap.test('WorkHosterHandler exposes shared mail address binding handlers', async () => {
const syncedRequests: Array<{ binding: any; userId: string }> = [];
const deletedRequests: Array<{ id: string; userId: string }> = [];
const binding: plugins.servezoneInterfaces.data.IMailAddressBinding = {
id: 'mail-1',
owner: {
gatewayClientType: 'onebox',
gatewayClientId: 'box-1',
appInstanceId: 'app-1',
},
address: 'hello@example.com',
localPart: 'hello',
domain: 'example.com',
enabled: true,
status: 'active',
inboundTarget: {
type: 'smtpForward',
smtpForward: {
host: '10.0.0.2',
port: 2525,
},
},
createdAt: 1,
updatedAt: 1,
createdBy: 'token-user',
};
const { typedrouter } = setupHandler({
scopes: ['gateway-clients:read', 'gateway-clients:write'],
dcRouterRef: {
options: {},
workAppMailManager: {
listMailAddressBindings: async (filter: any) => filter.owner?.appInstanceId === 'app-1' ? [binding] : [],
syncMailAddressBinding: async (data: any, userId: string) => {
syncedRequests.push({ binding: data, userId });
return { success: true, binding };
},
deleteMailAddressBinding: async (id: string, userId: string) => {
deletedRequests.push({ id, userId });
return { success: true };
},
listWorkAppMailBindings: async () => [{
id: 'workapp-mail-1',
owner: binding.owner as plugins.servezoneInterfaces.data.IMailResourceOwner & { appInstanceId: string },
enabled: true,
status: 'active' as const,
addressBindingIds: [binding.id],
createdAt: 1,
updatedAt: 1,
createdBy: 'token-user',
}],
},
},
});
const listResult = await fireTypedRequest(typedrouter, 'listMailAddressBindings', {
auth: { apiToken: 'valid-token' },
owner: { appInstanceId: 'app-1' },
});
expect(listResult.error).toBeUndefined();
expect(listResult.response.bindings).toEqual([binding]);
const syncResult = await fireTypedRequest(typedrouter, 'syncMailAddressBinding', {
auth: { apiToken: 'valid-token' },
binding,
});
expect(syncResult.error).toBeUndefined();
expect(syncResult.response.success).toEqual(true);
expect(syncedRequests[0].userId).toEqual('token-user');
const workAppListResult = await fireTypedRequest(typedrouter, 'listWorkAppMailBindings', {
auth: { apiToken: 'valid-token' },
owner: { appInstanceId: 'app-1' },
});
expect(workAppListResult.error).toBeUndefined();
expect(workAppListResult.response.bindings[0].addressBindingIds).toEqual(['mail-1']);
const deleteResult = await fireTypedRequest(typedrouter, 'deleteMailAddressBinding', {
auth: { apiToken: 'valid-token' },
id: binding.id,
});
expect(deleteResult.error).toBeUndefined();
expect(deleteResult.response.success).toEqual(true);
expect(deletedRequests[0]).toEqual({ id: 'mail-1', userId: 'token-user' });
});
tap.test('WorkHosterHandler scopes shared mail handlers to gateway client token policy', async () => {
const listFilters: any[] = [];
const workAppFilters: any[] = [];
const syncedRequests: Array<{ binding: any; userId: string }> = [];
const deletedRequests: Array<{ id: string; userId: string }> = [];
const binding: plugins.servezoneInterfaces.data.IMailAddressBinding = {
id: 'mail-owned',
owner: {
gatewayClientType: 'onebox',
gatewayClientId: 'box-policy',
appInstanceId: 'app-1',
},
address: 'hello@example.com',
localPart: 'hello',
domain: 'example.com',
enabled: true,
status: 'active',
inboundTarget: {
type: 'smtpForward',
smtpForward: {
host: '10.0.0.2',
port: 2525,
},
},
createdAt: 1,
updatedAt: 1,
createdBy: 'token-user',
};
const { typedrouter } = setupHandler({
scopes: ['gateway-clients:read', 'gateway-clients:write'],
policy: {
role: 'gatewayClient',
gatewayClient: { type: 'onebox', id: 'box-policy' },
allowedRouteTargets: [{ host: '10.0.0.2', ports: [2525] }],
capabilities: { syncRoutes: true },
},
dcRouterRef: {
options: {},
workAppMailManager: {
listMailAddressBindings: async (filter: any) => {
listFilters.push(filter);
return filter.owner?.gatewayClientId === 'box-policy' ? [binding] : [];
},
syncMailAddressBinding: async (data: any, userId: string) => {
syncedRequests.push({ binding: data, userId });
return { success: true, binding: data };
},
deleteMailAddressBinding: async (id: string, userId: string) => {
deletedRequests.push({ id, userId });
return { success: true };
},
listWorkAppMailBindings: async (owner: any) => {
workAppFilters.push(owner);
return owner?.gatewayClientId === 'box-policy' ? [{
id: 'workapp-mail-1',
owner: binding.owner as plugins.servezoneInterfaces.data.IMailResourceOwner & { appInstanceId: string },
enabled: true,
status: 'active' as const,
addressBindingIds: [binding.id],
createdAt: 1,
updatedAt: 1,
createdBy: 'token-user',
}] : [];
},
},
},
});
const listResult = await fireTypedRequest(typedrouter, 'listMailAddressBindings', {
auth: { apiToken: 'valid-token' },
});
expect(listResult.error).toBeUndefined();
expect(listResult.response.bindings).toEqual([binding]);
expect(listFilters[0].owner.gatewayClientId).toEqual('box-policy');
const workAppListResult = await fireTypedRequest(typedrouter, 'listWorkAppMailBindings', {
auth: { apiToken: 'valid-token' },
owner: { appInstanceId: 'app-1' },
});
expect(workAppListResult.error).toBeUndefined();
expect(workAppListResult.response.bindings[0].addressBindingIds).toEqual(['mail-owned']);
expect(workAppFilters[0].gatewayClientId).toEqual('box-policy');
const spoofResult = await fireTypedRequest(typedrouter, 'syncMailAddressBinding', {
auth: { apiToken: 'valid-token' },
binding: {
...binding,
owner: { ...binding.owner, gatewayClientId: 'other-box' },
},
});
expect(spoofResult.error).toBeUndefined();
expect(spoofResult.response.success).toEqual(false);
expect(spoofResult.response.message).toEqual('gateway client token cannot act for this ownership');
const blockedTargetResult = await fireTypedRequest(typedrouter, 'syncMailAddressBinding', {
auth: { apiToken: 'valid-token' },
binding: {
...binding,
inboundTarget: {
type: 'smtpForward',
smtpForward: { host: '10.0.0.9', port: 2525 },
},
},
});
expect(blockedTargetResult.error).toBeUndefined();
expect(blockedTargetResult.response.success).toEqual(false);
expect(blockedTargetResult.response.message).toEqual('mail target is outside token policy: 10.0.0.9:2525');
const syncResult = await fireTypedRequest(typedrouter, 'syncMailAddressBinding', {
auth: { apiToken: 'valid-token' },
binding,
});
expect(syncResult.error).toBeUndefined();
expect(syncResult.response.success).toEqual(true);
expect(syncedRequests[0].binding.owner.gatewayClientId).toEqual('box-policy');
const skippedDeleteResult = await fireTypedRequest(typedrouter, 'deleteMailAddressBinding', {
auth: { apiToken: 'valid-token' },
id: 'mail-other',
});
expect(skippedDeleteResult.error).toBeUndefined();
expect(skippedDeleteResult.response.success).toEqual(true);
expect(deletedRequests.length).toEqual(0);
const deleteResult = await fireTypedRequest(typedrouter, 'deleteMailAddressBinding', {
auth: { apiToken: 'valid-token' },
id: binding.id,
});
expect(deleteResult.error).toBeUndefined();
expect(deleteResult.response.success).toEqual(true);
expect(deletedRequests[0]).toEqual({ id: 'mail-owned', userId: 'token-user' });
});
tap.test('WorkHosterHandler rejects shared mail sync without gateway-clients:write', async () => {
const { typedrouter } = setupHandler({
scopes: ['gateway-clients:read'],
dcRouterRef: {
options: {},
workAppMailManager: {
syncMailAddressBinding: async () => ({ success: true }),
},
},
});
const result = await fireTypedRequest(typedrouter, 'syncMailAddressBinding', {
auth: { apiToken: 'valid-token' },
binding: {
owner: {
gatewayClientType: 'onebox',
gatewayClientId: 'box-1',
appInstanceId: 'app-1',
},
address: 'hello@example.com',
localPart: 'hello',
domain: 'example.com',
enabled: true,
},
});
expect(result.error?.text).toEqual('insufficient scope');
});
export default tap.start();
+1 -1
View File
@@ -3,6 +3,6 @@
*/
export const commitinfo = {
name: '@serve.zone/dcrouter',
version: '13.44.1',
version: '14.1.0',
description: 'A multifaceted routing service handling mail and SMS delivery functions.'
}
+3 -77
View File
@@ -1,14 +1,12 @@
import { logger } from '../logger.js';
import { AcmeConfigDoc } from '../db/documents/index.js';
import type { IDcRouterOptions } from '../classes.dcrouter.js';
import type { IAcmeConfig } from '../../ts_interfaces/data/acme-config.js';
/**
* AcmeConfigManager — owns the singleton ACME configuration in the DB.
*
* Lifecycle:
* - `start()` — loads from the DB; if empty, seeds from legacy constructor
* fields (`tls.contactEmail`, `smartProxyConfig.acme.*`) on first boot.
* - `start()` — loads the DB-backed singleton configuration.
* - `getConfig()` — returns the in-memory cached `IAcmeConfig` (or null)
* - `updateConfig(args, updatedBy)` — upserts and refreshes the cache
*
@@ -20,32 +18,12 @@ import type { IAcmeConfig } from '../../ts_interfaces/data/acme-config.js';
export class AcmeConfigManager {
private cached: IAcmeConfig | null = null;
constructor(private options: IDcRouterOptions) {}
public async start(): Promise<void> {
logger.log('info', 'AcmeConfigManager: starting');
let doc = await AcmeConfigDoc.load();
const doc = await AcmeConfigDoc.load();
if (!doc) {
// First-boot path: seed from legacy constructor fields if present.
const seed = this.deriveSeedFromOptions();
if (seed) {
doc = await this.createSeedDoc(seed);
logger.log(
'info',
`AcmeConfigManager: seeded from constructor legacy fields (accountEmail=${seed.accountEmail}, useProduction=${seed.useProduction})`,
);
} else {
logger.log(
'info',
'AcmeConfigManager: no AcmeConfig in DB and no legacy constructor fields — ACME disabled until configured via Domains > Certificates > Settings.',
);
}
} else if (this.deriveSeedFromOptions()) {
logger.log(
'warn',
'AcmeConfigManager: ignoring constructor tls.contactEmail / smartProxyConfig.acme — DB already has AcmeConfigDoc. Manage via Domains > Certificates > Settings.',
);
logger.log('info', 'AcmeConfigManager: no AcmeConfig in DB — ACME disabled until configured via Domains > Certificates > Settings.');
}
this.cached = doc ? this.toPlain(doc) : null;
@@ -116,58 +94,6 @@ export class AcmeConfigManager {
// Internal helpers
// ==========================================================================
/**
* Build a seed object from the legacy constructor fields. Returns null
* if the user has not provided any of them.
*
* Supports BOTH `tls.contactEmail` (short form) and `smartProxyConfig.acme`
* (full form). `smartProxyConfig.acme` wins when both are present.
*/
private deriveSeedFromOptions(): Omit<IAcmeConfig, 'updatedAt' | 'updatedBy'> | null {
const acme = this.options.smartProxyConfig?.acme;
const tls = this.options.tls;
// Prefer the explicit smartProxyConfig.acme block if present.
if (acme?.accountEmail) {
return {
accountEmail: acme.accountEmail,
enabled: acme.enabled !== false,
useProduction: acme.useProduction !== false,
autoRenew: acme.autoRenew !== false,
renewThresholdDays: acme.renewThresholdDays ?? 30,
};
}
// Fall back to the short tls.contactEmail form.
if (tls?.contactEmail) {
return {
accountEmail: tls.contactEmail,
enabled: true,
useProduction: true,
autoRenew: true,
renewThresholdDays: 30,
};
}
return null;
}
private async createSeedDoc(
seed: Omit<IAcmeConfig, 'updatedAt' | 'updatedBy'>,
): Promise<AcmeConfigDoc> {
const doc = new AcmeConfigDoc();
doc.configId = 'acme-config';
doc.accountEmail = seed.accountEmail;
doc.enabled = seed.enabled;
doc.useProduction = seed.useProduction;
doc.autoRenew = seed.autoRenew;
doc.renewThresholdDays = seed.renewThresholdDays;
doc.updatedAt = Date.now();
doc.updatedBy = 'seed';
await doc.save();
return doc;
}
private toPlain(doc: AcmeConfigDoc): IAcmeConfig {
return {
accountEmail: doc.accountEmail,
+247 -50
View File
@@ -37,6 +37,8 @@ import type { IEmailPortConfig, IEmailServerSettings, IEmailServerSettingsSeed,
import type { IDcRouterRouteConfig, IRemoteIngressHubSettings, IRemoteIngressPerformanceConfig, TRemoteIngressHubSettingsUpdate } from '../ts_interfaces/data/remoteingress.js';
import type { ISecurityCompiledPolicy } from '../ts_interfaces/data/security-policy.js';
type TInboundProxyProtocolPolicy = NonNullable<plugins.smartproxy.IRouteMatch['inboundProxyProtocol']>;
export interface IDcRouterOptions {
/** Base directory for all dcrouter data. Defaults to ~/.serve.zone/dcrouter */
baseDir?: string;
@@ -454,14 +456,13 @@ export class DcRouter {
// AcmeConfigManager: optional, depends on DcRouterDb — owns the singleton
// ACME configuration (accountEmail, useProduction, etc.). Must run before
// SmartProxy so setupSmartProxy() can read the ACME config from the DB.
// On first boot, seeds from legacy `tls.contactEmail` / `smartProxyConfig.acme`.
if (this.options.dbConfig?.enabled !== false) {
this.serviceManager.addService(
new plugins.taskbuffer.Service('AcmeConfigManager')
.optional()
.dependsOn('DcRouterDb')
.withStart(async () => {
this.acmeConfigManager = new AcmeConfigManager(this.options);
this.acmeConfigManager = new AcmeConfigManager();
await this.acmeConfigManager.start();
})
.withStop(async () => {
@@ -648,6 +649,7 @@ export class DcRouter {
},
(preparedRoutes) => buildHttpRedirectRuntimeRoutes(preparedRoutes || []),
(storedRoute: IRoute) => this.hydrateStoredRouteForRuntime(storedRoute),
(routes) => this.applyInboundProxyProtocolPolicies(routes),
);
this.apiTokenManager = new ApiTokenManager();
await this.apiTokenManager.initialize();
@@ -813,7 +815,7 @@ export class DcRouter {
?? false;
}
private getRemoteIngressHubSettingsLegacySeed(): TRemoteIngressHubSettingsUpdate {
private getRemoteIngressHubSettingsMigrationSeed(): TRemoteIngressHubSettingsUpdate {
const remoteIngressConfig = this.options.remoteIngressConfig;
const seed: TRemoteIngressHubSettingsUpdate = {};
if (remoteIngressConfig?.enabled !== undefined) {
@@ -831,7 +833,7 @@ export class DcRouter {
return seed;
}
private getEmailSettingsLegacySeed(): IEmailServerSettingsSeed {
private getEmailSettingsMigrationSeed(): IEmailServerSettingsSeed {
const seed: IEmailServerSettingsSeed = {};
if (this.options.emailConfig) {
seed.enabled = true;
@@ -1106,8 +1108,8 @@ export class DcRouter {
// Run any pending data migrations before anything else reads from the DB.
// This must complete before ConfigManagers loads profiles.
const migration = await createMigrationRunner(this.dcRouterDb.getDb(), commitinfo.version, {
remoteIngressHubSettings: this.getRemoteIngressHubSettingsLegacySeed(),
emailServerSettings: this.getEmailSettingsLegacySeed(),
remoteIngressHubSettings: this.getRemoteIngressHubSettingsMigrationSeed(),
emailServerSettings: this.getEmailSettingsMigrationSeed(),
});
const migrationResult = await migration.run();
if (migrationResult.stepsApplied.length > 0) {
@@ -1172,7 +1174,7 @@ export class DcRouter {
// Combined routes for SmartProxy bootstrap (before DB routes are loaded)
let routes: plugins.smartproxy.IRouteConfig[] = [
...this.seedConfigRoutes,
...this.seedEmailRoutes,
...this.getRuntimeEmailRoutes(this.seedEmailRoutes as IDcRouterRouteConfig[]),
...this.runtimeDnsRoutes,
];
@@ -1221,6 +1223,7 @@ export class DcRouter {
routes = augmentRoutesWithHttp3(routes, http3Config);
logger.log('info', 'HTTP/3: Augmented qualifying HTTPS routes with QUIC/H3 configuration');
}
routes = this.applyInboundProxyProtocolPolicies(routes);
const compiledSecurityPolicy = await this.securityPolicyManager?.compileSmartProxyPolicy();
const mergedSecurityPolicy = this.mergeSecurityPolicies(
@@ -1380,27 +1383,12 @@ export class DcRouter {
};
}
// When remoteIngress is enabled, the hub binary forwards tunneled connections
// to SmartProxy with PROXY protocol v1 headers to preserve client IPs.
if (this.isRemoteIngressHubEnabled()) {
smartProxyConfig.acceptProxyProtocol = true;
if (!smartProxyConfig.proxyIPs) {
smartProxyConfig.proxyIPs = [];
}
if (!smartProxyConfig.proxyIPs.includes('127.0.0.1')) {
smartProxyConfig.proxyIPs.push('127.0.0.1');
}
}
// VPN uses socket mode with PP v2 — SmartProxy must accept proxy protocol from localhost
if (this.options.vpnConfig?.enabled) {
smartProxyConfig.acceptProxyProtocol = true;
if (!smartProxyConfig.proxyIPs) {
smartProxyConfig.proxyIPs = [];
}
if (!smartProxyConfig.proxyIPs.includes('127.0.0.1')) {
smartProxyConfig.proxyIPs.push('127.0.0.1');
}
// RemoteIngress and VPN forward through localhost with PROXY protocol.
// SmartProxy only uses this as a trust list; routes still opt in per listener.
if (this.isRemoteIngressHubEnabled() || this.options.vpnConfig?.enabled) {
const trustedProxyIPs = new Set(smartProxyConfig.trustedProxyIPs || []);
trustedProxyIPs.add('127.0.0.1');
smartProxyConfig.trustedProxyIPs = [...trustedProxyIPs];
}
// Create SmartProxy instance
@@ -1577,6 +1565,101 @@ export class DcRouter {
private applyInboundProxyProtocolPolicies(
routes: plugins.smartproxy.IRouteConfig[],
): plugins.smartproxy.IRouteConfig[] {
const policiesByListener = new Map<string, TInboundProxyProtocolPolicy>();
for (const route of routes) {
const policy = route.match?.inboundProxyProtocol || this.getDesiredInboundProxyProtocolPolicy(route);
if (!policy) {
continue;
}
for (const listenerKey of this.getInboundProxyListenerKeys(route)) {
const mergedPolicy = this.mergeInboundProxyProtocolPolicies(
policiesByListener.get(listenerKey),
policy,
);
if (mergedPolicy) {
policiesByListener.set(listenerKey, mergedPolicy);
}
}
}
if (policiesByListener.size === 0) {
return routes;
}
return routes.map((route) => {
if (route.match?.inboundProxyProtocol) {
return route;
}
let listenerPolicy: TInboundProxyProtocolPolicy | undefined;
for (const listenerKey of this.getInboundProxyListenerKeys(route)) {
listenerPolicy = this.mergeInboundProxyProtocolPolicies(
listenerPolicy,
policiesByListener.get(listenerKey),
);
}
if (!listenerPolicy) {
return route;
}
return {
...route,
match: {
...route.match,
inboundProxyProtocol: listenerPolicy,
},
};
});
}
private getDesiredInboundProxyProtocolPolicy(
route: plugins.smartproxy.IRouteConfig,
): TInboundProxyProtocolPolicy | undefined {
const dcRoute = route as IDcRouterRouteConfig;
if (this.isRemoteIngressHubEnabled() && dcRoute.remoteIngress?.enabled) {
const ports = plugins.smartproxy.expandPortRange(route.match.ports as any) as number[];
if (ports.some((port) => port === 25 || port === 587)) {
return { mode: 'required' };
}
return { mode: 'optional' };
}
if (this.options.vpnConfig?.enabled) {
return { mode: 'optional' };
}
return undefined;
}
private getInboundProxyListenerKeys(route: plugins.smartproxy.IRouteConfig): string[] {
const ports = plugins.smartproxy.expandPortRange(route.match.ports as any) as number[];
const transports = route.match.transport === 'udp'
? ['udp']
: route.match.transport === 'all'
? ['tcp', 'udp']
: ['tcp'];
const keys: string[] = [];
for (const port of ports) {
for (const transport of transports) {
keys.push(`${transport}:${port}`);
}
}
return keys;
}
private mergeInboundProxyProtocolPolicies(
current?: TInboundProxyProtocolPolicy,
next?: TInboundProxyProtocolPolicy,
): TInboundProxyProtocolPolicy | undefined {
if (!current) return next;
if (!next) return current;
if (current.mode === 'required') return current;
if (next.mode === 'required') return next;
if (current.mode === 'optional') return current;
if (next.mode === 'optional') return next;
return current;
}
/**
* Generate SmartProxy routes for email configuration
*/
@@ -1658,13 +1741,18 @@ export class DcRouter {
const routeConfig: IDcRouterRouteConfig = {
name: routeName,
match: {
ports: [port]
ports: [port],
transport: 'tcp',
},
action: action
};
if (this.isRemoteIngressHubEnabled()) {
routeConfig.remoteIngress = { enabled: true };
const inboundProxyProtocol = this.getRemoteIngressEmailInboundProxyPolicy(port);
if (inboundProxyProtocol) {
routeConfig.match.inboundProxyProtocol = inboundProxyProtocol;
}
}
// Add the route to our list
@@ -1715,6 +1803,131 @@ export class DcRouter {
return dnsRoutes;
}
private getRuntimeEmailRoutes(emailRoutes: IDcRouterRouteConfig[]): plugins.smartproxy.IRouteConfig[] {
return emailRoutes.map((route) => this.createServerFirstEmailRuntimeRoute(route) || route);
}
private getCurrentGeneratedEmailRouteNames(): Set<string> {
const sourceRoutes = this.seedEmailRoutes.length > 0
? this.seedEmailRoutes
: this.options.emailConfig
? this.generateEmailRoutes(this.options.emailConfig)
: [];
return new Set(sourceRoutes.map((route) => route.name).filter(Boolean) as string[]);
}
private shouldHydrateGeneratedEmailRoute(storedRoute: IRoute): boolean {
if (storedRoute.origin !== 'email') {
return false;
}
const routeName = storedRoute.route.name;
if (!routeName || !this.getCurrentGeneratedEmailRouteNames().has(routeName)) {
return false;
}
const expectedSystemKey = `email:${routeName}`;
return !storedRoute.systemKey || storedRoute.systemKey === expectedSystemKey;
}
private createServerFirstEmailRuntimeRoute(
route: plugins.smartproxy.IRouteConfig,
): plugins.smartproxy.IRouteConfig | undefined {
const action = route.action as any;
if (action?.type !== 'forward') {
return undefined;
}
const tlsMode = action.tls?.mode;
if (tlsMode === 'terminate' || tlsMode === 'terminate-and-reencrypt') {
return undefined;
}
const routePorts = plugins.smartproxy.expandPortRange(route.match?.ports as any) as number[];
if (routePorts.length !== 1) {
return undefined;
}
const target = action.targets?.[0];
if (!target || action.targets.length !== 1 || typeof target.port !== 'number') {
return undefined;
}
if (typeof target.host !== 'string') {
return undefined;
}
const targetHost = target.host === 'localhost' ? '127.0.0.1' : target.host;
const inboundProxyProtocol = this.getRemoteIngressEmailInboundProxyPolicy(routePorts[0]);
return {
...route,
match: {
...route.match,
...(inboundProxyProtocol
? { inboundProxyProtocol }
: {}),
},
action: {
type: 'socket-handler' as any,
socketHandler: this.createEmailSocketProxyHandler(targetHost, target.port),
} as any,
};
}
private getRemoteIngressEmailInboundProxyPolicy(
port: number,
): TInboundProxyProtocolPolicy | undefined {
if (!this.isRemoteIngressHubEnabled()) {
return undefined;
}
return { mode: port === 25 || port === 587 ? 'required' : 'optional' };
}
private createEmailSocketProxyHandler(
targetHost: string,
targetPort: number,
): NonNullable<plugins.smartproxy.IRouteConfig['action']['socketHandler']> {
return (clientSocket) => {
let backendSocket: plugins.net.Socket | undefined;
let connectTimeout: ReturnType<typeof setTimeout> & { unref?: () => void };
let cleanupDone = false;
const cleanup = () => {
if (cleanupDone) return;
cleanupDone = true;
clearTimeout(connectTimeout);
clientSocket.removeListener('timeout', cleanup);
clientSocket.removeListener('error', cleanup);
clientSocket.removeListener('end', cleanup);
clientSocket.removeListener('close', cleanup);
backendSocket?.removeListener('timeout', cleanup);
backendSocket?.removeListener('error', cleanup);
backendSocket?.removeListener('end', cleanup);
backendSocket?.removeListener('close', cleanup);
clientSocket.destroy();
backendSocket?.destroy();
};
connectTimeout = setTimeout(() => {
cleanup();
}, 30_000);
connectTimeout.unref?.();
clientSocket.setTimeout(300_000);
clientSocket.on('timeout', cleanup);
clientSocket.on('error', cleanup);
clientSocket.on('end', cleanup);
clientSocket.on('close', cleanup);
backendSocket = plugins.net.connect(targetPort, targetHost, () => {
clearTimeout(connectTimeout);
backendSocket?.setTimeout(300_000);
clientSocket.pipe(backendSocket!);
backendSocket!.pipe(clientSocket);
});
backendSocket.setTimeout(30_000);
backendSocket.on('timeout', cleanup);
backendSocket.on('error', cleanup);
backendSocket.on('end', cleanup);
backendSocket.on('close', cleanup);
};
}
private hydrateStoredRouteForRuntime(storedRoute: IRoute): plugins.smartproxy.IRouteConfig | undefined {
const routeName = storedRoute.route.name || '';
const isDohRoute = storedRoute.origin === 'dns'
@@ -1722,6 +1935,9 @@ export class DcRouter {
&& routeName.startsWith('dns-over-https-');
if (!isDohRoute) {
if (this.shouldHydrateGeneratedEmailRoute(storedRoute)) {
return this.createServerFirstEmailRuntimeRoute(storedRoute.route);
}
return undefined;
}
@@ -1860,28 +2076,9 @@ export class DcRouter {
465: 10465 // SMTPS
};
// Transform domains if they are provided as strings
let transformedDomains = this.options.emailConfig.domains;
if (transformedDomains && transformedDomains.length > 0) {
// Check if domains are strings (for backward compatibility)
if (typeof transformedDomains[0] === 'string') {
transformedDomains = (transformedDomains as any).map((domain: string) => ({
domain,
dnsMode: 'external-dns' as const,
dkim: {
selector: 'default',
keySize: 2048,
rotateKeys: false,
rotationInterval: 90
}
}));
}
}
// Create config with mapped ports
const emailConfig: IUnifiedEmailServerOptions = await this.workAppMailManager.applyStoredIdentitiesToEmailConfig({
...this.options.emailConfig,
domains: transformedDomains,
ports: this.options.emailConfig.ports.map(port => portMapping[port] || port + 10000),
persistRoutes: this.options.emailConfig.persistRoutes ?? false,
queue: {
@@ -2251,8 +2448,8 @@ export class DcRouter {
// Ensure DKIM keys exist for internal-dns domains before generating records.
await this.initializeDkimForEmailDomains();
// Generate DKIM records directly from smartmta instead of scanning legacy JSON files.
const dkimRecords = await this.loadDkimRecords();
// Generate DKIM records directly from smartmta.
const dkimRecords = await this.loadDkimRecords();
// Combine all records: authoritative, email, DKIM, and user-defined
const allRecords = [...authoritativeRecords, ...emailDnsRecords, ...dkimRecords];
+2 -2
View File
@@ -111,13 +111,13 @@ export class ApiTokenManager {
const scopes = new Set<TApiTokenScope>([...token.scopes, ...(token.policy?.scopes || [])]);
if (scopes.has(scope)) return true;
const compatibilityAliases: Partial<Record<TApiTokenScope, TApiTokenScope[]>> = {
const equivalentScopes: Partial<Record<TApiTokenScope, TApiTokenScope[]>> = {
'gateway-clients:read': ['workhosters:read'],
'gateway-clients:write': ['workhosters:write'],
'workhosters:read': ['gateway-clients:read'],
'workhosters:write': ['gateway-clients:write'],
};
return Boolean(compatibilityAliases[scope]?.some((alias) => scopes.has(alias)));
return Boolean(equivalentScopes[scope]?.some((alias) => scopes.has(alias)));
}
/**
+5 -1
View File
@@ -68,6 +68,7 @@ export class RouteConfigManager {
private onRoutesApplied?: (routes: plugins.smartproxy.IRouteConfig[]) => void | Promise<void>,
private getRuntimeRoutes?: (preparedRoutes?: plugins.smartproxy.IRouteConfig[]) => plugins.smartproxy.IRouteConfig[],
private hydrateStoredRoute?: (storedRoute: IRoute) => plugins.smartproxy.IRouteConfig | undefined,
private applyInboundProxyPolicies?: (routes: plugins.smartproxy.IRouteConfig[]) => plugins.smartproxy.IRouteConfig[],
) {}
/** Expose routes map for reference resolution lookups. */
@@ -714,12 +715,15 @@ export class RouteConfigManager {
const smartProxy = this.getSmartProxy();
if (!smartProxy) return;
const enabledRoutes = this.getPreparedEnabledRoutesForApply();
let enabledRoutes = this.getPreparedEnabledRoutesForApply();
const runtimeRoutes = this.getRuntimeRoutes?.(enabledRoutes) || [];
for (const route of runtimeRoutes) {
enabledRoutes.push(this.prepareRouteForApply(route));
}
if (this.applyInboundProxyPolicies) {
enabledRoutes = this.applyInboundProxyPolicies(enabledRoutes);
}
await smartProxy.updateRoutes(enabledRoutes);
+1 -3
View File
@@ -8,9 +8,7 @@ const getDb = () => DcRouterDb.getInstance().getDb();
* keyed on the fixed `configId = 'acme-config'` following the
* `VpnServerKeysDoc` pattern.
*
* Replaces the legacy `tls.contactEmail` and `smartProxyConfig.acme.*`
* constructor fields. Managed via the OpsServer UI at
* **Domains > Certificates > Settings**.
* Managed via the OpsServer UI at **Domains > Certificates > Settings**.
*/
@plugins.smartdata.Collection(() => getDb())
export class AcmeConfigDoc extends plugins.smartdata.SmartDataDbDoc<AcmeConfigDoc, AcmeConfigDoc> {
-103
View File
@@ -24,7 +24,6 @@ import type {
*
* Responsibilities:
* - Load Domain/DnsRecord docs from the DB on start
* - First-boot seeding from legacy constructor config (dnsScopes/dnsRecords/dnsNsDomains)
* - Register dcrouter-hosted domain records with smartdns.DnsServer at startup
* - Provide CRUD methods used by OpsServer handlers (dcrouter-hosted domains hit
* smartdns, provider domains hit the provider API)
@@ -53,13 +52,8 @@ export class DnsManager {
// Lifecycle
// ==========================================================================
/**
* Called from DcRouter after DcRouterDb is up. Performs first-boot seeding
* from legacy constructor config if (and only if) the DB is empty.
*/
public async start(): Promise<void> {
logger.log('info', 'DnsManager: starting');
await this.seedFromConstructorConfigIfEmpty();
}
public async stop(): Promise<void> {
@@ -77,103 +71,6 @@ export class DnsManager {
await this.applyDcrouterDomainsToDnsServer();
}
// ==========================================================================
// First-boot seeding
// ==========================================================================
/**
* If no DomainDocs exist yet but the constructor has legacy DNS fields,
* seed them as dcrouter-hosted (`domain.source: 'dcrouter'`) zones with
* local (`record.source: 'local'`) records. On subsequent boots (DB has
* entries), constructor config is ignored with a warning.
*/
private async seedFromConstructorConfigIfEmpty(): Promise<void> {
const existingDomains = await DomainDoc.findAll();
const hasLegacyConfig =
(this.options.dnsScopes && this.options.dnsScopes.length > 0) ||
(this.options.dnsRecords && this.options.dnsRecords.length > 0);
if (existingDomains.length > 0) {
if (hasLegacyConfig) {
logger.log(
'warn',
'DnsManager: DB has DomainDoc entries — ignoring legacy dnsScopes/dnsRecords constructor config. ' +
'dnsNsDomains is still required for nameserver and DoH bootstrap unless that moves into DB-backed config.',
);
}
return;
}
if (!hasLegacyConfig) {
return;
}
logger.log('info', 'DnsManager: seeding DB from legacy constructor DNS config');
const now = Date.now();
const seededDomains = new Map<string, DomainDoc>();
// Create one DomainDoc per dnsScope (these are the authoritative zones)
for (const scope of this.options.dnsScopes ?? []) {
const domain = new DomainDoc();
domain.id = plugins.uuid.v4();
domain.name = scope.toLowerCase();
domain.source = 'dcrouter';
domain.authoritative = true;
domain.createdAt = now;
domain.updatedAt = now;
domain.createdBy = 'seed';
await domain.save();
seededDomains.set(domain.name, domain);
logger.log('info', `DnsManager: seeded DomainDoc for ${domain.name}`);
}
// Map each legacy dnsRecord to its parent DomainDoc
for (const rec of this.options.dnsRecords ?? []) {
const parent = this.findParentDomain(rec.name, seededDomains);
if (!parent) {
logger.log(
'warn',
`DnsManager: legacy dnsRecord '${rec.name}' has no matching dnsScope — skipping seed`,
);
continue;
}
const record = new DnsRecordDoc();
record.id = plugins.uuid.v4();
record.domainId = parent.id;
record.name = rec.name.toLowerCase();
record.type = rec.type as TDnsRecordType;
record.value = rec.value;
record.ttl = rec.ttl ?? 300;
record.source = 'local';
record.createdAt = now;
record.updatedAt = now;
record.createdBy = 'seed';
await record.save();
}
logger.log(
'info',
`DnsManager: seeded ${seededDomains.size} domain(s) and ${this.options.dnsRecords?.length ?? 0} record(s) from legacy config`,
);
}
private findParentDomain(
recordName: string,
domains: Map<string, DomainDoc>,
): DomainDoc | null {
const lower = recordName.toLowerCase().replace(/^\*\./, '');
let candidate: DomainDoc | null = null;
for (const [name, doc] of domains) {
if (lower === name || lower.endsWith(`.${name}`)) {
if (!candidate || name.length > candidate.name.length) {
candidate = doc;
}
}
}
return candidate;
}
// ==========================================================================
// DcRouter-hosted domain DnsServer wiring
// ==========================================================================
+234
View File
@@ -6,6 +6,12 @@ import * as plugins from '../plugins.js';
import type * as interfaces from '../../ts_interfaces/index.js';
type TSyncRequest = interfaces.requests.IReq_SyncWorkAppMailIdentity['request'];
type TMailResourceOwner = plugins.servezoneInterfaces.data.IMailResourceOwner;
type TMailAddressBinding = plugins.servezoneInterfaces.data.IMailAddressBinding;
type TMailAddressBindingSync = plugins.servezoneInterfaces.requests.mail.TMailAddressBindingSync;
type TMailAddressBindingSyncResponse = plugins.servezoneInterfaces.requests.mail.IReq_SyncMailAddressBinding['response'];
type TMailAddressBindingDeleteResponse = plugins.servezoneInterfaces.requests.mail.IReq_DeleteMailAddressBinding['response'];
type TWorkAppMailBinding = plugins.servezoneInterfaces.data.IWorkAppMailBinding;
interface IStoredWorkAppMailIdentity extends interfaces.data.IWorkAppMailIdentity {
smtpPassword: string;
@@ -109,6 +115,89 @@ export class WorkAppMailManager {
return response;
}
public async listMailAddressBindings(options: {
owner?: Partial<TMailResourceOwner>;
domain?: string;
address?: string;
} = {}): Promise<TMailAddressBinding[]> {
const domain = options.domain ? this.normalizeDomain(options.domain) : undefined;
const address = options.address ? this.normalizeAddress(options.address) : undefined;
const identities = await this.readStoredIdentities();
return identities
.filter((identity) => this.matchesMailOwner(this.toMailOwner(identity.ownership), options.owner))
.filter((identity) => domain ? identity.domain === domain : true)
.filter((identity) => address ? identity.address === address : true)
.map((identity) => this.toMailAddressBinding(identity));
}
public async listWorkAppMailBindings(
owner?: Partial<TMailResourceOwner>,
): Promise<TWorkAppMailBinding[]> {
const identities = (await this.readStoredIdentities())
.filter((identity) => this.matchesMailOwner(this.toMailOwner(identity.ownership), owner));
const groups = new Map<string, IStoredWorkAppMailIdentity[]>();
for (const identity of identities) {
const ownerKey = this.buildMailOwnerKey(this.toMailOwner(identity.ownership));
const group = groups.get(ownerKey) || [];
group.push(identity);
groups.set(ownerKey, group);
}
return Array.from(groups.values()).map((group) => this.toWorkAppMailBinding(group));
}
public async syncMailAddressBinding(
binding: TMailAddressBindingSync,
createdBy: string,
): Promise<TMailAddressBindingSyncResponse> {
const ownership = this.normalizeMailResourceOwner(binding.owner);
const { localPart, domain } = this.normalizeMailAddressParts(binding);
const syncRequest: TSyncRequest = {
ownership,
localPart,
domain,
inbound: this.toLegacyInboundRoute(binding.inboundTarget),
enabled: binding.enabled,
};
if (binding.outboundIdentityId !== undefined) {
syncRequest.smtpEnabled = Boolean(binding.outboundIdentityId);
}
const result = await this.syncMailIdentity(syncRequest, createdBy);
return {
success: result.success,
binding: result.identity ? this.toMailAddressBinding(result.identity) : undefined,
message: result.message,
};
}
public async deleteMailAddressBinding(
id: string,
createdBy: string,
): Promise<TMailAddressBindingDeleteResponse> {
const identities = await this.readStoredIdentities();
const identity = identities.find((storedIdentity) => storedIdentity.id === id || storedIdentity.externalKey === id);
if (!identity) {
return { success: true };
}
const result = await this.syncMailIdentity({
ownership: identity.ownership,
localPart: identity.localPart,
domain: identity.domain,
delete: true,
}, createdBy);
return {
success: result.success,
message: result.message,
};
}
public async applyStoredIdentitiesToEmailConfig<TConfig extends IUnifiedEmailServerOptions>(
emailConfig: TConfig,
): Promise<TConfig> {
@@ -251,6 +340,63 @@ export class WorkAppMailManager {
return normalized;
}
private normalizeAddress(address: string): string {
const normalized = address?.trim().toLowerCase();
const [localPart, domain, extra] = normalized?.split('@') || [];
if (!localPart || !domain || extra) {
throw new Error(`Invalid email address: ${address}`);
}
return `${this.normalizeLocalPart(localPart)}@${this.normalizeDomain(domain)}`;
}
private normalizeMailResourceOwner(owner: TMailResourceOwner): interfaces.data.IWorkAppMailOwnership {
const gatewayClientType = owner.gatewayClientType;
const gatewayClientId = owner.gatewayClientId?.trim();
const appInstanceId = owner.appInstanceId?.trim();
if (gatewayClientType !== 'onebox' && gatewayClientType !== 'cloudly' && gatewayClientType !== 'custom') {
throw new Error(`Invalid gateway client type: ${gatewayClientType}`);
}
if (!gatewayClientId) throw new Error('gatewayClientId is required');
if (!appInstanceId) throw new Error('appInstanceId is required');
return {
workHosterType: gatewayClientType as interfaces.data.TGatewayClientType,
workHosterId: gatewayClientId,
workAppId: appInstanceId,
};
}
private normalizeMailAddressParts(binding: TMailAddressBindingSync): {
localPart: string;
domain: string;
} {
const localPart = this.normalizeLocalPart(binding.localPart);
const domain = this.normalizeDomain(binding.domain);
const address = this.normalizeAddress(binding.address);
if (address !== `${localPart}@${domain}`) {
throw new Error('mail address, localPart, and domain do not match');
}
return { localPart, domain };
}
private toLegacyInboundRoute(
inboundTarget?: TMailAddressBinding['inboundTarget'],
): interfaces.data.IWorkAppMailInboundRoute | undefined {
if (!inboundTarget) return undefined;
if (inboundTarget.type !== 'smtpForward' || !inboundTarget.smtpForward) {
throw new Error(`Unsupported WorkApp mail inbound target: ${inboundTarget.type}`);
}
return this.normalizeInboundRoute({
enabled: true,
targetHost: inboundTarget.smtpForward.host,
targetPort: inboundTarget.smtpForward.port,
preserveHeaders: inboundTarget.smtpForward.preserveHeaders,
addHeaders: inboundTarget.smtpForward.addHeaders,
});
}
private normalizeInboundRoute(
inbound?: interfaces.data.IWorkAppMailInboundRoute,
): interfaces.data.IWorkAppMailInboundRoute | undefined {
@@ -282,6 +428,17 @@ export class WorkAppMailManager {
return true;
}
private matchesMailOwner(
owner: TMailResourceOwner,
filter?: Partial<TMailResourceOwner>,
): boolean {
if (!filter) return true;
if (filter.gatewayClientType && filter.gatewayClientType !== owner.gatewayClientType) return false;
if (filter.gatewayClientId && filter.gatewayClientId !== owner.gatewayClientId) return false;
if (filter.appInstanceId && filter.appInstanceId !== owner.appInstanceId) return false;
return true;
}
private buildExternalKey(
ownership: interfaces.data.IWorkAppMailOwnership,
address: string,
@@ -298,6 +455,14 @@ export class WorkAppMailManager {
return `workapp-${this.hashExternalKey(externalKey).slice(0, 24)}`;
}
private buildMailOwnerKey(owner: TMailResourceOwner): string {
return [
owner.gatewayClientType,
owner.gatewayClientId,
owner.appInstanceId,
].join(':');
}
private buildRouteName(externalKey: string): string {
return `workapp-mail-${this.hashExternalKey(externalKey).slice(0, 32)}`;
}
@@ -334,6 +499,75 @@ export class WorkAppMailManager {
};
}
private toMailOwner(ownership: interfaces.data.IWorkAppMailOwnership): TMailResourceOwner & { appInstanceId: string } {
return {
gatewayClientType: ownership.workHosterType,
gatewayClientId: ownership.workHosterId,
appInstanceId: ownership.workAppId,
};
}
private toMailInboundTarget(
inbound?: interfaces.data.IWorkAppMailInboundRoute,
): TMailAddressBinding['inboundTarget'] {
if (!inbound?.enabled) return undefined;
return {
type: 'smtpForward',
smtpForward: {
host: inbound.targetHost,
port: inbound.targetPort,
preserveHeaders: inbound.preserveHeaders,
addHeaders: inbound.addHeaders,
},
};
}
private toMailAddressBinding(
identity: interfaces.data.IWorkAppMailIdentity,
): TMailAddressBinding {
return {
id: identity.id,
owner: this.toMailOwner(identity.ownership),
address: identity.address,
localPart: identity.localPart,
domain: identity.domain,
enabled: identity.enabled,
status: identity.enabled ? 'active' : 'disabled',
inboundTarget: this.toMailInboundTarget(identity.inbound),
outboundIdentityId: identity.smtp.enabled ? identity.smtp.username : undefined,
recipientPolicy: {
mode: 'staticList',
staticRecipients: [identity.address],
},
createdAt: identity.createdAt,
updatedAt: identity.updatedAt,
createdBy: identity.createdBy,
};
}
private toWorkAppMailBinding(
identities: IStoredWorkAppMailIdentity[],
): TWorkAppMailBinding {
const [firstIdentity] = identities;
const owner = this.toMailOwner(firstIdentity.ownership);
const enabledIdentities = identities.filter((identity) => identity.enabled);
const smtpIdentities = identities.filter((identity) => identity.smtp.enabled);
return {
id: `workapp-mail-${this.hashExternalKey(this.buildMailOwnerKey(owner)).slice(0, 32)}`,
owner,
enabled: enabledIdentities.length > 0,
status: enabledIdentities.length > 0 ? 'active' : 'disabled',
addressBindingIds: identities.map((identity) => identity.id),
outboundIdentityIds: smtpIdentities.map((identity) => identity.smtp.username),
defaultFrom: enabledIdentities[0]?.address || firstIdentity.address,
inboundTarget: identities.length === 1 ? this.toMailInboundTarget(firstIdentity.inbound) : undefined,
createdAt: Math.min(...identities.map((identity) => identity.createdAt)),
updatedAt: Math.max(...identities.map((identity) => identity.updatedAt)),
createdBy: firstIdentity.createdBy,
};
}
private toPublicIdentity(
identity: IStoredWorkAppMailIdentity,
): interfaces.data.IWorkAppMailIdentity {
@@ -61,17 +61,6 @@ export class CertificateHandler {
)
);
// Legacy route-based reprovision (backward compat)
router.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ReprovisionCertificate>(
'reprovisionCertificate',
async (dataArg) => {
await this.requireAuth(dataArg, 'certificates:write');
return this.reprovisionCertificateByRoute(dataArg.routeName);
}
)
);
// Domain-based reprovision (preferred)
router.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ReprovisionCertificateDomain>(
@@ -336,42 +325,6 @@ export class CertificateHandler {
return summary;
}
/**
* Legacy route-based reprovisioning. Kept for backward compatibility with
* older clients that send `reprovisionCertificate` typed-requests.
*
* Like reprovisionCertificateDomain, this triggers the full route apply
* pipeline rather than smartProxy.provisionCertificate(routeName) — which
* is a no-op when certProvisionFunction is set (Rust ACME disabled).
*/
private async reprovisionCertificateByRoute(routeName: string): Promise<{ success: boolean; message?: string }> {
const dcRouter = this.opsServerRef.dcRouterRef;
const smartProxy = dcRouter.smartProxy;
if (!smartProxy) {
return { success: false, message: 'SmartProxy is not running' };
}
// Clear event-based status for domains in this route so the
// certificate-issued event can refresh them
for (const [domain, entry] of dcRouter.certificateStatusMap) {
if (entry.routeNames.includes(routeName)) {
dcRouter.certificateStatusMap.delete(domain);
}
}
try {
if (dcRouter.routeConfigManager) {
await dcRouter.routeConfigManager.applyRoutes();
} else {
await smartProxy.updateRoutes(smartProxy.routeManager.getRoutes());
}
return { success: true, message: `Certificate reprovisioning triggered for route '${routeName}'` };
} catch (err: unknown) {
return { success: false, message: (err as Error).message || 'Failed to reprovision certificate' };
}
}
/**
* Domain-based reprovisioning — clears backoff first, refreshes the smartacme
* cert (when forceRenew is set), then re-applies routes so the running Rust
+11 -19
View File
@@ -39,14 +39,7 @@ export class ConfigHandler {
? 'custom'
: 'filesystem';
// Resolve proxy IPs: fall back to SmartProxy's runtime proxyIPs if not in opts
let proxyIps = opts.proxyIps || [];
if (proxyIps.length === 0 && dcRouter.smartProxy) {
const spSettings = (dcRouter.smartProxy as any).settings;
if (spSettings?.proxyIPs?.length > 0) {
proxyIps = spSettings.proxyIPs;
}
}
const proxyIps = opts.proxyIps || [];
const system: interfaces.requests.IConfigData['system'] = {
baseDir: resolvedPaths.dcrouterHomeDir,
@@ -59,15 +52,15 @@ export class ConfigHandler {
};
// --- SmartProxy ---
const acmeConfig = dcRouter.acmeConfigManager?.getConfig();
let acmeInfo: interfaces.requests.IConfigData['smartProxy']['acme'] = null;
if (opts.smartProxyConfig?.acme) {
const acme = opts.smartProxyConfig.acme;
if (acmeConfig) {
acmeInfo = {
enabled: acme.enabled !== false,
accountEmail: acme.accountEmail || '',
useProduction: acme.useProduction !== false,
autoRenew: acme.autoRenew !== false,
renewThresholdDays: acme.renewThresholdDays || 30,
enabled: acmeConfig.enabled,
accountEmail: acmeConfig.accountEmail,
useProduction: acmeConfig.useProduction,
autoRenew: acmeConfig.autoRenew,
renewThresholdDays: acmeConfig.renewThresholdDays,
};
}
@@ -127,8 +120,7 @@ export class ConfigHandler {
ttl: r.ttl,
}));
// dnsChallenge: true when at least one DnsProviderDoc exists in the DB
// (replaces the legacy `dnsChallenge.cloudflareApiKey` constructor field).
// dnsChallenge: true when at least one DnsProviderDoc exists in the DB.
let dnsChallengeEnabled = false;
try {
dnsChallengeEnabled = (await dcRouter.dnsManager?.hasAnyManagedDomain()) ?? false;
@@ -150,12 +142,12 @@ export class ConfigHandler {
let tlsSource: 'acme' | 'static' | 'none' = 'none';
if (opts.tls?.certPath && opts.tls?.keyPath) {
tlsSource = 'static';
} else if (opts.smartProxyConfig?.acme?.enabled !== false && opts.smartProxyConfig?.acme) {
} else if (acmeConfig?.enabled) {
tlsSource = 'acme';
}
const tls: interfaces.requests.IConfigData['tls'] = {
contactEmail: opts.tls?.contactEmail || opts.smartProxyConfig?.acme?.accountEmail || null,
contactEmail: acmeConfig?.accountEmail || null,
domain: opts.tls?.domain || null,
source: tlsSource,
certPath: opts.tls?.certPath || null,
@@ -66,7 +66,7 @@ export class EmailSettingsHandler {
routeCount: emailConfig?.routes?.length || 0,
authUserCount: emailConfig?.auth?.users?.length || 0,
updatedAt: 0,
updatedBy: 'legacy-options',
updatedBy: 'runtime-options',
};
}
}
+131 -1
View File
@@ -257,6 +257,83 @@ export class WorkHosterHandler {
},
),
);
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<plugins.servezoneInterfaces.requests.mail.IReq_ListMailAddressBindings>(
'listMailAddressBindings',
async (dataArg) => {
const auth = await this.requireAuth(dataArg.auth || {}, 'gateway-clients:read');
const manager = this.opsServerRef.dcRouterRef.workAppMailManager;
if (!manager) return { bindings: [] };
return {
bindings: await manager.listMailAddressBindings({
owner: this.resolveMailOwnerFilter(auth, dataArg.owner),
domain: dataArg.domain,
address: dataArg.address,
}),
};
},
),
);
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<plugins.servezoneInterfaces.requests.mail.IReq_SyncMailAddressBinding>(
'syncMailAddressBinding',
async (dataArg) => {
const auth = await this.requireAuth(dataArg.auth || {}, 'gateway-clients:write');
this.assertCapability(auth, 'syncRoutes');
const manager = this.opsServerRef.dcRouterRef.workAppMailManager;
if (!manager) {
return { success: false, message: 'WorkApp mail manager not initialized' };
}
try {
const binding = {
...dataArg.binding,
owner: this.resolveMailOwner(auth, dataArg.binding.owner),
};
this.assertMailForwardTargetAllowed(auth, binding.inboundTarget);
return await manager.syncMailAddressBinding(binding, auth.userId);
} catch (error) {
return { success: false, message: (error as Error).message };
}
},
),
);
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<plugins.servezoneInterfaces.requests.mail.IReq_DeleteMailAddressBinding>(
'deleteMailAddressBinding',
async (dataArg) => {
const auth = await this.requireAuth(dataArg.auth || {}, 'gateway-clients:write');
this.assertCapability(auth, 'syncRoutes');
const manager = this.opsServerRef.dcRouterRef.workAppMailManager;
if (!manager) {
return { success: false, message: 'WorkApp mail manager not initialized' };
}
if (auth.token?.policy?.role === 'gatewayClient') {
const bindings = await manager.listMailAddressBindings({
owner: this.resolveMailOwnerFilter(auth),
});
const binding = bindings.find((candidate) => candidate.id === dataArg.id);
if (!binding) return { success: true };
return await manager.deleteMailAddressBinding(binding.id, auth.userId);
}
return await manager.deleteMailAddressBinding(dataArg.id, auth.userId);
},
),
);
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<plugins.servezoneInterfaces.requests.mail.IReq_ListWorkAppMailBindings>(
'listWorkAppMailBindings',
async (dataArg) => {
const auth = await this.requireAuth(dataArg.auth || {}, 'gateway-clients:read');
const manager = this.opsServerRef.dcRouterRef.workAppMailManager;
if (!manager) return { bindings: [] };
return { bindings: await manager.listWorkAppMailBindings(this.resolveMailOwnerFilter(auth, dataArg.owner)) };
},
),
);
}
private getGatewayCapabilities(): interfaces.data.IGatewayCapabilities {
@@ -335,7 +412,7 @@ export class WorkHosterHandler {
const policy = auth.token?.policy;
if (!policy || policy.role !== 'gatewayClient') return;
if (policy.capabilities?.[capability] === true) return;
throw new plugins.typedrequest.TypedResponseError(`token capability missing: ${capability}`);
throw new plugins.typedrequest.TypedResponseError(`token capability missing: ${String(capability)}`);
}
private resolveGatewayClientId(auth: TAuthContext, requestedId?: string): string | undefined {
@@ -376,6 +453,39 @@ export class WorkHosterHandler {
return ownership as Required<interfaces.data.IGatewayClientOwnership>;
}
private resolveMailOwnerFilter(
auth: TAuthContext,
owner?: Partial<plugins.servezoneInterfaces.data.IMailResourceOwner>,
): Partial<plugins.servezoneInterfaces.data.IMailResourceOwner> | undefined {
const policy = auth.token?.policy;
if (policy?.role !== 'gatewayClient') return owner;
if (!policy.gatewayClient) {
throw new plugins.typedrequest.TypedResponseError('gateway client token is missing gatewayClient binding');
}
if (owner?.gatewayClientType && owner.gatewayClientType !== policy.gatewayClient.type) {
throw new plugins.typedrequest.TypedResponseError('gateway client token cannot act for this ownership');
}
if (owner?.gatewayClientId && owner.gatewayClientId !== policy.gatewayClient.id) {
throw new plugins.typedrequest.TypedResponseError('gateway client token cannot act for this ownership');
}
return {
...owner,
gatewayClientType: policy.gatewayClient.type,
gatewayClientId: policy.gatewayClient.id,
};
}
private resolveMailOwner(
auth: TAuthContext,
owner: plugins.servezoneInterfaces.data.IMailResourceOwner,
): plugins.servezoneInterfaces.data.IMailResourceOwner {
const resolvedOwner = this.resolveMailOwnerFilter(auth, owner);
if (!resolvedOwner?.gatewayClientType || !resolvedOwner.gatewayClientId) {
throw new plugins.typedrequest.TypedResponseError('mail owner is missing gateway client type or id');
}
return resolvedOwner as plugins.servezoneInterfaces.data.IMailResourceOwner;
}
private assertGatewayClientOwnership(auth: TAuthContext, ownership: Required<interfaces.data.IGatewayClientOwnership>): void {
const policy = auth.token?.policy;
if (!policy || policy.role !== 'gatewayClient') return;
@@ -404,6 +514,26 @@ export class WorkHosterHandler {
}
}
private assertMailForwardTargetAllowed(
auth: TAuthContext,
target?: plugins.servezoneInterfaces.data.IMailInboundTarget,
): void {
const policy = auth.token?.policy;
if (!policy || policy.role !== 'gatewayClient' || !target?.smtpForward) return;
const allowedTargets = policy.allowedRouteTargets || [];
if (allowedTargets.length === 0) {
throw new plugins.typedrequest.TypedResponseError('gateway client token has no allowed route targets');
}
const host = target.smtpForward.host.trim().toLowerCase();
const port = Number(target.smtpForward.port);
const allowed = allowedTargets.some((allowedTarget) => {
return allowedTarget.host.trim().toLowerCase() === host && allowedTarget.ports.includes(port);
});
if (!allowed) {
throw new plugins.typedrequest.TypedResponseError(`mail target is outside token policy: ${host}:${port}`);
}
}
private matchesHostnamePatterns(hostname: string, patterns: string[]): boolean {
const normalizedHostname = hostname.trim().toLowerCase();
if (!normalizedHostname) return false;
+1 -3
View File
@@ -1,9 +1,7 @@
/**
* ACME configuration for automated TLS certificate issuance via Let's Encrypt.
*
* Persisted as a singleton `AcmeConfigDoc` in the DcRouterDb. Replaces the
* legacy constructor fields `tls.contactEmail` / `smartProxyConfig.acme.*`
* which are now seed-only (used once on first boot if the DB is empty).
* Persisted as a singleton `AcmeConfigDoc` in the DcRouterDb.
*
* Managed via the OpsServer UI at **Domains > Certificates > Settings**.
*/
-18
View File
@@ -44,24 +44,6 @@ export interface IReq_GetCertificateOverview extends plugins.typedrequestInterfa
};
}
// Legacy route-based reprovision (kept for backward compat)
export interface IReq_ReprovisionCertificate extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_ReprovisionCertificate
> {
method: 'reprovisionCertificate';
request: {
identity?: authInterfaces.IIdentity;
apiToken?: string;
routeName: string;
};
response: {
success: boolean;
message?: string;
};
}
// Domain-based reprovision (preferred)
export interface IReq_ReprovisionCertificateDomain extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_ReprovisionCertificateDomain
+2 -2
View File
@@ -541,7 +541,7 @@ export async function createMigrationRunner(
.from('13.1.0').to('13.8.1')
.description('Rename DomainDoc.source value from "manual" to "dcrouter"')
.up(async (ctx) => {
const collection = ctx.mongo!.collection('domaindoc');
const collection = ctx.mongo!.collection('DomainDoc');
const result = await collection.updateMany(
{ source: 'manual' },
{ $set: { source: 'dcrouter' } },
@@ -555,7 +555,7 @@ export async function createMigrationRunner(
.from('13.8.1').to('13.8.2')
.description('Rename DnsRecordDoc.source value from "manual" to "local"')
.up(async (ctx) => {
const collection = ctx.mongo!.collection('dnsrecorddoc');
const collection = ctx.mongo!.collection('DnsRecordDoc');
const result = await collection.updateMany(
{ source: 'manual' },
{ $set: { source: 'local' } },
+1 -1
View File
@@ -3,6 +3,6 @@
*/
export const commitinfo = {
name: '@serve.zone/dcrouter',
version: '13.44.1',
version: '14.1.0',
description: 'A multifaceted routing service handling mail and SMS delivery functions.'
}
+126 -148
View File
@@ -2,6 +2,12 @@ import * as appstate from '../../appstate.js';
import * as interfaces from '../../../dist_ts_interfaces/index.js';
import { viewHostCss } from '../shared/css.js';
import { type IStatsTile } from '@design.estate/dees-catalog';
import type {
IRoutePathClassOption as ISzRoutePathClassOption,
IRouteSourcePolicyPreset as ISzRouteSourcePolicyPreset,
ISourceProfileOption as ISzSourceProfileOption,
SzInputRouteSourcePolicy,
} from '@serve.zone/catalog';
import {
DeesElement,
@@ -24,8 +30,8 @@ const tlsCertOptions = [
{ key: 'auto', option: 'Auto (ACME/Let\'s Encrypt)' },
{ key: 'custom', option: 'Custom certificate' },
];
const maxSourceBindingRows = 16;
const giteaSourcePolicyProfileNames = ['TRUSTED NETWORKS', 'AI CRAWLERS', 'PUBLIC'] as const;
type TSzRouteSecurity = NonNullable<ISzSourceProfileOption['security']>;
function rateLimit(maxRequests: number): interfaces.data.IRouteSecurity['rateLimit'] {
return { enabled: true, maxRequests, window: 60, keyBy: 'ip' };
@@ -35,36 +41,6 @@ function getDropdownKey(value: any): string {
return typeof value === 'string' ? value : value?.key || '';
}
function getSourceBindingRefsFromFormData(formData: Record<string, any>): string[] {
const refs: string[] = [];
for (let index = 0; index < maxSourceBindingRows; index++) {
const ref = getDropdownKey(formData[`sourceBindingProfileRef${index}`]);
if (ref && !refs.includes(ref)) {
refs.push(ref);
}
}
return refs;
}
function buildSourceBindingsMetadata(
profileRefs: string[],
existingSourceBindings?: interfaces.data.IRouteSourceBinding[],
): interfaces.data.IRouteSourceBinding[] {
return profileRefs.map((sourceProfileRef) => {
const existingBinding = existingSourceBindings?.find((binding) => binding.sourceProfileRef === sourceProfileRef);
return existingBinding
? {
...existingBinding,
sourceProfileRef,
onExceeded: existingBinding.onExceeded || { type: '429' as const },
}
: {
sourceProfileRef,
onExceeded: { type: '429' as const },
};
});
}
function getGiteaPresetProfileRefs(profiles: interfaces.data.ISourceProfile[]): {
refs: string[];
missingNames: string[];
@@ -116,70 +92,111 @@ function buildGiteaSourceBindingsMetadata(profileRefs: string[]): interfaces.dat
];
}
function getGiteaPresetSourceBindings(profiles: interfaces.data.ISourceProfile[]): interfaces.data.IRouteSourceBinding[] | null {
function getGiteaSourcePolicyPresets(profiles: interfaces.data.ISourceProfile[]): ISzRouteSourcePolicyPreset[] {
const { refs, missingNames } = getGiteaPresetProfileRefs(profiles);
if (missingNames.length > 0) {
alert(`Gitea source-policy preset needs these seeded profiles: ${missingNames.join(', ')}`);
return null;
return [];
}
if (!validateSourceBindingSelection(refs, profiles)) {
return null;
}
return buildGiteaSourceBindingsMetadata(refs);
return [
{
key: 'gitea-bot-protection',
label: 'Gitea bot protection',
description: 'TRUSTED NETWORKS -> AI CRAWLERS -> PUBLIC with path-class rate limits.',
bindings: buildGiteaSourceBindingsMetadata(refs),
},
];
}
function metadataUsesPathPolicies(metadata?: interfaces.data.IRouteMetadata): boolean {
return Boolean(metadata?.sourceBindings?.some((binding) => binding.pathPolicies?.length));
function normalizeSecurityListEntries(entries: unknown): string[] {
if (!Array.isArray(entries)) {
return [];
}
return entries
.map((entry) => {
if (typeof entry === 'string') return entry.trim();
if (entry && typeof entry === 'object' && 'ip' in entry) {
const ip = (entry as Record<string, unknown>).ip;
return typeof ip === 'string' ? ip.trim() : '';
}
return '';
})
.filter(Boolean);
}
function sourceProfileMatchesAll(profile: interfaces.data.ISourceProfile): boolean {
return (profile.security?.ipAllowList || []).some((entry) => {
const source = typeof entry === 'string' ? entry : entry.ip;
return normalizeSecurityListEntries(profile.security?.ipAllowList).some((source) => {
return ['*', '0.0.0.0/0', '::/0'].includes(source.trim());
});
}
function sourceProfileHasSourceMatches(profile: interfaces.data.ISourceProfile): boolean {
return (profile.security?.ipAllowList || []).some((entry) => {
const source = typeof entry === 'string' ? entry : entry.ip;
return source.trim().length > 0;
return normalizeSecurityListEntries(profile.security?.ipAllowList).length > 0;
}
function normalizeCatalogRateLimit(
rateLimitValue: interfaces.data.IRouteSecurity['rateLimit'] | undefined,
): TSzRouteSecurity['rateLimit'] | undefined {
if (!rateLimitValue) return undefined;
return {
enabled: Boolean(rateLimitValue.enabled),
maxRequests: Number(rateLimitValue.maxRequests) || 0,
window: Number(rateLimitValue.window) || 0,
...(rateLimitValue.keyBy ? { keyBy: String(rateLimitValue.keyBy) } : {}),
};
}
function getSourceProfileOptions(profiles: interfaces.data.ISourceProfile[]): ISzSourceProfileOption[] {
return profiles.map((profile) => {
const ipAllowList = normalizeSecurityListEntries(profile.security?.ipAllowList);
const ipBlockList = normalizeSecurityListEntries(profile.security?.ipBlockList);
const rateLimitValue = normalizeCatalogRateLimit(profile.security?.rateLimit);
const security: TSzRouteSecurity = {
...(ipAllowList.length ? { ipAllowList } : {}),
...(ipBlockList.length ? { ipBlockList } : {}),
...(typeof profile.security?.maxConnections === 'number' ? { maxConnections: profile.security.maxConnections } : {}),
...(rateLimitValue ? { rateLimit: rateLimitValue } : {}),
};
return {
id: profile.id,
name: profile.name,
description: profile.description,
security,
hasSourceMatches: sourceProfileHasSourceMatches(profile),
matchesAllSources: sourceProfileMatchesAll(profile),
};
});
}
function validateSourceBindingSelection(
profileRefs: string[],
profiles: interfaces.data.ISourceProfile[],
): boolean {
if (profileRefs.length === 0) {
function getRoutePathClassOptions(): ISzRoutePathClassOption[] {
return interfaces.data.routePathClasses.map((pathClass) => ({
key: pathClass,
label: interfaces.data.giteaRoutePathClassLabels[pathClass],
defaultPatterns: interfaces.data.giteaRoutePathClassPatterns[pathClass],
}));
}
function getSourcePolicyInfoText(profiles: interfaces.data.ISourceProfile[]): string {
const { missingNames } = getGiteaPresetProfileRefs(profiles);
const presetText = missingNames.length > 0
? `Gitea preset hidden until these source profiles exist: ${missingNames.join(', ')}.`
: 'Use the Gitea preset as a starting point, then edit the generated bindings before saving.';
return `First matching source profile wins. Leave empty for no route-level source access control. ${presetText}`;
}
function validateSourcePolicyInput(form: Element): boolean {
const sourcePolicyInput = form.querySelector('sz-input-route-source-policy') as SzInputRouteSourcePolicy | null;
if (!sourcePolicyInput || sourcePolicyInput.isValid()) {
return true;
}
alert(sourcePolicyInput.getValidationMessages().join('\n'));
return false;
}
const selectedProfiles = profileRefs
.map((profileRef) => profiles.find((profile) => profile.id === profileRef))
.filter(Boolean) as interfaces.data.ISourceProfile[];
if (selectedProfiles.length !== profileRefs.length) {
alert('One or more selected source profiles could not be found. Refresh profiles and try again.');
return false;
}
const profilesWithoutMatches = selectedProfiles.filter((profile) => !sourceProfileHasSourceMatches(profile));
if (profilesWithoutMatches.length > 0) {
alert(`Source profiles need IP/CIDR match entries before use: ${profilesWithoutMatches.map((profile) => profile.name).join(', ')}`);
return false;
}
if (selectedProfiles.slice(0, -1).some((profile) => sourceProfileMatchesAll(profile))) {
alert('Wildcard source profiles must be last. Earlier wildcard profiles would shadow all following profiles.');
return false;
}
const fallbackProfile = selectedProfiles[selectedProfiles.length - 1];
if (sourceProfileMatchesAll(fallbackProfile) && fallbackProfile.security?.rateLimit?.enabled !== true) {
return confirm(`The wildcard profile "${fallbackProfile.name}" has no enabled rate limit. Save anyway?`);
}
return true;
function getSourceBindingsFromFormData(formData: Record<string, unknown>): interfaces.data.IRouteSourceBinding[] {
const sourceBindings = formData.sourceBindings;
return Array.isArray(sourceBindings)
? sourceBindings as interfaces.data.IRouteSourceBinding[]
: [];
}
function parseTargetPort(value: any): number | undefined {
@@ -620,13 +637,6 @@ export class OpsViewRoutes extends DeesElement {
const profiles = this.profilesTargetsState.profiles;
const targets = this.profilesTargetsState.targets;
const profileOptions = [
{ key: '', option: '(none — inline security)' },
...profiles.map((p) => ({
key: p.id,
option: `${p.name}${p.description ? ' — ' + p.description : ''}`,
})),
];
const targetOptions = [
{ key: '', option: '(none — inline target)' },
...targets.map((t) => ({
@@ -651,7 +661,10 @@ export class OpsViewRoutes extends DeesElement {
const currentVpnOnly = route.vpnOnly === true;
const currentRemoteIngressEnabled = route.remoteIngress?.enabled === true;
const currentEdgeFilter = route.remoteIngress?.edgeFilter || [];
const currentSourceBindingRefs = this.getSourceBindingRefs(merged.metadata);
const sourceProfileOptions = getSourceProfileOptions(profiles);
const pathClassOptions = getRoutePathClassOptions();
const sourcePolicyPresets = getGiteaSourcePolicyPresets(profiles);
const sourcePolicyInfoText = getSourcePolicyInfoText(profiles);
// Compute current TLS state for pre-population
const currentTls = (route.action as any).tls;
@@ -672,24 +685,15 @@ export class OpsViewRoutes extends DeesElement {
<dees-input-text .key=${'ports'} .label=${'Ports'} .description=${'Comma-separated, e.g. 80, 443'} .value=${currentPorts} .required=${true}></dees-input-text>
<dees-input-list .key=${'domains'} .label=${'Domains'} .placeholder=${'Add domain...'} .value=${currentDomains}></dees-input-list>
<dees-input-text .key=${'priority'} .label=${'Priority'} .description=${'Higher values are matched first'} .value=${route.priority != null ? String(route.priority) : ''}></dees-input-text>
<div class="sourcePolicyGroup" style="display: flex; flex-direction: column; gap: 12px; padding: 12px; border: 1px solid rgba(255,255,255,0.12); border-radius: 8px;">
<strong>Source Bindings</strong>
<small>First matching source profile wins. Leave all rows empty to remove route-level source access control.</small>
<dees-input-checkbox
.key=${'useGiteaTemplate'}
.label=${'Apply Gitea bot protection template on save'}
.description=${'Replaces these rows with TRUSTED NETWORKS -> AI CRAWLERS -> PUBLIC and path-class limits.'}
.value=${false}
></dees-input-checkbox>
${Array.from({ length: maxSourceBindingRows }, (_item, index) => html`
<dees-input-dropdown
.key=${`sourceBindingProfileRef${index}`}
.label=${`Binding ${index + 1}`}
.options=${profileOptions}
.selectedOption=${profileOptions.find((o) => o.key === (currentSourceBindingRefs[index] || '')) || profileOptions[0]}
></dees-input-dropdown>
`)}
</div>
<sz-input-route-source-policy
.key=${'sourceBindings'}
.label=${'Source Policy'}
.infoText=${sourcePolicyInfoText}
.sourceProfiles=${sourceProfileOptions}
.pathClassOptions=${pathClassOptions}
.presets=${sourcePolicyPresets}
.value=${merged.metadata?.sourceBindings || []}
></sz-input-route-source-policy>
<dees-input-dropdown .key=${'networkTargetRef'} .label=${'Network Target'} .options=${targetOptions} .selectedOption=${targetOptions.find((o) => o.key === (merged.metadata?.networkTargetRef || '')) || null}></dees-input-dropdown>
<dees-input-text .key=${'targetHost'} .label=${'Target Host'} .description=${'Used when no network target is selected'} .value=${currentTargetHost}></dees-input-text>
<dees-input-text .key=${'targetPort'} .label=${'Target Port'} .description=${'Used when no network target is selected'} .value=${currentTargetPort}></dees-input-text>
@@ -723,6 +727,7 @@ export class OpsViewRoutes extends DeesElement {
if (!form) return;
const formData = await form.collectFormData();
if (!formData.name || !formData.ports) return;
if (!validateSourcePolicyInput(form)) return;
const ports = formData.ports.split(',').map((p: string) => parseInt(p.trim(), 10)).filter((p: number) => !isNaN(p));
const domains: string[] = Array.isArray(formData.domains)
@@ -730,11 +735,7 @@ export class OpsViewRoutes extends DeesElement {
: [];
const priority = formData.priority ? parseInt(formData.priority, 10) : undefined;
const useGiteaTemplate = Boolean(formData.useGiteaTemplate);
const sourceBindingRefs = useGiteaTemplate
? []
: getSourceBindingRefsFromFormData(formData);
if (!useGiteaTemplate && !validateSourceBindingSelection(sourceBindingRefs, profiles)) return;
const sourceBindings = getSourceBindingsFromFormData(formData);
const targetKey = getDropdownKey(formData.networkTargetRef);
const preserveMatchPort = !targetKey && Boolean(formData.preserveMatchPort);
const targetPort = preserveMatchPort
@@ -798,12 +799,8 @@ export class OpsViewRoutes extends DeesElement {
}
const metadata: any = {};
if (useGiteaTemplate) {
const sourceBindings = getGiteaPresetSourceBindings(profiles);
if (!sourceBindings) return;
if (sourceBindings.length > 0) {
metadata.sourceBindings = sourceBindings;
} else if (sourceBindingRefs.length > 0) {
metadata.sourceBindings = buildSourceBindingsMetadata(sourceBindingRefs, merged.metadata?.sourceBindings);
} else if (merged.metadata?.sourceBindings) {
metadata.sourceBindings = [];
}
@@ -841,14 +838,11 @@ export class OpsViewRoutes extends DeesElement {
const profiles = this.profilesTargetsState.profiles;
const targets = this.profilesTargetsState.targets;
// Build dropdown options for profiles and targets
const profileOptions = [
{ key: '', option: '(none — inline security)' },
...profiles.map((p) => ({
key: p.id,
option: `${p.name}${p.description ? ' — ' + p.description : ''}`,
})),
];
// Build dropdown options for targets and source policy metadata
const sourceProfileOptions = getSourceProfileOptions(profiles);
const pathClassOptions = getRoutePathClassOptions();
const sourcePolicyPresets = getGiteaSourcePolicyPresets(profiles);
const sourcePolicyInfoText = getSourcePolicyInfoText(profiles);
const targetOptions = [
{ key: '', option: '(none — inline target)' },
...targets.map((t) => ({
@@ -865,24 +859,15 @@ export class OpsViewRoutes extends DeesElement {
<dees-input-text .key=${'ports'} .label=${'Ports'} .description=${'Comma-separated, e.g. 80, 443'} .required=${true}></dees-input-text>
<dees-input-list .key=${'domains'} .label=${'Domains'} .placeholder=${'Add domain...'}></dees-input-list>
<dees-input-text .key=${'priority'} .label=${'Priority'} .description=${'Higher values are matched first'}></dees-input-text>
<div class="sourcePolicyGroup" style="display: flex; flex-direction: column; gap: 12px; padding: 12px; border: 1px solid rgba(255,255,255,0.12); border-radius: 8px;">
<strong>Source Bindings</strong>
<small>First matching source profile wins. Leave all rows empty for no route-level source access control.</small>
<dees-input-checkbox
.key=${'useGiteaTemplate'}
.label=${'Apply Gitea bot protection template on save'}
.description=${'Writes TRUSTED NETWORKS -> AI CRAWLERS -> PUBLIC and path-class limits.'}
.value=${false}
></dees-input-checkbox>
${Array.from({ length: maxSourceBindingRows }, (_item, index) => html`
<dees-input-dropdown
.key=${`sourceBindingProfileRef${index}`}
.label=${`Binding ${index + 1}`}
.options=${profileOptions}
.selectedOption=${profileOptions[0]}
></dees-input-dropdown>
`)}
</div>
<sz-input-route-source-policy
.key=${'sourceBindings'}
.label=${'Source Policy'}
.infoText=${sourcePolicyInfoText}
.sourceProfiles=${sourceProfileOptions}
.pathClassOptions=${pathClassOptions}
.presets=${sourcePolicyPresets}
.value=${[]}
></sz-input-route-source-policy>
<dees-input-dropdown .key=${'networkTargetRef'} .label=${'Network Target'} .options=${targetOptions}></dees-input-dropdown>
<dees-input-text .key=${'targetHost'} .label=${'Target Host'} .description=${'Used when no network target is selected'} .value=${'localhost'}></dees-input-text>
<dees-input-text .key=${'targetPort'} .label=${'Target Port'} .description=${'Used when no network target is selected'}></dees-input-text>
@@ -916,6 +901,7 @@ export class OpsViewRoutes extends DeesElement {
if (!form) return;
const formData = await form.collectFormData();
if (!formData.name || !formData.ports) return;
if (!validateSourcePolicyInput(form)) return;
const ports = formData.ports.split(',').map((p: string) => parseInt(p.trim(), 10)).filter((p: number) => !isNaN(p));
const domains: string[] = Array.isArray(formData.domains)
@@ -923,11 +909,7 @@ export class OpsViewRoutes extends DeesElement {
: [];
const priority = formData.priority ? parseInt(formData.priority, 10) : undefined;
const useGiteaTemplate = Boolean(formData.useGiteaTemplate);
const sourceBindingRefs = useGiteaTemplate
? []
: getSourceBindingRefsFromFormData(formData);
if (!useGiteaTemplate && !validateSourceBindingSelection(sourceBindingRefs, profiles)) return;
const sourceBindings = getSourceBindingsFromFormData(formData);
const targetKey = getDropdownKey(formData.networkTargetRef);
const preserveMatchPort = !targetKey && Boolean(formData.preserveMatchPort);
const targetPort = preserveMatchPort
@@ -992,12 +974,8 @@ export class OpsViewRoutes extends DeesElement {
// Build metadata if profile/target selected
const metadata: any = {};
if (useGiteaTemplate) {
const sourceBindings = getGiteaPresetSourceBindings(profiles);
if (!sourceBindings) return;
if (sourceBindings.length > 0) {
metadata.sourceBindings = sourceBindings;
} else if (sourceBindingRefs.length > 0) {
metadata.sourceBindings = buildSourceBindingsMetadata(sourceBindingRefs);
}
if (targetKey) {
metadata.networkTargetRef = targetKey;