Compare commits

..

2 Commits

Author SHA1 Message Date
6d447f0086 v11.6.0
Some checks failed
Docker (tags) / security (push) Failing after 3s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-03-19 19:06:15 +00:00
c7de3873d8 feat(http3): add automatic HTTP/3 route augmentation for qualifying HTTPS routes 2026-03-19 19:06:15 +00:00
11 changed files with 504 additions and 11 deletions

View File

@@ -1,5 +1,13 @@
# Changelog
## 2026-03-19 - 11.6.0 - feat(http3)
add automatic HTTP/3 route augmentation for qualifying HTTPS routes
- introduce configurable HTTP/3 augmentation utilities for eligible SmartProxy routes on port 443
- apply HTTP/3 settings to both constructor-defined and stored programmatic routes, with global and per-route opt-out support
- export the HTTP/3 config type and add test coverage for qualification, augmentation behavior, and defaults
- bump @push.rocks/smartproxy to ^25.15.0 for HTTP/3-related support
## 2026-03-19 - 11.5.1 - fix(project)
no changes to commit

View File

@@ -1,7 +1,7 @@
{
"name": "@serve.zone/dcrouter",
"private": false,
"version": "11.5.1",
"version": "11.6.0",
"description": "A multifaceted routing service handling mail and SMS delivery functions.",
"type": "module",
"exports": {
@@ -53,7 +53,7 @@
"@push.rocks/smartnetwork": "^4.4.0",
"@push.rocks/smartpath": "^6.0.0",
"@push.rocks/smartpromise": "^4.2.3",
"@push.rocks/smartproxy": "^25.14.1",
"@push.rocks/smartproxy": "^25.15.0",
"@push.rocks/smartradius": "^1.1.1",
"@push.rocks/smartrequest": "^5.0.1",
"@push.rocks/smartrx": "^3.0.10",

10
pnpm-lock.yaml generated
View File

@@ -78,8 +78,8 @@ importers:
specifier: ^4.2.3
version: 4.2.3
'@push.rocks/smartproxy':
specifier: ^25.14.1
version: 25.14.1
specifier: ^25.15.0
version: 25.15.0
'@push.rocks/smartradius':
specifier: ^1.1.1
version: 1.1.1
@@ -1256,8 +1256,8 @@ packages:
'@push.rocks/smartpromise@4.2.3':
resolution: {integrity: sha512-Ycg/TJR+tMt+S3wSFurOpEoW6nXv12QBtKXgBcjMZ4RsdO28geN46U09osPn9N9WuwQy1PkmTV5J/V4F9U8qEw==}
'@push.rocks/smartproxy@25.14.1':
resolution: {integrity: sha512-QXJ1M7Or81lmCusAKkmIB8M9jJwl1/AKItnmTkn8IQ9zsPd6r+0uhP1j5tCO/LwRQRpzOADnpSrpVcrtMKK9kQ==}
'@push.rocks/smartproxy@25.15.0':
resolution: {integrity: sha512-quw4MH6Snr6X2vy27iykXbBwN1oDKU7AntbUAPOgsWERTTDZGZU79fk9VZTvk5hGNemb2wEgnkgsUxAnj0y4dQ==}
'@push.rocks/smartpuppeteer@2.0.5':
resolution: {integrity: sha512-yK/qSeWVHIGWRp3c8S5tfdGP6WCKllZC4DR8d8CQlEjszOSBmHtlTdyyqOMBZ/BA4kd+eU5f3A1r4K2tGYty1g==}
@@ -6539,7 +6539,7 @@ snapshots:
'@push.rocks/smartpromise@4.2.3': {}
'@push.rocks/smartproxy@25.14.1':
'@push.rocks/smartproxy@25.15.0':
dependencies:
'@push.rocks/smartcrypto': 2.0.4
'@push.rocks/smartlog': 3.2.1

View File

@@ -0,0 +1,304 @@
import { tap, expect } from '@git.zone/tstest/tapbundle';
import {
routeQualifiesForHttp3,
augmentRouteWithHttp3,
augmentRoutesWithHttp3,
type IHttp3Config,
} from '../ts/http3/index.js';
import type * as plugins from '../ts/plugins.js';
// Helper to create a basic HTTPS forward route on port 443
function makeRoute(
overrides: Partial<plugins.smartproxy.IRouteConfig> = {},
): plugins.smartproxy.IRouteConfig {
return {
match: { ports: 443, ...overrides.match },
action: {
type: 'forward',
targets: [{ host: 'localhost', port: 8080 }],
tls: { mode: 'terminate', certificate: 'auto' },
...overrides.action,
},
name: overrides.name ?? 'test-https-route',
...Object.fromEntries(
Object.entries(overrides).filter(([k]) => !['match', 'action', 'name'].includes(k)),
),
} as plugins.smartproxy.IRouteConfig;
}
const defaultConfig: IHttp3Config = { enabled: true };
// ──────────────────────────────────────────────────────────────────────────────
// Qualification tests
// ──────────────────────────────────────────────────────────────────────────────
tap.test('should augment qualifying HTTPS route on port 443', async () => {
const route = makeRoute();
const result = augmentRouteWithHttp3(route, defaultConfig);
expect(result.match.transport).toEqual('all');
expect(result.action.udp).toBeTruthy();
expect(result.action.udp!.quic).toBeTruthy();
expect(result.action.udp!.quic!.enableHttp3).toBeTrue();
expect(result.action.udp!.quic!.altSvcMaxAge).toEqual(86400);
});
tap.test('should NOT augment route on non-443 port', async () => {
const route = makeRoute({ match: { ports: 8080 } });
const result = augmentRouteWithHttp3(route, defaultConfig);
expect(result.match.transport).toBeUndefined();
expect(result.action.udp).toBeUndefined();
});
tap.test('should NOT augment socket-handler type route', async () => {
const route = makeRoute({
action: {
type: 'socket-handler' as any,
socketHandler: (() => {}) as any,
},
});
const result = augmentRouteWithHttp3(route, defaultConfig);
expect(result.match.transport).toBeUndefined();
});
tap.test('should NOT augment route without TLS', async () => {
const route: plugins.smartproxy.IRouteConfig = {
match: { ports: 443 },
action: {
type: 'forward',
targets: [{ host: 'localhost', port: 8080 }],
},
name: 'no-tls-route',
};
const result = augmentRouteWithHttp3(route, defaultConfig);
expect(result.match.transport).toBeUndefined();
});
tap.test('should NOT augment email routes', async () => {
const emailNames = ['smtp-route', 'submission-route', 'smtps-route', 'email-port-2525-route'];
for (const name of emailNames) {
const route = makeRoute({ name });
const result = augmentRouteWithHttp3(route, defaultConfig);
expect(result.match.transport).toBeUndefined();
}
});
tap.test('should respect per-route opt-out (options.http3 = false)', async () => {
const route = makeRoute({
action: {
type: 'forward',
targets: [{ host: 'localhost', port: 8080 }],
tls: { mode: 'terminate', certificate: 'auto' },
options: { http3: false },
},
});
const result = augmentRouteWithHttp3(route, defaultConfig);
expect(result.match.transport).toBeUndefined();
expect(result.action.udp).toBeUndefined();
});
tap.test('should respect per-route opt-in when global is disabled', async () => {
const route = makeRoute({
action: {
type: 'forward',
targets: [{ host: 'localhost', port: 8080 }],
tls: { mode: 'terminate', certificate: 'auto' },
options: { http3: true },
},
});
const result = augmentRouteWithHttp3(route, { enabled: false });
expect(result.match.transport).toEqual('all');
expect(result.action.udp!.quic!.enableHttp3).toBeTrue();
});
tap.test('should NOT double-augment routes with transport: all', async () => {
const route = makeRoute({
match: { ports: 443, transport: 'all' as any },
});
const result = augmentRouteWithHttp3(route, defaultConfig);
// Should be the exact same object (no augmentation)
expect(result).toEqual(route);
});
tap.test('should NOT double-augment routes with existing udp.quic', async () => {
const route = makeRoute({
action: {
type: 'forward',
targets: [{ host: 'localhost', port: 8080 }],
tls: { mode: 'terminate', certificate: 'auto' },
udp: { quic: { enableHttp3: true } },
},
});
const result = augmentRouteWithHttp3(route, defaultConfig);
expect(result).toEqual(route);
});
tap.test('should augment route with port range including 443', async () => {
const route = makeRoute({
match: { ports: [{ from: 400, to: 500 }] },
});
const result = augmentRouteWithHttp3(route, defaultConfig);
expect(result.match.transport).toEqual('all');
expect(result.action.udp!.quic!.enableHttp3).toBeTrue();
});
tap.test('should augment route with port array including 443', async () => {
const route = makeRoute({
match: { ports: [80, 443] },
});
const result = augmentRouteWithHttp3(route, defaultConfig);
expect(result.match.transport).toEqual('all');
expect(result.action.udp!.quic!.enableHttp3).toBeTrue();
});
tap.test('should NOT augment route with port range NOT including 443', async () => {
const route = makeRoute({
match: { ports: [{ from: 8000, to: 9000 }] },
});
const result = augmentRouteWithHttp3(route, defaultConfig);
expect(result.match.transport).toBeUndefined();
});
tap.test('should augment TLS passthrough routes', async () => {
const route = makeRoute({
action: {
type: 'forward',
targets: [{ host: 'localhost', port: 8080 }],
tls: { mode: 'passthrough' },
},
});
const result = augmentRouteWithHttp3(route, defaultConfig);
expect(result.match.transport).toEqual('all');
expect(result.action.udp!.quic!.enableHttp3).toBeTrue();
});
tap.test('should augment terminate-and-reencrypt routes', async () => {
const route = makeRoute({
action: {
type: 'forward',
targets: [{ host: 'localhost', port: 8080 }],
tls: { mode: 'terminate-and-reencrypt', certificate: 'auto' },
},
});
const result = augmentRouteWithHttp3(route, defaultConfig);
expect(result.match.transport).toEqual('all');
expect(result.action.udp!.quic!.enableHttp3).toBeTrue();
});
// ──────────────────────────────────────────────────────────────────────────────
// Configuration tests
// ──────────────────────────────────────────────────────────────────────────────
tap.test('should apply default QUIC settings when none provided', async () => {
const route = makeRoute();
const result = augmentRouteWithHttp3(route, defaultConfig);
expect(result.action.udp!.quic!.altSvcMaxAge).toEqual(86400);
// Undefined means SmartProxy will use its own defaults
expect(result.action.udp!.quic!.maxIdleTimeout).toBeUndefined();
expect(result.action.udp!.quic!.altSvcPort).toBeUndefined();
});
tap.test('should apply custom QUIC settings', async () => {
const route = makeRoute();
const config: IHttp3Config = {
enabled: true,
quicSettings: {
maxIdleTimeout: 60000,
maxConcurrentBidiStreams: 200,
maxConcurrentUniStreams: 50,
initialCongestionWindow: 65536,
},
altSvc: {
port: 8443,
maxAge: 3600,
},
udpSettings: {
sessionTimeout: 120000,
maxSessionsPerIP: 500,
maxDatagramSize: 32768,
},
};
const result = augmentRouteWithHttp3(route, config);
expect(result.action.udp!.quic!.maxIdleTimeout).toEqual(60000);
expect(result.action.udp!.quic!.maxConcurrentBidiStreams).toEqual(200);
expect(result.action.udp!.quic!.maxConcurrentUniStreams).toEqual(50);
expect(result.action.udp!.quic!.initialCongestionWindow).toEqual(65536);
expect(result.action.udp!.quic!.altSvcPort).toEqual(8443);
expect(result.action.udp!.quic!.altSvcMaxAge).toEqual(3600);
expect(result.action.udp!.sessionTimeout).toEqual(120000);
expect(result.action.udp!.maxSessionsPerIP).toEqual(500);
expect(result.action.udp!.maxDatagramSize).toEqual(32768);
});
tap.test('should not mutate the original route', async () => {
const route = makeRoute();
const originalTransport = route.match.transport;
const originalUdp = route.action.udp;
augmentRouteWithHttp3(route, defaultConfig);
expect(route.match.transport).toEqual(originalTransport);
expect(route.action.udp).toEqual(originalUdp);
});
// ──────────────────────────────────────────────────────────────────────────────
// Batch augmentation
// ──────────────────────────────────────────────────────────────────────────────
tap.test('should augment multiple routes in a batch', async () => {
const routes = [
makeRoute({ name: 'web-app' }),
makeRoute({ name: 'smtp-route', match: { ports: 25 } }),
makeRoute({ name: 'api-gateway' }),
makeRoute({
name: 'dns-query',
action: { type: 'socket-handler' as any, socketHandler: (() => {}) as any },
}),
];
const results = augmentRoutesWithHttp3(routes, defaultConfig);
// web-app and api-gateway should be augmented
expect(results[0].match.transport).toEqual('all');
expect(results[2].match.transport).toEqual('all');
// smtp and dns should NOT be augmented
expect(results[1].match.transport).toBeUndefined();
expect(results[3].match.transport).toBeUndefined();
});
// ──────────────────────────────────────────────────────────────────────────────
// Default enabled behavior
// ──────────────────────────────────────────────────────────────────────────────
tap.test('should treat undefined enabled as true (default on)', async () => {
const route = makeRoute();
const result = augmentRouteWithHttp3(route, {}); // no enabled field at all
expect(result.match.transport).toEqual('all');
expect(result.action.udp!.quic!.enableHttp3).toBeTrue();
});
tap.test('should disable when enabled is explicitly false', async () => {
const route = makeRoute();
const result = augmentRouteWithHttp3(route, { enabled: false });
expect(result.match.transport).toBeUndefined();
expect(result.action.udp).toBeUndefined();
});
export default tap.start();

View File

@@ -3,6 +3,6 @@
*/
export const commitinfo = {
name: '@serve.zone/dcrouter',
version: '11.5.1',
version: '11.6.0',
description: 'A multifaceted routing service handling mail and SMS delivery functions.'
}

View File

@@ -24,6 +24,7 @@ import { RadiusServer, type IRadiusServerConfig } from './radius/index.js';
import { RemoteIngressManager, TunnelManager } from './remoteingress/index.js';
import { RouteConfigManager, ApiTokenManager } from './config/index.js';
import { SecurityLogger, ContentScanner, IPReputationChecker } from './security/index.js';
import { type IHttp3Config, augmentRoutesWithHttp3 } from './http3/index.js';
export interface IDcRouterOptions {
/** Base directory for all dcrouter data. Defaults to ~/.serve.zone/dcrouter */
@@ -163,6 +164,14 @@ export interface IDcRouterOptions {
* Remote Ingress configuration for edge tunnel nodes
* Enables edge nodes to accept incoming connections and tunnel them to this DcRouter
*/
/**
* HTTP/3 (QUIC) configuration for HTTPS routes.
* Enabled by default — qualifying HTTPS routes on port 443 are automatically
* augmented with QUIC/H3 fields. Set { enabled: false } to disable globally.
* Individual routes can opt out via action.options.http3 = false.
*/
http3?: IHttp3Config;
/** Port for the OpsServer web UI (default: 3000) */
opsServerPort?: number;
@@ -297,6 +306,7 @@ export class DcRouter {
this.storageManager,
() => this.getConstructorRoutes(),
() => this.smartProxy,
() => this.options.http3,
);
this.apiTokenManager = new ApiTokenManager(this.storageManager);
await this.apiTokenManager.initialize();
@@ -469,6 +479,13 @@ export class DcRouter {
challengeHandlers.push(dns01Handler);
}
// HTTP/3 augmentation (enabled by default unless explicitly disabled)
if (this.options.http3?.enabled !== false) {
const http3Config: IHttp3Config = { enabled: true, ...this.options.http3 };
routes = augmentRoutesWithHttp3(routes, http3Config);
logger.log('info', 'HTTP/3: Augmented qualifying HTTPS routes with QUIC/H3 configuration');
}
// Cache constructor routes for RouteConfigManager
this.constructorRoutes = [...routes];

View File

@@ -7,6 +7,7 @@ import type {
IMergedRoute,
IRouteWarning,
} from '../../ts_interfaces/data/route-management.js';
import { type IHttp3Config, augmentRouteWithHttp3 } from '../http3/index.js';
const ROUTES_PREFIX = '/config-api/routes/';
const OVERRIDES_PREFIX = '/config-api/overrides/';
@@ -20,6 +21,7 @@ export class RouteConfigManager {
private storageManager: StorageManager,
private getHardcodedRoutes: () => plugins.smartproxy.IRouteConfig[],
private getSmartProxy: () => plugins.smartproxy.SmartProxy | undefined,
private getHttp3Config?: () => IHttp3Config | undefined,
) {}
/**
@@ -258,10 +260,15 @@ export class RouteConfigManager {
enabledRoutes.push(route);
}
// Add enabled programmatic routes
// Add enabled programmatic routes (with HTTP/3 augmentation if enabled)
const http3Config = this.getHttp3Config?.();
for (const stored of this.storedRoutes.values()) {
if (stored.enabled) {
enabledRoutes.push(stored.route);
if (http3Config && http3Config.enabled !== false) {
enabledRoutes.push(augmentRouteWithHttp3(stored.route, { enabled: true, ...http3Config }));
} else {
enabledRoutes.push(stored.route);
}
}
}

View File

@@ -0,0 +1,153 @@
import type * as plugins from '../plugins.js';
/**
* Configuration for HTTP/3 (QUIC) route augmentation.
* HTTP/3 is enabled by default on all qualifying HTTPS routes.
*/
export interface IHttp3Config {
/** Enable HTTP/3 augmentation on qualifying routes (default: true) */
enabled?: boolean;
/** QUIC-specific settings applied to all augmented routes */
quicSettings?: {
/** QUIC connection idle timeout in ms (default: 30000) */
maxIdleTimeout?: number;
/** Max concurrent bidirectional streams per connection (default: 100) */
maxConcurrentBidiStreams?: number;
/** Max concurrent unidirectional streams per connection (default: 100) */
maxConcurrentUniStreams?: number;
/** Initial congestion window size in bytes */
initialCongestionWindow?: number;
};
/** Alt-Svc header settings */
altSvc?: {
/** Port advertised in Alt-Svc header (default: same as listening port) */
port?: number;
/** Max age for Alt-Svc advertisement in seconds (default: 86400) */
maxAge?: number;
};
/** UDP session settings */
udpSettings?: {
/** Idle timeout for UDP sessions in ms (default: 60000) */
sessionTimeout?: number;
/** Max concurrent UDP sessions per source IP (default: 1000) */
maxSessionsPerIP?: number;
/** Max accepted datagram size in bytes (default: 65535) */
maxDatagramSize?: number;
};
}
type TPortRange = plugins.smartproxy.IRouteConfig['match']['ports'];
/**
* Check whether a TPortRange includes port 443.
*/
function portRangeIncludes443(ports: TPortRange): boolean {
if (typeof ports === 'number') return ports === 443;
if (Array.isArray(ports)) {
return ports.some((p) => {
if (typeof p === 'number') return p === 443;
return p.from <= 443 && p.to >= 443;
});
}
return false;
}
/**
* Check if a route name indicates an email route that should not get HTTP/3.
*/
function isEmailRoute(route: plugins.smartproxy.IRouteConfig): boolean {
const name = route.name?.toLowerCase() || '';
return (
name.startsWith('smtp-') ||
name.startsWith('submission-') ||
name.startsWith('smtps-') ||
name.startsWith('email-')
);
}
/**
* Determine if a route qualifies for HTTP/3 augmentation.
*/
export function routeQualifiesForHttp3(
route: plugins.smartproxy.IRouteConfig,
globalConfig: IHttp3Config,
): boolean {
// Check global enable + per-route override
const globalEnabled = globalConfig.enabled !== false; // default true
const perRouteOverride = route.action.options?.http3;
// If per-route explicitly set, use that; otherwise use global
const shouldAugment =
perRouteOverride !== undefined ? perRouteOverride : globalEnabled;
if (!shouldAugment) return false;
// Must be forward type
if (route.action.type !== 'forward') return false;
// Must include port 443
if (!portRangeIncludes443(route.match.ports)) return false;
// Must have TLS
if (!route.action.tls) return false;
// Skip email routes
if (isEmailRoute(route)) return false;
// Skip if already configured with transport 'all' or 'udp'
if (route.match.transport === 'all' || route.match.transport === 'udp') return false;
// Skip if already has QUIC config
if (route.action.udp?.quic) return false;
return true;
}
/**
* Augment a single route with HTTP/3 fields.
* Returns a new route object (does not mutate the original).
*/
export function augmentRouteWithHttp3(
route: plugins.smartproxy.IRouteConfig,
config: IHttp3Config,
): plugins.smartproxy.IRouteConfig {
if (!routeQualifiesForHttp3(route, config)) {
return route;
}
return {
...route,
match: {
...route.match,
transport: 'all' as const,
},
action: {
...route.action,
udp: {
...(route.action.udp || {}),
sessionTimeout: config.udpSettings?.sessionTimeout,
maxSessionsPerIP: config.udpSettings?.maxSessionsPerIP,
maxDatagramSize: config.udpSettings?.maxDatagramSize,
quic: {
enableHttp3: true,
maxIdleTimeout: config.quicSettings?.maxIdleTimeout,
maxConcurrentBidiStreams: config.quicSettings?.maxConcurrentBidiStreams,
maxConcurrentUniStreams: config.quicSettings?.maxConcurrentUniStreams,
altSvcPort: config.altSvc?.port,
altSvcMaxAge: config.altSvc?.maxAge ?? 86400,
initialCongestionWindow: config.quicSettings?.initialCongestionWindow,
},
},
},
};
}
/**
* Augment all qualifying routes in an array.
* Returns a new array (does not mutate originals).
*/
export function augmentRoutesWithHttp3(
routes: plugins.smartproxy.IRouteConfig[],
config: IHttp3Config,
): plugins.smartproxy.IRouteConfig[] {
return routes.map((route) => augmentRouteWithHttp3(route, config));
}

1
ts/http3/index.ts Normal file
View File

@@ -0,0 +1 @@
export * from './http3-route-augmentation.js';

View File

@@ -14,6 +14,9 @@ export * from './radius/index.js';
// Remote Ingress module
export * from './remoteingress/index.js';
// HTTP/3 module
export type { IHttp3Config } from './http3/index.js';
export const runCli = async () => {
let options: import('./classes.dcrouter.js').IDcRouterOptions = {};

View File

@@ -3,6 +3,6 @@
*/
export const commitinfo = {
name: '@serve.zone/dcrouter',
version: '11.5.1',
version: '11.6.0',
description: 'A multifaceted routing service handling mail and SMS delivery functions.'
}