Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1567606c49 | |||
| af31982d58 | |||
| a322308623 | |||
| ec5374900c | |||
| 49ce265d7e | |||
| 63729697c5 | |||
| ce93b726ef | |||
| 1c3aa89f8d | |||
| b3751abd17 | |||
| 97017ede98 | |||
| 4b928b038e | |||
| a466b88408 |
@@ -1,5 +1,40 @@
|
||||
# Changelog
|
||||
|
||||
## 2026-04-26 - 13.23.0 - feat(security)
|
||||
add managed security policies with IP intelligence and remote ingress firewall propagation
|
||||
|
||||
- introduces a SecurityPolicyManager that observes public IPs, stores IP intelligence, compiles block policies, and audits policy changes
|
||||
- adds database documents and shared interfaces for security block rules, IP intelligence records, and security policy audit events
|
||||
- exposes ops/admin request handlers to list IP intelligence and create, update, or delete security block rules
|
||||
- applies merged security policies to SmartProxy and propagates firewall snapshots to remote ingress edges and tunnel synchronization
|
||||
|
||||
## 2026-04-26 - 13.22.0 - feat(remoteingress)
|
||||
add remote ingress performance configuration and expose tunnel transport metrics
|
||||
|
||||
- upgrade @serve.zone/remoteingress to support performance tuning and richer tunnel status data
|
||||
- pass remote ingress performance settings through router startup and config APIs
|
||||
- serialize allowed-edge sync operations and await route update hooks to avoid tunnel sync races
|
||||
- expose UDP listen ports and transport, flow control, queue, and traffic metrics in remote ingress APIs and ops UI
|
||||
|
||||
## 2026-04-26 - 13.21.1 - fix(deps)
|
||||
bump @push.rocks/smartproxy to ^27.8.1
|
||||
|
||||
- Updates @push.rocks/smartproxy from ^27.8.0 to ^27.8.1 in package.json.
|
||||
|
||||
## 2026-04-25 - 13.21.0 - feat(monitoring)
|
||||
improve network activity metrics with live domain request rates and backend identifiers
|
||||
|
||||
- use SmartProxy per-domain live request rates to rank and attribute domain activity metrics, while retaining lifetime request totals as fallback data
|
||||
- separate aggregate backend rows from protocol cache rows with stable ids so cached protocol entries no longer duplicate active backend connection counts
|
||||
- expose frontend and backend protocol distributions plus aggregated connectionCount fields through ops and web network views
|
||||
|
||||
## 2026-04-17 - 13.20.2 - fix(vpn)
|
||||
handle VPN forwarding mode downgrades and support runtime VPN config updates
|
||||
|
||||
- restart the VPN server back to socket mode when host-IP clients are removed while preserving explicit hybrid mode
|
||||
- allow DcRouter to update VPN configuration at runtime and refresh route allow-list resolution without recreating the router
|
||||
- improve VPN operations UI target profile rendering and loading behavior for create and edit flows
|
||||
|
||||
## 2026-04-17 - 13.20.1 - fix(docs)
|
||||
refresh package readmes with clearer runtime, API client, interfaces, migrations, and dashboard guidance
|
||||
|
||||
|
||||
+4
-4
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@serve.zone/dcrouter",
|
||||
"private": false,
|
||||
"version": "13.20.1",
|
||||
"version": "13.23.0",
|
||||
"description": "A multifaceted routing service handling mail and SMS delivery functions.",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
@@ -54,7 +54,7 @@
|
||||
"@push.rocks/smartnetwork": "^4.6.0",
|
||||
"@push.rocks/smartpath": "^6.0.0",
|
||||
"@push.rocks/smartpromise": "^4.2.3",
|
||||
"@push.rocks/smartproxy": "^27.7.4",
|
||||
"@push.rocks/smartproxy": "^27.9.0",
|
||||
"@push.rocks/smartradius": "^1.1.1",
|
||||
"@push.rocks/smartrequest": "^5.0.1",
|
||||
"@push.rocks/smartrx": "^3.0.10",
|
||||
@@ -63,8 +63,8 @@
|
||||
"@push.rocks/smartvpn": "1.19.2",
|
||||
"@push.rocks/taskbuffer": "^8.0.2",
|
||||
"@serve.zone/catalog": "^2.12.4",
|
||||
"@serve.zone/interfaces": "^5.3.0",
|
||||
"@serve.zone/remoteingress": "^4.15.3",
|
||||
"@serve.zone/interfaces": "^5.4.3",
|
||||
"@serve.zone/remoteingress": "^4.17.1",
|
||||
"@tsclass/tsclass": "^9.5.0",
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"lru-cache": "^11.3.5",
|
||||
|
||||
Generated
+24
-19
@@ -81,8 +81,8 @@ importers:
|
||||
specifier: ^4.2.3
|
||||
version: 4.2.3
|
||||
'@push.rocks/smartproxy':
|
||||
specifier: ^27.7.4
|
||||
version: 27.7.4
|
||||
specifier: ^27.9.0
|
||||
version: 27.9.0
|
||||
'@push.rocks/smartradius':
|
||||
specifier: ^1.1.1
|
||||
version: 1.1.1
|
||||
@@ -108,11 +108,11 @@ importers:
|
||||
specifier: ^2.12.4
|
||||
version: 2.12.4(@tiptap/pm@2.27.2)
|
||||
'@serve.zone/interfaces':
|
||||
specifier: ^5.3.0
|
||||
version: 5.3.0
|
||||
specifier: ^5.4.3
|
||||
version: 5.4.3
|
||||
'@serve.zone/remoteingress':
|
||||
specifier: ^4.15.3
|
||||
version: 4.15.3
|
||||
specifier: ^4.17.1
|
||||
version: 4.17.1
|
||||
'@tsclass/tsclass':
|
||||
specifier: ^9.5.0
|
||||
version: 9.5.0
|
||||
@@ -147,9 +147,6 @@ importers:
|
||||
'@types/node':
|
||||
specifier: ^25.6.0
|
||||
version: 25.6.0
|
||||
typescript:
|
||||
specifier: ^6.0.2
|
||||
version: 6.0.2
|
||||
|
||||
packages:
|
||||
|
||||
@@ -1266,6 +1263,9 @@ packages:
|
||||
'@push.rocks/smartnftables@1.1.0':
|
||||
resolution: {integrity: sha512-7JNzerlW20HEl2wKMBIHltwneCQRpXiD2lJkXZZc02ctnfjgFejXVDIeWomhPx6PZ0Z6zmqdF6rrFDtDHyqqfA==}
|
||||
|
||||
'@push.rocks/smartnftables@1.2.0':
|
||||
resolution: {integrity: sha512-VTRHnxHrJj9VOq2MaCOqxiA4JLGRnzEaZ7kXxA7v3ljX+Y2wWK9VYpwKKBEbjgjoTpQyOf+I0gEG9wkR/jtUvQ==}
|
||||
|
||||
'@push.rocks/smartnpm@2.0.6':
|
||||
resolution: {integrity: sha512-7anKDOjX6gXWs1IAc+YWz9ZZ8gDsTwaLh+CxRnGHjAawOmK788NrrgVCg2Fb3qojrPnoxecc46F8Ivp1BT7Izw==}
|
||||
|
||||
@@ -1287,8 +1287,8 @@ packages:
|
||||
'@push.rocks/smartpromise@4.2.3':
|
||||
resolution: {integrity: sha512-Ycg/TJR+tMt+S3wSFurOpEoW6nXv12QBtKXgBcjMZ4RsdO28geN46U09osPn9N9WuwQy1PkmTV5J/V4F9U8qEw==}
|
||||
|
||||
'@push.rocks/smartproxy@27.7.4':
|
||||
resolution: {integrity: sha512-WY9Jp6Jtqo5WbW29XpATuxzGyLs8LGkAlrycgMN/IdYfvgtEB2HWuztBZCDLFMuD3Qnv4vVdci9s0nF0ZPyJcQ==}
|
||||
'@push.rocks/smartproxy@27.9.0':
|
||||
resolution: {integrity: sha512-lzOxueA89pBf4ZcTzF+VkjXQ0es8z8C20PW6FA0HcIzcCpnh4NjLwnXyD8NnTpCf+HKh/EAgD77Kt9Dn+sssUQ==}
|
||||
|
||||
'@push.rocks/smartpuppeteer@2.0.5':
|
||||
resolution: {integrity: sha512-yK/qSeWVHIGWRp3c8S5tfdGP6WCKllZC4DR8d8CQlEjszOSBmHtlTdyyqOMBZ/BA4kd+eU5f3A1r4K2tGYty1g==}
|
||||
@@ -1594,11 +1594,11 @@ packages:
|
||||
'@serve.zone/catalog@2.12.4':
|
||||
resolution: {integrity: sha512-GRfJZ0yQxChUy7Gp4mxhuN5y4GXZMOEk0W7rJiyZbezA938q+pFTplb9ahSaEHjiUht1MmTu/5WtoJFwgAP8SQ==}
|
||||
|
||||
'@serve.zone/interfaces@5.3.0':
|
||||
resolution: {integrity: sha512-venO7wtDR9ixzD9NhdERBGjNKbFA5LL0yHw4eqGh0UpmvtXVc3SFG0uuHDilOKMZqZ8bttV88qVsFy1aSTJrtA==}
|
||||
'@serve.zone/interfaces@5.4.3':
|
||||
resolution: {integrity: sha512-9ijFhHoC7GYyyAUJbBoDYmcoCmIXTFPiD6fI3x68SWiC0xA+2LG0nOe14D32c1QN9X/3i2Ac5/1sUibfjHsIGg==}
|
||||
|
||||
'@serve.zone/remoteingress@4.15.3':
|
||||
resolution: {integrity: sha512-kg/bmR+qcFRFuigTDr5Fao72cb7m/mSkI5APm7KZDKSUYTFuytNoj6KCIE0ICkc3Nh34y8oDwFJsS6oFo64AyQ==}
|
||||
'@serve.zone/remoteingress@4.17.1':
|
||||
resolution: {integrity: sha512-k3n+AF1rNybiKPlHHyhwCVEF0/T7eZD46kNn7JlEJPCxfUy09mjkpwDQ2CzaUkppqNgFOAYXgAKqjDqpJ27RvA==}
|
||||
|
||||
'@sindresorhus/is@5.6.0':
|
||||
resolution: {integrity: sha512-TV7t8GKYaJWsn00tFDqBw8+Uqmr8A0fRU1tvTQhyZzGv0sJCGRQL3JGMI3ucuKo3XIZdUP+Lx7/gh2t3lewy7g==}
|
||||
@@ -6446,6 +6446,11 @@ snapshots:
|
||||
'@push.rocks/smartlog': 3.2.2
|
||||
'@push.rocks/smartpromise': 4.2.3
|
||||
|
||||
'@push.rocks/smartnftables@1.2.0':
|
||||
dependencies:
|
||||
'@push.rocks/smartlog': 3.2.2
|
||||
'@push.rocks/smartpromise': 4.2.3
|
||||
|
||||
'@push.rocks/smartnpm@2.0.6':
|
||||
dependencies:
|
||||
'@push.rocks/consolecolor': 2.0.3
|
||||
@@ -6515,7 +6520,7 @@ snapshots:
|
||||
|
||||
'@push.rocks/smartpromise@4.2.3': {}
|
||||
|
||||
'@push.rocks/smartproxy@27.7.4':
|
||||
'@push.rocks/smartproxy@27.9.0':
|
||||
dependencies:
|
||||
'@push.rocks/smartcrypto': 2.0.4
|
||||
'@push.rocks/smartlog': 3.2.2
|
||||
@@ -6930,16 +6935,16 @@ snapshots:
|
||||
- supports-color
|
||||
- vue
|
||||
|
||||
'@serve.zone/interfaces@5.3.0':
|
||||
'@serve.zone/interfaces@5.4.3':
|
||||
dependencies:
|
||||
'@api.global/typedrequest-interfaces': 3.0.19
|
||||
'@push.rocks/smartlog-interfaces': 3.0.2
|
||||
'@tsclass/tsclass': 9.5.0
|
||||
|
||||
'@serve.zone/remoteingress@4.15.3':
|
||||
'@serve.zone/remoteingress@4.17.1':
|
||||
dependencies:
|
||||
'@push.rocks/qenv': 6.1.3
|
||||
'@push.rocks/smartnftables': 1.1.0
|
||||
'@push.rocks/smartnftables': 1.2.0
|
||||
'@push.rocks/smartrust': 1.3.2
|
||||
|
||||
'@sindresorhus/is@5.6.0': {}
|
||||
|
||||
@@ -101,7 +101,13 @@ tap.test('should login as admin for email API tests', async () => {
|
||||
password: 'admin',
|
||||
});
|
||||
|
||||
adminIdentity = response.identity;
|
||||
const responseIdentity = response.identity;
|
||||
expect(responseIdentity).toBeDefined();
|
||||
if (!responseIdentity) {
|
||||
throw new Error('Expected admin login response to include identity');
|
||||
}
|
||||
|
||||
adminIdentity = responseIdentity;
|
||||
expect(adminIdentity.jwt).toBeTruthy();
|
||||
});
|
||||
|
||||
|
||||
@@ -103,6 +103,9 @@ tap.test('ErrorHandler should properly handle and format errors', async () => {
|
||||
}, 'TEST_EXECUTION_ERROR', { operation: 'testExecution' });
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(PlatformError);
|
||||
if (!(error instanceof PlatformError)) {
|
||||
throw error;
|
||||
}
|
||||
expect(error.code).toEqual('TEST_EXECUTION_ERROR');
|
||||
expect(error.context.operation).toEqual('testExecution');
|
||||
}
|
||||
@@ -197,6 +200,9 @@ tap.test('Error retry utilities should work correctly', async () => {
|
||||
}
|
||||
);
|
||||
} catch (error) {
|
||||
if (!(error instanceof Error)) {
|
||||
throw error;
|
||||
}
|
||||
expect(error.message).toEqual('Critical error');
|
||||
expect(attempts).toEqual(1); // Should only attempt once
|
||||
}
|
||||
@@ -262,6 +268,9 @@ tap.test('Error handling can be combined with retry for robust operations', asyn
|
||||
// Should not reach here
|
||||
expect(false).toEqual(true);
|
||||
} catch (error) {
|
||||
if (!(error instanceof Error)) {
|
||||
throw error;
|
||||
}
|
||||
expect(error.message).toContain('Flaky failure');
|
||||
expect(flaky.counter).toEqual(3); // Initial + 2 retries = 3 attempts
|
||||
}
|
||||
|
||||
+22
-10
@@ -29,14 +29,18 @@ tap.test('should login with admin credentials and receive JWT', async () => {
|
||||
});
|
||||
|
||||
expect(response).toHaveProperty('identity');
|
||||
expect(response.identity).toHaveProperty('jwt');
|
||||
expect(response.identity).toHaveProperty('userId');
|
||||
expect(response.identity).toHaveProperty('name');
|
||||
expect(response.identity).toHaveProperty('expiresAt');
|
||||
expect(response.identity).toHaveProperty('role');
|
||||
expect(response.identity.role).toEqual('admin');
|
||||
const responseIdentity = response.identity;
|
||||
if (!responseIdentity) {
|
||||
throw new Error('Expected admin login response to include identity');
|
||||
}
|
||||
expect(responseIdentity).toHaveProperty('jwt');
|
||||
expect(responseIdentity).toHaveProperty('userId');
|
||||
expect(responseIdentity).toHaveProperty('name');
|
||||
expect(responseIdentity).toHaveProperty('expiresAt');
|
||||
expect(responseIdentity).toHaveProperty('role');
|
||||
expect(responseIdentity.role).toEqual('admin');
|
||||
|
||||
identity = response.identity;
|
||||
identity = responseIdentity;
|
||||
console.log('JWT:', identity.jwt);
|
||||
});
|
||||
|
||||
@@ -53,7 +57,11 @@ tap.test('should verify valid JWT identity', async () => {
|
||||
expect(response).toHaveProperty('valid');
|
||||
expect(response.valid).toBeTrue();
|
||||
expect(response).toHaveProperty('identity');
|
||||
expect(response.identity.userId).toEqual(identity.userId);
|
||||
const responseIdentity = response.identity;
|
||||
if (!responseIdentity) {
|
||||
throw new Error('Expected verify response to include identity');
|
||||
}
|
||||
expect(responseIdentity.userId).toEqual(identity.userId);
|
||||
});
|
||||
|
||||
tap.test('should reject invalid JWT', async () => {
|
||||
@@ -86,8 +94,12 @@ tap.test('should verify JWT matches identity data', async () => {
|
||||
|
||||
expect(response).toHaveProperty('valid');
|
||||
expect(response.valid).toBeTrue();
|
||||
expect(response.identity.expiresAt).toEqual(identity.expiresAt);
|
||||
expect(response.identity.userId).toEqual(identity.userId);
|
||||
const responseIdentity = response.identity;
|
||||
if (!responseIdentity) {
|
||||
throw new Error('Expected verify response to include identity');
|
||||
}
|
||||
expect(responseIdentity.expiresAt).toEqual(identity.expiresAt);
|
||||
expect(responseIdentity.userId).toEqual(identity.userId);
|
||||
});
|
||||
|
||||
tap.test('should handle logout', async () => {
|
||||
|
||||
@@ -18,6 +18,9 @@ function createProxyMetrics(args: {
|
||||
connectionsByRoute: Map<string, number>;
|
||||
throughputByRoute: Map<string, { in: number; out: number }>;
|
||||
domainRequestsByIP: Map<string, Map<string, number>>;
|
||||
domainRequestRates?: Map<string, { perSecond: number; lastMinute: number }>;
|
||||
backendMetrics?: Map<string, any>;
|
||||
protocolCache?: any[];
|
||||
requestsTotal?: number;
|
||||
}) {
|
||||
return {
|
||||
@@ -45,6 +48,7 @@ function createProxyMetrics(args: {
|
||||
perSecond: () => 0,
|
||||
perMinute: () => 0,
|
||||
total: () => args.requestsTotal || 0,
|
||||
byDomain: () => args.domainRequestRates || new Map<string, { perSecond: number; lastMinute: number }>(),
|
||||
},
|
||||
totals: {
|
||||
bytesIn: () => 0,
|
||||
@@ -52,10 +56,10 @@ function createProxyMetrics(args: {
|
||||
connections: () => 0,
|
||||
},
|
||||
backends: {
|
||||
byBackend: () => new Map<string, any>(),
|
||||
byBackend: () => args.backendMetrics || new Map<string, any>(),
|
||||
protocols: () => new Map<string, string>(),
|
||||
topByErrors: () => [],
|
||||
detectedProtocols: () => [],
|
||||
detectedProtocols: () => args.protocolCache || [],
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -117,4 +121,122 @@ tap.test('MetricsManager joins domain activity to id-keyed route metrics', async
|
||||
expect(beta!.bytesOutPerSecond).toEqual(600);
|
||||
});
|
||||
|
||||
tap.test('MetricsManager prefers live domain request rates for current activity', async () => {
|
||||
const proxyMetrics = createProxyMetrics({
|
||||
connectionsByRoute: new Map([
|
||||
['route-id-only', 10],
|
||||
]),
|
||||
throughputByRoute: new Map([
|
||||
['route-id-only', { in: 1000, out: 1000 }],
|
||||
]),
|
||||
domainRequestsByIP: new Map([
|
||||
['192.0.2.10', new Map([
|
||||
['alpha.example.com', 1000],
|
||||
['beta.example.com', 1],
|
||||
])],
|
||||
]),
|
||||
domainRequestRates: new Map([
|
||||
['alpha.example.com', { perSecond: 0, lastMinute: 0 }],
|
||||
['beta.example.com', { perSecond: 5, lastMinute: 60 }],
|
||||
]),
|
||||
});
|
||||
|
||||
const smartProxy = {
|
||||
getMetrics: () => proxyMetrics,
|
||||
routeManager: {
|
||||
getRoutes: () => [
|
||||
{
|
||||
id: 'route-id-only',
|
||||
match: {
|
||||
ports: [443],
|
||||
domains: ['alpha.example.com', 'beta.example.com'],
|
||||
},
|
||||
action: {
|
||||
type: 'forward',
|
||||
targets: [{ host: '127.0.0.1', port: 8443 }],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const manager = new MetricsManager({ smartProxy } as any);
|
||||
const stats = await manager.getNetworkStats();
|
||||
const alpha = stats.domainActivity.find((item) => item.domain === 'alpha.example.com');
|
||||
const beta = stats.domainActivity.find((item) => item.domain === 'beta.example.com');
|
||||
|
||||
expect(alpha!.activeConnections).toEqual(0);
|
||||
expect(alpha!.requestsPerSecond).toEqual(0);
|
||||
expect(beta!.activeConnections).toEqual(10);
|
||||
expect(beta!.requestsPerSecond).toEqual(5);
|
||||
expect(beta!.bytesInPerSecond).toEqual(1000);
|
||||
});
|
||||
|
||||
tap.test('MetricsManager does not duplicate backend active counts onto protocol cache rows', async () => {
|
||||
const proxyMetrics = createProxyMetrics({
|
||||
connectionsByRoute: new Map(),
|
||||
throughputByRoute: new Map(),
|
||||
domainRequestsByIP: new Map(),
|
||||
backendMetrics: new Map([
|
||||
['192.0.2.1:443', {
|
||||
protocol: 'h2',
|
||||
activeConnections: 257,
|
||||
totalConnections: 1000,
|
||||
connectErrors: 1,
|
||||
handshakeErrors: 2,
|
||||
requestErrors: 3,
|
||||
avgConnectTimeMs: 4,
|
||||
poolHitRate: 0.9,
|
||||
h2Failures: 5,
|
||||
}],
|
||||
]),
|
||||
protocolCache: [
|
||||
{
|
||||
host: '192.0.2.1',
|
||||
port: 443,
|
||||
domain: 'alpha.example.com',
|
||||
protocol: 'h2',
|
||||
h2Suppressed: false,
|
||||
h3Suppressed: false,
|
||||
h2CooldownRemainingSecs: null,
|
||||
h3CooldownRemainingSecs: null,
|
||||
h2ConsecutiveFailures: null,
|
||||
h3ConsecutiveFailures: null,
|
||||
h3Port: null,
|
||||
ageSecs: 1,
|
||||
},
|
||||
{
|
||||
host: '192.0.2.1',
|
||||
port: 443,
|
||||
domain: 'beta.example.com',
|
||||
protocol: 'h2',
|
||||
h2Suppressed: false,
|
||||
h3Suppressed: false,
|
||||
h2CooldownRemainingSecs: null,
|
||||
h3CooldownRemainingSecs: null,
|
||||
h2ConsecutiveFailures: null,
|
||||
h3ConsecutiveFailures: null,
|
||||
h3Port: null,
|
||||
ageSecs: 1,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const smartProxy = {
|
||||
getMetrics: () => proxyMetrics,
|
||||
routeManager: {
|
||||
getRoutes: () => [],
|
||||
},
|
||||
};
|
||||
|
||||
const manager = new MetricsManager({ smartProxy } as any);
|
||||
const stats = await manager.getNetworkStats();
|
||||
const aggregate = stats.backends.find((item) => item.id === 'backend:192.0.2.1:443');
|
||||
const cacheRows = stats.backends.filter((item) => item.id?.startsWith('cache:'));
|
||||
|
||||
expect(aggregate!.activeConnections).toEqual(257);
|
||||
expect(cacheRows.length).toEqual(2);
|
||||
expect(cacheRows.every((item) => item.activeConnections === 0)).toBeTrue();
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
|
||||
@@ -29,7 +29,11 @@ tap.test('should login as admin', async () => {
|
||||
});
|
||||
|
||||
expect(response).toHaveProperty('identity');
|
||||
adminIdentity = response.identity;
|
||||
const responseIdentity = response.identity;
|
||||
if (!responseIdentity) {
|
||||
throw new Error('Expected admin login response to include identity');
|
||||
}
|
||||
adminIdentity = responseIdentity;
|
||||
});
|
||||
|
||||
tap.test('should respond to health status request', async () => {
|
||||
|
||||
@@ -29,7 +29,11 @@ tap.test('should login as admin', async () => {
|
||||
});
|
||||
|
||||
expect(response).toHaveProperty('identity');
|
||||
adminIdentity = response.identity;
|
||||
const responseIdentity = response.identity;
|
||||
if (!responseIdentity) {
|
||||
throw new Error('Expected admin login response to include identity');
|
||||
}
|
||||
adminIdentity = responseIdentity;
|
||||
console.log('Admin logged in with JWT');
|
||||
});
|
||||
|
||||
|
||||
@@ -35,7 +35,11 @@ tap.test('should login as admin', async () => {
|
||||
});
|
||||
|
||||
expect(response).toHaveProperty('identity');
|
||||
adminIdentity = response.identity;
|
||||
const responseIdentity = response.identity;
|
||||
if (!responseIdentity) {
|
||||
throw new Error('Expected admin login response to include identity');
|
||||
}
|
||||
adminIdentity = responseIdentity;
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
import { DcRouter } from '../ts/classes.dcrouter.js';
|
||||
import { VpnManager } from '../ts/vpn/classes.vpn-manager.js';
|
||||
|
||||
tap.test('VpnManager downgrades back to socket mode when no host-IP clients remain', async () => {
|
||||
const manager = new VpnManager({ forwardingMode: 'socket' });
|
||||
|
||||
let stopCalls = 0;
|
||||
let startCalls = 0;
|
||||
|
||||
(manager as any).vpnServer = { running: true };
|
||||
(manager as any).resolvedForwardingMode = 'hybrid';
|
||||
(manager as any).clients = new Map([
|
||||
['client-1', { useHostIp: false }],
|
||||
]);
|
||||
(manager as any).stop = async () => {
|
||||
stopCalls++;
|
||||
};
|
||||
(manager as any).start = async () => {
|
||||
startCalls++;
|
||||
(manager as any).resolvedForwardingMode = (manager as any).forwardingModeOverride ?? 'socket';
|
||||
(manager as any).forwardingModeOverride = undefined;
|
||||
(manager as any).vpnServer = { running: true };
|
||||
};
|
||||
|
||||
const restarted = await (manager as any).reconcileForwardingMode();
|
||||
|
||||
expect(restarted).toEqual(true);
|
||||
expect(stopCalls).toEqual(1);
|
||||
expect(startCalls).toEqual(1);
|
||||
expect((manager as any).resolvedForwardingMode).toEqual('socket');
|
||||
});
|
||||
|
||||
tap.test('VpnManager keeps explicit hybrid mode even without host-IP clients', async () => {
|
||||
const manager = new VpnManager({ forwardingMode: 'hybrid' });
|
||||
|
||||
let stopCalls = 0;
|
||||
let startCalls = 0;
|
||||
|
||||
(manager as any).vpnServer = { running: true };
|
||||
(manager as any).resolvedForwardingMode = 'hybrid';
|
||||
(manager as any).clients = new Map([
|
||||
['client-1', { useHostIp: false }],
|
||||
]);
|
||||
(manager as any).stop = async () => {
|
||||
stopCalls++;
|
||||
};
|
||||
(manager as any).start = async () => {
|
||||
startCalls++;
|
||||
};
|
||||
|
||||
const restarted = await (manager as any).reconcileForwardingMode();
|
||||
|
||||
expect(restarted).toEqual(false);
|
||||
expect(stopCalls).toEqual(0);
|
||||
expect(startCalls).toEqual(0);
|
||||
expect((manager as any).resolvedForwardingMode).toEqual('hybrid');
|
||||
});
|
||||
|
||||
tap.test('DcRouter.updateVpnConfig swaps the runtime VPN resolver and restarts VPN services', async () => {
|
||||
const dcRouter = new DcRouter({
|
||||
smartProxyConfig: { routes: [] },
|
||||
dbConfig: { enabled: false },
|
||||
vpnConfig: { enabled: false },
|
||||
});
|
||||
|
||||
let stopCalls = 0;
|
||||
let setupCalls = 0;
|
||||
let applyCalls = 0;
|
||||
const resolverValues: Array<unknown> = [];
|
||||
|
||||
dcRouter.vpnManager = {
|
||||
stop: async () => {
|
||||
stopCalls++;
|
||||
},
|
||||
} as any;
|
||||
(dcRouter as any).routeConfigManager = {
|
||||
setVpnClientIpsResolver: (resolver: unknown) => {
|
||||
resolverValues.push(resolver);
|
||||
},
|
||||
applyRoutes: async () => {
|
||||
applyCalls++;
|
||||
},
|
||||
};
|
||||
(dcRouter as any).setupVpnServer = async () => {
|
||||
setupCalls++;
|
||||
dcRouter.vpnManager = {
|
||||
stop: async () => {
|
||||
stopCalls++;
|
||||
},
|
||||
} as any;
|
||||
};
|
||||
|
||||
await dcRouter.updateVpnConfig({ enabled: true, subnet: '10.9.0.0/24' });
|
||||
|
||||
expect(stopCalls).toEqual(1);
|
||||
expect(setupCalls).toEqual(1);
|
||||
expect(applyCalls).toEqual(0);
|
||||
expect(typeof resolverValues.at(-1)).toEqual('function');
|
||||
|
||||
await dcRouter.updateVpnConfig({ enabled: false });
|
||||
|
||||
expect(stopCalls).toEqual(2);
|
||||
expect(setupCalls).toEqual(1);
|
||||
expect(applyCalls).toEqual(1);
|
||||
expect(resolverValues.at(-1)).toBeUndefined();
|
||||
expect(dcRouter.vpnManager).toBeUndefined();
|
||||
});
|
||||
|
||||
export default tap.start()
|
||||
@@ -3,6 +3,6 @@
|
||||
*/
|
||||
export const commitinfo = {
|
||||
name: '@serve.zone/dcrouter',
|
||||
version: '13.20.1',
|
||||
version: '13.23.0',
|
||||
description: 'A multifaceted routing service handling mail and SMS delivery functions.'
|
||||
}
|
||||
|
||||
+155
-18
@@ -26,12 +26,14 @@ 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, ReferenceResolver, DbSeeder, TargetProfileManager } from './config/index.js';
|
||||
import { SecurityLogger, ContentScanner, IPReputationChecker } from './security/index.js';
|
||||
import type { TIpAllowEntry } from './config/classes.route-config-manager.js';
|
||||
import { SecurityLogger, ContentScanner, IPReputationChecker, SecurityPolicyManager } from './security/index.js';
|
||||
import { type IHttp3Config, augmentRoutesWithHttp3 } from './http3/index.js';
|
||||
import { DnsManager } from './dns/manager.dns.js';
|
||||
import { AcmeConfigManager } from './acme/manager.acme-config.js';
|
||||
import { EmailDomainManager, SmartMtaStorageManager, buildEmailDnsRecords } from './email/index.js';
|
||||
import type { IRoute } from '../ts_interfaces/data/route-management.js';
|
||||
import type { ISecurityCompiledPolicy } from '../ts_interfaces/data/security-policy.js';
|
||||
|
||||
export interface IDcRouterOptions {
|
||||
/** Base directory for all dcrouter data. Defaults to ~/.serve.zone/dcrouter */
|
||||
@@ -177,6 +179,8 @@ export interface IDcRouterOptions {
|
||||
certPath?: string;
|
||||
keyPath?: string;
|
||||
};
|
||||
/** Performance profile and limits for remote ingress hub/edge tunnels. */
|
||||
performance?: import('../ts_interfaces/data/remoteingress.js').IRemoteIngressPerformanceConfig;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -281,6 +285,7 @@ export class DcRouter {
|
||||
// ACME configuration (DB-backed singleton, replaces tls.contactEmail)
|
||||
public acmeConfigManager?: AcmeConfigManager;
|
||||
public emailDomainManager?: EmailDomainManager;
|
||||
public securityPolicyManager?: SecurityPolicyManager;
|
||||
|
||||
// Auto-discovered public IP (populated by generateAuthoritativeRecords)
|
||||
public detectedPublicIp: string | null = null;
|
||||
@@ -468,12 +473,36 @@ export class DcRouter {
|
||||
);
|
||||
}
|
||||
|
||||
// SecurityPolicyManager: optional, depends on DcRouterDb — owns IP intelligence
|
||||
// and compiles the global block policy for SmartProxy and remote ingress edges.
|
||||
if (this.options.dbConfig?.enabled !== false) {
|
||||
this.serviceManager.addService(
|
||||
new plugins.taskbuffer.Service('SecurityPolicyManager')
|
||||
.optional()
|
||||
.dependsOn('DcRouterDb')
|
||||
.withStart(async () => {
|
||||
this.securityPolicyManager = new SecurityPolicyManager({
|
||||
onPolicyChanged: () => this.applySecurityPolicy(),
|
||||
});
|
||||
await this.securityPolicyManager.start();
|
||||
})
|
||||
.withStop(async () => {
|
||||
if (this.securityPolicyManager) {
|
||||
await this.securityPolicyManager.stop();
|
||||
this.securityPolicyManager = undefined;
|
||||
}
|
||||
})
|
||||
.withRetry({ maxRetries: 1, baseDelayMs: 500 }),
|
||||
);
|
||||
}
|
||||
|
||||
// SmartProxy: critical, depends on DcRouterDb + DnsManager + AcmeConfigManager (if enabled)
|
||||
const smartProxyDeps: string[] = [];
|
||||
if (this.options.dbConfig?.enabled !== false) {
|
||||
smartProxyDeps.push('DcRouterDb');
|
||||
smartProxyDeps.push('DnsManager');
|
||||
smartProxyDeps.push('AcmeConfigManager');
|
||||
smartProxyDeps.push('SecurityPolicyManager');
|
||||
}
|
||||
this.serviceManager.addService(
|
||||
new plugins.taskbuffer.Service('SmartProxy')
|
||||
@@ -565,29 +594,20 @@ export class DcRouter {
|
||||
this.routeConfigManager = new RouteConfigManager(
|
||||
() => this.smartProxy,
|
||||
() => this.options.http3,
|
||||
this.options.vpnConfig?.enabled
|
||||
? (route: import('../ts_interfaces/data/remoteingress.js').IDcRouterRouteConfig, routeId?: string) => {
|
||||
if (!this.vpnManager || !this.targetProfileManager) {
|
||||
// VPN not ready yet — deny all until re-apply after VPN starts
|
||||
return [];
|
||||
}
|
||||
return this.targetProfileManager.getMatchingClientIps(
|
||||
route,
|
||||
routeId,
|
||||
this.vpnManager.listClients(),
|
||||
this.routeConfigManager?.getRoutes() || new Map(),
|
||||
);
|
||||
}
|
||||
: undefined,
|
||||
this.createVpnRouteAllowListResolver(),
|
||||
this.referenceResolver,
|
||||
// Sync routes to RemoteIngressManager whenever routes change,
|
||||
// then push updated derived ports to the Rust hub binary
|
||||
(routes) => {
|
||||
async (routes) => {
|
||||
if (this.remoteIngressManager) {
|
||||
this.remoteIngressManager.setRoutes(routes as any[]);
|
||||
}
|
||||
if (this.tunnelManager) {
|
||||
this.tunnelManager.syncAllowedEdges();
|
||||
try {
|
||||
await this.tunnelManager.syncAllowedEdges();
|
||||
} catch (err: unknown) {
|
||||
logger.log('error', `Failed to sync Remote Ingress allowed edges: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
},
|
||||
undefined,
|
||||
@@ -977,6 +997,12 @@ export class DcRouter {
|
||||
logger.log('info', 'HTTP/3: Augmented qualifying HTTPS routes with QUIC/H3 configuration');
|
||||
}
|
||||
|
||||
const compiledSecurityPolicy = await this.securityPolicyManager?.compileSmartProxyPolicy();
|
||||
const mergedSecurityPolicy = this.mergeSecurityPolicies(
|
||||
(this.options.smartProxyConfig as any)?.securityPolicy,
|
||||
compiledSecurityPolicy,
|
||||
);
|
||||
|
||||
// If we have routes or need a basic SmartProxy instance, create it
|
||||
if (routes.length > 0 || this.options.smartProxyConfig) {
|
||||
logger.log('info', 'Setting up SmartProxy with combined configuration');
|
||||
@@ -1008,6 +1034,7 @@ export class DcRouter {
|
||||
// --- always set by dcrouter (after spread) ---
|
||||
routes,
|
||||
acme: acmeConfig,
|
||||
...(mergedSecurityPolicy ? { securityPolicy: mergedSecurityPolicy } as any : {}),
|
||||
certStore: {
|
||||
loadAll: async () => {
|
||||
const docs = await ProxyCertDoc.findAll();
|
||||
@@ -1132,7 +1159,12 @@ export class DcRouter {
|
||||
// to SmartProxy with PROXY protocol v1 headers to preserve client IPs.
|
||||
if (this.options.remoteIngressConfig?.enabled) {
|
||||
smartProxyConfig.acceptProxyProtocol = true;
|
||||
smartProxyConfig.proxyIPs = ['127.0.0.1'];
|
||||
if (!smartProxyConfig.proxyIPs) {
|
||||
smartProxyConfig.proxyIPs = [];
|
||||
}
|
||||
if (!smartProxyConfig.proxyIPs.includes('127.0.0.1')) {
|
||||
smartProxyConfig.proxyIPs.push('127.0.0.1');
|
||||
}
|
||||
}
|
||||
|
||||
// VPN uses socket mode with PP v2 — SmartProxy must accept proxy protocol from localhost
|
||||
@@ -1246,6 +1278,58 @@ export class DcRouter {
|
||||
}
|
||||
}
|
||||
|
||||
public async applySecurityPolicy(): Promise<void> {
|
||||
if (!this.securityPolicyManager) {
|
||||
return;
|
||||
}
|
||||
|
||||
const compiledSmartProxyPolicy = await this.securityPolicyManager.compileSmartProxyPolicy();
|
||||
const mergedSecurityPolicy = this.mergeSecurityPolicies(
|
||||
(this.options.smartProxyConfig as any)?.securityPolicy,
|
||||
compiledSmartProxyPolicy,
|
||||
);
|
||||
|
||||
if (this.smartProxy && mergedSecurityPolicy) {
|
||||
const smartProxyWithPolicyApi = this.smartProxy as any;
|
||||
if (typeof smartProxyWithPolicyApi.updateSecurityPolicy === 'function') {
|
||||
await smartProxyWithPolicyApi.updateSecurityPolicy(mergedSecurityPolicy);
|
||||
}
|
||||
}
|
||||
|
||||
const firewallConfig = await this.securityPolicyManager.compileRemoteIngressFirewall();
|
||||
if (this.remoteIngressManager) {
|
||||
(this.remoteIngressManager as any).setFirewallConfig?.(firewallConfig);
|
||||
}
|
||||
if (this.tunnelManager) {
|
||||
await this.tunnelManager.syncAllowedEdges();
|
||||
}
|
||||
}
|
||||
|
||||
private mergeSecurityPolicies(
|
||||
...policies: Array<Partial<ISecurityCompiledPolicy> | undefined>
|
||||
): ISecurityCompiledPolicy | undefined {
|
||||
const blockedIps = new Set<string>();
|
||||
const blockedCidrs = new Set<string>();
|
||||
|
||||
for (const policy of policies) {
|
||||
for (const ip of policy?.blockedIps || []) {
|
||||
if (ip) blockedIps.add(ip);
|
||||
}
|
||||
for (const cidr of policy?.blockedCidrs || []) {
|
||||
if (cidr) blockedCidrs.add(cidr);
|
||||
}
|
||||
}
|
||||
|
||||
if (blockedIps.size === 0 && blockedCidrs.size === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
blockedIps: [...blockedIps].sort(),
|
||||
blockedCidrs: [...blockedCidrs].sort(),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
@@ -2233,6 +2317,9 @@ export class DcRouter {
|
||||
// Initialize the edge registration manager
|
||||
this.remoteIngressManager = new RemoteIngressManager();
|
||||
await this.remoteIngressManager.initialize();
|
||||
this.remoteIngressManager.setFirewallConfig(
|
||||
await this.securityPolicyManager?.compileRemoteIngressFirewall(),
|
||||
);
|
||||
|
||||
// Pass current bootstrap routes so the manager can derive edge ports initially.
|
||||
// Once RouteConfigManager applies the full DB set, the onRoutesApplied callback
|
||||
@@ -2282,6 +2369,7 @@ export class DcRouter {
|
||||
tunnelPort: riCfg.tunnelPort ?? 8443,
|
||||
targetHost: '127.0.0.1',
|
||||
tls: tlsConfig,
|
||||
performance: riCfg.performance,
|
||||
});
|
||||
await this.tunnelManager.start();
|
||||
|
||||
@@ -2292,6 +2380,32 @@ export class DcRouter {
|
||||
/**
|
||||
* Set up VPN server for VPN-based route access control.
|
||||
*/
|
||||
private createVpnRouteAllowListResolver(): ((
|
||||
route: import('../ts_interfaces/data/remoteingress.js').IDcRouterRouteConfig,
|
||||
routeId?: string,
|
||||
) => TIpAllowEntry[]) | undefined {
|
||||
if (!this.options.vpnConfig?.enabled) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return (
|
||||
route: import('../ts_interfaces/data/remoteingress.js').IDcRouterRouteConfig,
|
||||
routeId?: string,
|
||||
) => {
|
||||
if (!this.vpnManager || !this.targetProfileManager) {
|
||||
// VPN not ready yet — deny all until re-apply after VPN starts.
|
||||
return [];
|
||||
}
|
||||
|
||||
return this.targetProfileManager.getMatchingClientIps(
|
||||
route,
|
||||
routeId,
|
||||
this.vpnManager.listClients(),
|
||||
this.routeConfigManager?.getRoutes() || new Map(),
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
private async setupVpnServer(): Promise<void> {
|
||||
if (!this.options.vpnConfig?.enabled) {
|
||||
return;
|
||||
@@ -2441,6 +2555,29 @@ export class DcRouter {
|
||||
|
||||
logger.log('info', 'RADIUS configuration updated');
|
||||
}
|
||||
|
||||
/**
|
||||
* Update VPN configuration at runtime.
|
||||
*/
|
||||
public async updateVpnConfig(config: IDcRouterOptions['vpnConfig']): Promise<void> {
|
||||
if (this.vpnManager) {
|
||||
await this.vpnManager.stop();
|
||||
this.vpnManager = undefined;
|
||||
}
|
||||
|
||||
this.options.vpnConfig = config;
|
||||
this.vpnDomainIpCache.clear();
|
||||
this.warnedWildcardVpnDomains.clear();
|
||||
this.routeConfigManager?.setVpnClientIpsResolver(this.createVpnRouteAllowListResolver());
|
||||
|
||||
if (this.options.vpnConfig?.enabled) {
|
||||
await this.setupVpnServer();
|
||||
} else {
|
||||
await this.routeConfigManager?.applyRoutes();
|
||||
}
|
||||
|
||||
logger.log('info', 'VPN configuration updated');
|
||||
}
|
||||
}
|
||||
|
||||
// Re-export email server types for convenience
|
||||
|
||||
@@ -59,7 +59,7 @@ export class RouteConfigManager {
|
||||
private getHttp3Config?: () => IHttp3Config | undefined,
|
||||
private getVpnClientIpsForRoute?: (route: IDcRouterRouteConfig, routeId?: string) => TIpAllowEntry[],
|
||||
private referenceResolver?: ReferenceResolver,
|
||||
private onRoutesApplied?: (routes: plugins.smartproxy.IRouteConfig[]) => void,
|
||||
private onRoutesApplied?: (routes: plugins.smartproxy.IRouteConfig[]) => void | Promise<void>,
|
||||
private getRuntimeRoutes?: () => plugins.smartproxy.IRouteConfig[],
|
||||
private hydrateStoredRoute?: (storedRoute: IRoute) => plugins.smartproxy.IRouteConfig | undefined,
|
||||
) {}
|
||||
@@ -73,6 +73,12 @@ export class RouteConfigManager {
|
||||
return this.routes.get(id);
|
||||
}
|
||||
|
||||
public setVpnClientIpsResolver(
|
||||
resolver?: (route: IDcRouterRouteConfig, routeId?: string) => TIpAllowEntry[],
|
||||
): void {
|
||||
this.getVpnClientIpsForRoute = resolver;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load persisted routes, seed serializable config/email/dns routes,
|
||||
* compute warnings, and apply the combined DB-backed + runtime route set to SmartProxy.
|
||||
@@ -534,7 +540,7 @@ export class RouteConfigManager {
|
||||
|
||||
// Notify listeners (e.g. RemoteIngressManager) of the route set
|
||||
if (this.onRoutesApplied) {
|
||||
this.onRoutesApplied(enabledRoutes);
|
||||
await this.onRoutesApplied(enabledRoutes);
|
||||
}
|
||||
|
||||
logger.log('info', `Applied ${enabledRoutes.length} routes to SmartProxy (${this.routes.size} total)`);
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import * as plugins from '../../plugins.js';
|
||||
import { DcRouterDb } from '../classes.dcrouter-db.js';
|
||||
import type { IIpIntelligenceRecord } from '../../../ts_interfaces/data/security-policy.js';
|
||||
|
||||
const getDb = () => DcRouterDb.getInstance().getDb();
|
||||
|
||||
@plugins.smartdata.Collection(() => getDb())
|
||||
export class IpIntelligenceDoc extends plugins.smartdata.SmartDataDbDoc<IpIntelligenceDoc, IpIntelligenceDoc> implements IIpIntelligenceRecord {
|
||||
@plugins.smartdata.unI()
|
||||
@plugins.smartdata.svDb()
|
||||
public ipAddress!: string;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public asn: number | null = null;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public asnOrg: string | null = null;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public registrantOrg: string | null = null;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public registrantCountry: string | null = null;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public networkRange: string | null = null;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public abuseContact: string | null = null;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public country: string | null = null;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public countryCode: string | null = null;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public city: string | null = null;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public latitude: number | null = null;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public longitude: number | null = null;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public accuracyRadius: number | null = null;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public timezone: string | null = null;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public firstSeenAt: number = Date.now();
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public lastSeenAt: number = Date.now();
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public updatedAt: number = Date.now();
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public seenCount: number = 0;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
}
|
||||
|
||||
public static async findByIp(ipAddress: string): Promise<IpIntelligenceDoc | null> {
|
||||
return await IpIntelligenceDoc.getInstance({ ipAddress });
|
||||
}
|
||||
|
||||
public static async findAll(): Promise<IpIntelligenceDoc[]> {
|
||||
return await IpIntelligenceDoc.getInstances({});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import * as plugins from '../../plugins.js';
|
||||
import { DcRouterDb } from '../classes.dcrouter-db.js';
|
||||
import type { ISecurityBlockRule, TSecurityBlockRuleMatchMode, TSecurityBlockRuleType } from '../../../ts_interfaces/data/security-policy.js';
|
||||
|
||||
const getDb = () => DcRouterDb.getInstance().getDb();
|
||||
|
||||
@plugins.smartdata.Collection(() => getDb())
|
||||
export class SecurityBlockRuleDoc extends plugins.smartdata.SmartDataDbDoc<SecurityBlockRuleDoc, SecurityBlockRuleDoc> implements ISecurityBlockRule {
|
||||
@plugins.smartdata.unI()
|
||||
@plugins.smartdata.svDb()
|
||||
public id!: string;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public type!: TSecurityBlockRuleType;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public value!: string;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public matchMode?: TSecurityBlockRuleMatchMode;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public enabled: boolean = true;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public reason?: string;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public createdAt: number = Date.now();
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public updatedAt: number = Date.now();
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public createdBy: string = 'system';
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
}
|
||||
|
||||
public static async findById(id: string): Promise<SecurityBlockRuleDoc | null> {
|
||||
return await SecurityBlockRuleDoc.getInstance({ id });
|
||||
}
|
||||
|
||||
public static async findAll(): Promise<SecurityBlockRuleDoc[]> {
|
||||
return await SecurityBlockRuleDoc.getInstances({});
|
||||
}
|
||||
|
||||
public static async findEnabled(): Promise<SecurityBlockRuleDoc[]> {
|
||||
return await SecurityBlockRuleDoc.getInstances({ enabled: true });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import * as plugins from '../../plugins.js';
|
||||
import { DcRouterDb } from '../classes.dcrouter-db.js';
|
||||
import type { ISecurityPolicyAuditEvent } from '../../../ts_interfaces/data/security-policy.js';
|
||||
|
||||
const getDb = () => DcRouterDb.getInstance().getDb();
|
||||
|
||||
@plugins.smartdata.Collection(() => getDb())
|
||||
export class SecurityPolicyAuditDoc extends plugins.smartdata.SmartDataDbDoc<SecurityPolicyAuditDoc, SecurityPolicyAuditDoc> implements ISecurityPolicyAuditEvent {
|
||||
@plugins.smartdata.unI()
|
||||
@plugins.smartdata.svDb()
|
||||
public id!: string;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public action!: string;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public actor!: string;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public details!: Record<string, unknown>;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public createdAt: number = Date.now();
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
}
|
||||
|
||||
public static async findRecent(limit = 100): Promise<SecurityPolicyAuditDoc[]> {
|
||||
const docs = await SecurityPolicyAuditDoc.getInstances({});
|
||||
return docs.sort((a, b) => b.createdAt - a.createdAt).slice(0, limit);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,9 @@
|
||||
// Cached/TTL document classes
|
||||
export * from './classes.cached.email.js';
|
||||
export * from './classes.cached.ip.reputation.js';
|
||||
export * from './classes.ip-intelligence.doc.js';
|
||||
export * from './classes.security-block-rule.doc.js';
|
||||
export * from './classes.security-policy-audit.doc.js';
|
||||
|
||||
// Config document classes
|
||||
export * from './classes.route.doc.js';
|
||||
|
||||
@@ -560,7 +560,9 @@ export class MetricsManager {
|
||||
requestsPerSecond: 0,
|
||||
requestsTotal: 0,
|
||||
backends: [] as Array<any>,
|
||||
domainActivity: [] as Array<{ domain: string; bytesInPerSecond: number; bytesOutPerSecond: number; activeConnections: number; routeCount: number; requestCount: number }>,
|
||||
domainActivity: [] as Array<{ domain: string; bytesInPerSecond: number; bytesOutPerSecond: number; activeConnections: number; routeCount: number; requestCount: number; requestsPerSecond?: number; requestsLastMinute?: number }>,
|
||||
frontendProtocols: null,
|
||||
backendProtocols: null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -592,6 +594,7 @@ export class MetricsManager {
|
||||
// Get HTTP request rates
|
||||
const requestsPerSecond = proxyMetrics.requests.perSecond();
|
||||
const requestsTotal = proxyMetrics.requests.total();
|
||||
const domainRequestRates = proxyMetrics.requests.byDomain();
|
||||
|
||||
// Get frontend/backend protocol distribution
|
||||
const frontendProtocols = proxyMetrics.connections.frontendProtocols() ?? null;
|
||||
@@ -619,10 +622,8 @@ export class MetricsManager {
|
||||
const seenCacheKeys = new Set<string>();
|
||||
|
||||
for (const [key, bm] of backendMetrics) {
|
||||
const cacheEntries = cacheByBackend.get(key);
|
||||
if (!cacheEntries || cacheEntries.length === 0) {
|
||||
// No protocol cache entry — emit one row with backend metrics only
|
||||
backends.push({
|
||||
id: `backend:${key}`,
|
||||
backend: key,
|
||||
domain: null,
|
||||
protocol: bm.protocol,
|
||||
@@ -643,23 +644,26 @@ export class MetricsManager {
|
||||
h3Port: null,
|
||||
cacheAgeSecs: null,
|
||||
});
|
||||
} else {
|
||||
// One row per domain, each enriched with the shared backend metrics
|
||||
|
||||
const cacheEntries = cacheByBackend.get(key);
|
||||
if (cacheEntries && cacheEntries.length > 0) {
|
||||
// Protocol cache rows are domain-scoped metadata, not live backend connections.
|
||||
for (const cache of cacheEntries) {
|
||||
const compositeKey = `${cache.host}:${cache.port}:${cache.domain ?? ''}`;
|
||||
seenCacheKeys.add(compositeKey);
|
||||
backends.push({
|
||||
id: `cache:${compositeKey}`,
|
||||
backend: key,
|
||||
domain: cache.domain ?? null,
|
||||
protocol: cache.protocol ?? bm.protocol,
|
||||
activeConnections: bm.activeConnections,
|
||||
totalConnections: bm.totalConnections,
|
||||
connectErrors: bm.connectErrors,
|
||||
handshakeErrors: bm.handshakeErrors,
|
||||
requestErrors: bm.requestErrors,
|
||||
avgConnectTimeMs: Math.round(bm.avgConnectTimeMs * 10) / 10,
|
||||
poolHitRate: Math.round(bm.poolHitRate * 1000) / 1000,
|
||||
h2Failures: bm.h2Failures,
|
||||
activeConnections: 0,
|
||||
totalConnections: 0,
|
||||
connectErrors: 0,
|
||||
handshakeErrors: 0,
|
||||
requestErrors: 0,
|
||||
avgConnectTimeMs: 0,
|
||||
poolHitRate: 0,
|
||||
h2Failures: 0,
|
||||
h2Suppressed: cache.h2Suppressed,
|
||||
h3Suppressed: cache.h3Suppressed,
|
||||
h2CooldownRemainingSecs: cache.h2CooldownRemainingSecs,
|
||||
@@ -678,6 +682,7 @@ export class MetricsManager {
|
||||
const compositeKey = `${entry.host}:${entry.port}:${entry.domain ?? ''}`;
|
||||
if (!seenCacheKeys.has(compositeKey)) {
|
||||
backends.push({
|
||||
id: `cache:${compositeKey}`,
|
||||
backend: `${entry.host}:${entry.port}`,
|
||||
domain: entry.domain,
|
||||
protocol: entry.protocol,
|
||||
@@ -720,6 +725,8 @@ export class MetricsManager {
|
||||
.slice(0, 10)
|
||||
.map(([ip, data]) => ({ ip, count: data.count, bwIn: data.bwIn, bwOut: data.bwOut }));
|
||||
|
||||
void this.dcRouter.securityPolicyManager?.observeIps([...allIPData.keys()]);
|
||||
|
||||
// Build domain activity using per-IP domain request counts from Rust engine
|
||||
const connectionsByRoute = proxyMetrics.connections.byRoute();
|
||||
const throughputByRoute = proxyMetrics.throughput.byRoute();
|
||||
@@ -750,6 +757,9 @@ export class MetricsManager {
|
||||
|
||||
// Resolve wildcards using domains seen in request metrics
|
||||
const allKnownDomains = new Set<string>(domainRequestTotals.keys());
|
||||
for (const domain of domainRequestRates.keys()) {
|
||||
allKnownDomains.add(domain);
|
||||
}
|
||||
for (const entry of protocolCache) {
|
||||
if (entry.domain) allKnownDomains.add(entry.domain);
|
||||
}
|
||||
@@ -775,11 +785,20 @@ export class MetricsManager {
|
||||
}
|
||||
}
|
||||
|
||||
// For each route, compute the total request count across all its resolved domains
|
||||
// so we can distribute throughput/connections proportionally
|
||||
const hasLiveDomainRates = domainRequestRates.size > 0;
|
||||
const getDomainWeight = (domain: string): number => {
|
||||
const liveRate = domainRequestRates.get(domain);
|
||||
return hasLiveDomainRates
|
||||
? (liveRate?.lastMinute ?? 0)
|
||||
: (domainRequestTotals.get(domain) || 0);
|
||||
};
|
||||
|
||||
// For each route, compute the total activity weight across all resolved domains
|
||||
// so we can distribute route-level throughput/connections. Prefer live domain
|
||||
// request rates from SmartProxy 27.8+, falling back to lifetime counters.
|
||||
const routeTotalRequests = new Map<string, number>();
|
||||
for (const [domain, routeKeys] of domainToRoutes) {
|
||||
const reqs = domainRequestTotals.get(domain) || 0;
|
||||
const reqs = getDomainWeight(domain);
|
||||
for (const routeKey of routeKeys) {
|
||||
routeTotalRequests.set(routeKey, (routeTotalRequests.get(routeKey) || 0) + reqs);
|
||||
}
|
||||
@@ -792,10 +811,13 @@ export class MetricsManager {
|
||||
bytesOutPerSec: number;
|
||||
routeCount: number;
|
||||
requestCount: number;
|
||||
requestsPerSecond: number;
|
||||
requestsLastMinute: number;
|
||||
}>();
|
||||
|
||||
for (const [domain, routeKeys] of domainToRoutes) {
|
||||
const domainReqs = domainRequestTotals.get(domain) || 0;
|
||||
const domainReqs = getDomainWeight(domain);
|
||||
const requestRate = domainRequestRates.get(domain);
|
||||
let totalConns = 0;
|
||||
let totalIn = 0;
|
||||
let totalOut = 0;
|
||||
@@ -816,7 +838,9 @@ export class MetricsManager {
|
||||
bytesInPerSec: totalIn,
|
||||
bytesOutPerSec: totalOut,
|
||||
routeCount: routeKeys.length,
|
||||
requestCount: domainReqs,
|
||||
requestCount: domainRequestTotals.get(domain) || 0,
|
||||
requestsPerSecond: requestRate?.perSecond ?? 0,
|
||||
requestsLastMinute: requestRate?.lastMinute ?? 0,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -828,8 +852,17 @@ export class MetricsManager {
|
||||
activeConnections: data.activeConnections,
|
||||
routeCount: data.routeCount,
|
||||
requestCount: data.requestCount,
|
||||
requestsPerSecond: data.requestsPerSecond,
|
||||
requestsLastMinute: data.requestsLastMinute,
|
||||
}))
|
||||
.sort((a, b) => (b.bytesInPerSecond + b.bytesOutPerSecond) - (a.bytesInPerSecond + a.bytesOutPerSecond));
|
||||
.sort((a, b) => {
|
||||
if (hasLiveDomainRates) {
|
||||
return (b.requestsPerSecond - a.requestsPerSecond) ||
|
||||
(b.requestsLastMinute - a.requestsLastMinute) ||
|
||||
((b.bytesInPerSecond + b.bytesOutPerSecond) - (a.bytesInPerSecond + a.bytesOutPerSecond));
|
||||
}
|
||||
return (b.bytesInPerSecond + b.bytesOutPerSecond) - (a.bytesInPerSecond + a.bytesOutPerSecond);
|
||||
});
|
||||
|
||||
return {
|
||||
connectionsByIP,
|
||||
|
||||
@@ -206,6 +206,7 @@ export class ConfigHandler {
|
||||
hubDomain: riCfg?.hubDomain || null,
|
||||
tlsMode,
|
||||
connectedEdgeIps,
|
||||
performance: riCfg?.performance,
|
||||
};
|
||||
|
||||
return {
|
||||
|
||||
@@ -29,6 +29,7 @@ export class RemoteIngressHandler {
|
||||
...e,
|
||||
secret: '********', // Never expose secrets via API
|
||||
effectiveListenPorts: manager.getEffectiveListenPorts(e),
|
||||
effectiveListenPortsUdp: manager.getEffectiveListenPortsUdp(e),
|
||||
manualPorts: breakdown.manual,
|
||||
derivedPorts: breakdown.derived,
|
||||
};
|
||||
@@ -133,6 +134,7 @@ export class RemoteIngressHandler {
|
||||
...edge,
|
||||
secret: '********',
|
||||
effectiveListenPorts: manager.getEffectiveListenPorts(edge),
|
||||
effectiveListenPortsUdp: manager.getEffectiveListenPortsUdp(edge),
|
||||
manualPorts: breakdown.manual,
|
||||
derivedPorts: breakdown.derived,
|
||||
},
|
||||
|
||||
@@ -50,19 +50,21 @@ export class SecurityHandler {
|
||||
localAddress: conn.destination.ip,
|
||||
startTime: conn.startTime,
|
||||
protocol: conn.type === 'http' ? 'https' : conn.type as any,
|
||||
state: conn.status as any,
|
||||
state: conn.status === 'active' ? 'connected' : conn.status as any,
|
||||
bytesReceived: (conn as any)._throughputIn || 0,
|
||||
bytesSent: (conn as any)._throughputOut || 0,
|
||||
connectionCount: conn.bytesTransferred || 1,
|
||||
}));
|
||||
const totalConnections = connectionInfos.reduce((sum, conn) => sum + (conn.connectionCount || 1), 0);
|
||||
|
||||
const summary = {
|
||||
total: connectionInfos.length,
|
||||
total: totalConnections,
|
||||
byProtocol: connectionInfos.reduce((acc, conn) => {
|
||||
acc[conn.protocol] = (acc[conn.protocol] || 0) + 1;
|
||||
acc[conn.protocol] = (acc[conn.protocol] || 0) + (conn.connectionCount || 1);
|
||||
return acc;
|
||||
}, {} as { [protocol: string]: number }),
|
||||
byState: connectionInfos.reduce((acc, conn) => {
|
||||
acc[conn.state] = (acc[conn.state] || 0) + 1;
|
||||
acc[conn.state] = (acc[conn.state] || 0) + (conn.connectionCount || 1);
|
||||
return acc;
|
||||
}, {} as { [state: string]: number }),
|
||||
};
|
||||
@@ -104,6 +106,8 @@ export class SecurityHandler {
|
||||
requestsPerSecond: networkStats.requestsPerSecond || 0,
|
||||
requestsTotal: networkStats.requestsTotal || 0,
|
||||
backends: networkStats.backends || [],
|
||||
frontendProtocols: networkStats.frontendProtocols || null,
|
||||
backendProtocols: networkStats.backendProtocols || null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -120,6 +124,8 @@ export class SecurityHandler {
|
||||
requestsPerSecond: 0,
|
||||
requestsTotal: 0,
|
||||
backends: [],
|
||||
frontendProtocols: null,
|
||||
backendProtocols: null,
|
||||
};
|
||||
}
|
||||
)
|
||||
@@ -151,6 +157,75 @@ export class SecurityHandler {
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
router.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ListSecurityBlockRules>(
|
||||
'listSecurityBlockRules',
|
||||
async () => {
|
||||
const manager = this.opsServerRef.dcRouterRef.securityPolicyManager;
|
||||
return { rules: manager ? await manager.listBlockRules() : [] };
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
router.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ListIpIntelligence>(
|
||||
'listIpIntelligence',
|
||||
async () => {
|
||||
const manager = this.opsServerRef.dcRouterRef.securityPolicyManager;
|
||||
return { records: manager ? await manager.listIpIntelligence() : [] };
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
const adminRouter = this.opsServerRef.adminRouter;
|
||||
|
||||
adminRouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_CreateSecurityBlockRule>(
|
||||
'createSecurityBlockRule',
|
||||
async (dataArg) => {
|
||||
const manager = this.opsServerRef.dcRouterRef.securityPolicyManager;
|
||||
if (!manager) return { success: false, message: 'Security policy manager not initialized' };
|
||||
const rule = await manager.createBlockRule({
|
||||
type: dataArg.type,
|
||||
value: dataArg.value,
|
||||
matchMode: dataArg.matchMode,
|
||||
reason: dataArg.reason,
|
||||
enabled: dataArg.enabled,
|
||||
}, dataArg.identity.userId);
|
||||
return { success: true, rule };
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
adminRouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_UpdateSecurityBlockRule>(
|
||||
'updateSecurityBlockRule',
|
||||
async (dataArg) => {
|
||||
const manager = this.opsServerRef.dcRouterRef.securityPolicyManager;
|
||||
if (!manager) return { success: false, message: 'Security policy manager not initialized' };
|
||||
const rule = await manager.updateBlockRule(dataArg.id, {
|
||||
value: dataArg.value,
|
||||
matchMode: dataArg.matchMode,
|
||||
reason: dataArg.reason,
|
||||
enabled: dataArg.enabled,
|
||||
}, dataArg.identity.userId);
|
||||
return rule ? { success: true, rule } : { success: false, message: 'Rule not found' };
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
adminRouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_DeleteSecurityBlockRule>(
|
||||
'deleteSecurityBlockRule',
|
||||
async (dataArg) => {
|
||||
const manager = this.opsServerRef.dcRouterRef.securityPolicyManager;
|
||||
if (!manager) return { success: false, message: 'Security policy manager not initialized' };
|
||||
const success = await manager.deleteBlockRule(dataArg.id, dataArg.identity.userId);
|
||||
return { success, message: success ? undefined : 'Rule not found' };
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
private async collectSecurityMetrics(): Promise<{
|
||||
|
||||
@@ -302,6 +302,7 @@ export class StatsHandler {
|
||||
startTime: 0,
|
||||
bytesIn: tp?.in || 0,
|
||||
bytesOut: tp?.out || 0,
|
||||
connectionCount: count,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,10 @@ import * as plugins from '../plugins.js';
|
||||
import type { IRemoteIngress, IDcRouterRouteConfig } from '../../ts_interfaces/data/remoteingress.js';
|
||||
import { RemoteIngressEdgeDoc } from '../db/index.js';
|
||||
|
||||
interface IRemoteIngressFirewallConfig {
|
||||
blockedIps?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Flatten a port range (number | number[] | Array<{from, to}>) to a sorted unique number array.
|
||||
*/
|
||||
@@ -31,6 +35,7 @@ function extractPorts(portRange: number | Array<number | { from: number; to: num
|
||||
export class RemoteIngressManager {
|
||||
private edges: Map<string, IRemoteIngress> = new Map();
|
||||
private routes: IDcRouterRouteConfig[] = [];
|
||||
private firewallConfig?: IRemoteIngressFirewallConfig;
|
||||
|
||||
constructor() {
|
||||
}
|
||||
@@ -69,6 +74,13 @@ export class RemoteIngressManager {
|
||||
this.routes = routes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the full desired firewall snapshot pushed to all edges.
|
||||
*/
|
||||
public setFirewallConfig(firewallConfig?: IRemoteIngressFirewallConfig): void {
|
||||
this.firewallConfig = firewallConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive listen ports for an edge from routes tagged with remoteIngress.enabled.
|
||||
* When a route specifies edgeFilter, only edges whose id or tags match get that route's ports.
|
||||
@@ -305,8 +317,8 @@ export class RemoteIngressManager {
|
||||
* Get the list of allowed edges (enabled only) for the Rust hub.
|
||||
* Includes listenPortsUdp when routes with transport 'udp' or 'all' are present.
|
||||
*/
|
||||
public getAllowedEdges(): Array<{ id: string; secret: string; listenPorts: number[]; listenPortsUdp?: number[] }> {
|
||||
const result: Array<{ id: string; secret: string; listenPorts: number[]; listenPortsUdp?: number[] }> = [];
|
||||
public getAllowedEdges(): Array<{ id: string; secret: string; listenPorts: number[]; listenPortsUdp?: number[]; firewallConfig?: IRemoteIngressFirewallConfig }> {
|
||||
const result: Array<{ id: string; secret: string; listenPorts: number[]; listenPortsUdp?: number[]; firewallConfig?: IRemoteIngressFirewallConfig }> = [];
|
||||
for (const edge of this.edges.values()) {
|
||||
if (edge.enabled) {
|
||||
const listenPortsUdp = this.getEffectiveListenPortsUdp(edge);
|
||||
@@ -315,6 +327,7 @@ export class RemoteIngressManager {
|
||||
secret: edge.secret,
|
||||
listenPorts: this.getEffectiveListenPorts(edge),
|
||||
...(listenPortsUdp.length > 0 ? { listenPortsUdp } : {}),
|
||||
...(this.firewallConfig ? { firewallConfig: this.firewallConfig } : {}),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ export interface ITunnelManagerConfig {
|
||||
certPem?: string;
|
||||
keyPem?: string;
|
||||
};
|
||||
performance?: import('../../ts_interfaces/data/remoteingress.js').IRemoteIngressPerformanceConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -20,6 +21,7 @@ export class TunnelManager {
|
||||
private config: ITunnelManagerConfig;
|
||||
private edgeStatuses: Map<string, IRemoteIngressStatus> = new Map();
|
||||
private reconcileInterval: ReturnType<typeof setInterval> | null = null;
|
||||
private syncChain: Promise<void> = Promise.resolve();
|
||||
|
||||
constructor(manager: RemoteIngressManager, config: ITunnelManagerConfig = {}) {
|
||||
this.manager = manager;
|
||||
@@ -66,7 +68,8 @@ export class TunnelManager {
|
||||
tunnelPort: this.config.tunnelPort ?? 8443,
|
||||
targetHost: this.config.targetHost ?? '127.0.0.1',
|
||||
tls: this.config.tls,
|
||||
});
|
||||
...(this.config.performance ? { performance: this.config.performance } : {}),
|
||||
} as any);
|
||||
|
||||
// Send allowed edges to the hub
|
||||
await this.syncAllowedEdges();
|
||||
@@ -107,20 +110,23 @@ export class TunnelManager {
|
||||
if (existing) {
|
||||
existing.activeTunnels = rustEdge.activeStreams;
|
||||
existing.lastHeartbeat = Date.now();
|
||||
this.applyRustStatus(existing, rustEdge);
|
||||
// Update peer address if available from Rust hub
|
||||
if (rustEdge.peerAddr) {
|
||||
existing.publicIp = rustEdge.peerAddr;
|
||||
}
|
||||
} else {
|
||||
// Missed edgeConnected event — add entry
|
||||
this.edgeStatuses.set(rustEdge.edgeId, {
|
||||
const status: IRemoteIngressStatus = {
|
||||
edgeId: rustEdge.edgeId,
|
||||
connected: true,
|
||||
publicIp: rustEdge.peerAddr || null,
|
||||
activeTunnels: rustEdge.activeStreams,
|
||||
lastHeartbeat: Date.now(),
|
||||
connectedAt: rustEdge.connectedAt * 1000,
|
||||
});
|
||||
};
|
||||
this.applyRustStatus(status, rustEdge);
|
||||
this.edgeStatuses.set(rustEdge.edgeId, status);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -137,8 +143,22 @@ export class TunnelManager {
|
||||
* Call this after creating/deleting/updating edges.
|
||||
*/
|
||||
public async syncAllowedEdges(): Promise<void> {
|
||||
const run = this.syncChain.catch(() => {}).then(async () => {
|
||||
const edges = this.manager.getAllowedEdges();
|
||||
await this.hub.updateAllowedEdges(edges);
|
||||
await this.hub.updateAllowedEdges(edges as any);
|
||||
});
|
||||
this.syncChain = run;
|
||||
await run;
|
||||
}
|
||||
|
||||
private applyRustStatus(status: IRemoteIngressStatus, rustEdge: any): void {
|
||||
status.transportMode = rustEdge.transportMode;
|
||||
status.fallbackUsed = rustEdge.fallbackUsed;
|
||||
status.performance = rustEdge.performance;
|
||||
status.flowControl = rustEdge.flowControl;
|
||||
status.queues = rustEdge.queues;
|
||||
status.traffic = rustEdge.traffic;
|
||||
status.udp = rustEdge.udp;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,315 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
import { logger } from '../logger.js';
|
||||
import { IpIntelligenceDoc, SecurityBlockRuleDoc, SecurityPolicyAuditDoc } from '../db/index.js';
|
||||
import type {
|
||||
IIpIntelligenceRecord,
|
||||
ISecurityBlockRule,
|
||||
ISecurityCompiledPolicy,
|
||||
TSecurityBlockRuleMatchMode,
|
||||
TSecurityBlockRuleType,
|
||||
} from '../../ts_interfaces/data/security-policy.js';
|
||||
|
||||
export interface ISecurityPolicyManagerOptions {
|
||||
intelligenceRefreshMs?: number;
|
||||
onPolicyChanged?: () => void | Promise<void>;
|
||||
}
|
||||
|
||||
export interface IRemoteIngressFirewallSnapshot {
|
||||
blockedIps?: string[];
|
||||
}
|
||||
|
||||
export class SecurityPolicyManager {
|
||||
private readonly smartNetwork = new plugins.smartnetwork.SmartNetwork({
|
||||
cacheTtl: 24 * 60 * 60 * 1000,
|
||||
});
|
||||
private readonly intelligenceRefreshMs: number;
|
||||
private readonly inFlightObservations = new Set<string>();
|
||||
private readonly onPolicyChanged?: () => void | Promise<void>;
|
||||
|
||||
constructor(options: ISecurityPolicyManagerOptions = {}) {
|
||||
this.intelligenceRefreshMs = options.intelligenceRefreshMs ?? 24 * 60 * 60 * 1000;
|
||||
this.onPolicyChanged = options.onPolicyChanged;
|
||||
}
|
||||
|
||||
public async start(): Promise<void> {
|
||||
logger.log('info', 'SecurityPolicyManager started');
|
||||
}
|
||||
|
||||
public async stop(): Promise<void> {
|
||||
await this.smartNetwork.stop();
|
||||
}
|
||||
|
||||
public async observeIps(ips: string[]): Promise<void> {
|
||||
const uniqueIps = [...new Set(ips.map((ip) => this.normalizeIp(ip)).filter(Boolean) as string[])];
|
||||
await Promise.allSettled(uniqueIps.map((ip) => this.observeIp(ip)));
|
||||
}
|
||||
|
||||
public async observeIp(ipAddress: string): Promise<void> {
|
||||
const ip = this.normalizeIp(ipAddress);
|
||||
if (!ip || !this.isPublicIp(ip) || this.inFlightObservations.has(ip)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.inFlightObservations.add(ip);
|
||||
try {
|
||||
const now = Date.now();
|
||||
let doc = await IpIntelligenceDoc.findByIp(ip);
|
||||
if (doc && now - doc.updatedAt < this.intelligenceRefreshMs) {
|
||||
if (now - doc.lastSeenAt > 60_000) {
|
||||
doc.lastSeenAt = now;
|
||||
doc.seenCount = (doc.seenCount || 0) + 1;
|
||||
await doc.save();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const intelligence = await this.smartNetwork.getIpIntelligence(ip);
|
||||
if (!doc) {
|
||||
doc = new IpIntelligenceDoc();
|
||||
doc.ipAddress = ip;
|
||||
doc.firstSeenAt = now;
|
||||
}
|
||||
Object.assign(doc, intelligence);
|
||||
doc.lastSeenAt = now;
|
||||
doc.updatedAt = now;
|
||||
doc.seenCount = (doc.seenCount || 0) + 1;
|
||||
await doc.save();
|
||||
|
||||
if (await this.matchesAnyReactiveRule(doc)) {
|
||||
await this.notifyPolicyChanged();
|
||||
}
|
||||
} catch (err) {
|
||||
logger.log('warn', `Failed to enrich IP ${ip}: ${(err as Error).message}`);
|
||||
} finally {
|
||||
this.inFlightObservations.delete(ip);
|
||||
}
|
||||
}
|
||||
|
||||
public async listBlockRules(): Promise<ISecurityBlockRule[]> {
|
||||
return (await SecurityBlockRuleDoc.findAll()).map((doc) => this.ruleFromDoc(doc));
|
||||
}
|
||||
|
||||
public async listIpIntelligence(): Promise<IIpIntelligenceRecord[]> {
|
||||
return (await IpIntelligenceDoc.findAll()).map((doc) => ({
|
||||
ipAddress: doc.ipAddress,
|
||||
asn: doc.asn,
|
||||
asnOrg: doc.asnOrg,
|
||||
registrantOrg: doc.registrantOrg,
|
||||
registrantCountry: doc.registrantCountry,
|
||||
networkRange: doc.networkRange,
|
||||
abuseContact: doc.abuseContact,
|
||||
country: doc.country,
|
||||
countryCode: doc.countryCode,
|
||||
city: doc.city,
|
||||
latitude: doc.latitude,
|
||||
longitude: doc.longitude,
|
||||
accuracyRadius: doc.accuracyRadius,
|
||||
timezone: doc.timezone,
|
||||
firstSeenAt: doc.firstSeenAt,
|
||||
lastSeenAt: doc.lastSeenAt,
|
||||
updatedAt: doc.updatedAt,
|
||||
seenCount: doc.seenCount,
|
||||
}));
|
||||
}
|
||||
|
||||
public async createBlockRule(input: {
|
||||
type: TSecurityBlockRuleType;
|
||||
value: string;
|
||||
matchMode?: TSecurityBlockRuleMatchMode;
|
||||
reason?: string;
|
||||
enabled?: boolean;
|
||||
}, actor = 'system'): Promise<ISecurityBlockRule> {
|
||||
const now = Date.now();
|
||||
const doc = new SecurityBlockRuleDoc();
|
||||
doc.id = plugins.uuid.v4();
|
||||
doc.type = input.type;
|
||||
doc.value = input.value.trim();
|
||||
doc.matchMode = input.matchMode;
|
||||
doc.reason = input.reason;
|
||||
doc.enabled = input.enabled ?? true;
|
||||
doc.createdAt = now;
|
||||
doc.updatedAt = now;
|
||||
doc.createdBy = actor;
|
||||
await doc.save();
|
||||
await this.writeAudit('createBlockRule', actor, { rule: this.ruleFromDoc(doc) });
|
||||
await this.notifyPolicyChanged();
|
||||
return this.ruleFromDoc(doc);
|
||||
}
|
||||
|
||||
public async updateBlockRule(id: string, patch: Partial<Pick<ISecurityBlockRule, 'value' | 'matchMode' | 'reason' | 'enabled'>>, actor = 'system'): Promise<ISecurityBlockRule | null> {
|
||||
const doc = await SecurityBlockRuleDoc.findById(id);
|
||||
if (!doc) {
|
||||
return null;
|
||||
}
|
||||
if (patch.value !== undefined) doc.value = patch.value.trim();
|
||||
if (patch.matchMode !== undefined) doc.matchMode = patch.matchMode;
|
||||
if (patch.reason !== undefined) doc.reason = patch.reason;
|
||||
if (patch.enabled !== undefined) doc.enabled = patch.enabled;
|
||||
doc.updatedAt = Date.now();
|
||||
await doc.save();
|
||||
await this.writeAudit('updateBlockRule', actor, { id, patch });
|
||||
await this.notifyPolicyChanged();
|
||||
return this.ruleFromDoc(doc);
|
||||
}
|
||||
|
||||
public async deleteBlockRule(id: string, actor = 'system'): Promise<boolean> {
|
||||
const doc = await SecurityBlockRuleDoc.findById(id);
|
||||
if (!doc) {
|
||||
return false;
|
||||
}
|
||||
await doc.delete();
|
||||
await this.writeAudit('deleteBlockRule', actor, { id });
|
||||
await this.notifyPolicyChanged();
|
||||
return true;
|
||||
}
|
||||
|
||||
public async compilePolicy(): Promise<ISecurityCompiledPolicy> {
|
||||
const rules = await SecurityBlockRuleDoc.findEnabled();
|
||||
const intelligenceDocs = await IpIntelligenceDoc.findAll();
|
||||
const blockedIps = new Set<string>();
|
||||
const blockedCidrs = new Set<string>();
|
||||
|
||||
for (const rule of rules) {
|
||||
const normalizedValue = rule.value.trim();
|
||||
if (!normalizedValue) continue;
|
||||
|
||||
if (rule.type === 'ip') {
|
||||
const ip = this.normalizeIp(normalizedValue);
|
||||
if (ip && plugins.net.isIP(ip)) blockedIps.add(ip);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (rule.type === 'cidr') {
|
||||
const cidr = this.normalizeCidr(normalizedValue);
|
||||
if (cidr) blockedCidrs.add(cidr);
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const doc of intelligenceDocs) {
|
||||
if (!this.ruleMatchesIntelligence(rule, doc)) continue;
|
||||
const cidr = this.normalizeCidr(doc.networkRange || '');
|
||||
if (cidr) {
|
||||
blockedCidrs.add(cidr);
|
||||
} else if (this.normalizeIp(doc.ipAddress)) {
|
||||
blockedIps.add(this.normalizeIp(doc.ipAddress)!);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
blockedIps: [...blockedIps].sort(),
|
||||
blockedCidrs: [...blockedCidrs].sort(),
|
||||
};
|
||||
}
|
||||
|
||||
public async compileSmartProxyPolicy(): Promise<ISecurityCompiledPolicy> {
|
||||
return await this.compilePolicy();
|
||||
}
|
||||
|
||||
public async compileRemoteIngressFirewall(): Promise<IRemoteIngressFirewallSnapshot | undefined> {
|
||||
const policy = await this.compilePolicy();
|
||||
const blockedIps = [
|
||||
...policy.blockedIps.filter((ip) => plugins.net.isIP(ip) === 4),
|
||||
...policy.blockedCidrs.filter((cidr) => plugins.net.isIP(cidr.split('/')[0]) === 4),
|
||||
];
|
||||
return blockedIps.length > 0 ? { blockedIps } : undefined;
|
||||
}
|
||||
|
||||
private async matchesAnyReactiveRule(doc: IpIntelligenceDoc): Promise<boolean> {
|
||||
const rules = await SecurityBlockRuleDoc.findEnabled();
|
||||
return rules.some((rule) => rule.type === 'asn' || rule.type === 'organization'
|
||||
? this.ruleMatchesIntelligence(rule, doc)
|
||||
: false);
|
||||
}
|
||||
|
||||
private ruleMatchesIntelligence(rule: SecurityBlockRuleDoc, doc: IpIntelligenceDoc): boolean {
|
||||
const value = rule.value.trim().toLowerCase();
|
||||
if (!value) return false;
|
||||
|
||||
if (rule.type === 'asn') {
|
||||
return String(doc.asn ?? '') === value.replace(/^as/i, '');
|
||||
}
|
||||
|
||||
if (rule.type === 'organization') {
|
||||
const candidates = [doc.asnOrg, doc.registrantOrg]
|
||||
.filter(Boolean)
|
||||
.map((candidate) => candidate!.toLowerCase());
|
||||
if (rule.matchMode === 'exact') {
|
||||
return candidates.some((candidate) => candidate === value);
|
||||
}
|
||||
return candidates.some((candidate) => candidate.includes(value));
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private normalizeIp(ipAddress: string): string | undefined {
|
||||
const ip = ipAddress.trim();
|
||||
if (ip.startsWith('::ffff:')) {
|
||||
return ip.slice('::ffff:'.length);
|
||||
}
|
||||
return plugins.net.isIP(ip) ? ip : undefined;
|
||||
}
|
||||
|
||||
private normalizeCidr(value: string): string | undefined {
|
||||
const [rawIp, rawPrefix] = value.trim().split('/');
|
||||
if (!rawIp || !rawPrefix) return undefined;
|
||||
const ip = this.normalizeIp(rawIp);
|
||||
if (!ip) return undefined;
|
||||
const prefix = Number(rawPrefix);
|
||||
const maxPrefix = plugins.net.isIP(ip) === 4 ? 32 : 128;
|
||||
if (!Number.isInteger(prefix) || prefix < 0 || prefix > maxPrefix) return undefined;
|
||||
return `${ip}/${prefix}`;
|
||||
}
|
||||
|
||||
private isPublicIp(ip: string): boolean {
|
||||
const family = plugins.net.isIP(ip);
|
||||
if (family === 4) {
|
||||
const parts = ip.split('.').map((part) => Number(part));
|
||||
const [a, b] = parts;
|
||||
if (a === 10 || a === 127 || a === 0 || a >= 224) return false;
|
||||
if (a === 100 && b >= 64 && b <= 127) return false;
|
||||
if (a === 169 && b === 254) return false;
|
||||
if (a === 172 && b >= 16 && b <= 31) return false;
|
||||
if (a === 192 && b === 168) return false;
|
||||
return true;
|
||||
}
|
||||
if (family === 6) {
|
||||
const lower = ip.toLowerCase();
|
||||
if (lower === '::1' || lower === '::') return false;
|
||||
if (lower.startsWith('fe80:') || lower.startsWith('fc') || lower.startsWith('fd')) return false;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private ruleFromDoc(doc: SecurityBlockRuleDoc): ISecurityBlockRule {
|
||||
return {
|
||||
id: doc.id,
|
||||
type: doc.type,
|
||||
value: doc.value,
|
||||
matchMode: doc.matchMode,
|
||||
enabled: doc.enabled,
|
||||
reason: doc.reason,
|
||||
createdAt: doc.createdAt,
|
||||
updatedAt: doc.updatedAt,
|
||||
createdBy: doc.createdBy,
|
||||
};
|
||||
}
|
||||
|
||||
private async writeAudit(action: string, actor: string, details: Record<string, unknown>): Promise<void> {
|
||||
const doc = new SecurityPolicyAuditDoc();
|
||||
doc.id = plugins.uuid.v4();
|
||||
doc.action = action;
|
||||
doc.actor = actor;
|
||||
doc.details = details;
|
||||
doc.createdAt = Date.now();
|
||||
await doc.save();
|
||||
}
|
||||
|
||||
private async notifyPolicyChanged(): Promise<void> {
|
||||
if (this.onPolicyChanged) {
|
||||
await this.onPolicyChanged();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -19,3 +19,9 @@ export {
|
||||
type IScanResult,
|
||||
type IContentScannerOptions
|
||||
} from './classes.contentscanner.js';
|
||||
|
||||
export {
|
||||
SecurityPolicyManager,
|
||||
type ISecurityPolicyManagerOptions,
|
||||
type IRemoteIngressFirewallSnapshot,
|
||||
} from './classes.security-policy-manager.js';
|
||||
|
||||
@@ -112,14 +112,11 @@ export class VpnManager {
|
||||
const subnet = this.getSubnet();
|
||||
const wgListenPort = this.config.wgListenPort ?? 51820;
|
||||
|
||||
// Auto-detect hybrid mode: if any persisted client uses host IP and mode is
|
||||
// 'socket' (or unset), upgrade to 'hybrid' so the daemon can handle both
|
||||
let configuredMode = this.forwardingModeOverride ?? this.config.forwardingMode ?? 'socket';
|
||||
if (anyClientUsesHostIp && configuredMode === 'socket') {
|
||||
configuredMode = 'hybrid';
|
||||
const desiredForwardingMode = this.getDesiredForwardingMode(anyClientUsesHostIp);
|
||||
if (anyClientUsesHostIp && desiredForwardingMode === 'hybrid') {
|
||||
logger.log('info', 'VPN: Auto-upgrading forwarding mode to hybrid (client with useHostIp detected)');
|
||||
}
|
||||
const forwardingMode = configuredMode === 'hybrid' ? 'hybrid' : configuredMode;
|
||||
const forwardingMode = desiredForwardingMode;
|
||||
const isBridge = forwardingMode === 'bridge';
|
||||
this.resolvedForwardingMode = forwardingMode;
|
||||
this.forwardingModeOverride = undefined;
|
||||
@@ -218,7 +215,7 @@ export class VpnManager {
|
||||
throw new Error('VPN server not running');
|
||||
}
|
||||
|
||||
await this.ensureForwardingModeForHostIpClient(opts.useHostIp === true);
|
||||
await this.ensureForwardingModeForNextClient(opts.useHostIp === true);
|
||||
|
||||
const doc = new VpnClientDoc();
|
||||
doc.clientId = opts.clientId;
|
||||
@@ -298,6 +295,7 @@ export class VpnManager {
|
||||
if (doc) {
|
||||
await doc.delete();
|
||||
}
|
||||
await this.reconcileForwardingMode();
|
||||
this.config.onClientChanged?.();
|
||||
}
|
||||
|
||||
@@ -368,9 +366,11 @@ export class VpnManager {
|
||||
await this.persistClient(client);
|
||||
|
||||
if (this.vpnServer) {
|
||||
await this.ensureForwardingModeForHostIpClient(client.useHostIp === true);
|
||||
const restarted = await this.reconcileForwardingMode();
|
||||
if (!restarted) {
|
||||
await this.vpnServer.updateClient(clientId, this.buildClientRuntimeUpdate(client));
|
||||
}
|
||||
}
|
||||
|
||||
this.config.onClientChanged?.();
|
||||
}
|
||||
@@ -563,6 +563,28 @@ export class VpnManager {
|
||||
?? 'socket';
|
||||
}
|
||||
|
||||
private hasHostIpClients(extraHostIpClient = false): boolean {
|
||||
if (extraHostIpClient) {
|
||||
return true;
|
||||
}
|
||||
|
||||
for (const client of this.clients.values()) {
|
||||
if (client.useHostIp) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private getDesiredForwardingMode(hasHostIpClients = this.hasHostIpClients()): 'socket' | 'bridge' | 'hybrid' {
|
||||
const configuredMode = this.forwardingModeOverride ?? this.config.forwardingMode ?? 'socket';
|
||||
if (configuredMode !== 'socket') {
|
||||
return configuredMode;
|
||||
}
|
||||
return hasHostIpClients ? 'hybrid' : 'socket';
|
||||
}
|
||||
|
||||
private getDefaultDestinationPolicy(
|
||||
forwardingMode: 'socket' | 'bridge' | 'hybrid',
|
||||
useHostIp = false,
|
||||
@@ -633,16 +655,45 @@ export class VpnManager {
|
||||
};
|
||||
}
|
||||
|
||||
private async ensureForwardingModeForHostIpClient(useHostIp: boolean): Promise<void> {
|
||||
if (!useHostIp || !this.vpnServer) return;
|
||||
if (this.getResolvedForwardingMode() !== 'socket') return;
|
||||
|
||||
logger.log('info', 'VPN: Restarting server in hybrid mode to support a host-IP client');
|
||||
this.forwardingModeOverride = 'hybrid';
|
||||
private async restartWithForwardingMode(
|
||||
forwardingMode: 'socket' | 'bridge' | 'hybrid',
|
||||
reason: string,
|
||||
): Promise<void> {
|
||||
logger.log('info', `VPN: Restarting server in ${forwardingMode} mode ${reason}`);
|
||||
this.forwardingModeOverride = forwardingMode;
|
||||
await this.stop();
|
||||
await this.start();
|
||||
}
|
||||
|
||||
private async ensureForwardingModeForNextClient(useHostIp: boolean): Promise<void> {
|
||||
if (!this.vpnServer) return;
|
||||
|
||||
const desiredForwardingMode = this.getDesiredForwardingMode(this.hasHostIpClients(useHostIp));
|
||||
if (desiredForwardingMode === this.getResolvedForwardingMode()) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.restartWithForwardingMode(desiredForwardingMode, 'to support a host-IP client');
|
||||
}
|
||||
|
||||
private async reconcileForwardingMode(): Promise<boolean> {
|
||||
if (!this.vpnServer) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const desiredForwardingMode = this.getDesiredForwardingMode();
|
||||
const currentForwardingMode = this.getResolvedForwardingMode();
|
||||
if (desiredForwardingMode === currentForwardingMode) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const reason = desiredForwardingMode === 'socket'
|
||||
? 'because no host-IP clients remain'
|
||||
: 'to support host-IP clients';
|
||||
await this.restartWithForwardingMode(desiredForwardingMode, reason);
|
||||
return true;
|
||||
}
|
||||
|
||||
private async persistClient(client: VpnClientDoc): Promise<void> {
|
||||
await client.save();
|
||||
}
|
||||
|
||||
@@ -9,12 +9,14 @@ export class RemoteIngress {
|
||||
public name: string;
|
||||
public secret: string;
|
||||
public listenPorts: number[];
|
||||
public listenPortsUdp?: number[];
|
||||
public enabled: boolean;
|
||||
public autoDerivePorts: boolean;
|
||||
public tags?: string[];
|
||||
public createdAt: number;
|
||||
public updatedAt: number;
|
||||
public effectiveListenPorts?: number[];
|
||||
public effectiveListenPortsUdp?: number[];
|
||||
public manualPorts?: number[];
|
||||
public derivedPorts?: number[];
|
||||
|
||||
@@ -24,12 +26,14 @@ export class RemoteIngress {
|
||||
this.name = data.name;
|
||||
this.secret = data.secret;
|
||||
this.listenPorts = data.listenPorts;
|
||||
this.listenPortsUdp = data.listenPortsUdp;
|
||||
this.enabled = data.enabled;
|
||||
this.autoDerivePorts = data.autoDerivePorts;
|
||||
this.tags = data.tags;
|
||||
this.createdAt = data.createdAt;
|
||||
this.updatedAt = data.updatedAt;
|
||||
this.effectiveListenPorts = data.effectiveListenPorts;
|
||||
this.effectiveListenPortsUdp = data.effectiveListenPortsUdp;
|
||||
this.manualPorts = data.manualPorts;
|
||||
this.derivedPorts = data.derivedPorts;
|
||||
}
|
||||
@@ -52,11 +56,13 @@ export class RemoteIngress {
|
||||
const edge = response.edge;
|
||||
this.name = edge.name;
|
||||
this.listenPorts = edge.listenPorts;
|
||||
this.listenPortsUdp = edge.listenPortsUdp;
|
||||
this.enabled = edge.enabled;
|
||||
this.autoDerivePorts = edge.autoDerivePorts;
|
||||
this.tags = edge.tags;
|
||||
this.updatedAt = edge.updatedAt;
|
||||
this.effectiveListenPorts = edge.effectiveListenPorts;
|
||||
this.effectiveListenPortsUdp = edge.effectiveListenPortsUdp;
|
||||
this.manualPorts = edge.manualPorts;
|
||||
this.derivedPorts = edge.derivedPorts;
|
||||
}
|
||||
|
||||
@@ -9,3 +9,4 @@ export * from './domain.js';
|
||||
export * from './dns-record.js';
|
||||
export * from './acme-config.js';
|
||||
export * from './email-domain.js';
|
||||
export * from './security-policy.js';
|
||||
|
||||
@@ -36,6 +36,64 @@ export interface IRemoteIngressStatus {
|
||||
activeTunnels: number;
|
||||
lastHeartbeat: number | null;
|
||||
connectedAt: number | null;
|
||||
transportMode?: 'tcpTls' | 'quic' | 'quicWithFallback';
|
||||
fallbackUsed?: boolean;
|
||||
performance?: IRemoteIngressPerformanceEffective;
|
||||
flowControl?: IRemoteIngressFlowControlStatus;
|
||||
queues?: IRemoteIngressQueueStatus;
|
||||
traffic?: IRemoteIngressTrafficStatus;
|
||||
udp?: IRemoteIngressUdpStatus;
|
||||
}
|
||||
|
||||
export type TRemoteIngressPerformanceProfile = 'balanced' | 'throughput' | 'highConcurrency';
|
||||
|
||||
export interface IRemoteIngressPerformanceConfig {
|
||||
profile?: TRemoteIngressPerformanceProfile;
|
||||
maxStreamsPerEdge?: number;
|
||||
totalWindowBudgetBytes?: number;
|
||||
minStreamWindowBytes?: number;
|
||||
maxStreamWindowBytes?: number;
|
||||
sustainedStreamWindowBytes?: number;
|
||||
quicDatagramReceiveBufferBytes?: number;
|
||||
}
|
||||
|
||||
export interface IRemoteIngressPerformanceEffective {
|
||||
profile: TRemoteIngressPerformanceProfile;
|
||||
maxStreamsPerEdge: number;
|
||||
totalWindowBudgetBytes: number;
|
||||
minStreamWindowBytes: number;
|
||||
maxStreamWindowBytes: number;
|
||||
sustainedStreamWindowBytes: number;
|
||||
quicDatagramReceiveBufferBytes: number;
|
||||
}
|
||||
|
||||
export interface IRemoteIngressFlowControlStatus {
|
||||
applies: boolean;
|
||||
currentWindowBytes: number;
|
||||
minWindowBytes: number;
|
||||
maxWindowBytes: number;
|
||||
totalWindowBudgetBytes: number;
|
||||
estimatedInFlightBytes: number;
|
||||
stalledStreams: number;
|
||||
}
|
||||
|
||||
export interface IRemoteIngressQueueStatus {
|
||||
ctrlQueueDepth: number;
|
||||
dataQueueDepth: number;
|
||||
sustainedQueueDepth: number;
|
||||
}
|
||||
|
||||
export interface IRemoteIngressTrafficStatus {
|
||||
bytesIn: number;
|
||||
bytesOut: number;
|
||||
streamsOpenedTotal: number;
|
||||
streamsClosedTotal: number;
|
||||
rejectedStreams: number;
|
||||
}
|
||||
|
||||
export interface IRemoteIngressUdpStatus {
|
||||
activeSessions: number;
|
||||
droppedDatagrams: number;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { IIpIntelligenceResult } from '@push.rocks/smartnetwork';
|
||||
|
||||
export type TSecurityBlockRuleType = 'ip' | 'cidr' | 'asn' | 'organization';
|
||||
export type TSecurityBlockRuleMatchMode = 'exact' | 'contains';
|
||||
|
||||
export interface IIpIntelligenceRecord extends IIpIntelligenceResult {
|
||||
ipAddress: string;
|
||||
firstSeenAt: number;
|
||||
lastSeenAt: number;
|
||||
updatedAt: number;
|
||||
seenCount: number;
|
||||
}
|
||||
|
||||
export interface ISecurityBlockRule {
|
||||
id: string;
|
||||
type: TSecurityBlockRuleType;
|
||||
value: string;
|
||||
matchMode?: TSecurityBlockRuleMatchMode;
|
||||
enabled: boolean;
|
||||
reason?: string;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
createdBy: string;
|
||||
}
|
||||
|
||||
export interface ISecurityCompiledPolicy {
|
||||
blockedIps: string[];
|
||||
blockedCidrs: string[];
|
||||
}
|
||||
|
||||
export interface ISecurityPolicyAuditEvent {
|
||||
id: string;
|
||||
action: string;
|
||||
actor: string;
|
||||
details: Record<string, unknown>;
|
||||
createdAt: number;
|
||||
}
|
||||
@@ -119,6 +119,8 @@ export interface IConnectionInfo {
|
||||
state: 'connecting' | 'connected' | 'authenticated' | 'transmitting' | 'closing';
|
||||
bytesReceived: number;
|
||||
bytesSent: number;
|
||||
/** Present when the row is an aggregate, e.g. one row per remote IP. */
|
||||
connectionCount?: number;
|
||||
}
|
||||
|
||||
export interface IQueueStatus {
|
||||
@@ -149,7 +151,12 @@ export interface IDomainActivity {
|
||||
bytesOutPerSecond: number;
|
||||
activeConnections: number;
|
||||
routeCount: number;
|
||||
/** Lifetime request count when available from SmartProxy. */
|
||||
requestCount: number;
|
||||
/** Live HTTP request rate when SmartProxy exposes per-domain rates. */
|
||||
requestsPerSecond?: number;
|
||||
/** HTTP requests over the last minute when SmartProxy exposes per-domain rates. */
|
||||
requestsLastMinute?: number;
|
||||
}
|
||||
|
||||
export interface INetworkMetrics {
|
||||
@@ -208,9 +215,12 @@ export interface IConnectionDetails {
|
||||
startTime: number;
|
||||
bytesIn: number;
|
||||
bytesOut: number;
|
||||
/** Present when the row is an aggregate, e.g. one row per remote IP. */
|
||||
connectionCount?: number;
|
||||
}
|
||||
|
||||
export interface IBackendInfo {
|
||||
id?: string;
|
||||
backend: string;
|
||||
domain: string | null;
|
||||
protocol: string;
|
||||
|
||||
@@ -71,6 +71,7 @@ export interface IConfigData {
|
||||
hubDomain: string | null;
|
||||
tlsMode: 'custom' | 'acme' | 'self-signed';
|
||||
connectedEdgeIps: string[];
|
||||
performance?: import('../data/remoteingress.js').IRemoteIngressPerformanceConfig;
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -19,3 +19,4 @@ export * from './domains.js';
|
||||
export * from './dns-records.js';
|
||||
export * from './acme-config.js';
|
||||
export * from './email-domains.js';
|
||||
export * from './security-policy.js';
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
import type * as authInterfaces from '../data/auth.js';
|
||||
import type {
|
||||
IIpIntelligenceRecord,
|
||||
ISecurityBlockRule,
|
||||
TSecurityBlockRuleMatchMode,
|
||||
TSecurityBlockRuleType,
|
||||
} from '../data/security-policy.js';
|
||||
|
||||
export interface IReq_ListSecurityBlockRules extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_ListSecurityBlockRules
|
||||
> {
|
||||
method: 'listSecurityBlockRules';
|
||||
request: {
|
||||
identity: authInterfaces.IIdentity;
|
||||
};
|
||||
response: {
|
||||
rules: ISecurityBlockRule[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface IReq_CreateSecurityBlockRule extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_CreateSecurityBlockRule
|
||||
> {
|
||||
method: 'createSecurityBlockRule';
|
||||
request: {
|
||||
identity: authInterfaces.IIdentity;
|
||||
type: TSecurityBlockRuleType;
|
||||
value: string;
|
||||
matchMode?: TSecurityBlockRuleMatchMode;
|
||||
reason?: string;
|
||||
enabled?: boolean;
|
||||
};
|
||||
response: {
|
||||
success: boolean;
|
||||
rule?: ISecurityBlockRule;
|
||||
message?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface IReq_UpdateSecurityBlockRule extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_UpdateSecurityBlockRule
|
||||
> {
|
||||
method: 'updateSecurityBlockRule';
|
||||
request: {
|
||||
identity: authInterfaces.IIdentity;
|
||||
id: string;
|
||||
value?: string;
|
||||
matchMode?: TSecurityBlockRuleMatchMode;
|
||||
reason?: string;
|
||||
enabled?: boolean;
|
||||
};
|
||||
response: {
|
||||
success: boolean;
|
||||
rule?: ISecurityBlockRule;
|
||||
message?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface IReq_DeleteSecurityBlockRule extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_DeleteSecurityBlockRule
|
||||
> {
|
||||
method: 'deleteSecurityBlockRule';
|
||||
request: {
|
||||
identity: authInterfaces.IIdentity;
|
||||
id: string;
|
||||
};
|
||||
response: {
|
||||
success: boolean;
|
||||
message?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface IReq_ListIpIntelligence extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_ListIpIntelligence
|
||||
> {
|
||||
method: 'listIpIntelligence';
|
||||
request: {
|
||||
identity: authInterfaces.IIdentity;
|
||||
};
|
||||
response: {
|
||||
records: IIpIntelligenceRecord[];
|
||||
};
|
||||
}
|
||||
@@ -3,6 +3,6 @@
|
||||
*/
|
||||
export const commitinfo = {
|
||||
name: '@serve.zone/dcrouter',
|
||||
version: '13.20.1',
|
||||
version: '13.23.0',
|
||||
description: 'A multifaceted routing service handling mail and SMS delivery functions.'
|
||||
}
|
||||
|
||||
+23
-18
@@ -512,15 +512,6 @@ export const fetchNetworkStatsAction = networkStatePart.createAction(async (stat
|
||||
if (!context.identity) return currentState;
|
||||
|
||||
try {
|
||||
// Fetch active connections using the existing endpoint
|
||||
const connectionsRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||
interfaces.requests.IReq_GetActiveConnections
|
||||
>('/typedrequest', 'getActiveConnections');
|
||||
|
||||
const connectionsResponse = await connectionsRequest.fire({
|
||||
identity: context.identity,
|
||||
});
|
||||
|
||||
// Get network stats for throughput and IP data
|
||||
const networkStatsRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||
interfaces.requests.IReq_GetNetworkStats
|
||||
@@ -533,22 +524,35 @@ export const fetchNetworkStatsAction = networkStatePart.createAction(async (stat
|
||||
// Use the connections data for the connection list
|
||||
// and network stats for throughput and IP analytics
|
||||
const connectionsByIP: { [ip: string]: number } = {};
|
||||
const throughputByIP = new Map<string, { in: number; out: number }>();
|
||||
for (const item of networkStatsResponse.throughputByIP || []) {
|
||||
throughputByIP.set(item.ip, { in: item.in, out: item.out });
|
||||
}
|
||||
|
||||
// Build connectionsByIP from network stats if available
|
||||
if (networkStatsResponse.connectionsByIP && Array.isArray(networkStatsResponse.connectionsByIP)) {
|
||||
networkStatsResponse.connectionsByIP.forEach((item: { ip: string; count: number }) => {
|
||||
connectionsByIP[item.ip] = item.count;
|
||||
});
|
||||
} else {
|
||||
// Fallback: calculate from connections
|
||||
connectionsResponse.connections.forEach(conn => {
|
||||
const ip = conn.remoteAddress;
|
||||
connectionsByIP[ip] = (connectionsByIP[ip] || 0) + 1;
|
||||
});
|
||||
}
|
||||
|
||||
const connections: interfaces.data.IConnectionInfo[] = Object.entries(connectionsByIP).map(([ip, count]) => {
|
||||
const tp = throughputByIP.get(ip);
|
||||
return {
|
||||
connections: connectionsResponse.connections,
|
||||
id: `ip-${ip}`,
|
||||
remoteAddress: ip,
|
||||
localAddress: 'server',
|
||||
startTime: 0,
|
||||
protocol: 'https',
|
||||
state: 'connected',
|
||||
bytesReceived: tp?.in || 0,
|
||||
bytesSent: tp?.out || 0,
|
||||
connectionCount: count,
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
connections,
|
||||
connectionsByIP,
|
||||
throughputRate: networkStatsResponse.throughputRate || { bytesInPerSecond: 0, bytesOutPerSecond: 0 },
|
||||
totalBytes: networkStatsResponse.totalDataTransferred
|
||||
@@ -2589,7 +2593,7 @@ async function dispatchCombinedRefreshActionInner() {
|
||||
email: true,
|
||||
dns: true,
|
||||
security: true,
|
||||
network: currentView === 'network', // Only fetch network if on network view
|
||||
network: currentView === 'network' && currentSubview === 'activity',
|
||||
radius: true,
|
||||
vpn: true,
|
||||
},
|
||||
@@ -2617,7 +2621,7 @@ async function dispatchCombinedRefreshActionInner() {
|
||||
|
||||
// Build connectionsByIP from connectionDetails (now populated with real per-IP data)
|
||||
network.connectionDetails.forEach(conn => {
|
||||
connectionsByIP[conn.remoteAddress] = (connectionsByIP[conn.remoteAddress] || 0) + 1;
|
||||
connectionsByIP[conn.remoteAddress] = (connectionsByIP[conn.remoteAddress] || 0) + (conn.connectionCount || 1);
|
||||
});
|
||||
|
||||
// Build connections from connectionDetails (real per-IP aggregates)
|
||||
@@ -2630,6 +2634,7 @@ async function dispatchCombinedRefreshActionInner() {
|
||||
state: conn.state as any,
|
||||
bytesReceived: conn.bytesIn,
|
||||
bytesSent: conn.bytesOut,
|
||||
connectionCount: conn.connectionCount,
|
||||
}));
|
||||
|
||||
networkStatePart.setState({
|
||||
|
||||
@@ -79,7 +79,6 @@ export class OpsViewNetworkActivity extends DeesElement {
|
||||
// Subscribe and track unsubscribe functions
|
||||
const statsUnsubscribe = appstate.statsStatePart.select().subscribe((state) => {
|
||||
this.statsState = state;
|
||||
this.updateNetworkData();
|
||||
});
|
||||
this.rxSubscriptions.push(statsUnsubscribe);
|
||||
|
||||
@@ -560,6 +559,8 @@ export class OpsViewNetworkActivity extends DeesElement {
|
||||
'Throughput Out': this.formatBitsPerSecond(item.bytesOutPerSecond),
|
||||
'Transferred / min': this.formatBytes(totalBytesPerMin),
|
||||
'Connections': item.activeConnections,
|
||||
'Req/s': item.requestsPerSecond != null ? item.requestsPerSecond.toFixed(1) : '-',
|
||||
'Req/min': item.requestsLastMinute != null ? item.requestsLastMinute.toFixed(0) : '-',
|
||||
'Requests': item.requestCount?.toLocaleString() ?? '0',
|
||||
'Routes': item.routeCount,
|
||||
};
|
||||
@@ -583,7 +584,7 @@ export class OpsViewNetworkActivity extends DeesElement {
|
||||
return html`
|
||||
<dees-table
|
||||
.data=${backends}
|
||||
.rowKey=${'backend'}
|
||||
.rowKey=${'id'}
|
||||
.highlightUpdates=${'flash'}
|
||||
.displayFunction=${(item: interfaces.data.IBackendInfo) => {
|
||||
const totalErrors = item.connectErrors + item.handshakeErrors + item.requestErrors;
|
||||
@@ -707,6 +708,9 @@ export class OpsViewNetworkActivity extends DeesElement {
|
||||
}
|
||||
|
||||
const throughput = this.calculateThroughput();
|
||||
if (this.networkState.lastUpdated && now - this.networkState.lastUpdated > 3000) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Convert to Mbps (bytes * 8 / 1,000,000)
|
||||
const throughputInMbps = (throughput.in * 8) / 1000000;
|
||||
|
||||
@@ -125,6 +125,18 @@ export class OpsViewRemoteIngress extends DeesElement {
|
||||
color: ${cssManager.bdTheme('#047857', '#34d399')};
|
||||
border: 1px dashed ${cssManager.bdTheme('#6ee7b7', '#065f46')};
|
||||
}
|
||||
|
||||
.metricStack {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
font-size: 12px;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.metricMuted {
|
||||
color: var(--text-muted, #6b7280);
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
||||
@@ -226,9 +238,13 @@ export class OpsViewRemoteIngress extends DeesElement {
|
||||
.displayFunction=${(edge: interfaces.data.IRemoteIngress) => ({
|
||||
name: edge.name,
|
||||
status: this.getEdgeStatusHtml(edge),
|
||||
transport: this.getTransportHtml(edge.id),
|
||||
publicIp: this.getEdgePublicIp(edge.id),
|
||||
ports: this.getPortsHtml(edge),
|
||||
tunnels: this.getEdgeTunnelCount(edge.id),
|
||||
window: this.getWindowHtml(edge.id),
|
||||
queues: this.getQueuesHtml(edge.id),
|
||||
traffic: this.getTrafficHtml(edge.id),
|
||||
lastHeartbeat: this.getLastHeartbeat(edge.id),
|
||||
})}
|
||||
.dataActions=${[
|
||||
@@ -459,6 +475,46 @@ export class OpsViewRemoteIngress extends DeesElement {
|
||||
return status?.activeTunnels || 0;
|
||||
}
|
||||
|
||||
private getTransportHtml(edgeId: string): TemplateResult | string {
|
||||
const status = this.getEdgeStatus(edgeId);
|
||||
if (!status?.connected) return '-';
|
||||
const mode = status.transportMode || 'unknown';
|
||||
const label = mode === 'quic' ? 'QUIC' : mode === 'tcpTls' ? 'TCP/TLS' : mode;
|
||||
return html`<div class="metricStack"><strong>${label}</strong><span class="metricMuted">${status.fallbackUsed ? 'fallback' : status.performance?.profile || 'default'}</span></div>`;
|
||||
}
|
||||
|
||||
private getWindowHtml(edgeId: string): TemplateResult | string {
|
||||
const status = this.getEdgeStatus(edgeId);
|
||||
if (!status?.connected || !status.flowControl) return '-';
|
||||
if (!status.flowControl.applies) {
|
||||
return html`<div class="metricStack"><span>native QUIC</span><span class="metricMuted">max ${status.performance?.maxStreamsPerEdge || '-'} streams</span></div>`;
|
||||
}
|
||||
return html`
|
||||
<div class="metricStack">
|
||||
<span>${this.formatBytes(status.flowControl.currentWindowBytes)} window</span>
|
||||
<span class="metricMuted">${this.formatBytes(status.flowControl.estimatedInFlightBytes)} est. in-flight</span>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private getQueuesHtml(edgeId: string): TemplateResult | string {
|
||||
const status = this.getEdgeStatus(edgeId);
|
||||
if (!status?.connected || !status.queues) return '-';
|
||||
return html`<div class="metricStack"><span>C ${status.queues.ctrlQueueDepth} / D ${status.queues.dataQueueDepth}</span><span class="metricMuted">S ${status.queues.sustainedQueueDepth}</span></div>`;
|
||||
}
|
||||
|
||||
private getTrafficHtml(edgeId: string): TemplateResult | string {
|
||||
const status = this.getEdgeStatus(edgeId);
|
||||
if (!status?.connected || !status.traffic) return '-';
|
||||
const drops = (status.traffic.rejectedStreams || 0) + (status.udp?.droppedDatagrams || 0);
|
||||
return html`
|
||||
<div class="metricStack">
|
||||
<span>${this.formatBytes(status.traffic.bytesIn)} in / ${this.formatBytes(status.traffic.bytesOut)} out</span>
|
||||
<span class="metricMuted">${drops} rejected/dropped</span>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private getLastHeartbeat(edgeId: string): string {
|
||||
const status = this.getEdgeStatus(edgeId);
|
||||
if (!status?.lastHeartbeat) return '-';
|
||||
@@ -467,4 +523,16 @@ export class OpsViewRemoteIngress extends DeesElement {
|
||||
if (ago < 3600000) return `${Math.floor(ago / 60000)}m ago`;
|
||||
return `${Math.floor(ago / 3600000)}h ago`;
|
||||
}
|
||||
|
||||
private formatBytes(bytes: number): string {
|
||||
if (!Number.isFinite(bytes) || bytes <= 0) return '0 B';
|
||||
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
let value = bytes;
|
||||
let unitIndex = 0;
|
||||
while (value >= 1024 && unitIndex < units.length - 1) {
|
||||
value = value / 1024;
|
||||
unitIndex++;
|
||||
}
|
||||
return `${value >= 10 || unitIndex === 0 ? value.toFixed(0) : value.toFixed(1)} ${units[unitIndex]}`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,19 +49,28 @@ export class OpsViewVpn extends DeesElement {
|
||||
@state()
|
||||
accessor vpnState: appstate.IVpnState = appstate.vpnStatePart.getState()!;
|
||||
|
||||
@state()
|
||||
accessor targetProfilesState: appstate.ITargetProfilesState = appstate.targetProfilesStatePart.getState()!;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
const sub = appstate.vpnStatePart.select().subscribe((newState) => {
|
||||
this.vpnState = newState;
|
||||
});
|
||||
this.rxSubscriptions.push(sub);
|
||||
|
||||
const targetProfilesSub = appstate.targetProfilesStatePart.select().subscribe((newState) => {
|
||||
this.targetProfilesState = newState;
|
||||
});
|
||||
this.rxSubscriptions.push(targetProfilesSub);
|
||||
}
|
||||
|
||||
async connectedCallback() {
|
||||
await super.connectedCallback();
|
||||
await appstate.vpnStatePart.dispatchAction(appstate.fetchVpnAction, null);
|
||||
// Ensure target profiles are loaded for autocomplete candidates
|
||||
await appstate.targetProfilesStatePart.dispatchAction(appstate.fetchTargetProfilesAction, null);
|
||||
await Promise.all([
|
||||
appstate.vpnStatePart.dispatchAction(appstate.fetchVpnAction, null),
|
||||
appstate.targetProfilesStatePart.dispatchAction(appstate.fetchTargetProfilesAction, null),
|
||||
]);
|
||||
}
|
||||
|
||||
public static styles = [
|
||||
@@ -330,13 +339,7 @@ export class OpsViewVpn extends DeesElement {
|
||||
'Status': statusHtml,
|
||||
'Routing': routingHtml,
|
||||
'VPN IP': client.assignedIp || '-',
|
||||
'Target Profiles': client.targetProfileIds?.length
|
||||
? html`${client.targetProfileIds.map(id => {
|
||||
const profileState = appstate.targetProfilesStatePart.getState();
|
||||
const profile = profileState?.profiles.find(p => p.id === id);
|
||||
return html`<span class="tagBadge">${profile?.name || id}</span>`;
|
||||
})}`
|
||||
: '-',
|
||||
'Target Profiles': this.renderTargetProfileBadges(client.targetProfileIds),
|
||||
'Description': client.description || '-',
|
||||
'Created': new Date(client.createdAt).toLocaleDateString(),
|
||||
};
|
||||
@@ -347,6 +350,7 @@ export class OpsViewVpn extends DeesElement {
|
||||
iconName: 'lucide:plus',
|
||||
type: ['header'],
|
||||
actionFunc: async () => {
|
||||
await this.ensureTargetProfilesLoaded();
|
||||
const { DeesModal } = await import('@design.estate/dees-catalog');
|
||||
const profileCandidates = this.getTargetProfileCandidates();
|
||||
const createModal = await DeesModal.createAndShow({
|
||||
@@ -647,6 +651,7 @@ export class OpsViewVpn extends DeesElement {
|
||||
type: ['contextmenu', 'inRow'],
|
||||
actionFunc: async (actionData: any) => {
|
||||
const client = actionData.item as interfaces.data.IVpnClient;
|
||||
await this.ensureTargetProfilesLoaded();
|
||||
const { DeesModal } = await import('@design.estate/dees-catalog');
|
||||
const currentDescription = client.description ?? '';
|
||||
const currentTargetProfileNames = this.resolveProfileIdsToLabels(client.targetProfileIds) || [];
|
||||
@@ -810,12 +815,28 @@ export class OpsViewVpn extends DeesElement {
|
||||
`;
|
||||
}
|
||||
|
||||
private async ensureTargetProfilesLoaded(): Promise<void> {
|
||||
await appstate.targetProfilesStatePart.dispatchAction(appstate.fetchTargetProfilesAction, null);
|
||||
}
|
||||
|
||||
private renderTargetProfileBadges(ids?: string[]): TemplateResult | string {
|
||||
const labels = this.resolveProfileIdsToLabels(ids, {
|
||||
pendingLabel: 'Loading profile...',
|
||||
missingLabel: (id) => `Unknown profile (${id})`,
|
||||
});
|
||||
|
||||
if (!labels?.length) {
|
||||
return '-';
|
||||
}
|
||||
|
||||
return html`${labels.map((label) => html`<span class="tagBadge">${label}</span>`)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build stable profile labels for list inputs.
|
||||
*/
|
||||
private getTargetProfileChoices() {
|
||||
const profileState = appstate.targetProfilesStatePart.getState();
|
||||
const profiles = profileState?.profiles || [];
|
||||
const profiles = this.targetProfilesState.profiles || [];
|
||||
const nameCounts = new Map<string, number>();
|
||||
|
||||
for (const profile of profiles) {
|
||||
@@ -837,12 +858,27 @@ export class OpsViewVpn extends DeesElement {
|
||||
/**
|
||||
* Convert profile IDs to form labels (for populating edit form values).
|
||||
*/
|
||||
private resolveProfileIdsToLabels(ids?: string[]): string[] | undefined {
|
||||
private resolveProfileIdsToLabels(
|
||||
ids?: string[],
|
||||
options: {
|
||||
pendingLabel?: string;
|
||||
missingLabel?: (id: string) => string;
|
||||
} = {},
|
||||
): string[] | undefined {
|
||||
if (!ids?.length) return undefined;
|
||||
const choices = this.getTargetProfileChoices();
|
||||
const labelsById = new Map(choices.map((profile) => [profile.id, profile.label]));
|
||||
return ids.map((id) => {
|
||||
return labelsById.get(id) || id;
|
||||
const label = labelsById.get(id);
|
||||
if (label) {
|
||||
return label;
|
||||
}
|
||||
|
||||
if (this.targetProfilesState.lastUpdated === 0 && !this.targetProfilesState.error) {
|
||||
return options.pendingLabel || 'Loading profile...';
|
||||
}
|
||||
|
||||
return options.missingLabel?.(id) || id;
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user