Compare commits

..

16 Commits

Author SHA1 Message Date
jkunz df9cc3e49b v13.25.0
Docker (tags) / security (push) Failing after 0s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-04-26 20:49:57 +00:00
jkunz 7f3ab2499d feat(security): compile network ranges and CIDR arrays into edge firewall policies 2026-04-26 20:49:57 +00:00
jkunz 89ab918826 v13.24.0
Docker (tags) / security (push) Failing after 0s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-04-26 19:51:08 +00:00
jkunz e5c3578163 feat(security): add security policy management and IP intelligence operations to the ops UI 2026-04-26 19:51:08 +00:00
jkunz 1567606c49 v13.23.0
Docker (tags) / security (push) Failing after 0s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-04-26 15:15:27 +00:00
jkunz af31982d58 feat(security): add managed security policies with IP intelligence and remote ingress firewall propagation 2026-04-26 15:15:27 +00:00
jkunz a322308623 v13.22.0
Docker (tags) / security (push) Failing after 0s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-04-26 12:14:51 +00:00
jkunz ec5374900c feat(remoteingress): add remote ingress performance configuration and expose tunnel transport metrics 2026-04-26 12:14:51 +00:00
jkunz 49ce265d7e fix(deps): bump @push.rocks/smartproxy to ^27.8.2 2026-04-26 11:32:57 +00:00
jkunz 63729697c5 v13.21.1
Docker (tags) / security (push) Failing after 0s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-04-26 09:29:29 +00:00
jkunz ce93b726ef fix(deps): bump @push.rocks/smartproxy to ^27.8.1 2026-04-26 09:29:29 +00:00
jkunz 1c3aa89f8d v13.21.0
Docker (tags) / security (push) Failing after 10s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-04-25 20:37:28 +00:00
jkunz b3751abd17 feat(monitoring): improve network activity metrics with live domain request rates and backend identifiers 2026-04-25 20:37:28 +00:00
jkunz 97017ede98 chore(deps): update serve.zone interfaces 2026-04-25 14:01:26 +00:00
jkunz 4b928b038e v13.20.2
Docker (tags) / security (push) Failing after 2s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-04-17 14:28:19 +00:00
jkunz a466b88408 fix(vpn): handle VPN forwarding mode downgrades and support runtime VPN config updates 2026-04-17 14:28:19 +00:00
43 changed files with 2837 additions and 236 deletions
+51
View File
@@ -1,5 +1,56 @@
# Changelog
## 2026-04-26 - 13.25.0 - feat(security)
compile network ranges and CIDR arrays into edge firewall policies
- add support for storing intelligence network CIDR arrays alongside single network ranges
- convert start-end IPv4 ranges into CIDR blocks when compiling security policies
- always return an explicit remote ingress firewall snapshot with a blockedIps array
- add tests covering range normalization, ASN-derived CIDRs, and empty firewall snapshots
## 2026-04-26 - 13.24.0 - feat(security)
add security policy management and IP intelligence operations to the ops UI
- adds typed request endpoints to fetch compiled security policy, list audit events, and force-refresh IP intelligence
- introduces dedicated security policy state and actions for loading, creating, updating, deleting, and refreshing security data
- enhances the network activity view with IP intelligence columns, detail dialogs, and block-rule actions
- expands the security blocked view into a full management interface for rules, compiled policy, IP intelligence, and audit history
## 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
+5 -5
View File
@@ -1,7 +1,7 @@
{
"name": "@serve.zone/dcrouter",
"private": false,
"version": "13.20.1",
"version": "13.25.0",
"description": "A multifaceted routing service handling mail and SMS delivery functions.",
"type": "module",
"exports": {
@@ -51,10 +51,10 @@
"@push.rocks/smartmetrics": "^3.0.3",
"@push.rocks/smartmigration": "1.2.0",
"@push.rocks/smartmta": "^5.3.3",
"@push.rocks/smartnetwork": "^4.6.0",
"@push.rocks/smartnetwork": "^4.7.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",
+32 -27
View File
@@ -72,8 +72,8 @@ importers:
specifier: ^5.3.3
version: 5.3.3
'@push.rocks/smartnetwork':
specifier: ^4.6.0
version: 4.6.0
specifier: ^4.7.0
version: 4.7.0
'@push.rocks/smartpath':
specifier: ^6.0.0
version: 6.0.0
@@ -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:
@@ -1260,12 +1257,15 @@ packages:
'@push.rocks/smartmustache@3.0.2':
resolution: {integrity: sha512-G3LyRXoJhyM+iQhkvP/MR/2WYMvC9U7zc2J44JxUM5tPdkQ+o3++FbfRtnZj6rz5X/A7q03//vsxPitVQwoi2Q==}
'@push.rocks/smartnetwork@4.6.0':
resolution: {integrity: sha512-ubaO/Qp8r30A+qwk33M/0+nQi+o8gNHEI9zq3jv1MwqiLxhiV1hnbr4CL9AUcvs4EhwUBiw0EswKjCJROwDqvQ==}
'@push.rocks/smartnetwork@4.7.0':
resolution: {integrity: sha512-WZ46pJlklDRcw1AqkyyBhmGSNSK3i7IYM9D9vcVJOUhlLmgUSai8o1NbpWlb7HvOkp1IhQ7iZeuJV2JiWLtl1g==}
'@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==}
@@ -5163,7 +5163,7 @@ snapshots:
'@push.rocks/smartjson': 6.0.0
'@push.rocks/smartlog': 3.2.2
'@push.rocks/smartmongo': 5.1.1(socks@2.8.7)
'@push.rocks/smartnetwork': 4.6.0
'@push.rocks/smartnetwork': 4.7.0
'@push.rocks/smartpath': 6.0.0
'@push.rocks/smartpromise': 4.2.3
'@push.rocks/smartrequest': 5.0.1
@@ -5966,7 +5966,7 @@ snapshots:
'@push.rocks/smartdelay': 3.0.5
'@push.rocks/smartdns': 7.9.0
'@push.rocks/smartlog': 3.2.2
'@push.rocks/smartnetwork': 4.6.0
'@push.rocks/smartnetwork': 4.7.0
'@push.rocks/smartstring': 4.1.0
'@push.rocks/smarttime': 4.2.3
'@push.rocks/smartunique': 3.0.9
@@ -6433,7 +6433,7 @@ snapshots:
dependencies:
handlebars: 4.7.9
'@push.rocks/smartnetwork@4.6.0':
'@push.rocks/smartnetwork@4.7.0':
dependencies:
'@push.rocks/smartdns': 7.9.0
'@push.rocks/smartrust': 1.3.2
@@ -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
@@ -6494,7 +6499,7 @@ snapshots:
'@push.rocks/smartdelay': 3.0.5
'@push.rocks/smartfs': 1.5.0
'@push.rocks/smartjimp': 1.2.0
'@push.rocks/smartnetwork': 4.6.0
'@push.rocks/smartnetwork': 4.7.0
'@push.rocks/smartpath': 6.0.0
'@push.rocks/smartpromise': 4.2.3
'@push.rocks/smartpuppeteer': 2.0.5(typescript@6.0.2)
@@ -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': {}
+7 -1
View File
@@ -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();
});
+9
View File
@@ -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
}
+25 -13
View File
@@ -27,16 +27,20 @@ tap.test('should login with admin credentials and receive JWT', async () => {
username: 'admin',
password: 'admin'
});
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');
identity = response.identity;
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 = 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 () => {
@@ -129,4 +141,4 @@ tap.test('should stop DCRouter', async () => {
await testDcRouter.stop();
});
export default tap.start();
export default tap.start();
+124 -2
View File
@@ -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();
+5 -1
View File
@@ -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 () => {
+5 -1
View File
@@ -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');
});
+129
View File
@@ -0,0 +1,129 @@
import { tap, expect } from '@git.zone/tstest/tapbundle';
import * as plugins from '../ts/plugins.js';
import { DcRouterDb, IpIntelligenceDoc, SecurityBlockRuleDoc, SecurityPolicyAuditDoc } from '../ts/db/index.js';
import { SecurityPolicyManager } from '../ts/security/index.js';
const createTestDb = async () => {
const storagePath = plugins.path.join(
plugins.os.tmpdir(),
`dcrouter-security-policy-${Date.now()}-${Math.random().toString(16).slice(2)}`,
);
DcRouterDb.resetInstance();
const db = DcRouterDb.getInstance({
storagePath,
dbName: `dcrouter-security-policy-${Date.now()}-${Math.random().toString(16).slice(2)}`,
});
await db.start();
await db.getDb().mongoDb.createCollection('__test_init');
return {
async cleanup() {
await db.stop();
DcRouterDb.resetInstance();
await plugins.fs.promises.rm(storagePath, { recursive: true, force: true });
},
};
};
const testDbPromise = createTestDb();
const clearTestState = async () => {
for (const rule of await SecurityBlockRuleDoc.findAll()) {
await rule.delete();
}
for (const record of await IpIntelligenceDoc.findAll()) {
await record.delete();
}
for (const event of await SecurityPolicyAuditDoc.findRecent(1000)) {
await event.delete();
}
};
tap.test('SecurityPolicyManager compiles start-end CIDR rules for edge firewall snapshots', async () => {
await testDbPromise;
await clearTestState();
const manager = new SecurityPolicyManager();
await manager.createBlockRule({
type: 'cidr',
value: '203.0.113.0 - 203.0.113.255',
reason: 'test range',
});
const policy = await manager.compilePolicy();
expect(policy.blockedCidrs).toEqual(['203.0.113.0/24']);
const firewall = await manager.compileRemoteIngressFirewall();
expect(firewall.blockedIps).toEqual(['203.0.113.0/24']);
});
tap.test('SecurityPolicyManager compiles intelligence network ranges for ASN rules', async () => {
await testDbPromise;
await clearTestState();
const manager = new SecurityPolicyManager();
const intelligenceDoc = new IpIntelligenceDoc();
intelligenceDoc.ipAddress = '198.51.100.23';
intelligenceDoc.asn = 64500;
intelligenceDoc.asnOrg = 'Example Network';
intelligenceDoc.networkRange = '198.51.100.0 - 198.51.100.127';
intelligenceDoc.firstSeenAt = Date.now();
intelligenceDoc.lastSeenAt = Date.now();
intelligenceDoc.updatedAt = Date.now();
intelligenceDoc.seenCount = 1;
await intelligenceDoc.save();
await manager.createBlockRule({
type: 'asn',
value: 'AS64500',
reason: 'test asn range',
});
const policy = await manager.compilePolicy();
expect(policy.blockedCidrs).toEqual(['198.51.100.0/25']);
});
tap.test('SecurityPolicyManager compiles intelligence CIDR arrays for ASN rules', async () => {
await testDbPromise;
await clearTestState();
const manager = new SecurityPolicyManager();
const intelligenceDoc = new IpIntelligenceDoc();
intelligenceDoc.ipAddress = '198.51.100.130';
intelligenceDoc.asn = 64501;
intelligenceDoc.asnOrg = 'Example Split Network';
intelligenceDoc.networkRange = null;
intelligenceDoc.networkCidrs = ['198.51.100.128/25', '198.51.101.0/24'];
intelligenceDoc.firstSeenAt = Date.now();
intelligenceDoc.lastSeenAt = Date.now();
intelligenceDoc.updatedAt = Date.now();
intelligenceDoc.seenCount = 1;
await intelligenceDoc.save();
await manager.createBlockRule({
type: 'asn',
value: 'AS64501',
reason: 'test asn cidr array',
});
const policy = await manager.compilePolicy();
expect(policy.blockedCidrs).toEqual(['198.51.100.128/25', '198.51.101.0/24']);
});
tap.test('SecurityPolicyManager returns an explicit empty edge firewall snapshot', async () => {
await testDbPromise;
await clearTestState();
const manager = new SecurityPolicyManager();
const firewall = await manager.compileRemoteIngressFirewall();
expect(firewall).toEqual({ blockedIps: [] });
});
tap.test('cleanup security policy test db', async () => {
const dbHandle = await testDbPromise;
await clearTestState();
await dbHandle.cleanup();
});
export default tap.start();
+5 -1
View File
@@ -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;
});
// ============================================================================
+110
View File
@@ -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()
+1 -1
View File
@@ -3,6 +3,6 @@
*/
export const commitinfo = {
name: '@serve.zone/dcrouter',
version: '13.20.1',
version: '13.25.0',
description: 'A multifaceted routing service handling mail and SMS delivery functions.'
}
+157 -20
View File
@@ -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
@@ -1245,8 +1277,60 @@ export class DcRouter {
logger.log('info', `SmartProxy started with ${routes.length} routes`);
}
}
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(),
};
}
/**
* Generate SmartProxy routes for email configuration
@@ -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
+8 -2
View File
@@ -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,78 @@
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 networkCidrs: 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);
}
}
+3
View File
@@ -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';
+73 -40
View File
@@ -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,47 +622,48 @@ export class MetricsManager {
const seenCacheKeys = new Set<string>();
for (const [key, bm] of backendMetrics) {
backends.push({
id: `backend:${key}`,
backend: key,
domain: null,
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,
h2Suppressed: false,
h3Suppressed: false,
h2CooldownRemainingSecs: null,
h3CooldownRemainingSecs: null,
h2ConsecutiveFailures: null,
h3ConsecutiveFailures: null,
h3Port: null,
cacheAgeSecs: null,
});
const cacheEntries = cacheByBackend.get(key);
if (!cacheEntries || cacheEntries.length === 0) {
// No protocol cache entry — emit one row with backend metrics only
backends.push({
backend: key,
domain: null,
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,
h2Suppressed: false,
h3Suppressed: false,
h2CooldownRemainingSecs: null,
h3CooldownRemainingSecs: null,
h2ConsecutiveFailures: null,
h3ConsecutiveFailures: null,
h3Port: null,
cacheAgeSecs: null,
});
} else {
// One row per domain, each enriched with the shared backend metrics
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,
+1
View File
@@ -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,
},
+118 -5
View File
@@ -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,113 @@ 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() : [] };
},
),
);
router.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetCompiledSecurityPolicy>(
'getCompiledSecurityPolicy',
async () => {
const manager = this.opsServerRef.dcRouterRef.securityPolicyManager;
return {
policy: manager
? await manager.compilePolicy()
: { blockedIps: [], blockedCidrs: [] },
};
},
),
);
router.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ListSecurityPolicyAudit>(
'listSecurityPolicyAudit',
async (dataArg) => {
const manager = this.opsServerRef.dcRouterRef.securityPolicyManager;
return { events: manager ? await manager.listAuditEvents(dataArg.limit || 100) : [] };
},
),
);
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' };
},
),
);
adminRouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_RefreshIpIntelligence>(
'refreshIpIntelligence',
async (dataArg) => {
const manager = this.opsServerRef.dcRouterRef.securityPolicyManager;
if (!manager) return { success: false, message: 'Security policy manager not initialized' };
const record = await manager.refreshIpIntelligence(dataArg.ipAddress);
return record
? { success: true, record }
: { success: false, message: 'IP address is invalid or not public' };
},
),
);
}
private async collectSecurityMetrics(): Promise<{
@@ -335,4 +448,4 @@ export class SecurityHandler {
limits: [],
};
}
}
}
+1
View File
@@ -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 } : {}),
});
}
}
+25 -5
View File
@@ -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 edges = this.manager.getAllowedEdges();
await this.hub.updateAllowedEdges(edges);
const run = this.syncChain.catch(() => {}).then(async () => {
const edges = this.manager.getAllowedEdges();
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,422 @@
import * as plugins from '../plugins.js';
import { logger } from '../logger.js';
import { IpIntelligenceDoc, SecurityBlockRuleDoc, SecurityPolicyAuditDoc } from '../db/index.js';
import type {
IIpIntelligenceRecord,
ISecurityBlockRule,
ISecurityCompiledPolicy,
ISecurityPolicyAuditEvent,
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, options: { force?: boolean } = {}): 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 && !options.force && 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) => this.intelligenceFromDoc(doc));
}
public async refreshIpIntelligence(ipAddress: string): Promise<IIpIntelligenceRecord | null> {
const ip = this.normalizeIp(ipAddress);
if (!ip || !this.isPublicIp(ip)) {
return null;
}
await this.observeIp(ip, { force: true });
const doc = await IpIntelligenceDoc.findByIp(ip);
return doc ? this.intelligenceFromDoc(doc) : null;
}
public async listAuditEvents(limit = 100): Promise<ISecurityPolicyAuditEvent[]> {
return (await SecurityPolicyAuditDoc.findRecent(limit)).map((doc) => ({
id: doc.id,
action: doc.action,
actor: doc.actor,
details: doc.details,
createdAt: doc.createdAt,
}));
}
private intelligenceFromDoc(doc: IpIntelligenceDoc): IIpIntelligenceRecord {
return {
ipAddress: doc.ipAddress,
asn: doc.asn,
asnOrg: doc.asnOrg,
registrantOrg: doc.registrantOrg,
registrantCountry: doc.registrantCountry,
networkRange: doc.networkRange,
networkCidrs: doc.networkCidrs,
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') {
for (const cidr of this.normalizeNetworkEntries(normalizedValue)) {
blockedCidrs.add(cidr);
}
continue;
}
for (const doc of intelligenceDocs) {
if (!this.ruleMatchesIntelligence(rule, doc)) continue;
const networkEntries = this.normalizeNetworkEntryList([
...(doc.networkCidrs || []),
doc.networkRange,
]);
if (networkEntries.length > 0) {
for (const cidr of networkEntries) {
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> {
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 };
}
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 normalizeNetworkEntries(value: string): string[] {
const trimmed = value.trim();
if (!trimmed) return [];
const cidr = this.normalizeCidr(trimmed);
if (cidr) return [cidr];
const rangeParts = trimmed.split(/\s+-\s+/);
if (rangeParts.length === 2) {
return this.ipv4RangeToCidrs(rangeParts[0], rangeParts[1]);
}
return [];
}
private normalizeNetworkEntryList(values: Array<string | null | undefined>): string[] {
const cidrs = new Set<string>();
for (const value of values) {
if (!value) continue;
for (const entry of value.split(',').map((part) => part.trim()).filter(Boolean)) {
for (const cidr of this.normalizeNetworkEntries(entry)) {
cidrs.add(cidr);
}
}
}
return [...cidrs];
}
private ipv4RangeToCidrs(startIp: string, endIp: string): string[] {
const start = this.ipv4ToBigInt(startIp);
const end = this.ipv4ToBigInt(endIp);
if (start === undefined || end === undefined || start > end) return [];
const cidrs: string[] = [];
let current = start;
while (current <= end) {
let maxBlockSize = current === 0n ? 1n << 32n : current & -current;
const remaining = end - current + 1n;
while (maxBlockSize > remaining) {
maxBlockSize = maxBlockSize / 2n;
}
const prefixLength = 32 - this.powerOfTwoExponent(maxBlockSize);
cidrs.push(`${this.numberToIpv4(current)}/${prefixLength}`);
current += maxBlockSize;
}
return cidrs;
}
private ipv4ToBigInt(ip: string): bigint | undefined {
const normalized = this.normalizeIp(ip);
if (!normalized || plugins.net.isIP(normalized) !== 4) return undefined;
return normalized
.split('.')
.reduce((sum, part) => (sum * 256n) + BigInt(Number(part)), 0n);
}
private numberToIpv4(value: bigint): string {
return [
Number((value >> 24n) & 255n),
Number((value >> 16n) & 255n),
Number((value >> 8n) & 255n),
Number(value & 255n),
].join('.');
}
private powerOfTwoExponent(value: bigint): number {
let exponent = 0;
let remaining = value;
while (remaining > 1n) {
remaining >>= 1n;
exponent++;
}
return exponent;
}
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();
}
}
}
+7 -1
View File
@@ -18,4 +18,10 @@ export {
ThreatCategory,
type IScanResult,
type IContentScannerOptions
} from './classes.contentscanner.js';
} from './classes.contentscanner.js';
export {
SecurityPolicyManager,
type ISecurityPolicyManagerOptions,
type IRemoteIngressFirewallSnapshot,
} from './classes.security-policy-manager.js';
+66 -15
View File
@@ -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,8 +366,10 @@ export class VpnManager {
await this.persistClient(client);
if (this.vpnServer) {
await this.ensureForwardingModeForHostIpClient(client.useHostIp === true);
await this.vpnServer.updateClient(clientId, this.buildClientRuntimeUpdate(client));
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();
}
+6
View File
@@ -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;
}
+2 -1
View File
@@ -8,4 +8,5 @@ export * from './dns-provider.js';
export * from './domain.js';
export * from './dns-record.js';
export * from './acme-config.js';
export * from './email-domain.js';
export * from './email-domain.js';
export * from './security-policy.js';
+58
View File
@@ -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;
}
/**
+37
View File
@@ -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;
}
+11 -1
View File
@@ -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;
@@ -250,4 +260,4 @@ export interface IVpnStats {
registeredClients: number;
connectedClients: number;
wgListenPort: number;
}
}
+1
View File
@@ -71,6 +71,7 @@ export interface IConfigData {
hubDomain: string | null;
tlsMode: 'custom' | 'acme' | 'self-signed';
connectedEdgeIps: string[];
performance?: import('../data/remoteingress.js').IRemoteIngressPerformanceConfig;
};
}
+2 -1
View File
@@ -18,4 +18,5 @@ export * from './dns-providers.js';
export * from './domains.js';
export * from './dns-records.js';
export * from './acme-config.js';
export * from './email-domains.js';
export * from './email-domains.js';
export * from './security-policy.js';
+134
View File
@@ -0,0 +1,134 @@
import * as plugins from '../plugins.js';
import type * as authInterfaces from '../data/auth.js';
import type {
IIpIntelligenceRecord,
ISecurityBlockRule,
ISecurityCompiledPolicy,
ISecurityPolicyAuditEvent,
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[];
};
}
export interface IReq_GetCompiledSecurityPolicy extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_GetCompiledSecurityPolicy
> {
method: 'getCompiledSecurityPolicy';
request: {
identity: authInterfaces.IIdentity;
};
response: {
policy: ISecurityCompiledPolicy;
};
}
export interface IReq_ListSecurityPolicyAudit extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_ListSecurityPolicyAudit
> {
method: 'listSecurityPolicyAudit';
request: {
identity: authInterfaces.IIdentity;
limit?: number;
};
response: {
events: ISecurityPolicyAuditEvent[];
};
}
export interface IReq_RefreshIpIntelligence extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_RefreshIpIntelligence
> {
method: 'refreshIpIntelligence';
request: {
identity: authInterfaces.IIdentity;
ipAddress: string;
};
response: {
success: boolean;
record?: IIpIntelligenceRecord;
message?: string;
};
}
+1 -1
View File
@@ -3,6 +3,6 @@
*/
export const commitinfo = {
name: '@serve.zone/dcrouter',
version: '13.20.1',
version: '13.25.0',
description: 'A multifaceted routing service handling mail and SMS delivery functions.'
}
+259 -21
View File
@@ -54,6 +54,7 @@ export interface INetworkState {
topIPs: Array<{ ip: string; count: number }>;
topIPsByBandwidth: Array<{ ip: string; count: number; bwIn: number; bwOut: number }>;
throughputByIP: Array<{ ip: string; in: number; out: number }>;
ipIntelligence: interfaces.data.IIpIntelligenceRecord[];
domainActivity: interfaces.data.IDomainActivity[];
throughputHistory: Array<{ timestamp: number; in: number; out: number }>;
requestsPerSecond: number;
@@ -66,6 +67,16 @@ export interface INetworkState {
error: string | null;
}
export interface ISecurityPolicyState {
rules: interfaces.data.ISecurityBlockRule[];
ipIntelligence: interfaces.data.IIpIntelligenceRecord[];
compiledPolicy: interfaces.data.ISecurityCompiledPolicy | null;
auditEvents: interfaces.data.ISecurityPolicyAuditEvent[];
isLoading: boolean;
error: string | null;
lastUpdated: number;
}
export interface ICertificateState {
certificates: interfaces.requests.ICertificateInfo[];
summary: { total: number; valid: number; expiring: number; expired: number; failed: number; unknown: number };
@@ -164,6 +175,7 @@ export const networkStatePart = await appState.getStatePart<INetworkState>(
topIPs: [],
topIPsByBandwidth: [],
throughputByIP: [],
ipIntelligence: [],
domainActivity: [],
throughputHistory: [],
requestsPerSecond: 0,
@@ -178,6 +190,20 @@ export const networkStatePart = await appState.getStatePart<INetworkState>(
'soft'
);
export const securityPolicyStatePart = await appState.getStatePart<ISecurityPolicyState>(
'securityPolicy',
{
rules: [],
ipIntelligence: [],
compiledPolicy: null,
auditEvents: [],
isLoading: false,
error: null,
lastUpdated: 0,
},
'soft',
);
export const emailOpsStatePart = await appState.getStatePart<IEmailOpsState>(
'emailOps',
{
@@ -512,43 +538,56 @@ 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
>('/typedrequest', 'getNetworkStats');
const networkStatsResponse = await networkStatsRequest.fire({
identity: context.identity,
});
const ipIntelligenceRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_ListIpIntelligence
>('/typedrequest', 'listIpIntelligence');
const [networkStatsResponse, ipIntelligenceResponse] = await Promise.all([
networkStatsRequest.fire({
identity: context.identity,
}),
ipIntelligenceRequest.fire({
identity: context.identity,
}),
]);
// 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 {
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: connectionsResponse.connections,
connections,
connectionsByIP,
throughputRate: networkStatsResponse.throughputRate || { bytesInPerSecond: 0, bytesOutPerSecond: 0 },
totalBytes: networkStatsResponse.totalDataTransferred
@@ -557,6 +596,7 @@ export const fetchNetworkStatsAction = networkStatePart.createAction(async (stat
topIPs: networkStatsResponse.topIPs || [],
topIPsByBandwidth: networkStatsResponse.topIPsByBandwidth || [],
throughputByIP: networkStatsResponse.throughputByIP || [],
ipIntelligence: ipIntelligenceResponse.records || [],
domainActivity: networkStatsResponse.domainActivity || [],
throughputHistory: networkStatsResponse.throughputHistory || [],
requestsPerSecond: networkStatsResponse.requestsPerSecond || 0,
@@ -578,6 +618,182 @@ export const fetchNetworkStatsAction = networkStatePart.createAction(async (stat
}
});
// ============================================================================
// Security Policy Actions
// ============================================================================
export const fetchSecurityPolicyAction = securityPolicyStatePart.createAction(
async (statePartArg): Promise<ISecurityPolicyState> => {
const context = getActionContext();
const currentState = statePartArg.getState()!;
if (!context.identity) return currentState;
try {
const rulesRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_ListSecurityBlockRules
>('/typedrequest', 'listSecurityBlockRules');
const intelligenceRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_ListIpIntelligence
>('/typedrequest', 'listIpIntelligence');
const compiledPolicyRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_GetCompiledSecurityPolicy
>('/typedrequest', 'getCompiledSecurityPolicy');
const auditRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_ListSecurityPolicyAudit
>('/typedrequest', 'listSecurityPolicyAudit');
const [rulesResponse, intelligenceResponse, compiledPolicyResponse, auditResponse] = await Promise.all([
rulesRequest.fire({ identity: context.identity }),
intelligenceRequest.fire({ identity: context.identity }),
compiledPolicyRequest.fire({ identity: context.identity }),
auditRequest.fire({ identity: context.identity, limit: 100 }),
]);
return {
rules: rulesResponse.rules || [],
ipIntelligence: intelligenceResponse.records || [],
compiledPolicy: compiledPolicyResponse.policy,
auditEvents: auditResponse.events || [],
isLoading: false,
error: null,
lastUpdated: Date.now(),
};
} catch (error: unknown) {
return {
...currentState,
isLoading: false,
error: error instanceof Error ? error.message : 'Failed to fetch security policy',
};
}
},
);
export const createSecurityBlockRuleAction = securityPolicyStatePart.createAction<{
type: interfaces.data.TSecurityBlockRuleType;
value: string;
matchMode?: interfaces.data.TSecurityBlockRuleMatchMode;
reason?: string;
enabled?: boolean;
}>(async (statePartArg, dataArg, actionContext): Promise<ISecurityPolicyState> => {
const context = getActionContext();
const currentState = statePartArg.getState()!;
if (!context.identity) return currentState;
try {
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_CreateSecurityBlockRule
>('/typedrequest', 'createSecurityBlockRule');
const response = await request.fire({
identity: context.identity,
type: dataArg.type,
value: dataArg.value,
matchMode: dataArg.matchMode,
reason: dataArg.reason,
enabled: dataArg.enabled,
});
if (!response.success) {
return { ...currentState, error: response.message || 'Failed to create security block rule' };
}
return await actionContext!.dispatch(fetchSecurityPolicyAction, null);
} catch (error: unknown) {
return {
...currentState,
error: error instanceof Error ? error.message : 'Failed to create security block rule',
};
}
});
export const updateSecurityBlockRuleAction = securityPolicyStatePart.createAction<{
id: string;
value?: string;
matchMode?: interfaces.data.TSecurityBlockRuleMatchMode;
reason?: string;
enabled?: boolean;
}>(async (statePartArg, dataArg, actionContext): Promise<ISecurityPolicyState> => {
const context = getActionContext();
const currentState = statePartArg.getState()!;
if (!context.identity) return currentState;
try {
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_UpdateSecurityBlockRule
>('/typedrequest', 'updateSecurityBlockRule');
const response = await request.fire({
identity: context.identity,
id: dataArg.id,
value: dataArg.value,
matchMode: dataArg.matchMode,
reason: dataArg.reason,
enabled: dataArg.enabled,
});
if (!response.success) {
return { ...currentState, error: response.message || 'Failed to update security block rule' };
}
return await actionContext!.dispatch(fetchSecurityPolicyAction, null);
} catch (error: unknown) {
return {
...currentState,
error: error instanceof Error ? error.message : 'Failed to update security block rule',
};
}
});
export const deleteSecurityBlockRuleAction = securityPolicyStatePart.createAction<string>(
async (statePartArg, ruleId, actionContext): Promise<ISecurityPolicyState> => {
const context = getActionContext();
const currentState = statePartArg.getState()!;
if (!context.identity) return currentState;
try {
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_DeleteSecurityBlockRule
>('/typedrequest', 'deleteSecurityBlockRule');
const response = await request.fire({ identity: context.identity, id: ruleId });
if (!response.success) {
return { ...currentState, error: response.message || 'Failed to delete security block rule' };
}
return await actionContext!.dispatch(fetchSecurityPolicyAction, null);
} catch (error: unknown) {
return {
...currentState,
error: error instanceof Error ? error.message : 'Failed to delete security block rule',
};
}
},
);
export const refreshIpIntelligenceAction = securityPolicyStatePart.createAction<string>(
async (statePartArg, ipAddress, actionContext): Promise<ISecurityPolicyState> => {
const context = getActionContext();
const currentState = statePartArg.getState()!;
if (!context.identity) return currentState;
try {
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_RefreshIpIntelligence
>('/typedrequest', 'refreshIpIntelligence');
const response = await request.fire({ identity: context.identity, ipAddress });
if (!response.success) {
return { ...currentState, error: response.message || 'Failed to refresh IP intelligence' };
}
return await actionContext!.dispatch(fetchSecurityPolicyAction, null);
} catch (error: unknown) {
return {
...currentState,
error: error instanceof Error ? error.message : 'Failed to refresh IP intelligence',
};
}
},
);
// ============================================================================
// Email Operations Actions
// ============================================================================
@@ -2589,7 +2805,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 +2833,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 +2846,7 @@ async function dispatchCombinedRefreshActionInner() {
state: conn.state as any,
bytesReceived: conn.bytesIn,
bytesSent: conn.bytesOut,
connectionCount: conn.connectionCount,
}));
networkStatePart.setState({
@@ -2660,6 +2877,27 @@ async function dispatchCombinedRefreshActionInner() {
isLoading: false,
error: null,
});
try {
const intelligenceRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_ListIpIntelligence
>('/typedrequest', 'listIpIntelligence');
const intelligenceResponse = await intelligenceRequest.fire({ identity: context.identity });
networkStatePart.setState({
...networkStatePart.getState()!,
ipIntelligence: intelligenceResponse.records || [],
});
} catch (error) {
console.error('IP intelligence refresh failed:', error);
}
}
if (currentView === 'security') {
try {
await securityPolicyStatePart.dispatchAction(fetchSecurityPolicyAction, null);
} catch (error) {
console.error('Security policy refresh failed:', error);
}
}
// Refresh certificate data if on Domains > Certificates subview
@@ -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);
@@ -256,6 +255,17 @@ export class OpsViewNetworkActivity extends DeesElement {
color: ${cssManager.bdTheme('#f57c00', '#ff9933')};
}
.intelligenceBadge {
display: inline-flex;
align-items: center;
padding: 4px 8px;
border-radius: 999px;
font-size: 12px;
font-weight: 500;
background: ${cssManager.bdTheme('#eef2ff', '#1e1b4b')};
color: ${cssManager.bdTheme('#4338ca', '#a5b4fc')};
}
.protocolChartGrid {
display: grid;
grid-template-columns: repeat(2, 1fr);
@@ -346,6 +356,100 @@ export class OpsViewNetworkActivity extends DeesElement {
return `${size.toFixed(1)} ${units[unitIndex]}`;
}
private formatOptional(value: unknown): string {
if (value === null || value === undefined || value === '') return '-';
return String(value);
}
private formatDateTime(timestamp?: number | null): string {
return timestamp ? new Date(timestamp).toLocaleString() : '-';
}
private getIpIntelligence(ip: string): interfaces.data.IIpIntelligenceRecord | undefined {
return this.networkState.ipIntelligence?.find((record) => record.ipAddress === ip);
}
private getIpOrganization(record?: interfaces.data.IIpIntelligenceRecord): string {
return record?.asnOrg || record?.registrantOrg || '';
}
private getIpIntelligenceColumns(ip: string): Record<string, unknown> {
const record = this.getIpIntelligence(ip);
const organization = this.getIpOrganization(record);
return {
'Intelligence': record
? html`<span class="intelligenceBadge">${this.formatOptional(organization || record.countryCode || 'Known')}</span>`
: html`<span class="statusBadge warning">Enriching...</span>`,
'ASN': record?.asn ? `AS${record.asn}` : '-',
'Organization': this.formatOptional(organization),
'Country': this.formatOptional(record?.countryCode || record?.country),
'Network Range': this.formatOptional(record?.networkRange),
'Last Seen': this.formatDateTime(record?.lastSeenAt),
};
}
private getIpDataActions() {
return [
{
name: 'Refresh Intelligence',
iconName: 'lucide:refresh-cw',
type: ['inRow', 'contextmenu'] as any,
actionFunc: async (actionData: any) => {
const ip = actionData.item.ip;
await appstate.securityPolicyStatePart.dispatchAction(appstate.refreshIpIntelligenceAction, ip);
await appstate.networkStatePart.dispatchAction(appstate.fetchNetworkStatsAction, null);
},
},
{
name: 'Block IP',
iconName: 'lucide:shield-ban',
type: ['inRow', 'contextmenu'] as any,
actionFunc: async (actionData: any) => {
await this.createBlockRuleDialog('ip', actionData.item.ip, 'Blocked from Network Activity');
},
},
{
name: 'Block Network Range',
iconName: 'lucide:network',
type: ['contextmenu'] as any,
actionRelevancyCheckFunc: (actionData: any) => Boolean(this.getIpIntelligence(actionData.item.ip)?.networkRange),
actionFunc: async (actionData: any) => {
const record = this.getIpIntelligence(actionData.item.ip);
await this.createBlockRuleDialog('cidr', record!.networkRange!, 'Blocked network range from Network Activity');
},
},
{
name: 'Block ASN',
iconName: 'lucide:radio-tower',
type: ['contextmenu'] as any,
actionRelevancyCheckFunc: (actionData: any) => Boolean(this.getIpIntelligence(actionData.item.ip)?.asn),
actionFunc: async (actionData: any) => {
const record = this.getIpIntelligence(actionData.item.ip);
await this.createBlockRuleDialog('asn', String(record!.asn), 'Blocked ASN from Network Activity');
},
},
{
name: 'Block Organization',
iconName: 'lucide:building-2',
type: ['contextmenu'] as any,
actionRelevancyCheckFunc: (actionData: any) => Boolean(this.getIpOrganization(this.getIpIntelligence(actionData.item.ip))),
actionFunc: async (actionData: any) => {
const record = this.getIpIntelligence(actionData.item.ip);
await this.createBlockRuleDialog('organization', this.getIpOrganization(record), 'Blocked organization from Network Activity');
},
},
{
name: 'View Intelligence',
iconName: 'lucide:info',
type: ['doubleClick', 'contextmenu'] as any,
actionRelevancyCheckFunc: (actionData: any) => Boolean(this.getIpIntelligence(actionData.item.ip)),
actionFunc: async (actionData: any) => {
await this.showIpIntelligenceDetails(actionData.item.ip);
},
},
];
}
private calculateThroughput(): { in: number; out: number } {
// Use real throughput data from network state
return {
@@ -501,10 +605,12 @@ export class OpsViewNetworkActivity extends DeesElement {
'Bandwidth In': bw ? this.formatBitsPerSecond(bw.in) : '0 bit/s',
'Bandwidth Out': bw ? this.formatBitsPerSecond(bw.out) : '0 bit/s',
'Share': totalConnections > 0 ? ((ipData.count / totalConnections) * 100).toFixed(1) + '%' : '0%',
...this.getIpIntelligenceColumns(ipData.ip),
};
}}
.dataActions=${this.getIpDataActions()}
heading1="Top Connected IPs"
heading2="IPs with most active connections and bandwidth"
heading2="IPs with most active connections, bandwidth, and intelligence"
searchable
.showColumnFilters=${true}
.pagination=${false}
@@ -530,10 +636,12 @@ export class OpsViewNetworkActivity extends DeesElement {
'Bandwidth Out': this.formatBitsPerSecond(ipData.bwOut),
'Total Bandwidth': this.formatBitsPerSecond(ipData.bwIn + ipData.bwOut),
'Connections': ipData.count,
...this.getIpIntelligenceColumns(ipData.ip),
};
}}
.dataActions=${this.getIpDataActions()}
heading1="Top IPs by Bandwidth"
heading2="IPs with highest throughput"
heading2="IPs with highest throughput and intelligence"
searchable
.showColumnFilters=${true}
.pagination=${false}
@@ -560,6 +668,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 +693,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;
@@ -677,6 +787,114 @@ export class OpsViewNetworkActivity extends DeesElement {
});
}
private getDropdownKey(value: any): string {
return typeof value === 'string' ? value : value?.key || '';
}
private async createBlockRuleDialog(
type: interfaces.data.TSecurityBlockRuleType,
value: string,
reason: string,
): Promise<void> {
const { DeesModal } = await import('@design.estate/dees-catalog');
const typeOptions = [
{ key: 'ip', option: 'IP address' },
{ key: 'cidr', option: 'CIDR / network range' },
{ key: 'asn', option: 'ASN' },
{ key: 'organization', option: 'Organization' },
];
const matchModeOptions = [
{ key: 'contains', option: 'Organization contains value' },
{ key: 'exact', option: 'Organization exactly matches value' },
];
await DeesModal.createAndShow({
heading: 'Create Security Block Rule',
content: html`
<dees-form>
<dees-input-dropdown
.key=${'type'}
.label=${'Rule Type'}
.options=${typeOptions}
.selectedOption=${typeOptions.find((option) => option.key === type)}
></dees-input-dropdown>
<dees-input-text .key=${'value'} .label=${'Value'} .value=${value} .required=${true}></dees-input-text>
<dees-input-dropdown
.key=${'matchMode'}
.label=${'Organization Match Mode'}
.description=${'Only used for organization rules'}
.options=${matchModeOptions}
.selectedOption=${matchModeOptions[0]}
></dees-input-dropdown>
<dees-input-text .key=${'reason'} .label=${'Reason'} .value=${reason}></dees-input-text>
<dees-input-checkbox .key=${'enabled'} .label=${'Enable immediately'} .value=${true}></dees-input-checkbox>
</dees-form>
`,
menuOptions: [
{ name: 'Cancel', iconName: 'lucide:x', action: async (modalArg: any) => modalArg.destroy() },
{
name: 'Create',
iconName: 'lucide:shield-ban',
action: async (modalArg: any) => {
const form = modalArg.shadowRoot?.querySelector('.content')?.querySelector('dees-form');
if (!form) return;
const data = await form.collectFormData();
const selectedType = this.getDropdownKey(data.type) as interfaces.data.TSecurityBlockRuleType;
const selectedValue = String(data.value || '').trim();
if (!selectedType || !selectedValue) return;
const matchMode = selectedType === 'organization'
? this.getDropdownKey(data.matchMode) as interfaces.data.TSecurityBlockRuleMatchMode
: undefined;
await appstate.securityPolicyStatePart.dispatchAction(appstate.createSecurityBlockRuleAction, {
type: selectedType,
value: selectedValue,
matchMode,
reason: String(data.reason || '').trim() || undefined,
enabled: data.enabled !== false,
});
await appstate.networkStatePart.dispatchAction(appstate.fetchNetworkStatsAction, null);
await modalArg.destroy();
},
},
],
});
}
private async showIpIntelligenceDetails(ip: string): Promise<void> {
const record = this.getIpIntelligence(ip);
if (!record) return;
const { DeesModal } = await import('@design.estate/dees-catalog');
await DeesModal.createAndShow({
heading: `IP Intelligence: ${ip}`,
content: html`
<div style="padding: 20px;">
<dees-dataview-codebox
.heading=${'Intelligence Record'}
progLang="json"
.codeToDisplay=${JSON.stringify(record, null, 2)}
></dees-dataview-codebox>
</div>
`,
menuOptions: [
{
name: 'Copy Abuse Contact',
iconName: 'lucide:copy',
action: async () => {
if (record.abuseContact) await navigator.clipboard.writeText(record.abuseContact);
},
},
{
name: 'Block IP',
iconName: 'lucide:shield-ban',
action: async () => {
await this.createBlockRuleDialog('ip', record.ipAddress, 'Blocked from IP intelligence details');
},
},
],
});
}
private async updateNetworkData() {
// Track requests/sec history for the trend sparkline (moved out of render)
const reqPerSec = this.networkState.requestsPerSecond || 0;
@@ -707,6 +925,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]}`;
}
}
+50 -14
View File
@@ -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;
});
}
@@ -1,4 +1,5 @@
import * as appstate from '../../appstate.js';
import * as interfaces from '../../../dist_ts_interfaces/index.js';
import { viewHostCss } from '../shared/css.js';
import {
@@ -21,18 +22,23 @@ declare global {
@customElement('ops-view-security-blocked')
export class OpsViewSecurityBlocked extends DeesElement {
@state()
accessor statsState: appstate.IStatsState = appstate.statsStatePart.getState()!;
accessor securityPolicyState: appstate.ISecurityPolicyState = appstate.securityPolicyStatePart.getState()!;
constructor() {
super();
const sub = appstate.statsStatePart
const sub = appstate.securityPolicyStatePart
.select((s) => s)
.subscribe((s) => {
this.statsState = s;
this.securityPolicyState = s;
});
this.rxSubscriptions.push(sub);
}
public async connectedCallback() {
await super.connectedCallback();
await appstate.securityPolicyStatePart.dispatchAction(appstate.fetchSecurityPolicyAction, null);
}
public static styles = [
cssManager.defaultStyles,
viewHostCss,
@@ -40,79 +46,436 @@ export class OpsViewSecurityBlocked extends DeesElement {
dees-statsgrid {
margin-bottom: 32px;
}
.sectionStack {
display: flex;
flex-direction: column;
gap: 32px;
}
.statusBadge {
display: inline-flex;
align-items: center;
padding: 4px 8px;
border-radius: 4px;
font-size: 12px;
font-weight: 500;
}
.statusBadge.enabled {
background: ${cssManager.bdTheme('#e8f5e9', '#1a3a1a')};
color: ${cssManager.bdTheme('#388e3c', '#66bb6a')};
}
.statusBadge.disabled {
background: ${cssManager.bdTheme('#f5f5f5', '#2a2a2a')};
color: ${cssManager.bdTheme('#757575', '#999')};
}
.typeBadge {
display: inline-flex;
align-items: center;
padding: 4px 8px;
border-radius: 999px;
font-size: 12px;
font-weight: 500;
background: ${cssManager.bdTheme('#eef2ff', '#1e1b4b')};
color: ${cssManager.bdTheme('#4338ca', '#a5b4fc')};
}
.errorMessage {
padding: 12px 16px;
border-radius: 8px;
background: ${cssManager.bdTheme('#fef2f2', '#450a0a')};
color: ${cssManager.bdTheme('#b91c1c', '#fca5a5')};
}
`,
];
public render(): TemplateResult {
const metrics = this.statsState.securityMetrics;
if (!metrics) {
return html`
<div class="loadingMessage">
<p>Loading security metrics...</p>
</div>
`;
}
const blockedIPs: string[] = metrics.blockedIPs || [];
const state = this.securityPolicyState;
const activeRules = state.rules.filter((rule) => rule.enabled);
const disabledRules = state.rules.length - activeRules.length;
const compiledPolicy = state.compiledPolicy || { blockedIps: [], blockedCidrs: [] };
const tiles: IStatsTile[] = [
{
id: 'totalBlocked',
title: 'Blocked IPs',
value: blockedIPs.length,
id: 'activeRules',
title: 'Active Rules',
value: activeRules.length,
type: 'number',
icon: 'lucide:ShieldBan',
color: blockedIPs.length > 0 ? '#ef4444' : '#22c55e',
description: 'Currently blocked addresses',
icon: 'lucide:shield-check',
color: activeRules.length > 0 ? '#ef4444' : '#22c55e',
description: `${disabledRules} disabled`,
},
{
id: 'compiledIps',
title: 'Compiled IPs',
value: compiledPolicy.blockedIps.length,
type: 'number',
icon: 'lucide:server-off',
color: '#ef4444',
description: 'Direct IP blocks enforced by SmartProxy',
},
{
id: 'compiledCidrs',
title: 'Compiled CIDRs',
value: compiledPolicy.blockedCidrs.length,
type: 'number',
icon: 'lucide:network',
color: '#f97316',
description: 'Network ranges pushed to enforcement layers',
},
{
id: 'intelligenceRecords',
title: 'IP Intelligence',
value: state.ipIntelligence.length,
type: 'number',
icon: 'lucide:radar',
color: '#6366f1',
description: 'Observed public IPs with enrichment',
},
];
return html`
<dees-heading level="3">Blocked IPs</dees-heading>
<dees-heading level="3">Security Blocking</dees-heading>
${state.error ? html`<div class="errorMessage">${state.error}</div>` : html``}
<dees-statsgrid
.tiles=${tiles}
.minTileWidth=${200}
></dees-statsgrid>
<div class="sectionStack">
${this.renderRulesTable()}
${this.renderCompiledPolicyTable()}
${this.renderIpIntelligenceTable()}
${this.renderAuditTable()}
</div>
`;
}
private renderRulesTable(): TemplateResult {
return html`
<dees-table
.heading1=${'Blocked IP Addresses'}
.heading2=${'IPs blocked due to suspicious activity'}
.data=${blockedIPs.map((ip) => ({ ip }))}
.displayFunction=${(item) => ({
'IP Address': item.ip,
'Reason': 'Suspicious activity',
.heading1=${'Managed Block Rules'}
.heading2=${'Rules compiled into SmartProxy policy and remote ingress edge firewall snapshots'}
.data=${this.securityPolicyState.rules}
.rowKey=${'id'}
.displayFunction=${(rule: interfaces.data.ISecurityBlockRule) => ({
'Type': html`<span class="typeBadge">${rule.type}</span>`,
'Value': rule.value,
'Match': rule.type === 'organization' ? (rule.matchMode || 'contains') : '-',
'Reason': rule.reason || '-',
'Status': html`<span class="statusBadge ${rule.enabled ? 'enabled' : 'disabled'}">${rule.enabled ? 'Enabled' : 'Disabled'}</span>`,
'Created': this.formatDateTime(rule.createdAt),
'Updated': this.formatDateTime(rule.updatedAt),
})}
.dataActions=${[
{
name: 'Unblock',
iconName: 'lucide:shield-off',
type: ['contextmenu' as const],
actionFunc: async (item) => {
await this.unblockIP(item.ip);
},
},
{
name: 'Clear All',
iconName: 'lucide:trash-2',
type: ['header' as const],
actionFunc: async () => {
await this.clearBlockedIPs();
},
},
]}
.dataActions=${this.getRuleActions()}
searchable
.showColumnFilters=${true}
dataName="rule"
></dees-table>
`;
}
private async clearBlockedIPs() {
// SmartProxy manages IP blocking — not yet exposed via API
alert('Clearing blocked IPs is not yet supported from the UI.');
private renderCompiledPolicyTable(): TemplateResult {
const policy = this.securityPolicyState.compiledPolicy || { blockedIps: [], blockedCidrs: [] };
const rows = [
...policy.blockedIps.map((value) => ({ type: 'ip', value })),
...policy.blockedCidrs.map((value) => ({ type: 'cidr', value })),
];
return html`
<dees-table
.heading1=${'Compiled Enforcement Policy'}
.heading2=${'Concrete IPs and CIDRs currently sent to SmartProxy and remote ingress'}
.data=${rows}
.rowKey=${'value'}
.displayFunction=${(row: { type: string; value: string }) => ({
'Enforcement Type': html`<span class="typeBadge">${row.type}</span>`,
'Value': row.value,
})}
searchable
.showColumnFilters=${true}
dataName="compiled rule"
></dees-table>
`;
}
private async unblockIP(ip: string) {
// SmartProxy manages IP blocking — not yet exposed via API
alert(`Unblocking IP ${ip} is not yet supported from the UI.`);
private renderIpIntelligenceTable(): TemplateResult {
return html`
<dees-table
.heading1=${'Observed IP Intelligence'}
.heading2=${'Public IPs observed in network metrics and enriched for ASN / organization matching'}
.data=${this.securityPolicyState.ipIntelligence}
.rowKey=${'ipAddress'}
.displayFunction=${(record: interfaces.data.IIpIntelligenceRecord) => ({
'IP Address': record.ipAddress,
'ASN': record.asn ? `AS${record.asn}` : '-',
'ASN Org': record.asnOrg || '-',
'Registrant Org': record.registrantOrg || '-',
'Country': record.countryCode || record.country || '-',
'Network Range': record.networkRange || '-',
'Abuse Contact': record.abuseContact || '-',
'Seen': record.seenCount,
'Last Seen': this.formatDateTime(record.lastSeenAt),
})}
.dataActions=${this.getIpIntelligenceActions()}
searchable
.showColumnFilters=${true}
dataName="ip intelligence record"
></dees-table>
`;
}
private renderAuditTable(): TemplateResult {
return html`
<dees-table
.heading1=${'Policy Audit'}
.heading2=${'Recent security policy changes'}
.data=${this.securityPolicyState.auditEvents}
.rowKey=${'id'}
.displayFunction=${(event: interfaces.data.ISecurityPolicyAuditEvent) => ({
'Time': this.formatDateTime(event.createdAt),
'Action': event.action,
'Actor': event.actor,
'Details': this.formatAuditDetails(event.details),
})}
searchable
.showColumnFilters=${true}
dataName="audit event"
></dees-table>
`;
}
private getRuleActions() {
return [
{
name: 'Create Rule',
iconName: 'lucide:plus',
type: ['header'] as any,
actionFunc: async () => this.showRuleDialog(),
},
{
name: 'Edit',
iconName: 'lucide:pencil',
type: ['inRow', 'contextmenu'] as any,
actionFunc: async (actionData: any) => this.showRuleDialog(actionData.item),
},
{
name: 'Enable',
iconName: 'lucide:play',
type: ['contextmenu'] as any,
actionRelevancyCheckFunc: (actionData: any) => !actionData.item.enabled,
actionFunc: async (actionData: any) => {
const rule = actionData.item as interfaces.data.ISecurityBlockRule;
await appstate.securityPolicyStatePart.dispatchAction(appstate.updateSecurityBlockRuleAction, {
id: rule.id,
enabled: true,
});
},
},
{
name: 'Disable',
iconName: 'lucide:pause',
type: ['contextmenu'] as any,
actionRelevancyCheckFunc: (actionData: any) => actionData.item.enabled,
actionFunc: async (actionData: any) => {
const rule = actionData.item as interfaces.data.ISecurityBlockRule;
await appstate.securityPolicyStatePart.dispatchAction(appstate.updateSecurityBlockRuleAction, {
id: rule.id,
enabled: false,
});
},
},
{
name: 'Delete',
iconName: 'lucide:trash-2',
type: ['contextmenu'] as any,
actionFunc: async (actionData: any) => {
const rule = actionData.item as interfaces.data.ISecurityBlockRule;
if (!window.confirm(`Delete block rule ${rule.type}:${rule.value}?`)) return;
await appstate.securityPolicyStatePart.dispatchAction(appstate.deleteSecurityBlockRuleAction, rule.id);
},
},
];
}
private getIpIntelligenceActions() {
return [
{
name: 'Refresh Intelligence',
iconName: 'lucide:refresh-cw',
type: ['inRow', 'contextmenu'] as any,
actionFunc: async (actionData: any) => {
const record = actionData.item as interfaces.data.IIpIntelligenceRecord;
await appstate.securityPolicyStatePart.dispatchAction(appstate.refreshIpIntelligenceAction, record.ipAddress);
},
},
{
name: 'Block IP',
iconName: 'lucide:shield-ban',
type: ['contextmenu'] as any,
actionFunc: async (actionData: any) => {
const record = actionData.item as interfaces.data.IIpIntelligenceRecord;
await this.showRuleDialog(undefined, {
type: 'ip',
value: record.ipAddress,
reason: 'Blocked from IP intelligence table',
});
},
},
{
name: 'Block Network Range',
iconName: 'lucide:network',
type: ['contextmenu'] as any,
actionRelevancyCheckFunc: (actionData: any) => Boolean(actionData.item.networkRange),
actionFunc: async (actionData: any) => {
const record = actionData.item as interfaces.data.IIpIntelligenceRecord;
await this.showRuleDialog(undefined, {
type: 'cidr',
value: record.networkRange || '',
reason: 'Blocked network range from IP intelligence table',
});
},
},
{
name: 'Block ASN',
iconName: 'lucide:radio-tower',
type: ['contextmenu'] as any,
actionRelevancyCheckFunc: (actionData: any) => Boolean(actionData.item.asn),
actionFunc: async (actionData: any) => {
const record = actionData.item as interfaces.data.IIpIntelligenceRecord;
await this.showRuleDialog(undefined, {
type: 'asn',
value: String(record.asn),
reason: 'Blocked ASN from IP intelligence table',
});
},
},
{
name: 'Block Organization',
iconName: 'lucide:building-2',
type: ['contextmenu'] as any,
actionRelevancyCheckFunc: (actionData: any) => Boolean(actionData.item.asnOrg || actionData.item.registrantOrg),
actionFunc: async (actionData: any) => {
const record = actionData.item as interfaces.data.IIpIntelligenceRecord;
await this.showRuleDialog(undefined, {
type: 'organization',
value: record.asnOrg || record.registrantOrg || '',
reason: 'Blocked organization from IP intelligence table',
});
},
},
];
}
private async showRuleDialog(
rule?: interfaces.data.ISecurityBlockRule,
defaults: Partial<interfaces.data.ISecurityBlockRule> = {},
): Promise<void> {
const { DeesModal } = await import('@design.estate/dees-catalog');
const typeOptions = [
{ key: 'ip', option: 'IP address' },
{ key: 'cidr', option: 'CIDR / network range' },
{ key: 'asn', option: 'ASN' },
{ key: 'organization', option: 'Organization' },
];
const matchModeOptions = [
{ key: 'contains', option: 'Organization contains value' },
{ key: 'exact', option: 'Organization exactly matches value' },
];
const selectedType = rule?.type || defaults.type || 'ip';
const selectedMatchMode = rule?.matchMode || defaults.matchMode || 'contains';
await DeesModal.createAndShow({
heading: rule ? `Edit Block Rule: ${rule.type}:${rule.value}` : 'Create Block Rule',
content: html`
<dees-form>
${rule ? html`` : html`
<dees-input-dropdown
.key=${'type'}
.label=${'Rule Type'}
.options=${typeOptions}
.selectedOption=${typeOptions.find((option) => option.key === selectedType)}
></dees-input-dropdown>
`}
<dees-input-text
.key=${'value'}
.label=${'Value'}
.value=${rule?.value || defaults.value || ''}
.required=${true}
></dees-input-text>
<dees-input-dropdown
.key=${'matchMode'}
.label=${'Organization Match Mode'}
.description=${'Only used for organization rules'}
.options=${matchModeOptions}
.selectedOption=${matchModeOptions.find((option) => option.key === selectedMatchMode)}
></dees-input-dropdown>
<dees-input-text
.key=${'reason'}
.label=${'Reason'}
.value=${rule?.reason || defaults.reason || ''}
></dees-input-text>
<dees-input-checkbox
.key=${'enabled'}
.label=${'Enabled'}
.value=${rule ? rule.enabled : defaults.enabled !== false}
></dees-input-checkbox>
</dees-form>
`,
menuOptions: [
{ name: 'Cancel', iconName: 'lucide:x', action: async (modalArg: any) => modalArg.destroy() },
{
name: rule ? 'Save' : 'Create',
iconName: rule ? 'lucide:check' : 'lucide:plus',
action: async (modalArg: any) => {
const form = modalArg.shadowRoot?.querySelector('.content')?.querySelector('dees-form');
if (!form) return;
const data = await form.collectFormData();
const type = (rule?.type || this.getDropdownKey(data.type)) as interfaces.data.TSecurityBlockRuleType;
const value = String(data.value || '').trim();
if (!type || !value) return;
const matchMode = type === 'organization'
? this.getDropdownKey(data.matchMode) as interfaces.data.TSecurityBlockRuleMatchMode
: undefined;
const payload = {
value,
matchMode,
reason: String(data.reason || '').trim() || undefined,
enabled: data.enabled !== false,
};
if (rule) {
await appstate.securityPolicyStatePart.dispatchAction(appstate.updateSecurityBlockRuleAction, {
id: rule.id,
...payload,
});
} else {
await appstate.securityPolicyStatePart.dispatchAction(appstate.createSecurityBlockRuleAction, {
type,
...payload,
});
}
await modalArg.destroy();
},
},
],
});
}
private getDropdownKey(value: any): string {
return typeof value === 'string' ? value : value?.key || '';
}
private formatDateTime(timestamp?: number): string {
return timestamp ? new Date(timestamp).toLocaleString() : '-';
}
private formatAuditDetails(details: Record<string, unknown>): string {
const text = JSON.stringify(details);
return text.length > 160 ? `${text.slice(0, 157)}...` : text;
}
}