Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ba67e0d208 | |||
| e86fe0df7a | |||
| 71ee2133e4 | |||
| 6c8073b91a | |||
| 17bb63f129 |
@@ -3,10 +3,40 @@
|
||||
## Pending
|
||||
|
||||
|
||||
## 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,6 +1,6 @@
|
||||
{
|
||||
"name": "@serve.zone/dcrouter",
|
||||
"version": "13.44.1",
|
||||
"version": "14.0.0",
|
||||
"exports": "./binary/dcrouter.ts",
|
||||
"compile": {
|
||||
"include": [
|
||||
|
||||
+4
-4
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@serve.zone/dcrouter",
|
||||
"private": false,
|
||||
"version": "13.44.1",
|
||||
"version": "14.0.0",
|
||||
"description": "A multifaceted routing service handling mail and SMS delivery functions.",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
@@ -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.7",
|
||||
"@push.rocks/smartradius": "^1.3.0",
|
||||
"@push.rocks/smartrequest": "^5.0.3",
|
||||
"@push.rocks/smartrx": "^3.0.10",
|
||||
@@ -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",
|
||||
|
||||
Generated
+17
-17
@@ -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.7
|
||||
version: 27.12.7
|
||||
'@push.rocks/smartradius':
|
||||
specifier: ^1.3.0
|
||||
version: 1.3.0
|
||||
@@ -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==}
|
||||
@@ -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.7':
|
||||
resolution: {integrity: sha512-5QHQLNUqLn7wrMEP+X361aQSvc4p8RabgV9jPnx4G6DgR8a25Z4kN2PAgtsg75U9QyQbQicE2lyPqIPaSTQ+uQ==}
|
||||
|
||||
'@push.rocks/smartpuppeteer@2.0.6':
|
||||
resolution: {integrity: sha512-G+8cyDERvbXQcb9Sd8lnYdWYz8b3Mv2LfFf1ULmucDqQhcRHvxrWX/dKsvBZrwKPR4Wg+795Dyd+E1iOOh3tHw==}
|
||||
@@ -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
|
||||
@@ -6581,7 +6581,7 @@ snapshots:
|
||||
|
||||
'@push.rocks/smartpromise@4.2.4': {}
|
||||
|
||||
'@push.rocks/smartproxy@27.12.6':
|
||||
'@push.rocks/smartproxy@27.12.7':
|
||||
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
|
||||
|
||||
+5
-4
@@ -1,4 +1,5 @@
|
||||
allowBuilds:
|
||||
esbuild: true
|
||||
mongodb-memory-server: true
|
||||
puppeteer: true
|
||||
onlyBuiltDependencies:
|
||||
- '@design.estate/dees-catalog'
|
||||
- esbuild
|
||||
- mongodb-memory-server
|
||||
- puppeteer
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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,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)));
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
@@ -3,6 +3,6 @@
|
||||
*/
|
||||
export const commitinfo = {
|
||||
name: '@serve.zone/dcrouter',
|
||||
version: '13.44.1',
|
||||
version: '14.0.0',
|
||||
description: 'A multifaceted routing service handling mail and SMS delivery functions.'
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
+120
-28
@@ -454,14 +454,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 () => {
|
||||
@@ -813,7 +812,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 +830,7 @@ export class DcRouter {
|
||||
return seed;
|
||||
}
|
||||
|
||||
private getEmailSettingsLegacySeed(): IEmailServerSettingsSeed {
|
||||
private getEmailSettingsMigrationSeed(): IEmailServerSettingsSeed {
|
||||
const seed: IEmailServerSettingsSeed = {};
|
||||
if (this.options.emailConfig) {
|
||||
seed.enabled = true;
|
||||
@@ -1106,8 +1105,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 +1171,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 +1714,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 +1830,9 @@ export class DcRouter {
|
||||
&& routeName.startsWith('dns-over-https-');
|
||||
|
||||
if (!isDohRoute) {
|
||||
if (this.shouldHydrateGeneratedEmailRoute(storedRoute)) {
|
||||
return this.createServerFirstEmailRuntimeRoute(storedRoute.route);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -1860,28 +1971,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 +2343,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];
|
||||
|
||||
@@ -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)));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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
|
||||
// ==========================================================================
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -59,15 +59,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 +127,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 +149,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',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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**.
|
||||
*/
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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' } },
|
||||
|
||||
@@ -3,6 +3,6 @@
|
||||
*/
|
||||
export const commitinfo = {
|
||||
name: '@serve.zone/dcrouter',
|
||||
version: '13.44.1',
|
||||
version: '14.0.0',
|
||||
description: 'A multifaceted routing service handling mail and SMS delivery functions.'
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user