Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4ceb46b509 | |||
| 0aa1cde5eb | |||
| 584782dcb7 | |||
| 810ecf46f8 | |||
| 6d5d23a691 | |||
| c6617c79f5 | |||
| 135432260d | |||
| b55d2ac61d | |||
| c88e8e1758 | |||
| 6ee716e4ef | |||
| 1d4ed9af2c |
@@ -4,6 +4,53 @@
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
## 2026-06-03 - 13.43.0
|
||||
|
||||
### Features
|
||||
|
||||
- add derived HTTP-to-HTTPS redirects (http-redirects)
|
||||
- Generate 301 runtime redirect routes from eligible HTTPS routes while detecting existing HTTP route coverage or conflicts
|
||||
- Expose derived redirect metadata through the getHttpRedirects typed request API
|
||||
- Add an Ops Redirects network view with redirect status metrics and table details
|
||||
- Add tests for redirect derivation, conflict handling, and preserving request host/path
|
||||
|
||||
## 2026-06-02 - 13.42.4
|
||||
|
||||
### Fixes
|
||||
|
||||
- normalize source policy route priorities to stable integers (source-policy-compiler)
|
||||
- Assign integer priorities to compiled source policy route variants while preserving relative priority order.
|
||||
- Keep path-specific source policy variants ranked above fallback variants.
|
||||
- update Deno import dependencies (deps)
|
||||
- Bumped Deno import map versions for API, identity, push.rocks, serve.zone, and lru-cache dependencies.
|
||||
|
||||
## 2026-06-02 - 13.42.3
|
||||
|
||||
### Fixes
|
||||
|
||||
- update dependency versions (deps)
|
||||
- Bumped runtime dependencies including @serve.zone/interfaces to ^6.2.1, @serve.zone/catalog to ^2.12.7, and lru-cache to ^11.5.1.
|
||||
- Updated @git.zone/tsdocker dev dependency to ^2.4.2.
|
||||
|
||||
## 2026-06-02 - 13.42.2
|
||||
|
||||
### Fixes
|
||||
|
||||
- bump @git.zone/tsdocker to ^2.4.1 (dev-deps)
|
||||
- Updated @git.zone/tsdocker from ^2.4.0 to ^2.4.1.
|
||||
|
||||
## 2026-06-02 - 13.42.1
|
||||
|
||||
### Fixes
|
||||
|
||||
- bump @serve.zone/remoteingress to ^4.22.5 (deps)
|
||||
- Updates @serve.zone/remoteingress from ^4.22.4 to ^4.22.5.
|
||||
|
||||
## 2026-06-02 - 13.42.0
|
||||
|
||||
### Features
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@serve.zone/dcrouter",
|
||||
"version": "13.42.0",
|
||||
"version": "13.43.0",
|
||||
"exports": "./binary/dcrouter.ts",
|
||||
"compile": {
|
||||
"include": [
|
||||
@@ -8,18 +8,18 @@
|
||||
]
|
||||
},
|
||||
"imports": {
|
||||
"@api.global/typedrequest": "npm:@api.global/typedrequest@^3.3.1",
|
||||
"@api.global/typedrequest": "npm:@api.global/typedrequest@^3.3.2",
|
||||
"@api.global/typedrequest-interfaces": "npm:@api.global/typedrequest-interfaces@^3.0.19",
|
||||
"@api.global/typedserver": "npm:@api.global/typedserver@^8.4.6",
|
||||
"@api.global/typedsocket": "npm:@api.global/typedsocket@^4.1.3",
|
||||
"@api.global/typedserver": "npm:@api.global/typedserver@^8.4.7",
|
||||
"@api.global/typedsocket": "npm:@api.global/typedsocket@^4.1.4",
|
||||
"@apiclient.xyz/cloudflare": "npm:@apiclient.xyz/cloudflare@^7.1.0",
|
||||
"@idp.global/sdk/server": "npm:@idp.global/sdk@^1.3.1/server",
|
||||
"@idp.global/sdk/server": "npm:@idp.global/sdk@^1.4.0/server",
|
||||
"@push.rocks/lik": "npm:@push.rocks/lik@^6.4.1",
|
||||
"@push.rocks/projectinfo": "npm:@push.rocks/projectinfo@^5.1.0",
|
||||
"@push.rocks/qenv": "npm:@push.rocks/qenv@^6.1.4",
|
||||
"@push.rocks/smartacme": "npm:@push.rocks/smartacme@^9.5.0",
|
||||
"@push.rocks/smartdata": "npm:@push.rocks/smartdata@^7.1.7",
|
||||
"@push.rocks/smartdb": "npm:@push.rocks/smartdb@^2.10.1",
|
||||
"@push.rocks/smartdb": "npm:@push.rocks/smartdb@^2.10.2",
|
||||
"@push.rocks/smartdns": "npm:@push.rocks/smartdns@^7.9.3",
|
||||
"@push.rocks/smartfs": "npm:@push.rocks/smartfs@^1.5.1",
|
||||
"@push.rocks/smartguard": "npm:@push.rocks/smartguard@^3.1.0",
|
||||
@@ -31,18 +31,18 @@
|
||||
"@push.rocks/smartnetwork": "npm:@push.rocks/smartnetwork@^4.7.2",
|
||||
"@push.rocks/smartpath": "npm:@push.rocks/smartpath@^6.0.0",
|
||||
"@push.rocks/smartpromise": "npm:@push.rocks/smartpromise@^4.2.4",
|
||||
"@push.rocks/smartproxy": "npm:@push.rocks/smartproxy@^27.12.3",
|
||||
"@push.rocks/smartradius": "npm:@push.rocks/smartradius@^1.1.2",
|
||||
"@push.rocks/smartproxy": "npm:@push.rocks/smartproxy@^27.12.4",
|
||||
"@push.rocks/smartradius": "npm:@push.rocks/smartradius@^1.3.0",
|
||||
"@push.rocks/smartrequest": "npm:@push.rocks/smartrequest@^5.0.3",
|
||||
"@push.rocks/smartrx": "npm:@push.rocks/smartrx@^3.0.10",
|
||||
"@push.rocks/smartstate": "npm:@push.rocks/smartstate@^2.3.1",
|
||||
"@push.rocks/smartunique": "npm:@push.rocks/smartunique@^3.0.9",
|
||||
"@push.rocks/smartvpn": "npm:@push.rocks/smartvpn@1.20.0",
|
||||
"@push.rocks/taskbuffer": "npm:@push.rocks/taskbuffer@^8.0.2",
|
||||
"@serve.zone/interfaces": "npm:@serve.zone/interfaces@^5.8.0",
|
||||
"@serve.zone/remoteingress": "npm:@serve.zone/remoteingress@^4.22.4",
|
||||
"@serve.zone/interfaces": "npm:@serve.zone/interfaces@^6.2.1",
|
||||
"@serve.zone/remoteingress": "npm:@serve.zone/remoteingress@^4.22.5",
|
||||
"@tsclass/tsclass": "npm:@tsclass/tsclass@^9.5.1",
|
||||
"lru-cache": "npm:lru-cache@^11.4.0",
|
||||
"lru-cache": "npm:lru-cache@^11.5.1",
|
||||
"qrcode": "npm:qrcode@^1.5.4",
|
||||
"uuid": "npm:uuid@^14.0.0"
|
||||
}
|
||||
|
||||
+11
-11
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@serve.zone/dcrouter",
|
||||
"private": false,
|
||||
"version": "13.42.0",
|
||||
"version": "13.43.0",
|
||||
"description": "A multifaceted routing service handling mail and SMS delivery functions.",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
@@ -29,27 +29,27 @@
|
||||
"@git.zone/tsbuild": "^4.4.2",
|
||||
"@git.zone/tsbundle": "^2.10.4",
|
||||
"@git.zone/tsdeno": "^1.5.0",
|
||||
"@git.zone/tsdocker": "^2.4.0",
|
||||
"@git.zone/tsdocker": "^2.4.2",
|
||||
"@git.zone/tsrun": "^2.0.4",
|
||||
"@git.zone/tstest": "^3.6.6",
|
||||
"@git.zone/tswatch": "^3.3.5",
|
||||
"@types/node": "^25.9.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"@api.global/typedrequest": "^3.3.1",
|
||||
"@api.global/typedrequest": "^3.3.2",
|
||||
"@api.global/typedrequest-interfaces": "^3.0.19",
|
||||
"@api.global/typedserver": "^8.4.6",
|
||||
"@api.global/typedsocket": "^4.1.3",
|
||||
"@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-element": "^2.2.4",
|
||||
"@idp.global/sdk": "^1.3.1",
|
||||
"@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/smartdb": "^2.10.1",
|
||||
"@push.rocks/smartdb": "^2.10.2",
|
||||
"@push.rocks/smartdns": "^7.9.3",
|
||||
"@push.rocks/smartfs": "^1.5.1",
|
||||
"@push.rocks/smartguard": "^3.1.0",
|
||||
@@ -69,12 +69,12 @@
|
||||
"@push.rocks/smartunique": "^3.0.9",
|
||||
"@push.rocks/smartvpn": "1.20.0",
|
||||
"@push.rocks/taskbuffer": "^8.0.2",
|
||||
"@serve.zone/catalog": "^2.12.4",
|
||||
"@serve.zone/interfaces": "^5.8.0",
|
||||
"@serve.zone/remoteingress": "^4.22.4",
|
||||
"@serve.zone/catalog": "^2.12.7",
|
||||
"@serve.zone/interfaces": "^6.2.1",
|
||||
"@serve.zone/remoteingress": "^4.22.5",
|
||||
"@tsclass/tsclass": "^9.5.1",
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"lru-cache": "^11.4.0",
|
||||
"lru-cache": "^11.5.1",
|
||||
"qrcode": "^1.5.4",
|
||||
"uuid": "^14.0.0"
|
||||
},
|
||||
|
||||
Generated
+138
-368
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,232 @@
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
import { SmartProxy } from '@push.rocks/smartproxy';
|
||||
import * as http from 'node:http';
|
||||
import * as net from 'node:net';
|
||||
import {
|
||||
deriveHttpRedirectConfiguration,
|
||||
deriveHttpRedirects,
|
||||
} from '../ts/config/helpers.http-redirects.js';
|
||||
|
||||
async function getFreePort(): Promise<number> {
|
||||
return await new Promise<number>((resolve, reject) => {
|
||||
const server = net.createServer();
|
||||
server.once('error', reject);
|
||||
server.listen(0, '127.0.0.1', () => {
|
||||
const address = server.address();
|
||||
const port = typeof address === 'object' && address ? address.port : 0;
|
||||
server.close(() => resolve(port));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function requestHeaders(
|
||||
port: number,
|
||||
path: string,
|
||||
headers?: Record<string, string>,
|
||||
): Promise<http.IncomingMessage> {
|
||||
return await new Promise<http.IncomingMessage>((resolve, reject) => {
|
||||
const request = http.get({ host: '127.0.0.1', port, path, headers, agent: false }, resolve);
|
||||
request.once('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
tap.test('deriveHttpRedirectConfiguration creates active runtime redirects from HTTPS routes', async () => {
|
||||
const result = deriveHttpRedirectConfiguration([
|
||||
{
|
||||
id: 'route-1',
|
||||
name: 'app-route',
|
||||
match: { ports: 443, domains: 'app.example.com' },
|
||||
action: {
|
||||
type: 'forward',
|
||||
targets: [{ host: '127.0.0.1', port: 8080 }],
|
||||
tls: { mode: 'terminate', certificate: 'auto' },
|
||||
},
|
||||
remoteIngress: {
|
||||
enabled: true,
|
||||
edgeFilter: ['edge-a'],
|
||||
},
|
||||
} as any,
|
||||
]);
|
||||
|
||||
expect(result.redirects.length).toEqual(1);
|
||||
expect(result.redirects[0].status).toEqual('active');
|
||||
expect(result.redirects[0].domainPattern).toEqual('app.example.com');
|
||||
expect(result.redirects[0].remoteIngress).toEqual(true);
|
||||
expect(result.runtimeRoutes.length).toEqual(1);
|
||||
expect(result.runtimeRoutes[0].match.ports).toEqual(80);
|
||||
expect(result.runtimeRoutes[0].match.domains).toEqual('app.example.com');
|
||||
expect(result.runtimeRoutes[0].priority).toEqual(0);
|
||||
expect(result.runtimeRoutes[0].remoteIngress).toEqual({ enabled: true, edgeFilter: ['edge-a'] });
|
||||
expect(typeof result.runtimeRoutes[0].action.socketHandler).toEqual('function');
|
||||
});
|
||||
|
||||
tap.test('deriveHttpRedirectConfiguration deduplicates identical redirect scopes', async () => {
|
||||
const redirects = deriveHttpRedirects([
|
||||
{
|
||||
id: 'route-1',
|
||||
name: 'first-route',
|
||||
match: { ports: [443], domains: ['app.example.com'] },
|
||||
action: {
|
||||
type: 'forward',
|
||||
targets: [{ host: '127.0.0.1', port: 8080 }],
|
||||
tls: { mode: 'terminate', certificate: 'auto' },
|
||||
},
|
||||
} as any,
|
||||
{
|
||||
id: 'route-2',
|
||||
name: 'second-route',
|
||||
match: { ports: [443], domains: ['app.example.com'] },
|
||||
action: {
|
||||
type: 'forward',
|
||||
targets: [{ host: '127.0.0.1', port: 8081 }],
|
||||
tls: { mode: 'terminate', certificate: 'auto' },
|
||||
},
|
||||
} as any,
|
||||
]);
|
||||
|
||||
expect(redirects.length).toEqual(1);
|
||||
expect(redirects[0].sourceRouteNames).toEqual(['first-route', 'second-route']);
|
||||
});
|
||||
|
||||
tap.test('deriveHttpRedirectConfiguration treats broad explicit HTTP routes as covered', async () => {
|
||||
const result = deriveHttpRedirectConfiguration([
|
||||
{
|
||||
name: 'https-route',
|
||||
match: { ports: 443, domains: 'app.example.com' },
|
||||
action: {
|
||||
type: 'forward',
|
||||
targets: [{ host: '127.0.0.1', port: 8080 }],
|
||||
tls: { mode: 'terminate', certificate: 'auto' },
|
||||
},
|
||||
} as any,
|
||||
{
|
||||
name: 'existing-http-route',
|
||||
match: { ports: 80, domains: 'app.example.com' },
|
||||
action: {
|
||||
type: 'forward',
|
||||
targets: [{ host: '127.0.0.1', port: 8080 }],
|
||||
},
|
||||
} as any,
|
||||
]);
|
||||
|
||||
expect(result.redirects.length).toEqual(1);
|
||||
expect(result.redirects[0].status).toEqual('covered');
|
||||
expect(result.redirects[0].coveredByRouteNames).toEqual(['existing-http-route']);
|
||||
expect(result.runtimeRoutes.length).toEqual(0);
|
||||
});
|
||||
|
||||
tap.test('deriveHttpRedirectConfiguration skips broad redirects that overlap path-specific HTTP routes', async () => {
|
||||
const result = deriveHttpRedirectConfiguration([
|
||||
{
|
||||
name: 'https-route',
|
||||
match: { ports: 443, domains: 'app.example.com' },
|
||||
action: {
|
||||
type: 'forward',
|
||||
targets: [{ host: '127.0.0.1', port: 8080 }],
|
||||
tls: { mode: 'terminate', certificate: 'auto' },
|
||||
},
|
||||
} as any,
|
||||
{
|
||||
name: 'existing-http-health-route',
|
||||
match: { ports: 80, domains: 'app.example.com', path: '/health' },
|
||||
action: {
|
||||
type: 'forward',
|
||||
targets: [{ host: '127.0.0.1', port: 8080 }],
|
||||
},
|
||||
} as any,
|
||||
]);
|
||||
|
||||
expect(result.redirects[0].status).toEqual('skipped');
|
||||
expect(result.runtimeRoutes.length).toEqual(0);
|
||||
});
|
||||
|
||||
tap.test('deriveHttpRedirectConfiguration skips wildcard redirects that overlap explicit HTTP domains', async () => {
|
||||
const result = deriveHttpRedirectConfiguration([
|
||||
{
|
||||
name: 'wildcard-https-route',
|
||||
match: { ports: 443, domains: '*.example.com' },
|
||||
action: {
|
||||
type: 'forward',
|
||||
targets: [{ host: '127.0.0.1', port: 8080 }],
|
||||
tls: { mode: 'terminate', certificate: 'auto' },
|
||||
},
|
||||
} as any,
|
||||
{
|
||||
name: 'explicit-http-app-route',
|
||||
match: { ports: 80, domains: 'app.example.com' },
|
||||
action: {
|
||||
type: 'forward',
|
||||
targets: [{ host: '127.0.0.1', port: 8080 }],
|
||||
},
|
||||
} as any,
|
||||
]);
|
||||
|
||||
expect(result.redirects[0].status).toEqual('skipped');
|
||||
expect(result.runtimeRoutes.length).toEqual(0);
|
||||
});
|
||||
|
||||
tap.test('deriveHttpRedirectConfiguration ignores non-web or narrowed HTTPS routes', async () => {
|
||||
const redirects = deriveHttpRedirects([
|
||||
{
|
||||
name: 'udp-route',
|
||||
match: { ports: 443, domains: 'udp.example.com', transport: 'udp' },
|
||||
action: {
|
||||
type: 'forward',
|
||||
targets: [{ host: '127.0.0.1', port: 443 }],
|
||||
tls: { mode: 'passthrough' },
|
||||
},
|
||||
} as any,
|
||||
{
|
||||
name: 'header-route',
|
||||
match: { ports: 443, domains: 'header.example.com', headers: { 'x-test': 'yes' } },
|
||||
action: {
|
||||
type: 'forward',
|
||||
targets: [{ host: '127.0.0.1', port: 8080 }],
|
||||
tls: { mode: 'terminate', certificate: 'auto' },
|
||||
},
|
||||
} as any,
|
||||
{
|
||||
name: 'socket-handler-route',
|
||||
match: { ports: 443, domains: 'handler.example.com' },
|
||||
action: {
|
||||
type: 'socket-handler',
|
||||
socketHandler: () => {},
|
||||
},
|
||||
} as any,
|
||||
]);
|
||||
|
||||
expect(redirects.length).toEqual(0);
|
||||
});
|
||||
|
||||
tap.test('generated runtime redirect preserves host and path', async () => {
|
||||
const proxyPort = await getFreePort();
|
||||
const redirectRoute = deriveHttpRedirectConfiguration([
|
||||
{
|
||||
name: 'https-route',
|
||||
match: { ports: 443, domains: 'app.example.com' },
|
||||
action: {
|
||||
type: 'forward',
|
||||
targets: [{ host: '127.0.0.1', port: 8080 }],
|
||||
tls: { mode: 'terminate', certificate: 'auto' },
|
||||
},
|
||||
} as any,
|
||||
]).runtimeRoutes[0] as any;
|
||||
redirectRoute.match = { ...redirectRoute.match, ports: proxyPort };
|
||||
|
||||
const proxy = new SmartProxy({
|
||||
connectionRateLimitPerMinute: 1000,
|
||||
routes: [redirectRoute],
|
||||
});
|
||||
|
||||
try {
|
||||
await proxy.start();
|
||||
const response = await requestHeaders(proxyPort, '/some/path?x=1', { host: 'app.example.com' });
|
||||
expect(response.statusCode).toEqual(301);
|
||||
expect(response.headers.location).toEqual('https://app.example.com/some/path?x=1');
|
||||
response.destroy();
|
||||
} finally {
|
||||
await proxy.stop();
|
||||
}
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
@@ -75,6 +75,8 @@ tap.test('source policy compiler expands one route into ordered source variants'
|
||||
expect(variants[2].security?.rateLimit?.maxRequests).toEqual(120);
|
||||
expect(variants[0].priority! > variants[1].priority!).toBeTrue();
|
||||
expect(variants[1].priority! > variants[2].priority!).toBeTrue();
|
||||
expect(variants.every((variant) => Number.isInteger(variant.priority))).toBeTrue();
|
||||
expect(Math.min(...variants.map((variant) => variant.priority!))).toEqual(makeRoute().priority);
|
||||
});
|
||||
|
||||
tap.test('source policy binding can override profile rate limit and 429 message', async () => {
|
||||
@@ -258,6 +260,50 @@ tap.test('source policy compiler uses built-in Gitea path class patterns', async
|
||||
expect(variants[0].priority! > variants[5].priority!).toBeTrue();
|
||||
});
|
||||
|
||||
tap.test('source policy compiler keeps path-specific variants above fallback variants', async () => {
|
||||
const resolver = new ReferenceResolver();
|
||||
injectProfile(resolver, makeProfile({
|
||||
id: 'public',
|
||||
name: 'Public',
|
||||
security: {
|
||||
ipAllowList: ['*'],
|
||||
rateLimit: { enabled: true, maxRequests: 120, window: 60, keyBy: 'ip' },
|
||||
},
|
||||
}));
|
||||
|
||||
const variants = SourcePolicyCompiler.compileRoute(
|
||||
makeRoute(),
|
||||
{
|
||||
sourcePolicy: {
|
||||
bindings: [
|
||||
{
|
||||
sourceProfileRef: 'public',
|
||||
pathPolicies: [
|
||||
{
|
||||
pathClass: 'normal-html',
|
||||
rateLimit: { enabled: true, maxRequests: 20, window: 60, keyBy: 'ip' },
|
||||
},
|
||||
{
|
||||
pathClass: 'git-smart-http',
|
||||
pathPatterns: ['/*/*.git/info/refs'],
|
||||
rateLimit: { enabled: true, maxRequests: 600, window: 60, keyBy: 'ip' },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
resolver,
|
||||
'route-1',
|
||||
);
|
||||
|
||||
const fallbackVariant = variants.find((variant) => variant.match.path === undefined)!;
|
||||
const gitVariant = variants.find((variant) => variant.match.path === '/*/*.git/info/refs')!;
|
||||
|
||||
expect(gitVariant.priority! > fallbackVariant.priority!).toBeTrue();
|
||||
expect(variants.every((variant) => Number.isInteger(variant.priority))).toBeTrue();
|
||||
});
|
||||
|
||||
tap.test('source policy compiler fails closed when wildcard binding shadows later bindings', async () => {
|
||||
const resolver = new ReferenceResolver();
|
||||
injectProfile(resolver, makeProfile({
|
||||
|
||||
@@ -3,6 +3,6 @@
|
||||
*/
|
||||
export const commitinfo = {
|
||||
name: '@serve.zone/dcrouter',
|
||||
version: '13.42.0',
|
||||
version: '13.43.0',
|
||||
description: 'A multifaceted routing service handling mail and SMS delivery functions.'
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ import { MetricsManager } from './monitoring/index.js';
|
||||
import { RadiusServer, type IRadiusServerConfig } from './radius/index.js';
|
||||
import { RemoteIngressManager, TunnelManager } from './remoteingress/index.js';
|
||||
import { VpnManager, type IVpnManagerConfig } from './vpn/index.js';
|
||||
import { RouteConfigManager, ApiTokenManager, GatewayClientManager, ReferenceResolver, DbSeeder, TargetProfileManager } from './config/index.js';
|
||||
import { RouteConfigManager, ApiTokenManager, GatewayClientManager, ReferenceResolver, DbSeeder, TargetProfileManager, buildHttpRedirectRuntimeRoutes } from './config/index.js';
|
||||
import type { TVpnClientAllowEntry } from './config/classes.route-config-manager.js';
|
||||
import { SecurityLogger, ContentScanner, IPReputationChecker, SecurityPolicyManager } from './security/index.js';
|
||||
import { type IHttp3Config, augmentRoutesWithHttp3 } from './http3/index.js';
|
||||
@@ -597,7 +597,7 @@ export class DcRouter {
|
||||
logger.log('error', `Failed to sync Remote Ingress allowed edges: ${(err as Error).message}`);
|
||||
}
|
||||
},
|
||||
undefined,
|
||||
(preparedRoutes) => buildHttpRedirectRuntimeRoutes(preparedRoutes || []),
|
||||
(storedRoute: IRoute) => this.hydrateStoredRouteForRuntime(storedRoute),
|
||||
);
|
||||
this.apiTokenManager = new ApiTokenManager();
|
||||
|
||||
@@ -3,6 +3,7 @@ import { logger } from '../logger.js';
|
||||
import { RouteDoc } from '../db/index.js';
|
||||
import { routePathClasses } from '../../ts_interfaces/data/route-management.js';
|
||||
import type {
|
||||
IHttpRedirectInfo,
|
||||
IRoute,
|
||||
IMergedRoute,
|
||||
IRouteWarning,
|
||||
@@ -15,6 +16,7 @@ import type { IDcRouterRouteConfig } from '../../ts_interfaces/data/remoteingres
|
||||
import { type IHttp3Config, augmentRouteWithHttp3 } from '../http3/index.js';
|
||||
import type { ReferenceResolver } from './classes.reference-resolver.js';
|
||||
import { SourcePolicyCompiler } from './classes.source-policy-compiler.js';
|
||||
import { deriveHttpRedirects } from './helpers.http-redirects.js';
|
||||
|
||||
export type TVpnClientAllowEntry = string | { clientId: string; domains: string[] };
|
||||
|
||||
@@ -64,7 +66,7 @@ export class RouteConfigManager {
|
||||
private getVpnClientAccessForRoute?: (route: IDcRouterRouteConfig, routeId?: string) => TVpnClientAllowEntry[],
|
||||
private referenceResolver?: ReferenceResolver,
|
||||
private onRoutesApplied?: (routes: plugins.smartproxy.IRouteConfig[]) => void | Promise<void>,
|
||||
private getRuntimeRoutes?: () => plugins.smartproxy.IRouteConfig[],
|
||||
private getRuntimeRoutes?: (preparedRoutes?: plugins.smartproxy.IRouteConfig[]) => plugins.smartproxy.IRouteConfig[],
|
||||
private hydrateStoredRoute?: (storedRoute: IRoute) => plugins.smartproxy.IRouteConfig | undefined,
|
||||
) {}
|
||||
|
||||
@@ -124,6 +126,10 @@ export class RouteConfigManager {
|
||||
return { routes: merged, warnings: [...this.warnings] };
|
||||
}
|
||||
|
||||
public getHttpRedirects(): IHttpRedirectInfo[] {
|
||||
return deriveHttpRedirects(this.getPreparedEnabledRoutesForApply());
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Route CRUD
|
||||
// =========================================================================
|
||||
@@ -718,16 +724,9 @@ export class RouteConfigManager {
|
||||
const smartProxy = this.getSmartProxy();
|
||||
if (!smartProxy) return;
|
||||
|
||||
const enabledRoutes: plugins.smartproxy.IRouteConfig[] = [];
|
||||
const enabledRoutes = this.getPreparedEnabledRoutesForApply();
|
||||
|
||||
// Add all enabled routes with HTTP/3, VPN, and source-policy augmentation
|
||||
for (const route of this.routes.values()) {
|
||||
if (route.enabled) {
|
||||
enabledRoutes.push(...this.prepareStoredRoutesForApply(route));
|
||||
}
|
||||
}
|
||||
|
||||
const runtimeRoutes = this.getRuntimeRoutes?.() || [];
|
||||
const runtimeRoutes = this.getRuntimeRoutes?.(enabledRoutes) || [];
|
||||
for (const route of runtimeRoutes) {
|
||||
enabledRoutes.push(this.prepareRouteForApply(route));
|
||||
}
|
||||
@@ -743,6 +742,19 @@ export class RouteConfigManager {
|
||||
});
|
||||
}
|
||||
|
||||
private getPreparedEnabledRoutesForApply(): plugins.smartproxy.IRouteConfig[] {
|
||||
const enabledRoutes: plugins.smartproxy.IRouteConfig[] = [];
|
||||
|
||||
// Add all enabled routes with HTTP/3, VPN, and source-policy augmentation
|
||||
for (const route of this.routes.values()) {
|
||||
if (route.enabled) {
|
||||
enabledRoutes.push(...this.prepareStoredRoutesForApply(route));
|
||||
}
|
||||
}
|
||||
|
||||
return enabledRoutes;
|
||||
}
|
||||
|
||||
private prepareStoredRoutesForApply(storedRoute: IRoute): plugins.smartproxy.IRouteConfig[] {
|
||||
const hydratedRoute = this.hydrateStoredRoute?.(storedRoute);
|
||||
const sourcePolicyRoutes = SourcePolicyCompiler.compileRoute(
|
||||
|
||||
@@ -140,7 +140,7 @@ export class SourcePolicyCompiler {
|
||||
}
|
||||
});
|
||||
|
||||
return compiledRoutes;
|
||||
return this.applyIntegerPriorities(compiledRoutes, basePriority);
|
||||
}
|
||||
|
||||
public static validateSourcePolicyPayload(sourcePolicy?: Partial<IRouteSourcePolicy>): string | undefined {
|
||||
@@ -452,6 +452,38 @@ export class SourcePolicyCompiler {
|
||||
return safeBasePriority + ((sourceCount - sourceIndex) * sourceStep);
|
||||
}
|
||||
|
||||
private static applyIntegerPriorities(
|
||||
routes: plugins.smartproxy.IRouteConfig[],
|
||||
basePriority: number,
|
||||
): plugins.smartproxy.IRouteConfig[] {
|
||||
if (routes.length === 0) {
|
||||
return routes;
|
||||
}
|
||||
|
||||
const priorityOrder = routes
|
||||
.map((route, originalIndex) => ({
|
||||
originalIndex,
|
||||
priority: typeof route.priority === 'number' && Number.isFinite(route.priority)
|
||||
? route.priority
|
||||
: basePriority,
|
||||
}))
|
||||
.sort((a, b) => (b.priority - a.priority) || (a.originalIndex - b.originalIndex));
|
||||
const topPriority = Math.trunc(this.clampPriority(
|
||||
basePriority + routes.length - 1,
|
||||
MIN_ROUTE_PRIORITY + routes.length - 1,
|
||||
MAX_ROUTE_PRIORITY,
|
||||
));
|
||||
const integerPriorities = new Map<number, number>();
|
||||
priorityOrder.forEach((entry, index) => {
|
||||
integerPriorities.set(entry.originalIndex, topPriority - index);
|
||||
});
|
||||
|
||||
return routes.map((route, index) => ({
|
||||
...route,
|
||||
priority: integerPriorities.get(index) ?? MIN_ROUTE_PRIORITY,
|
||||
}));
|
||||
}
|
||||
|
||||
private static clampPriority(
|
||||
priority: number,
|
||||
min = MIN_ROUTE_PRIORITY,
|
||||
|
||||
@@ -0,0 +1,462 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
import type { IHttpRedirectInfo } from '../../ts_interfaces/data/route-management.js';
|
||||
import type { IDcRouterRouteConfig, IRouteRemoteIngress } from '../../ts_interfaces/data/remoteingress.js';
|
||||
|
||||
const AUTO_REDIRECT_ROUTE_PREFIX = 'dcrouter-auto-http-redirect';
|
||||
const REDIRECT_STATUS_CODE = 301;
|
||||
const REDIRECT_PRIORITY = 0;
|
||||
const REDIRECT_TARGET_TEMPLATE = 'https://{domain}{path}';
|
||||
const REDIRECT_INITIAL_DATA_TIMEOUT_MS = 10_000;
|
||||
|
||||
interface IRedirectCandidate {
|
||||
key: string;
|
||||
id: string;
|
||||
domainPattern: string;
|
||||
pathPattern?: string;
|
||||
sourceRouteNames: Set<string>;
|
||||
sourceRouteIds: Set<string>;
|
||||
remoteIngress?: IRouteRemoteIngress;
|
||||
}
|
||||
|
||||
interface IRedirectConflict {
|
||||
routeName: string;
|
||||
covers: boolean;
|
||||
}
|
||||
|
||||
export interface IHttpRedirectDerivationResult {
|
||||
redirects: IHttpRedirectInfo[];
|
||||
runtimeRoutes: IDcRouterRouteConfig[];
|
||||
}
|
||||
|
||||
export function deriveHttpRedirectConfiguration(
|
||||
routes: plugins.smartproxy.IRouteConfig[],
|
||||
): IHttpRedirectDerivationResult {
|
||||
const candidates = collectRedirectCandidates(routes);
|
||||
const httpRoutes = routes.filter((route) => isExplicitHttpRoute(route));
|
||||
const redirects: IHttpRedirectInfo[] = [];
|
||||
const runtimeRoutes: IDcRouterRouteConfig[] = [];
|
||||
|
||||
for (const candidate of candidates) {
|
||||
const conflict = findHttpConflict(candidate, httpRoutes);
|
||||
const redirectInfo: IHttpRedirectInfo = {
|
||||
id: candidate.id,
|
||||
status: conflict ? (conflict.covers ? 'covered' : 'skipped') : 'active',
|
||||
domainPattern: candidate.domainPattern,
|
||||
pathPattern: candidate.pathPattern,
|
||||
fromTemplate: 'http://{domain}{path}',
|
||||
toTemplate: REDIRECT_TARGET_TEMPLATE,
|
||||
statusCode: REDIRECT_STATUS_CODE,
|
||||
priority: REDIRECT_PRIORITY,
|
||||
sourceRouteNames: [...candidate.sourceRouteNames].sort(),
|
||||
sourceRouteIds: [...candidate.sourceRouteIds].sort(),
|
||||
coveredByRouteNames: conflict ? [conflict.routeName] : [],
|
||||
remoteIngress: Boolean(candidate.remoteIngress?.enabled),
|
||||
notes: conflict
|
||||
? conflict.covers
|
||||
? 'An explicit HTTP route already covers this redirect scope.'
|
||||
: 'Skipped because an explicit HTTP route overlaps this redirect scope.'
|
||||
: undefined,
|
||||
};
|
||||
|
||||
redirects.push(redirectInfo);
|
||||
|
||||
if (redirectInfo.status === 'active') {
|
||||
runtimeRoutes.push(buildRuntimeRedirectRoute(candidate));
|
||||
}
|
||||
}
|
||||
|
||||
return { redirects, runtimeRoutes };
|
||||
}
|
||||
|
||||
export function deriveHttpRedirects(
|
||||
routes: plugins.smartproxy.IRouteConfig[],
|
||||
): IHttpRedirectInfo[] {
|
||||
return deriveHttpRedirectConfiguration(routes).redirects;
|
||||
}
|
||||
|
||||
export function buildHttpRedirectRuntimeRoutes(
|
||||
routes: plugins.smartproxy.IRouteConfig[],
|
||||
): IDcRouterRouteConfig[] {
|
||||
return deriveHttpRedirectConfiguration(routes).runtimeRoutes;
|
||||
}
|
||||
|
||||
function collectRedirectCandidates(routes: plugins.smartproxy.IRouteConfig[]): IRedirectCandidate[] {
|
||||
const candidates = new Map<string, IRedirectCandidate>();
|
||||
|
||||
for (const route of routes) {
|
||||
if (!isHttpsRedirectSource(route)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const domainPattern of getDomainPatterns(route)) {
|
||||
const key = createRedirectKey(domainPattern, route.match.path);
|
||||
const existing = candidates.get(key);
|
||||
if (existing) {
|
||||
existing.sourceRouteNames.add(getRouteDisplayName(route));
|
||||
if (route.id) existing.sourceRouteIds.add(route.id);
|
||||
existing.remoteIngress = mergeRemoteIngress(existing.remoteIngress, (route as IDcRouterRouteConfig).remoteIngress);
|
||||
continue;
|
||||
}
|
||||
|
||||
const id = createRedirectRouteName(domainPattern, route.match.path);
|
||||
candidates.set(key, {
|
||||
key,
|
||||
id,
|
||||
domainPattern,
|
||||
pathPattern: route.match.path,
|
||||
sourceRouteNames: new Set([getRouteDisplayName(route)]),
|
||||
sourceRouteIds: new Set(route.id ? [route.id] : []),
|
||||
remoteIngress: mergeRemoteIngress(undefined, (route as IDcRouterRouteConfig).remoteIngress),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return [...candidates.values()].sort((a, b) => a.id.localeCompare(b.id));
|
||||
}
|
||||
|
||||
function isHttpsRedirectSource(route: plugins.smartproxy.IRouteConfig): boolean {
|
||||
if (isGeneratedRedirectRoute(route)) return false;
|
||||
if (route.enabled === false) return false;
|
||||
if (route.action.type !== 'forward') return false;
|
||||
if (!route.match.ports) return false;
|
||||
if (!plugins.smartproxy.portRangeIncludes(route.match.ports, 443)) return false;
|
||||
if (!route.action.tls) return false;
|
||||
if (!route.match.domains) return false;
|
||||
if (route.match.transport === 'udp') return false;
|
||||
if (route.match.protocol && route.match.protocol !== 'http') return false;
|
||||
if (route.match.clientIp || route.match.headers || route.match.tlsVersion) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
function isExplicitHttpRoute(route: plugins.smartproxy.IRouteConfig): boolean {
|
||||
if (isGeneratedRedirectRoute(route)) return false;
|
||||
if (route.enabled === false) return false;
|
||||
if (!route.match.ports) return false;
|
||||
if (!plugins.smartproxy.portRangeIncludes(route.match.ports, 80)) return false;
|
||||
if (route.match.transport === 'udp') return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
function findHttpConflict(
|
||||
candidate: IRedirectCandidate,
|
||||
httpRoutes: plugins.smartproxy.IRouteConfig[],
|
||||
): IRedirectConflict | undefined {
|
||||
for (const route of httpRoutes) {
|
||||
if (!httpRouteOverlapsCandidate(route, candidate)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
return {
|
||||
routeName: getRouteDisplayName(route),
|
||||
covers: httpRouteCoversCandidate(route, candidate),
|
||||
};
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function httpRouteOverlapsCandidate(
|
||||
route: plugins.smartproxy.IRouteConfig,
|
||||
candidate: IRedirectCandidate,
|
||||
): boolean {
|
||||
return routeDomainOverlapsCandidate(route, candidate.domainPattern)
|
||||
&& pathOverlaps(route.match.path, candidate.pathPattern);
|
||||
}
|
||||
|
||||
function httpRouteCoversCandidate(
|
||||
route: plugins.smartproxy.IRouteConfig,
|
||||
candidate: IRedirectCandidate,
|
||||
): boolean {
|
||||
if (route.match.clientIp || route.match.headers || route.match.tlsVersion) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return routeDomainCoversCandidate(route, candidate.domainPattern)
|
||||
&& pathCovers(route.match.path, candidate.pathPattern);
|
||||
}
|
||||
|
||||
function routeDomainOverlapsCandidate(
|
||||
route: plugins.smartproxy.IRouteConfig,
|
||||
candidatePattern: string,
|
||||
): boolean {
|
||||
const routePatterns = getDomainPatterns(route);
|
||||
if (routePatterns.length === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return routePatterns.some((pattern) => domainPatternsOverlap(pattern, candidatePattern));
|
||||
}
|
||||
|
||||
function routeDomainCoversCandidate(
|
||||
route: plugins.smartproxy.IRouteConfig,
|
||||
candidatePattern: string,
|
||||
): boolean {
|
||||
const routePatterns = getDomainPatterns(route);
|
||||
if (routePatterns.length === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return routePatterns.some((pattern) => domainPatternCovers(pattern, candidatePattern));
|
||||
}
|
||||
|
||||
function getDomainPatterns(route: plugins.smartproxy.IRouteConfig): string[] {
|
||||
if (!route.match.domains) return [];
|
||||
return Array.isArray(route.match.domains) ? route.match.domains : [route.match.domains];
|
||||
}
|
||||
|
||||
function normalizePattern(pattern: string): string {
|
||||
return pattern.trim().toLowerCase().replace(/\.$/, '');
|
||||
}
|
||||
|
||||
function domainPatternCovers(coverPattern: string, candidatePattern: string): boolean {
|
||||
const cover = normalizePattern(coverPattern);
|
||||
const candidate = normalizePattern(candidatePattern);
|
||||
if (cover === candidate) return true;
|
||||
if (!candidate.includes('*')) return domainPatternMatchesHostname(cover, candidate);
|
||||
|
||||
const coverSuffix = getLeadingWildcardSuffix(cover);
|
||||
const candidateSuffix = getLeadingWildcardSuffix(candidate);
|
||||
if (coverSuffix && candidateSuffix) {
|
||||
return candidateSuffix.endsWith(coverSuffix);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function domainPatternsOverlap(firstPattern: string, secondPattern: string): boolean {
|
||||
const first = normalizePattern(firstPattern);
|
||||
const second = normalizePattern(secondPattern);
|
||||
if (first === second) return true;
|
||||
if (!first.includes('*')) return domainPatternMatchesHostname(second, first);
|
||||
if (!second.includes('*')) return domainPatternMatchesHostname(first, second);
|
||||
|
||||
const firstSuffix = getLeadingWildcardSuffix(first);
|
||||
const secondSuffix = getLeadingWildcardSuffix(second);
|
||||
if (firstSuffix && secondSuffix) {
|
||||
return firstSuffix.endsWith(secondSuffix) || secondSuffix.endsWith(firstSuffix);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function domainPatternMatchesHostname(pattern: string, hostname: string): boolean {
|
||||
const regex = wildcardPatternToRegex(normalizePattern(pattern));
|
||||
return regex.test(normalizePattern(hostname));
|
||||
}
|
||||
|
||||
function wildcardPatternToRegex(pattern: string): RegExp {
|
||||
const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, '\\$&');
|
||||
return new RegExp(`^${escaped.replace(/\*/g, '.*')}$`, 'i');
|
||||
}
|
||||
|
||||
function getLeadingWildcardSuffix(pattern: string): string | undefined {
|
||||
if (!pattern.startsWith('*')) return undefined;
|
||||
if (pattern.slice(1).includes('*')) return undefined;
|
||||
return pattern.slice(1);
|
||||
}
|
||||
|
||||
function pathCovers(coverPath: string | undefined, candidatePath: string | undefined): boolean {
|
||||
if (!coverPath) return true;
|
||||
if (!candidatePath) return false;
|
||||
if (coverPath === candidatePath) return true;
|
||||
if (!coverPath.includes('*')) return false;
|
||||
const coverPrefix = coverPath.split('*')[0];
|
||||
if (!candidatePath.includes('*')) return candidatePath.startsWith(coverPrefix);
|
||||
const candidatePrefix = candidatePath.split('*')[0];
|
||||
return candidatePrefix.startsWith(coverPrefix);
|
||||
}
|
||||
|
||||
function pathOverlaps(firstPath: string | undefined, secondPath: string | undefined): boolean {
|
||||
if (!firstPath || !secondPath) return true;
|
||||
if (firstPath === secondPath) return true;
|
||||
const firstPrefix = firstPath.split('*')[0];
|
||||
const secondPrefix = secondPath.split('*')[0];
|
||||
return firstPrefix.startsWith(secondPrefix) || secondPrefix.startsWith(firstPrefix);
|
||||
}
|
||||
|
||||
function buildRuntimeRedirectRoute(candidate: IRedirectCandidate): IDcRouterRouteConfig {
|
||||
return {
|
||||
id: candidate.id,
|
||||
name: candidate.id,
|
||||
description: 'Generated HTTP to HTTPS redirect',
|
||||
priority: REDIRECT_PRIORITY,
|
||||
tags: ['system', 'redirect', 'auto'],
|
||||
match: {
|
||||
ports: 80,
|
||||
domains: candidate.domainPattern,
|
||||
...(candidate.pathPattern ? { path: candidate.pathPattern } : {}),
|
||||
},
|
||||
action: {
|
||||
type: 'socket-handler',
|
||||
socketHandler: createHttpRedirectHandler(REDIRECT_TARGET_TEMPLATE, REDIRECT_STATUS_CODE),
|
||||
},
|
||||
...(candidate.remoteIngress ? { remoteIngress: candidate.remoteIngress } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function mergeRemoteIngress(
|
||||
current: IRouteRemoteIngress | undefined,
|
||||
next: IRouteRemoteIngress | undefined,
|
||||
): IRouteRemoteIngress | undefined {
|
||||
if (!next?.enabled) return current;
|
||||
if (!current?.enabled) {
|
||||
return {
|
||||
enabled: true,
|
||||
...(next.edgeFilter?.length ? { edgeFilter: [...next.edgeFilter] } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
const currentFilter = current.edgeFilter || [];
|
||||
const nextFilter = next.edgeFilter || [];
|
||||
if (currentFilter.length === 0 || nextFilter.length === 0) {
|
||||
return { enabled: true };
|
||||
}
|
||||
|
||||
return {
|
||||
enabled: true,
|
||||
edgeFilter: [...new Set([...currentFilter, ...nextFilter])].sort(),
|
||||
};
|
||||
}
|
||||
|
||||
function createRedirectKey(domainPattern: string, pathPattern?: string): string {
|
||||
return `${normalizePattern(domainPattern)}|${pathPattern || ''}`;
|
||||
}
|
||||
|
||||
function createRedirectRouteName(domainPattern: string, pathPattern?: string): string {
|
||||
const key = createRedirectKey(domainPattern, pathPattern);
|
||||
const slug = key
|
||||
.replace(/\*/g, 'wildcard')
|
||||
.replace(/[^a-zA-Z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
.slice(0, 48) || 'route';
|
||||
const hash = plugins.crypto.createHash('sha1').update(key).digest('hex').slice(0, 8);
|
||||
return `${AUTO_REDIRECT_ROUTE_PREFIX}-${slug}-${hash}`;
|
||||
}
|
||||
|
||||
function getRouteDisplayName(route: plugins.smartproxy.IRouteConfig): string {
|
||||
return route.name || route.id || 'unnamed-route';
|
||||
}
|
||||
|
||||
function isGeneratedRedirectRoute(route: plugins.smartproxy.IRouteConfig): boolean {
|
||||
return Boolean(route.name?.startsWith(AUTO_REDIRECT_ROUTE_PREFIX) || route.id?.startsWith(AUTO_REDIRECT_ROUTE_PREFIX));
|
||||
}
|
||||
|
||||
function createHttpRedirectHandler(
|
||||
locationTemplate: string,
|
||||
statusCode: number,
|
||||
): NonNullable<plugins.smartproxy.IRouteConfig['action']['socketHandler']> {
|
||||
return (socket, context) => {
|
||||
const cleanup = () => {
|
||||
clearTimeout(timeout);
|
||||
socket.removeListener('data', handleData);
|
||||
socket.removeListener('error', cleanup);
|
||||
socket.removeListener('close', cleanup);
|
||||
};
|
||||
|
||||
const handleData = (data: string | Uint8Array) => {
|
||||
cleanup();
|
||||
const request = parseHttpRequest(data);
|
||||
if (!request) {
|
||||
socket.end('HTTP/1.1 400 Bad Request\r\nConnection: close\r\n\r\n');
|
||||
return;
|
||||
}
|
||||
|
||||
const domain = normalizeHostHeader(request.headers.host) || context.domain || 'localhost';
|
||||
const finalLocation = locationTemplate
|
||||
.replace('{domain}', domain)
|
||||
.replace('{port}', String(context.port))
|
||||
.replace('{path}', request.path || '/')
|
||||
.replace('{clientIp}', context.clientIp);
|
||||
const message = `Redirecting to ${finalLocation}`;
|
||||
const response = [
|
||||
`HTTP/1.1 ${statusCode} ${getHttpStatusText(statusCode)}`,
|
||||
`Location: ${finalLocation}`,
|
||||
'Content-Type: text/plain',
|
||||
`Content-Length: ${message.length}`,
|
||||
'Connection: close',
|
||||
'',
|
||||
message,
|
||||
].join('\r\n');
|
||||
|
||||
socket.end(response);
|
||||
};
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
cleanup();
|
||||
socket.end('HTTP/1.1 408 Request Timeout\r\nConnection: close\r\n\r\n');
|
||||
}, REDIRECT_INITIAL_DATA_TIMEOUT_MS) as ReturnType<typeof setTimeout> & { unref?: () => void };
|
||||
timeout.unref?.();
|
||||
|
||||
socket.once('data', handleData);
|
||||
socket.once('error', cleanup);
|
||||
socket.once('close', cleanup);
|
||||
};
|
||||
}
|
||||
|
||||
function parseHttpRequest(data: string | Uint8Array): {
|
||||
method: string;
|
||||
path: string;
|
||||
headers: Record<string, string>;
|
||||
} | undefined {
|
||||
const requestText = typeof data === 'string' ? data : new TextDecoder().decode(data);
|
||||
const headerEnd = requestText.indexOf('\r\n\r\n');
|
||||
const headerText = headerEnd >= 0 ? requestText.slice(0, headerEnd) : requestText;
|
||||
const lines = headerText.split('\r\n');
|
||||
const [method, rawPath] = (lines[0] || '').split(' ');
|
||||
if (!method || !rawPath) return undefined;
|
||||
|
||||
const headers: Record<string, string> = {};
|
||||
for (const line of lines.slice(1)) {
|
||||
const colonIndex = line.indexOf(':');
|
||||
if (colonIndex <= 0) continue;
|
||||
const key = line.slice(0, colonIndex).trim().toLowerCase();
|
||||
const value = line.slice(colonIndex + 1).trim();
|
||||
headers[key] = value;
|
||||
}
|
||||
|
||||
return {
|
||||
method,
|
||||
path: normalizeRequestPath(rawPath),
|
||||
headers,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeRequestPath(rawPath: string): string {
|
||||
if (rawPath.startsWith('http://') || rawPath.startsWith('https://')) {
|
||||
try {
|
||||
const url = new URL(rawPath);
|
||||
return `${url.pathname}${url.search}` || '/';
|
||||
} catch {
|
||||
return '/';
|
||||
}
|
||||
}
|
||||
|
||||
return rawPath.startsWith('/') ? rawPath : '/';
|
||||
}
|
||||
|
||||
function normalizeHostHeader(hostHeader: string | undefined): string | undefined {
|
||||
if (!hostHeader) return undefined;
|
||||
const host = hostHeader.split(',')[0].trim();
|
||||
if (!host || /[\s\x00-\x1f\x7f]/.test(host)) return undefined;
|
||||
if (host.startsWith('[')) {
|
||||
const bracketIndex = host.indexOf(']');
|
||||
return bracketIndex > 0 ? host.slice(0, bracketIndex + 1) : undefined;
|
||||
}
|
||||
|
||||
return host.replace(/:(80|443)$/, '');
|
||||
}
|
||||
|
||||
function getHttpStatusText(statusCode: number): string {
|
||||
switch (statusCode) {
|
||||
case 301:
|
||||
return 'Moved Permanently';
|
||||
case 302:
|
||||
return 'Found';
|
||||
case 307:
|
||||
return 'Temporary Redirect';
|
||||
case 308:
|
||||
return 'Permanent Redirect';
|
||||
default:
|
||||
return 'Redirect';
|
||||
}
|
||||
}
|
||||
@@ -5,5 +5,6 @@ export { ApiTokenManager } from './classes.api-token-manager.js';
|
||||
export { GatewayClientManager } from './classes.gateway-client-manager.js';
|
||||
export { ReferenceResolver } from './classes.reference-resolver.js';
|
||||
export { SourcePolicyCompiler } from './classes.source-policy-compiler.js';
|
||||
export * from './helpers.http-redirects.js';
|
||||
export { DbSeeder } from './classes.db-seeder.js';
|
||||
export { TargetProfileManager } from './classes.target-profile-manager.js';
|
||||
|
||||
@@ -42,6 +42,21 @@ export class RouteManagementHandler {
|
||||
),
|
||||
);
|
||||
|
||||
// Get generated HTTP redirects
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetHttpRedirects>(
|
||||
'getHttpRedirects',
|
||||
async (dataArg) => {
|
||||
await this.requireAuth(dataArg, 'routes:read');
|
||||
const manager = this.opsServerRef.dcRouterRef.routeConfigManager;
|
||||
if (!manager) {
|
||||
return { redirects: [] };
|
||||
}
|
||||
return { redirects: manager.getHttpRedirects() };
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// Create route
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_CreateRoute>(
|
||||
|
||||
@@ -285,6 +285,28 @@ export interface IRouteWarning {
|
||||
message: string;
|
||||
}
|
||||
|
||||
export type THttpRedirectStatus = 'active' | 'covered' | 'skipped';
|
||||
|
||||
/**
|
||||
* Derived HTTP-to-HTTPS redirect shown in the Ops UI.
|
||||
* These entries are generated from configured HTTPS routes and are not stored as routes.
|
||||
*/
|
||||
export interface IHttpRedirectInfo {
|
||||
id: string;
|
||||
status: THttpRedirectStatus;
|
||||
domainPattern: string;
|
||||
pathPattern?: string;
|
||||
fromTemplate: string;
|
||||
toTemplate: string;
|
||||
statusCode: number;
|
||||
priority: number;
|
||||
sourceRouteNames: string[];
|
||||
sourceRouteIds: string[];
|
||||
coveredByRouteNames: string[];
|
||||
remoteIngress: boolean;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Public info about an API token (never includes the hash).
|
||||
*/
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
import type * as authInterfaces from '../data/auth.js';
|
||||
import type { IMergedRoute, IRouteWarning, IRouteMetadata } from '../data/route-management.js';
|
||||
import type { IHttpRedirectInfo, IMergedRoute, IRouteWarning, IRouteMetadata } from '../data/route-management.js';
|
||||
import type { IRouteConfig } from '@push.rocks/smartproxy';
|
||||
import type { IDcRouterRouteConfig } from '../data/remoteingress.js';
|
||||
|
||||
@@ -26,6 +26,23 @@ export interface IReq_GetMergedRoutes extends plugins.typedrequestInterfaces.imp
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get derived HTTP-to-HTTPS redirects.
|
||||
*/
|
||||
export interface IReq_GetHttpRedirects extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_GetHttpRedirects
|
||||
> {
|
||||
method: 'getHttpRedirects';
|
||||
request: {
|
||||
identity?: authInterfaces.IIdentity;
|
||||
apiToken?: string;
|
||||
};
|
||||
response: {
|
||||
redirects: IHttpRedirectInfo[];
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new route.
|
||||
*/
|
||||
|
||||
@@ -3,6 +3,6 @@
|
||||
*/
|
||||
export const commitinfo = {
|
||||
name: '@serve.zone/dcrouter',
|
||||
version: '13.42.0',
|
||||
version: '13.43.0',
|
||||
description: 'A multifaceted routing service handling mail and SMS delivery functions.'
|
||||
}
|
||||
|
||||
@@ -290,6 +290,7 @@ export const remoteIngressStatePart = await appState.getStatePart<IRemoteIngress
|
||||
export interface IRouteManagementState {
|
||||
mergedRoutes: interfaces.data.IMergedRoute[];
|
||||
warnings: interfaces.data.IRouteWarning[];
|
||||
httpRedirects: interfaces.data.IHttpRedirectInfo[];
|
||||
apiTokens: interfaces.data.IApiTokenInfo[];
|
||||
gatewayClients: interfaces.data.IGatewayClient[];
|
||||
isLoading: boolean;
|
||||
@@ -302,6 +303,7 @@ export const routeManagementStatePart = await appState.getStatePart<IRouteManage
|
||||
{
|
||||
mergedRoutes: [],
|
||||
warnings: [],
|
||||
httpRedirects: [],
|
||||
apiTokens: [],
|
||||
gatewayClients: [],
|
||||
isLoading: false,
|
||||
@@ -2474,6 +2476,36 @@ export const fetchMergedRoutesAction = routeManagementStatePart.createAction(asy
|
||||
}
|
||||
});
|
||||
|
||||
export const fetchHttpRedirectsAction = routeManagementStatePart.createAction(async (statePartArg): Promise<IRouteManagementState> => {
|
||||
const context = getActionContext();
|
||||
const currentState = statePartArg.getState()!;
|
||||
if (!context.identity) return currentState;
|
||||
|
||||
try {
|
||||
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||
interfaces.requests.IReq_GetHttpRedirects
|
||||
>('/typedrequest', 'getHttpRedirects');
|
||||
|
||||
const response = await request.fire({
|
||||
identity: context.identity,
|
||||
});
|
||||
|
||||
return {
|
||||
...currentState,
|
||||
httpRedirects: response.redirects,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
lastUpdated: Date.now(),
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
...currentState,
|
||||
isLoading: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to fetch HTTP redirects',
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
export const createRouteAction = routeManagementStatePart.createAction<{
|
||||
route: any;
|
||||
enabled?: boolean;
|
||||
|
||||
@@ -19,6 +19,7 @@ export class OpsViewApiTokens extends DeesElement {
|
||||
@state() accessor routeState: appstate.IRouteManagementState = {
|
||||
mergedRoutes: [],
|
||||
warnings: [],
|
||||
httpRedirects: [],
|
||||
apiTokens: [],
|
||||
gatewayClients: [],
|
||||
isLoading: false,
|
||||
|
||||
@@ -17,6 +17,7 @@ export class OpsViewGatewayClients extends DeesElement {
|
||||
@state() accessor routeState: appstate.IRouteManagementState = {
|
||||
mergedRoutes: [],
|
||||
warnings: [],
|
||||
httpRedirects: [],
|
||||
apiTokens: [],
|
||||
gatewayClients: [],
|
||||
isLoading: false,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export * from './ops-view-network-activity.js';
|
||||
export * from './ops-view-routes.js';
|
||||
export * from './ops-view-redirects.js';
|
||||
export * from './ops-view-sourceprofiles.js';
|
||||
export * from './ops-view-networktargets.js';
|
||||
export * from './ops-view-targetprofiles.js';
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
import {
|
||||
DeesElement,
|
||||
html,
|
||||
customElement,
|
||||
type TemplateResult,
|
||||
css,
|
||||
state,
|
||||
cssManager,
|
||||
} from '@design.estate/dees-element';
|
||||
import { type IStatsTile } from '@design.estate/dees-catalog';
|
||||
import * as appstate from '../../appstate.js';
|
||||
import * as interfaces from '../../../dist_ts_interfaces/index.js';
|
||||
import { viewHostCss } from '../shared/css.js';
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'ops-view-redirects': OpsViewRedirects;
|
||||
}
|
||||
}
|
||||
|
||||
@customElement('ops-view-redirects')
|
||||
export class OpsViewRedirects extends DeesElement {
|
||||
@state()
|
||||
accessor routeState: appstate.IRouteManagementState = appstate.routeManagementStatePart.getState()!;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
const routeSub = appstate.routeManagementStatePart.select().subscribe((routeState) => {
|
||||
this.routeState = routeState;
|
||||
});
|
||||
this.rxSubscriptions.push(routeSub);
|
||||
|
||||
const loginSub = appstate.loginStatePart
|
||||
.select((state) => state.isLoggedIn)
|
||||
.subscribe((isLoggedIn) => {
|
||||
if (isLoggedIn) {
|
||||
void this.refreshData();
|
||||
}
|
||||
});
|
||||
this.rxSubscriptions.push(loginSub);
|
||||
}
|
||||
|
||||
async connectedCallback() {
|
||||
await super.connectedCallback();
|
||||
await this.refreshData();
|
||||
}
|
||||
|
||||
public static styles = [
|
||||
cssManager.defaultStyles,
|
||||
viewHostCss,
|
||||
css`
|
||||
.redirectsContainer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 48px 24px;
|
||||
color: ${cssManager.bdTheme('#6b7280', '#9ca3af')};
|
||||
}
|
||||
|
||||
.empty-state p {
|
||||
margin: 8px 0;
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
||||
public render(): TemplateResult {
|
||||
const redirects = this.routeState.httpRedirects || [];
|
||||
const activeCount = redirects.filter((redirect) => redirect.status === 'active').length;
|
||||
const coveredCount = redirects.filter((redirect) => redirect.status === 'covered').length;
|
||||
const skippedCount = redirects.filter((redirect) => redirect.status === 'skipped').length;
|
||||
const remoteIngressCount = redirects.filter((redirect) => redirect.remoteIngress).length;
|
||||
|
||||
const statsTiles: IStatsTile[] = [
|
||||
{
|
||||
id: 'totalRedirects',
|
||||
title: 'Total Redirects',
|
||||
type: 'number',
|
||||
value: redirects.length,
|
||||
icon: 'lucide:CornerDownRight',
|
||||
description: 'Derived HTTP to HTTPS scopes',
|
||||
color: '#3b82f6',
|
||||
},
|
||||
{
|
||||
id: 'activeRedirects',
|
||||
title: 'Active',
|
||||
type: 'number',
|
||||
value: activeCount,
|
||||
icon: 'lucide:CircleCheck',
|
||||
description: 'Generated at runtime',
|
||||
color: '#22c55e',
|
||||
},
|
||||
{
|
||||
id: 'coveredRedirects',
|
||||
title: 'Covered',
|
||||
type: 'number',
|
||||
value: coveredCount,
|
||||
icon: 'lucide:ShieldCheck',
|
||||
description: 'Handled by explicit HTTP routes',
|
||||
color: '#8b5cf6',
|
||||
},
|
||||
{
|
||||
id: 'skippedRedirects',
|
||||
title: 'Skipped',
|
||||
type: 'number',
|
||||
value: skippedCount,
|
||||
icon: 'lucide:AlertTriangle',
|
||||
description: 'Overlaps explicit HTTP routes',
|
||||
color: skippedCount > 0 ? '#f59e0b' : '#6b7280',
|
||||
},
|
||||
{
|
||||
id: 'remoteIngressRedirects',
|
||||
title: 'Remote Ingress',
|
||||
type: 'number',
|
||||
value: remoteIngressCount,
|
||||
icon: 'lucide:Globe',
|
||||
description: 'Also exposed to edge nodes',
|
||||
color: '#0ea5e9',
|
||||
},
|
||||
];
|
||||
|
||||
return html`
|
||||
<dees-heading level="3">Redirects</dees-heading>
|
||||
<div class="redirectsContainer">
|
||||
<dees-statsgrid .tiles=${statsTiles}></dees-statsgrid>
|
||||
|
||||
${redirects.length > 0
|
||||
? html`
|
||||
<dees-table
|
||||
.heading1=${'HTTP to HTTPS Redirects'}
|
||||
.heading2=${'Runtime redirects derived from enabled HTTPS routes'}
|
||||
.data=${redirects}
|
||||
.showColumnFilters=${true}
|
||||
.displayFunction=${(redirect: interfaces.data.IHttpRedirectInfo) => ({
|
||||
Status: this.formatStatus(redirect.status),
|
||||
'Domain Pattern': redirect.domainPattern,
|
||||
Path: redirect.pathPattern || '*',
|
||||
From: this.formatHttpTemplate(redirect, 'http'),
|
||||
To: this.formatHttpTemplate(redirect, 'https'),
|
||||
Code: redirect.statusCode,
|
||||
Priority: redirect.priority,
|
||||
'Source HTTPS Route': redirect.sourceRouteNames.join(', ') || '-',
|
||||
'Covered By': redirect.coveredByRouteNames.join(', ') || '-',
|
||||
Notes: this.formatNotes(redirect),
|
||||
})}
|
||||
.dataActions=${[
|
||||
{
|
||||
name: 'Refresh',
|
||||
iconName: 'lucide:RefreshCw',
|
||||
type: ['header' as const],
|
||||
actionFunc: async () => this.refreshData(),
|
||||
},
|
||||
]}
|
||||
></dees-table>
|
||||
`
|
||||
: html`
|
||||
<dees-table
|
||||
.heading1=${'HTTP to HTTPS Redirects'}
|
||||
.heading2=${'Runtime redirects derived from enabled HTTPS routes'}
|
||||
.data=${[]}
|
||||
.displayFunction=${() => ({})}
|
||||
.dataActions=${[
|
||||
{
|
||||
name: 'Refresh',
|
||||
iconName: 'lucide:RefreshCw',
|
||||
type: ['header' as const],
|
||||
actionFunc: async () => this.refreshData(),
|
||||
},
|
||||
]}
|
||||
></dees-table>
|
||||
<div class="empty-state">
|
||||
<p>No derived redirects</p>
|
||||
<p>Enable HTTPS routes with explicit domains to generate HTTP to HTTPS redirects.</p>
|
||||
</div>
|
||||
`}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private async refreshData(): Promise<void> {
|
||||
await appstate.routeManagementStatePart.dispatchAction(appstate.fetchHttpRedirectsAction, null);
|
||||
}
|
||||
|
||||
private formatStatus(status: interfaces.data.THttpRedirectStatus): string {
|
||||
return status.charAt(0).toUpperCase() + status.slice(1);
|
||||
}
|
||||
|
||||
private formatHttpTemplate(redirect: interfaces.data.IHttpRedirectInfo, protocol: 'http' | 'https'): string {
|
||||
return `${protocol}://${redirect.domainPattern}${redirect.pathPattern || '{path}'}`;
|
||||
}
|
||||
|
||||
private formatNotes(redirect: interfaces.data.IHttpRedirectInfo): string {
|
||||
const notes = redirect.notes ? [redirect.notes] : [];
|
||||
if (redirect.remoteIngress) {
|
||||
notes.push('Remote Ingress enabled');
|
||||
}
|
||||
return notes.join(' ') || 'Generated from HTTPS route';
|
||||
}
|
||||
}
|
||||
@@ -293,6 +293,7 @@ export class OpsViewRoutes extends DeesElement {
|
||||
@state() accessor routeState: appstate.IRouteManagementState = {
|
||||
mergedRoutes: [],
|
||||
warnings: [],
|
||||
httpRedirects: [],
|
||||
apiTokens: [],
|
||||
gatewayClients: [],
|
||||
isLoading: false,
|
||||
|
||||
@@ -23,6 +23,7 @@ import { OpsViewConfig } from './overview/ops-view-config.js';
|
||||
// Network group
|
||||
import { OpsViewNetworkActivity } from './network/ops-view-network-activity.js';
|
||||
import { OpsViewRoutes } from './network/ops-view-routes.js';
|
||||
import { OpsViewRedirects } from './network/ops-view-redirects.js';
|
||||
import { OpsViewSourceProfiles } from './network/ops-view-sourceprofiles.js';
|
||||
import { OpsViewNetworkTargets } from './network/ops-view-networktargets.js';
|
||||
import { OpsViewTargetProfiles } from './network/ops-view-targetprofiles.js';
|
||||
@@ -100,6 +101,7 @@ export class OpsDashboard extends DeesElement {
|
||||
subViews: [
|
||||
{ slug: 'activity', name: 'Network Activity', iconName: 'lucide:activity', element: OpsViewNetworkActivity },
|
||||
{ slug: 'routes', name: 'Routes', iconName: 'lucide:route', element: OpsViewRoutes },
|
||||
{ slug: 'redirects', name: 'Redirects', iconName: 'lucide:CornerDownRight', element: OpsViewRedirects },
|
||||
{ slug: 'sourceprofiles', name: 'Source Profiles', iconName: 'lucide:shieldCheck', element: OpsViewSourceProfiles },
|
||||
{ slug: 'networktargets', name: 'Network Targets', iconName: 'lucide:server', element: OpsViewNetworkTargets },
|
||||
{ slug: 'targetprofiles', name: 'Target Profiles', iconName: 'lucide:target', element: OpsViewTargetProfiles },
|
||||
|
||||
+1
-1
@@ -9,7 +9,7 @@ const flatViews = ['logs'] as const;
|
||||
// Tabbed views and their valid subviews
|
||||
const subviewMap: Record<string, readonly string[]> = {
|
||||
overview: ['stats', 'configuration'] as const,
|
||||
network: ['activity', 'routes', 'sourceprofiles', 'networktargets', 'targetprofiles', 'remoteingress', 'vpn'] as const,
|
||||
network: ['activity', 'routes', 'redirects', 'sourceprofiles', 'networktargets', 'targetprofiles', 'remoteingress', 'vpn'] as const,
|
||||
email: ['log', 'security', 'domains'] as const,
|
||||
access: ['gatewayclients', 'apitokens', 'users'] as const,
|
||||
security: ['overview', 'blocked', 'authentication'] as const,
|
||||
|
||||
Reference in New Issue
Block a user