Compare commits

...

5 Commits

Author SHA1 Message Date
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
jkunz 2ec647cd6c v13.44.1
Docker (tags) / release (push) Failing after 1s
Release / build-and-release (push) Successful in 5m24s
2026-06-04 10:18:40 +00:00
jkunz 01267cfeb5 fix(db): use smartdata cached document support 2026-06-04 10:13:57 +00:00
14 changed files with 456 additions and 326 deletions
+27
View File
@@ -7,6 +7,33 @@
## 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
### Fixes
- use smartdata cached document support (db)
- Migrate cached email and IP reputation documents to SmartdataCachedDocument and shared smartdata TTL values.
- Remove the local cached document base class and TTL export.
- Bump @push.rocks/smartdata to ^7.2.0.
## 2026-06-04 - 13.44.0
### Features
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@serve.zone/dcrouter",
"version": "13.44.0",
"version": "13.45.0",
"exports": "./binary/dcrouter.ts",
"compile": {
"include": [
+4 -4
View File
@@ -1,7 +1,7 @@
{
"name": "@serve.zone/dcrouter",
"private": false,
"version": "13.44.0",
"version": "13.45.0",
"description": "A multifaceted routing service handling mail and SMS delivery functions.",
"type": "module",
"bin": {
@@ -41,14 +41,14 @@
"@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",
"@push.rocks/projectinfo": "^5.1.0",
"@push.rocks/qenv": "^6.1.4",
"@push.rocks/smartacme": "^9.5.0",
"@push.rocks/smartdata": "^7.1.7",
"@push.rocks/smartdata": "^7.2.0",
"@push.rocks/smartdb": "^2.10.2",
"@push.rocks/smartdns": "^7.9.3",
"@push.rocks/smartfs": "^1.5.1",
@@ -69,7 +69,7 @@
"@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/catalog": "^2.13.0",
"@serve.zone/interfaces": "^6.2.1",
"@serve.zone/remoteingress": "^4.23.0",
"@tsclass/tsclass": "^9.5.1",
+24 -28
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
@@ -45,8 +45,8 @@ importers:
specifier: ^9.5.0
version: 9.5.0(socks@2.8.8)
'@push.rocks/smartdata':
specifier: ^7.1.7
version: 7.1.7(socks@2.8.8)
specifier: ^7.2.0
version: 7.2.0(socks@2.8.8)
'@push.rocks/smartdb':
specifier: ^2.10.2
version: 2.10.2(@tiptap/pm@2.27.2)(socks@2.8.8)
@@ -70,7 +70,7 @@ importers:
version: 3.0.3
'@push.rocks/smartmigration':
specifier: 1.4.1
version: 1.4.1(@push.rocks/smartbucket@4.6.1)(@push.rocks/smartdata@7.1.7(socks@2.8.8))
version: 1.4.1(@push.rocks/smartbucket@4.6.1)(@push.rocks/smartdata@7.2.0(socks@2.8.8))
'@push.rocks/smartmta':
specifier: ^5.3.3
version: 5.3.3
@@ -108,8 +108,8 @@ 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
@@ -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==}
@@ -1283,8 +1283,8 @@ packages:
'@push.rocks/smartdata@7.1.5':
resolution: {integrity: sha512-7x7VedEg6RocWndqUPuTbY2Bh85Q/x0LOVHL4o/NVXyh3IGNtiVQ8ple4WR0qYqlHRAojX4eDSBPMiYzIasqAg==}
'@push.rocks/smartdata@7.1.7':
resolution: {integrity: sha512-HDI/Q9dKybfsJ68oCzlE+S63Xpij9qXnMfi28yznKP0Li1ECVZZMDDGIW5IjsXlHjO+Q+RJMcVd72Pjt3QLY5Q==}
'@push.rocks/smartdata@7.2.0':
resolution: {integrity: sha512-pk1o/No8OHT/bwOZu/Ivy3WgQsZoRtEpk/6HzWHi5KflLoWYKB+qjjOqBaDFhAEdgddfJH9qtN23zTtpGImmUA==}
'@push.rocks/smartdb@2.10.2':
resolution: {integrity: sha512-nH8GfKPviQho2n6bQxKCDbjTspUBtoyL/BPVA04lbA34dYM/y0+nTdTWa93Vt4TJYfUqpdh4zNu4y60zZNU40g==}
@@ -1689,8 +1689,8 @@ 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==}
@@ -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
@@ -5298,7 +5298,7 @@ snapshots:
'@api.global/typedrequest': 3.3.2
'@api.global/typedsocket': 4.1.4(@push.rocks/smartserve@2.0.4)
'@idp.global/interfaces': 1.1.0
'@push.rocks/smartdata': 7.1.7(socks@2.8.8)
'@push.rocks/smartdata': 7.2.0(socks@2.8.8)
'@push.rocks/smartjson': 6.0.1
'@push.rocks/smartpromise': 4.2.4
'@push.rocks/smartrx': 3.0.10
@@ -6020,7 +6020,7 @@ snapshots:
'@apiclient.xyz/cloudflare': 7.1.0
'@peculiar/x509': 2.0.0
'@push.rocks/lik': 6.4.1
'@push.rocks/smartdata': 7.1.7(socks@2.8.8)
'@push.rocks/smartdata': 7.2.0(socks@2.8.8)
'@push.rocks/smartdelay': 3.1.0
'@push.rocks/smartdns': 7.9.3
'@push.rocks/smartlog': 3.2.2
@@ -6173,12 +6173,11 @@ snapshots:
- supports-color
- vue
'@push.rocks/smartdata@7.1.7(socks@2.8.8)':
'@push.rocks/smartdata@7.2.0(socks@2.8.8)':
dependencies:
'@push.rocks/lik': 6.4.1
'@push.rocks/smartdelay': 3.1.0
'@push.rocks/smartlog': 3.2.2
'@push.rocks/smartmongo': 5.1.1(socks@2.8.8)
'@push.rocks/smartpromise': 4.2.4
'@push.rocks/smartrx': 3.0.10
'@push.rocks/smartstring': 4.1.1
@@ -6191,13 +6190,10 @@ snapshots:
- '@aws-sdk/credential-providers'
- '@mongodb-js/zstd'
- '@nuxt/kit'
- bare-abort-controller
- bare-buffer
- gcp-metadata
- kerberos
- mongodb-client-encryption
- react
- react-native-b4a
- snappy
- socks
- supports-color
@@ -6418,13 +6414,13 @@ snapshots:
'@push.rocks/smartdelay': 3.1.0
'@push.rocks/smartlog': 3.2.2
'@push.rocks/smartmigration@1.4.1(@push.rocks/smartbucket@4.6.1)(@push.rocks/smartdata@7.1.7(socks@2.8.8))':
'@push.rocks/smartmigration@1.4.1(@push.rocks/smartbucket@4.6.1)(@push.rocks/smartdata@7.2.0(socks@2.8.8))':
dependencies:
'@push.rocks/smartlog': 3.2.2
'@push.rocks/smartversion': 3.1.0
optionalDependencies:
'@push.rocks/smartbucket': 4.6.1
'@push.rocks/smartdata': 7.1.7(socks@2.8.8)
'@push.rocks/smartdata': 7.2.0(socks@2.8.8)
'@push.rocks/smartmime@2.0.4':
dependencies:
@@ -6435,7 +6431,7 @@ snapshots:
'@push.rocks/smartmongo@5.1.1(socks@2.8.8)':
dependencies:
'@push.rocks/mongodump': 1.1.1(socks@2.8.8)
'@push.rocks/smartdata': 7.1.7(socks@2.8.8)
'@push.rocks/smartdata': 7.2.0(socks@2.8.8)
'@push.rocks/smartfs': 1.5.1
'@push.rocks/smartpath': 6.0.0
'@push.rocks/smartpromise': 4.2.4
@@ -6462,7 +6458,7 @@ snapshots:
'@push.rocks/smartmongo@7.0.0(socks@2.8.8)':
dependencies:
'@push.rocks/mongodump': 1.1.1(socks@2.8.8)
'@push.rocks/smartdata': 7.1.7(socks@2.8.8)
'@push.rocks/smartdata': 7.2.0(socks@2.8.8)
'@push.rocks/smartpath': 6.0.0
'@push.rocks/smartpromise': 4.2.4
mongodb-memory-server: 11.1.0(socks@2.8.8)
@@ -6919,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
+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
+148
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,89 @@ 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 = (route: any, origin = 'email') => (router 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(smtpRoute);
expect(runtimeSmtpRoute?.action.type).toEqual('socket-handler');
expect(typeof runtimeSmtpRoute?.action.socketHandler).toEqual('function');
const runtimeSubmissionRoute = hydrate(submissionRoute);
expect(runtimeSubmissionRoute?.action.type).toEqual('socket-handler');
expect(typeof runtimeSubmissionRoute?.action.socketHandler).toEqual('function');
expect(hydrate(smtpsRoute)).toBeUndefined();
expect(hydrate(smtpRoute, 'api')).toBeUndefined();
});
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],
+1 -1
View File
@@ -3,6 +3,6 @@
*/
export const commitinfo = {
name: '@serve.zone/dcrouter',
version: '13.44.0',
version: '13.45.0',
description: 'A multifaceted routing service handling mail and SMS delivery functions.'
}
+113 -1
View File
@@ -1172,7 +1172,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,
];
@@ -1715,6 +1715,115 @@ 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;
return {
...route,
action: {
type: 'socket-handler' as any,
socketHandler: this.createEmailSocketProxyHandler(targetHost, target.port),
} as any,
};
}
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 +1831,9 @@ export class DcRouter {
&& routeName.startsWith('dns-over-https-');
if (!isDohRoute) {
if (this.shouldHydrateGeneratedEmailRoute(storedRoute)) {
return this.createServerFirstEmailRuntimeRoute(storedRoute.route);
}
return undefined;
}
-111
View File
@@ -1,111 +0,0 @@
import * as plugins from '../plugins.js';
/**
* Base class for all cached documents with TTL support
*
* Extends smartdata's SmartDataDbDoc to add:
* - Automatic timestamps (createdAt, lastAccessedAt)
* - TTL/expiration support (expiresAt)
* - Helper methods for TTL management
*
* NOTE: Subclasses MUST add @svDb() decorators to createdAt, expiresAt, and lastAccessedAt
* since decorators on abstract classes don't propagate correctly.
*/
export abstract class CachedDocument<T extends CachedDocument<T>> extends plugins.smartdata.SmartDataDbDoc<T, T> {
/**
* Timestamp when the document was created
* NOTE: Subclasses must add @svDb() decorator
*/
public createdAt: Date = new Date();
/**
* Timestamp when the document expires and should be cleaned up
* NOTE: Subclasses must add @svDb() decorator
*/
public expiresAt!: Date;
/**
* Timestamp of last access (for LRU-style eviction if needed)
* NOTE: Subclasses must add @svDb() decorator
*/
public lastAccessedAt: Date = new Date();
/**
* Set the TTL (time to live) for this document
* @param ttlMs Time to live in milliseconds
*/
public setTTL(ttlMs: number): void {
this.expiresAt = new Date(Date.now() + ttlMs);
}
/**
* Set TTL using days
* @param days Number of days until expiration
*/
public setTTLDays(days: number): void {
this.setTTL(days * 24 * 60 * 60 * 1000);
}
/**
* Set TTL using hours
* @param hours Number of hours until expiration
*/
public setTTLHours(hours: number): void {
this.setTTL(hours * 60 * 60 * 1000);
}
/**
* Check if this document has expired
*/
public isExpired(): boolean {
if (!this.expiresAt) {
return false; // No expiration set
}
return new Date() > this.expiresAt;
}
/**
* Update the lastAccessedAt timestamp
*/
public touch(): void {
this.lastAccessedAt = new Date();
}
/**
* Get remaining TTL in milliseconds
* Returns 0 if expired, -1 if no expiration set
*/
public getRemainingTTL(): number {
if (!this.expiresAt) {
return -1;
}
const remaining = this.expiresAt.getTime() - Date.now();
return remaining > 0 ? remaining : 0;
}
/**
* Extend the TTL by the specified milliseconds from now
* @param ttlMs Additional time to live in milliseconds
*/
public extendTTL(ttlMs: number): void {
this.expiresAt = new Date(Date.now() + ttlMs);
}
/**
* Set the document to never expire (100 years in the future)
*/
public setNeverExpires(): void {
this.expiresAt = new Date(Date.now() + 100 * 365 * 24 * 60 * 60 * 1000);
}
}
/**
* TTL constants in milliseconds
*/
export const TTL = {
HOURS_1: 1 * 60 * 60 * 1000,
HOURS_24: 24 * 60 * 60 * 1000,
DAYS_7: 7 * 24 * 60 * 60 * 1000,
DAYS_30: 30 * 24 * 60 * 60 * 1000,
DAYS_90: 90 * 24 * 60 * 60 * 1000,
} as const;
+3 -12
View File
@@ -1,7 +1,8 @@
import * as plugins from '../../plugins.js';
import { CachedDocument, TTL } from '../classes.cached.document.js';
import { DcRouterDb } from '../classes.dcrouter-db.js';
const TTL = plugins.smartdata.smartdataTtlValues;
/**
* Email status in the cache
*/
@@ -19,17 +20,7 @@ const getDb = () => DcRouterDb.getInstance().getDb();
* and maintaining email history for the configured TTL period.
*/
@plugins.smartdata.Collection(() => getDb())
export class CachedEmail extends CachedDocument<CachedEmail> {
// TTL fields from base class (decorators required on concrete class)
@plugins.smartdata.svDb()
public createdAt: Date = new Date();
@plugins.smartdata.svDb()
public expiresAt: Date = new Date(Date.now() + TTL.DAYS_30);
@plugins.smartdata.svDb()
public lastAccessedAt: Date = new Date();
export class CachedEmail extends plugins.smartdata.SmartdataCachedDocument<CachedEmail> {
/**
* Unique identifier for this email
*/
@@ -1,7 +1,8 @@
import * as plugins from '../../plugins.js';
import { CachedDocument, TTL } from '../classes.cached.document.js';
import { DcRouterDb } from '../classes.dcrouter-db.js';
const TTL = plugins.smartdata.smartdataTtlValues;
/**
* Helper to get the smartdata database instance
*/
@@ -29,17 +30,7 @@ export interface IIPReputationData {
* external API calls. Default TTL is 24 hours.
*/
@plugins.smartdata.Collection(() => getDb())
export class CachedIPReputation extends CachedDocument<CachedIPReputation> {
// TTL fields from base class (decorators required on concrete class)
@plugins.smartdata.svDb()
public createdAt: Date = new Date();
@plugins.smartdata.svDb()
public expiresAt: Date = new Date(Date.now() + TTL.HOURS_24);
@plugins.smartdata.svDb()
public lastAccessedAt: Date = new Date();
export class CachedIPReputation extends plugins.smartdata.SmartdataCachedDocument<CachedIPReputation> {
/**
* IP address (unique identifier)
*/
-3
View File
@@ -1,9 +1,6 @@
// Unified database manager
export * from './classes.dcrouter-db.js';
// TTL base class and constants
export * from './classes.cached.document.js';
// Cache cleaner
export * from './classes.cache.cleaner.js';
+1 -1
View File
@@ -3,6 +3,6 @@
*/
export const commitinfo = {
name: '@serve.zone/dcrouter',
version: '13.44.0',
version: '13.45.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;