Compare commits
23 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d9d12427d3 | |||
| 91aa9a7228 | |||
| 877356b247 | |||
| 2325f01cde | |||
| 00fdadb088 | |||
| 2b76e05a40 | |||
| 1b37944aab | |||
| 35a01a6981 | |||
| 3058706d2a | |||
| 0e4d6a3c0c | |||
| 2bc2475878 | |||
| 37eab7c7b1 | |||
| 8ab7343606 | |||
| f04feec273 | |||
| d320590ce2 | |||
| 0ee57f433b | |||
| b28b5eea84 | |||
| 27d7489af9 | |||
| 940c7dc92e | |||
| 7fa6d82e58 | |||
| f29ed9757e | |||
| ad45d1b8b9 | |||
| 68473f8550 |
71
changelog.md
71
changelog.md
@@ -1,5 +1,76 @@
|
||||
# Changelog
|
||||
|
||||
## 2026-04-08 - 13.4.1 - fix(repo)
|
||||
no changes to commit
|
||||
|
||||
|
||||
## 2026-04-08 - 13.4.0 - feat(web-ui)
|
||||
reorganize dashboard views into grouped navigation with new email, access, and network subviews
|
||||
|
||||
- Restructures the ops dashboard and router to use grouped top-level sections with subviews for overview, network, email, access, and security.
|
||||
- Adds dedicated Email Security and API Tokens views and exposes Remote Ingress and VPN under Network subnavigation.
|
||||
- Updates refresh and initial view handling to work with nested subviews, including remote ingress and VPN refresh behavior.
|
||||
- Moves overview, configuration, email, API token, and remote ingress components into feature directories and standardizes shared view styling.
|
||||
|
||||
## 2026-04-08 - 13.3.0 - feat(web-ui)
|
||||
reorganize network and security views into tabbed subviews with route-aware navigation
|
||||
|
||||
- add URL-based subview support in app state and router for network and security sections
|
||||
- group routes, source profiles, network targets, and target profiles under the network view with tab navigation
|
||||
- split security into dedicated overview, blocked IPs, authentication, and email security subviews
|
||||
- update configuration navigation to deep-link directly to the network routes subview
|
||||
|
||||
## 2026-04-08 - 13.2.2 - fix(project)
|
||||
no changes to commit
|
||||
|
||||
|
||||
## 2026-04-08 - 13.2.1 - fix(project)
|
||||
no changes to commit
|
||||
|
||||
|
||||
## 2026-04-08 - 13.2.0 - feat(ops-ui)
|
||||
add column filters to operations tables across admin views
|
||||
|
||||
- Enable table column filters for API tokens, certificates, network requests, top IPs, backends, network targets, remote ingress edges, security views, source profiles, target profiles, and VPN clients.
|
||||
- Improves filtering and exploration of operational data throughout the admin interface without changing backend behavior.
|
||||
|
||||
## 2026-04-08 - 13.1.3 - fix(certificate-handler)
|
||||
preserve wildcard coverage during forced certificate renewals and propagate renewed certs to sibling domains
|
||||
|
||||
- add deriveCertDomainName helper to match shared ACME certificate identities across wildcard and subdomain routes
|
||||
- pass includeWildcard when force-renewing certificates so renewed certs keep wildcard SAN coverage for sibling subdomains
|
||||
- persist renewed certificate data to all sibling route domains that share the same cert identity and clear cached certificate status entries
|
||||
- add regression tests for certificate domain derivation and force-renew wildcard handling
|
||||
|
||||
## 2026-04-07 - 13.1.2 - fix(deps)
|
||||
bump @serve.zone/catalog to ^2.12.3
|
||||
|
||||
- Updates @serve.zone/catalog from ^2.12.0 to ^2.12.3 in package.json
|
||||
|
||||
## 2026-04-07 - 13.1.1 - fix(deps)
|
||||
bump catalog-related dependencies to newer patch and minor releases
|
||||
|
||||
- update @design.estate/dees-catalog from ^3.66.0 to ^3.67.1
|
||||
- update @serve.zone/catalog from ^2.11.2 to ^2.12.0
|
||||
|
||||
## 2026-04-07 - 13.1.0 - feat(vpn,target-profiles,migrations)
|
||||
add startup data migrations, support scoped VPN route allow entries, and rename target profile hosts to ips
|
||||
|
||||
- runs smartmigration at startup before configuration is loaded and adds a migration for target profile targets from host to ip
|
||||
- changes VPN client routing to always force traffic through SmartProxy while allowing direct target bypasses from target profiles
|
||||
- supports domain-scoped VPN ipAllowList entries for vpnOnly routes based on matching target profile domains
|
||||
- updates certificate reprovisioning to reapply routes so renewed certificates are loaded into the running proxy
|
||||
- removes the forceDestinationSmartproxy VPN client option from API, persistence, manager, and web UI
|
||||
|
||||
## 2026-04-06 - 13.0.11 - fix(routing)
|
||||
serialize route updates and correct VPN-gated route application
|
||||
|
||||
- RouteConfigManager now serializes concurrent applyRoutes calls to prevent overlapping SmartProxy updates and stale route overwrites.
|
||||
- VPN-only routes deny access until VPN state is ready, then re-apply routes after VPN clients load or change to refresh ipAllowLists safely.
|
||||
- Certificate provisioning retries now go through RouteConfigManager when available so the full merged route set is reapplied consistently.
|
||||
- Reference resolution now expands network targets with multiple hosts into multiple route targets.
|
||||
- Adds rollback when VPN client persistence fails, enforces unique target profile names, and fixes maxConnections parsing in the source profiles UI.
|
||||
|
||||
## 2026-04-06 - 13.0.10 - fix(repo)
|
||||
no changes to commit
|
||||
|
||||
|
||||
19
package.json
19
package.json
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@serve.zone/dcrouter",
|
||||
"private": false,
|
||||
"version": "13.0.10",
|
||||
"version": "13.4.1",
|
||||
"description": "A multifaceted routing service handling mail and SMS delivery functions.",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
@@ -35,38 +35,39 @@
|
||||
"@api.global/typedserver": "^8.4.6",
|
||||
"@api.global/typedsocket": "^4.1.2",
|
||||
"@apiclient.xyz/cloudflare": "^7.1.0",
|
||||
"@design.estate/dees-catalog": "^3.61.1",
|
||||
"@design.estate/dees-catalog": "^3.68.0",
|
||||
"@design.estate/dees-element": "^2.2.4",
|
||||
"@push.rocks/lik": "^6.4.0",
|
||||
"@push.rocks/projectinfo": "^5.1.0",
|
||||
"@push.rocks/qenv": "^6.1.3",
|
||||
"@push.rocks/smartacme": "^9.5.0",
|
||||
"@push.rocks/smartdata": "^7.1.6",
|
||||
"@push.rocks/smartdb": "^2.5.9",
|
||||
"@push.rocks/smartdata": "^7.1.7",
|
||||
"@push.rocks/smartdb": "^2.6.2",
|
||||
"@push.rocks/smartdns": "^7.9.0",
|
||||
"@push.rocks/smartfs": "^1.5.0",
|
||||
"@push.rocks/smartguard": "^3.1.0",
|
||||
"@push.rocks/smartjwt": "^2.2.1",
|
||||
"@push.rocks/smartlog": "^3.2.1",
|
||||
"@push.rocks/smartlog": "^3.2.2",
|
||||
"@push.rocks/smartmetrics": "^3.0.3",
|
||||
"@push.rocks/smartmigration": "1.1.1",
|
||||
"@push.rocks/smartmta": "^5.3.1",
|
||||
"@push.rocks/smartnetwork": "^4.5.2",
|
||||
"@push.rocks/smartpath": "^6.0.0",
|
||||
"@push.rocks/smartpromise": "^4.2.3",
|
||||
"@push.rocks/smartproxy": "^27.4.0",
|
||||
"@push.rocks/smartproxy": "^27.5.0",
|
||||
"@push.rocks/smartradius": "^1.1.1",
|
||||
"@push.rocks/smartrequest": "^5.0.1",
|
||||
"@push.rocks/smartrx": "^3.0.10",
|
||||
"@push.rocks/smartstate": "^2.3.0",
|
||||
"@push.rocks/smartunique": "^3.0.9",
|
||||
"@push.rocks/smartvpn": "1.19.1",
|
||||
"@push.rocks/smartvpn": "1.19.2",
|
||||
"@push.rocks/taskbuffer": "^8.0.2",
|
||||
"@serve.zone/catalog": "^2.11.2",
|
||||
"@serve.zone/catalog": "^2.12.3",
|
||||
"@serve.zone/interfaces": "^5.3.0",
|
||||
"@serve.zone/remoteingress": "^4.15.3",
|
||||
"@tsclass/tsclass": "^9.5.0",
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"lru-cache": "^11.2.7",
|
||||
"lru-cache": "^11.3.2",
|
||||
"qrcode": "^1.5.4",
|
||||
"uuid": "^13.0.0"
|
||||
},
|
||||
|
||||
2423
pnpm-lock.yaml
generated
2423
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
196
test/test.cert-renewal.ts
Normal file
196
test/test.cert-renewal.ts
Normal file
@@ -0,0 +1,196 @@
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
import { deriveCertDomainName } from '../ts/opsserver/handlers/certificate.handler.js';
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
// deriveCertDomainName — pure helper that mirrors smartacme's certmatcher.
|
||||
// Used by the force-renew sibling-propagation logic to identify which routes
|
||||
// share a single underlying ACME certificate.
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
tap.test('deriveCertDomainName collapses 3-level subdomain to base', async () => {
|
||||
expect(deriveCertDomainName('outline.task.vc')).toEqual('task.vc');
|
||||
expect(deriveCertDomainName('pr.task.vc')).toEqual('task.vc');
|
||||
expect(deriveCertDomainName('mtd.task.vc')).toEqual('task.vc');
|
||||
});
|
||||
|
||||
tap.test('deriveCertDomainName returns base domain unchanged for 2-level domain', async () => {
|
||||
expect(deriveCertDomainName('task.vc')).toEqual('task.vc');
|
||||
expect(deriveCertDomainName('example.com')).toEqual('example.com');
|
||||
});
|
||||
|
||||
tap.test('deriveCertDomainName strips wildcard prefix', async () => {
|
||||
expect(deriveCertDomainName('*.task.vc')).toEqual('task.vc');
|
||||
expect(deriveCertDomainName('*.example.com')).toEqual('example.com');
|
||||
});
|
||||
|
||||
tap.test('deriveCertDomainName collapses subdomain and wildcard to same identity', async () => {
|
||||
// This is the core property: outline.task.vc and *.task.vc must yield
|
||||
// the same cert identity, otherwise sibling propagation cannot work.
|
||||
const subdomain = deriveCertDomainName('outline.task.vc');
|
||||
const wildcard = deriveCertDomainName('*.task.vc');
|
||||
expect(subdomain).toEqual(wildcard);
|
||||
});
|
||||
|
||||
tap.test('deriveCertDomainName returns undefined for 4+ level domains', async () => {
|
||||
// Matches smartacme's "deeper domains not supported" behavior.
|
||||
expect(deriveCertDomainName('a.b.task.vc')).toBeUndefined();
|
||||
expect(deriveCertDomainName('one.two.three.example.com')).toBeUndefined();
|
||||
});
|
||||
|
||||
tap.test('deriveCertDomainName returns undefined for malformed inputs', async () => {
|
||||
expect(deriveCertDomainName('vc')).toBeUndefined();
|
||||
expect(deriveCertDomainName('')).toBeUndefined();
|
||||
});
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
// CertificateHandler.reprovisionCertificateDomain — verify the includeWildcard
|
||||
// option is forwarded to smartAcme.getCertificateForDomain on force renew.
|
||||
//
|
||||
// This is the regression test for Bug 1: previously the call passed only
|
||||
// `{ forceRenew: true }`, causing the re-issued cert to drop the wildcard SAN
|
||||
// and break every sibling subdomain.
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
import { CertificateHandler } from '../ts/opsserver/handlers/certificate.handler.js';
|
||||
|
||||
// Build a minimal stub of OpsServer + DcRouter that satisfies CertificateHandler.
|
||||
// We only need: viewRouter.addTypedHandler / adminRouter.addTypedHandler (no-op),
|
||||
// dcRouterRef.smartProxy.routeManager.getRoutes(), dcRouterRef.smartAcme,
|
||||
// dcRouterRef.findRouteNamesForDomain, dcRouterRef.certificateStatusMap.
|
||||
function makeStubOpsServer(opts: {
|
||||
routes: Array<{ name: string; domains: string[] }>;
|
||||
smartAcmeStub: { getCertificateForDomain: (domain: string, options: any) => Promise<any> };
|
||||
}) {
|
||||
const captured: { typedHandlers: any[] } = { typedHandlers: [] };
|
||||
const router = {
|
||||
addTypedHandler(handler: any) { captured.typedHandlers.push(handler); },
|
||||
};
|
||||
|
||||
const routes = opts.routes.map((r) => ({
|
||||
name: r.name,
|
||||
match: { domains: r.domains, ports: 443 },
|
||||
action: { type: 'forward', tls: { certificate: 'auto' } },
|
||||
}));
|
||||
|
||||
const dcRouterRef: any = {
|
||||
smartProxy: {
|
||||
routeManager: { getRoutes: () => routes },
|
||||
},
|
||||
smartAcme: opts.smartAcmeStub,
|
||||
findRouteNamesForDomain: (domain: string) =>
|
||||
routes.filter((r) => r.match.domains.includes(domain)).map((r) => r.name),
|
||||
certificateStatusMap: new Map<string, any>(),
|
||||
certProvisionScheduler: null,
|
||||
routeConfigManager: null,
|
||||
};
|
||||
|
||||
const opsServerRef: any = {
|
||||
viewRouter: router,
|
||||
adminRouter: router,
|
||||
dcRouterRef,
|
||||
};
|
||||
|
||||
return { opsServerRef, dcRouterRef, captured };
|
||||
}
|
||||
|
||||
tap.test('reprovisionCertificateDomain passes includeWildcard=true for non-wildcard domain', async () => {
|
||||
const calls: Array<{ domain: string; options: any }> = [];
|
||||
|
||||
const { opsServerRef, dcRouterRef } = makeStubOpsServer({
|
||||
routes: [
|
||||
{ name: 'outline-route', domains: ['outline.task.vc'] },
|
||||
{ name: 'pr-route', domains: ['pr.task.vc'] },
|
||||
{ name: 'mtd-route', domains: ['mtd.task.vc'] },
|
||||
],
|
||||
smartAcmeStub: {
|
||||
getCertificateForDomain: async (domain: string, options: any) => {
|
||||
calls.push({ domain, options });
|
||||
// Return a cert object shaped like SmartacmeCert
|
||||
return {
|
||||
id: 'test-id',
|
||||
domainName: 'task.vc',
|
||||
created: Date.now(),
|
||||
validUntil: Date.now() + 90 * 24 * 60 * 60 * 1000,
|
||||
privateKey: '-----BEGIN PRIVATE KEY-----\nfake\n-----END PRIVATE KEY-----',
|
||||
publicKey: '-----BEGIN CERTIFICATE-----\nfake\n-----END CERTIFICATE-----',
|
||||
csr: '',
|
||||
};
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Override updateRoutes/applyRoutes to no-op so the test doesn't try to talk to a real proxy
|
||||
dcRouterRef.smartProxy.updateRoutes = async () => {};
|
||||
|
||||
// Construct handler — registerHandlers will run and register typed handlers on our stub router.
|
||||
const handler = new CertificateHandler(opsServerRef);
|
||||
|
||||
// Invoke the private reprovision method directly. The Bug 1 fix is verified
|
||||
// by inspecting the captured smartAcme call options regardless of whether
|
||||
// sibling propagation succeeds (it relies on a real DB for ProxyCertDoc).
|
||||
await (handler as any).reprovisionCertificateDomain('outline.task.vc', true);
|
||||
|
||||
// Sibling propagation may fail because ProxyCertDoc.findByDomain needs a real DB.
|
||||
// The Bug 1 fix is verified by the captured smartAcme call regardless.
|
||||
expect(calls.length).toBeGreaterThanOrEqual(1);
|
||||
expect(calls[0].domain).toEqual('outline.task.vc');
|
||||
expect(calls[0].options).toEqual({ forceRenew: true, includeWildcard: true });
|
||||
});
|
||||
|
||||
tap.test('reprovisionCertificateDomain passes includeWildcard=false for wildcard domain', async () => {
|
||||
const calls: Array<{ domain: string; options: any }> = [];
|
||||
|
||||
const { opsServerRef, dcRouterRef } = makeStubOpsServer({
|
||||
routes: [
|
||||
{ name: 'wildcard-route', domains: ['*.task.vc'] },
|
||||
],
|
||||
smartAcmeStub: {
|
||||
getCertificateForDomain: async (domain: string, options: any) => {
|
||||
calls.push({ domain, options });
|
||||
return {
|
||||
id: 'test-id',
|
||||
domainName: 'task.vc',
|
||||
created: Date.now(),
|
||||
validUntil: Date.now() + 90 * 24 * 60 * 60 * 1000,
|
||||
privateKey: '-----BEGIN PRIVATE KEY-----\nfake\n-----END PRIVATE KEY-----',
|
||||
publicKey: '-----BEGIN CERTIFICATE-----\nfake\n-----END CERTIFICATE-----',
|
||||
csr: '',
|
||||
};
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
dcRouterRef.smartProxy.updateRoutes = async () => {};
|
||||
|
||||
const handler = new CertificateHandler(opsServerRef);
|
||||
await (handler as any).reprovisionCertificateDomain('*.task.vc', true);
|
||||
|
||||
expect(calls.length).toBeGreaterThanOrEqual(1);
|
||||
expect(calls[0].domain).toEqual('*.task.vc');
|
||||
expect(calls[0].options).toEqual({ forceRenew: true, includeWildcard: false });
|
||||
});
|
||||
|
||||
tap.test('reprovisionCertificateDomain does not call smartAcme when forceRenew is false', async () => {
|
||||
const calls: Array<{ domain: string; options: any }> = [];
|
||||
|
||||
const { opsServerRef, dcRouterRef } = makeStubOpsServer({
|
||||
routes: [{ name: 'outline-route', domains: ['outline.task.vc'] }],
|
||||
smartAcmeStub: {
|
||||
getCertificateForDomain: async (domain: string, options: any) => {
|
||||
calls.push({ domain, options });
|
||||
return {} as any;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
dcRouterRef.smartProxy.updateRoutes = async () => {};
|
||||
|
||||
const handler = new CertificateHandler(opsServerRef);
|
||||
await (handler as any).reprovisionCertificateDomain('outline.task.vc', false);
|
||||
|
||||
// forceRenew=false should NOT call getCertificateForDomain — it just triggers
|
||||
// applyRoutes and lets the cert provisioning pipeline handle it.
|
||||
expect(calls.length).toEqual(0);
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
@@ -3,6 +3,6 @@
|
||||
*/
|
||||
export const commitinfo = {
|
||||
name: '@serve.zone/dcrouter',
|
||||
version: '13.0.10',
|
||||
version: '13.4.1',
|
||||
description: 'A multifaceted routing service handling mail and SMS delivery functions.'
|
||||
}
|
||||
|
||||
@@ -15,6 +15,9 @@ import { StorageBackedCertManager } from './classes.storage-cert-manager.js';
|
||||
import { CertProvisionScheduler } from './classes.cert-provision-scheduler.js';
|
||||
// Import unified database
|
||||
import { DcRouterDb, type IDcRouterDbConfig, CacheCleaner, ProxyCertDoc, AcmeCertDoc } from './db/index.js';
|
||||
// Import migration runner and app version
|
||||
import { createMigrationRunner } from '../ts_migrations/index.js';
|
||||
import { commitinfo } from './00_commitinfo_data.js';
|
||||
|
||||
import { OpsServer } from './opsserver/index.js';
|
||||
import { MetricsManager } from './monitoring/index.js';
|
||||
@@ -431,7 +434,15 @@ export class DcRouter {
|
||||
// failed silently (SmartProxy doesn't emit certificate-failed for this path).
|
||||
// Calling updateRoutes() re-triggers provisionCertificatesViaCallback internally,
|
||||
// which calls certProvisionFunction again — now with smartAcmeReady === true.
|
||||
if (this.smartProxy) {
|
||||
if (this.routeConfigManager) {
|
||||
// Go through RouteConfigManager to get the full merged route set
|
||||
// and serialize via the route-update mutex (prevents stale overwrites)
|
||||
logger.log('info', 'Re-triggering certificate provisioning via RouteConfigManager');
|
||||
this.routeConfigManager.applyRoutes().catch((err: any) => {
|
||||
logger.log('warn', `Failed to re-trigger cert provisioning: ${err?.message || err}`);
|
||||
});
|
||||
} else if (this.smartProxy) {
|
||||
// No RouteConfigManager (DB disabled) — re-send current routes to trigger cert provisioning
|
||||
if (this.certProvisionScheduler) {
|
||||
this.certProvisionScheduler.clear();
|
||||
}
|
||||
@@ -477,7 +488,8 @@ export class DcRouter {
|
||||
this.options.vpnConfig?.enabled
|
||||
? (route: import('../ts_interfaces/data/remoteingress.js').IDcRouterRouteConfig, routeId?: string) => {
|
||||
if (!this.vpnManager || !this.targetProfileManager) {
|
||||
return [this.options.vpnConfig?.subnet || '10.8.0.0/24'];
|
||||
// VPN not ready yet — deny all until re-apply after VPN starts
|
||||
return [];
|
||||
}
|
||||
return this.targetProfileManager.getMatchingClientIps(
|
||||
route, routeId, this.vpnManager.listClients(),
|
||||
@@ -766,6 +778,19 @@ export class DcRouter {
|
||||
|
||||
await this.dcRouterDb.start();
|
||||
|
||||
// Run any pending data migrations before anything else reads from the DB.
|
||||
// This must complete before ConfigManagers loads profiles.
|
||||
const migration = await createMigrationRunner(this.dcRouterDb.getDb(), commitinfo.version);
|
||||
const migrationResult = await migration.run();
|
||||
if (migrationResult.stepsApplied.length > 0) {
|
||||
logger.log('info',
|
||||
`smartmigration: ${migrationResult.currentVersionBefore ?? 'fresh'} → ${migrationResult.currentVersionAfter} ` +
|
||||
`(${migrationResult.stepsApplied.length} step(s) applied in ${migrationResult.totalDurationMs}ms)`,
|
||||
);
|
||||
} else if (migrationResult.wasFreshInstall) {
|
||||
logger.log('info', `smartmigration: fresh install stamped to ${migrationResult.currentVersionAfter}`);
|
||||
}
|
||||
|
||||
// Start the cache cleaner for TTL-based document cleanup
|
||||
const cleanupIntervalMs = (dbConfig.cleanupIntervalHours || 1) * 60 * 60 * 1000;
|
||||
this.cacheCleaner = new CacheCleaner(this.dcRouterDb, {
|
||||
@@ -1033,15 +1058,9 @@ export class DcRouter {
|
||||
});
|
||||
});
|
||||
|
||||
this.smartProxy.on('certificate-renewed', (event: plugins.smartproxy.ICertificateIssuedEvent) => {
|
||||
logger.log('info', `Certificate renewed for ${event.domain} via ${event.source}, expires ${event.expiryDate}`);
|
||||
const routeNames = this.findRouteNamesForDomain(event.domain);
|
||||
this.certificateStatusMap.set(event.domain, {
|
||||
status: 'valid', routeNames,
|
||||
expiryDate: event.expiryDate, issuedAt: new Date().toISOString(),
|
||||
source: event.source,
|
||||
});
|
||||
});
|
||||
// Note: smartproxy v27.5.0 emits only 'certificate-issued' and 'certificate-failed'.
|
||||
// Renewals come through 'certificate-issued' (with optional isRenewal? in the payload).
|
||||
// The vestigial 'certificate-renewed' event from common-types.ts is never emitted.
|
||||
|
||||
this.smartProxy.on('certificate-failed', (event: plugins.smartproxy.ICertificateFailedEvent) => {
|
||||
logger.log('error', `Certificate failed for ${event.domain} (${event.source}): ${event.error}`);
|
||||
@@ -2149,7 +2168,10 @@ export class DcRouter {
|
||||
bridgeIpRangeEnd: this.options.vpnConfig.bridgeIpRangeEnd,
|
||||
onClientChanged: () => {
|
||||
// Re-apply routes so profile-based ipAllowLists get updated
|
||||
this.routeConfigManager?.applyRoutes();
|
||||
// (serialized by RouteConfigManager's mutex — safe as fire-and-forget)
|
||||
this.routeConfigManager?.applyRoutes().catch((err) => {
|
||||
logger.log('warn', `Failed to re-apply routes after VPN client change: ${err?.message || err}`);
|
||||
});
|
||||
},
|
||||
getClientDirectTargets: (targetProfileIds: string[]) => {
|
||||
if (!this.targetProfileManager) return [];
|
||||
@@ -2191,7 +2213,7 @@ export class DcRouter {
|
||||
// Re-apply routes now that VPN clients are loaded — ensures hardcoded routes
|
||||
// get correct profile-based ipAllowLists (not possible during setupSmartProxy since
|
||||
// VPN server wasn't ready yet)
|
||||
this.routeConfigManager?.applyRoutes();
|
||||
await this.routeConfigManager?.applyRoutes();
|
||||
}
|
||||
|
||||
/** Cache for DNS-resolved IPs of VPN-gated domains. TTL: 5 minutes. */
|
||||
@@ -2209,6 +2231,11 @@ export class DcRouter {
|
||||
const { promises: dnsPromises } = await import('dns');
|
||||
const ips = await dnsPromises.resolve4(domain);
|
||||
this.vpnDomainIpCache.set(domain, { ips, expiresAt: Date.now() + 5 * 60 * 1000 });
|
||||
// Evict oldest entries if cache exceeds 1000 entries
|
||||
if (this.vpnDomainIpCache.size > 1000) {
|
||||
const firstKey = this.vpnDomainIpCache.keys().next().value;
|
||||
if (firstKey) this.vpnDomainIpCache.delete(firstKey);
|
||||
}
|
||||
return ips;
|
||||
} catch (err) {
|
||||
logger.log('warn', `VPN: Failed to resolve ${domain} for AllowedIPs: ${(err as Error).message}`);
|
||||
|
||||
@@ -308,14 +308,15 @@ export class ReferenceResolver {
|
||||
if (resolvedMetadata.networkTargetRef) {
|
||||
const target = this.targets.get(resolvedMetadata.networkTargetRef);
|
||||
if (target) {
|
||||
const hosts = Array.isArray(target.host) ? target.host : [target.host];
|
||||
route = {
|
||||
...route,
|
||||
action: {
|
||||
...route.action,
|
||||
targets: [{
|
||||
host: target.host as string,
|
||||
targets: hosts.map((h) => ({
|
||||
host: h,
|
||||
port: target.port,
|
||||
}],
|
||||
})),
|
||||
},
|
||||
};
|
||||
resolvedMetadata.networkTargetName = target.name;
|
||||
|
||||
@@ -12,16 +12,50 @@ import type { IDcRouterRouteConfig } from '../../ts_interfaces/data/remoteingres
|
||||
import { type IHttp3Config, augmentRouteWithHttp3 } from '../http3/index.js';
|
||||
import type { ReferenceResolver } from './classes.reference-resolver.js';
|
||||
|
||||
/** An IP allow entry: plain IP/CIDR or domain-scoped. */
|
||||
export type TIpAllowEntry = string | { ip: string; domains: string[] };
|
||||
|
||||
/**
|
||||
* Simple async mutex — serializes concurrent applyRoutes() calls so the Rust engine
|
||||
* never receives rapid overlapping route updates that can churn UDP/QUIC listeners.
|
||||
*/
|
||||
class RouteUpdateMutex {
|
||||
private locked = false;
|
||||
private queue: Array<() => void> = [];
|
||||
|
||||
async runExclusive<T>(fn: () => Promise<T>): Promise<T> {
|
||||
await new Promise<void>((resolve) => {
|
||||
if (!this.locked) {
|
||||
this.locked = true;
|
||||
resolve();
|
||||
} else {
|
||||
this.queue.push(resolve);
|
||||
}
|
||||
});
|
||||
try {
|
||||
return await fn();
|
||||
} finally {
|
||||
this.locked = false;
|
||||
const next = this.queue.shift();
|
||||
if (next) {
|
||||
this.locked = true;
|
||||
next();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class RouteConfigManager {
|
||||
private storedRoutes = new Map<string, IStoredRoute>();
|
||||
private overrides = new Map<string, IRouteOverride>();
|
||||
private warnings: IRouteWarning[] = [];
|
||||
private routeUpdateMutex = new RouteUpdateMutex();
|
||||
|
||||
constructor(
|
||||
private getHardcodedRoutes: () => plugins.smartproxy.IRouteConfig[],
|
||||
private getSmartProxy: () => plugins.smartproxy.SmartProxy | undefined,
|
||||
private getHttp3Config?: () => IHttp3Config | undefined,
|
||||
private getVpnClientIpsForRoute?: (route: IDcRouterRouteConfig, routeId?: string) => string[],
|
||||
private getVpnClientIpsForRoute?: (route: IDcRouterRouteConfig, routeId?: string) => TIpAllowEntry[],
|
||||
private referenceResolver?: ReferenceResolver,
|
||||
private onRoutesApplied?: (routes: plugins.smartproxy.IRouteConfig[]) => void,
|
||||
) {}
|
||||
@@ -357,57 +391,60 @@ export class RouteConfigManager {
|
||||
// =========================================================================
|
||||
|
||||
public async applyRoutes(): Promise<void> {
|
||||
const smartProxy = this.getSmartProxy();
|
||||
if (!smartProxy) return;
|
||||
await this.routeUpdateMutex.runExclusive(async () => {
|
||||
const smartProxy = this.getSmartProxy();
|
||||
if (!smartProxy) return;
|
||||
|
||||
const enabledRoutes: plugins.smartproxy.IRouteConfig[] = [];
|
||||
const enabledRoutes: plugins.smartproxy.IRouteConfig[] = [];
|
||||
|
||||
const http3Config = this.getHttp3Config?.();
|
||||
const vpnCallback = this.getVpnClientIpsForRoute;
|
||||
const http3Config = this.getHttp3Config?.();
|
||||
const vpnCallback = this.getVpnClientIpsForRoute;
|
||||
|
||||
// Helper: inject VPN security into a vpnOnly route
|
||||
const injectVpn = (route: plugins.smartproxy.IRouteConfig, routeId?: string): plugins.smartproxy.IRouteConfig => {
|
||||
if (!vpnCallback) return route;
|
||||
const dcRoute = route as IDcRouterRouteConfig;
|
||||
if (!dcRoute.vpnOnly) return route;
|
||||
const allowList = vpnCallback(dcRoute, routeId);
|
||||
return {
|
||||
...route,
|
||||
security: {
|
||||
...route.security,
|
||||
ipAllowList: allowList,
|
||||
},
|
||||
// Helper: inject VPN security into a vpnOnly route
|
||||
const injectVpn = (route: plugins.smartproxy.IRouteConfig, routeId?: string): plugins.smartproxy.IRouteConfig => {
|
||||
if (!vpnCallback) return route;
|
||||
const dcRoute = route as IDcRouterRouteConfig;
|
||||
if (!dcRoute.vpnOnly) return route;
|
||||
const vpnEntries = vpnCallback(dcRoute, routeId);
|
||||
const existingEntries = route.security?.ipAllowList || [];
|
||||
return {
|
||||
...route,
|
||||
security: {
|
||||
...route.security,
|
||||
ipAllowList: [...existingEntries, ...vpnEntries],
|
||||
},
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
// Add enabled hardcoded routes (respecting overrides, with fresh VPN injection)
|
||||
for (const route of this.getHardcodedRoutes()) {
|
||||
const name = route.name || '';
|
||||
const override = this.overrides.get(name);
|
||||
if (override && !override.enabled) {
|
||||
continue; // Skip disabled hardcoded route
|
||||
}
|
||||
enabledRoutes.push(injectVpn(route));
|
||||
}
|
||||
|
||||
// Add enabled programmatic routes (with HTTP/3 and VPN augmentation)
|
||||
for (const stored of this.storedRoutes.values()) {
|
||||
if (stored.enabled) {
|
||||
let route = stored.route;
|
||||
if (http3Config?.enabled !== false) {
|
||||
route = augmentRouteWithHttp3(route, { enabled: true, ...http3Config });
|
||||
// Add enabled hardcoded routes (respecting overrides, with fresh VPN injection)
|
||||
for (const route of this.getHardcodedRoutes()) {
|
||||
const name = route.name || '';
|
||||
const override = this.overrides.get(name);
|
||||
if (override && !override.enabled) {
|
||||
continue; // Skip disabled hardcoded route
|
||||
}
|
||||
enabledRoutes.push(injectVpn(route, stored.id));
|
||||
enabledRoutes.push(injectVpn(route));
|
||||
}
|
||||
}
|
||||
|
||||
await smartProxy.updateRoutes(enabledRoutes);
|
||||
// Add enabled programmatic routes (with HTTP/3 and VPN augmentation)
|
||||
for (const stored of this.storedRoutes.values()) {
|
||||
if (stored.enabled) {
|
||||
let route = stored.route;
|
||||
if (http3Config?.enabled !== false) {
|
||||
route = augmentRouteWithHttp3(route, { enabled: true, ...http3Config });
|
||||
}
|
||||
enabledRoutes.push(injectVpn(route, stored.id));
|
||||
}
|
||||
}
|
||||
|
||||
// Notify listeners (e.g. RemoteIngressManager) of the merged route set
|
||||
if (this.onRoutesApplied) {
|
||||
this.onRoutesApplied(enabledRoutes);
|
||||
}
|
||||
await smartProxy.updateRoutes(enabledRoutes);
|
||||
|
||||
logger.log('info', `Applied ${enabledRoutes.length} routes to SmartProxy (${this.storedRoutes.size} programmatic, ${this.overrides.size} overrides)`);
|
||||
// Notify listeners (e.g. RemoteIngressManager) of the merged route set
|
||||
if (this.onRoutesApplied) {
|
||||
this.onRoutesApplied(enabledRoutes);
|
||||
}
|
||||
|
||||
logger.log('info', `Applied ${enabledRoutes.length} routes to SmartProxy (${this.storedRoutes.size} programmatic, ${this.overrides.size} overrides)`);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,6 +33,13 @@ export class TargetProfileManager {
|
||||
routeRefs?: string[];
|
||||
createdBy: string;
|
||||
}): Promise<string> {
|
||||
// Enforce unique profile names
|
||||
for (const existing of this.profiles.values()) {
|
||||
if (existing.name === data.name) {
|
||||
throw new Error(`Target profile with name '${data.name}' already exists (id: ${existing.id})`);
|
||||
}
|
||||
}
|
||||
|
||||
const id = plugins.uuid.v4();
|
||||
const now = Date.now();
|
||||
|
||||
@@ -139,7 +146,7 @@ export class TargetProfileManager {
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* For a set of target profile IDs, collect all explicit target host IPs.
|
||||
* For a set of target profile IDs, collect all explicit target IPs.
|
||||
* These IPs bypass the SmartProxy forceTarget rewrite — VPN clients can
|
||||
* connect to them directly through the tunnel.
|
||||
*/
|
||||
@@ -149,7 +156,7 @@ export class TargetProfileManager {
|
||||
const profile = this.profiles.get(profileId);
|
||||
if (!profile?.targets?.length) continue;
|
||||
for (const t of profile.targets) {
|
||||
ips.add(t.host);
|
||||
ips.add(t.ip);
|
||||
}
|
||||
}
|
||||
return [...ips];
|
||||
@@ -161,32 +168,50 @@ export class TargetProfileManager {
|
||||
|
||||
/**
|
||||
* For a vpnOnly route, find all enabled VPN clients whose assigned TargetProfile
|
||||
* matches the route. Returns their assigned IPs for injection into ipAllowList.
|
||||
* matches the route. Returns IP allow entries for injection into ipAllowList.
|
||||
*
|
||||
* Entries are domain-scoped when a profile matches via specific domains that are
|
||||
* a subset of the route's wildcard. Plain IPs are returned for routeRef/target matches
|
||||
* or when profile domains exactly equal the route's domains.
|
||||
*/
|
||||
public getMatchingClientIps(
|
||||
route: IDcRouterRouteConfig,
|
||||
routeId: string | undefined,
|
||||
clients: VpnClientDoc[],
|
||||
): string[] {
|
||||
const ips: string[] = [];
|
||||
): Array<string | { ip: string; domains: string[] }> {
|
||||
const entries: Array<string | { ip: string; domains: string[] }> = [];
|
||||
const routeDomains: string[] = (route.match as any)?.domains || [];
|
||||
|
||||
for (const client of clients) {
|
||||
if (!client.enabled || !client.assignedIp) continue;
|
||||
if (!client.targetProfileIds?.length) continue;
|
||||
|
||||
// Check if any of the client's profiles match this route
|
||||
const matches = client.targetProfileIds.some((profileId) => {
|
||||
const profile = this.profiles.get(profileId);
|
||||
if (!profile) return false;
|
||||
return this.routeMatchesProfile(route, routeId, profile);
|
||||
});
|
||||
// Collect scoped domains from all matching profiles for this client
|
||||
let fullAccess = false;
|
||||
const scopedDomains = new Set<string>();
|
||||
|
||||
if (matches) {
|
||||
ips.push(client.assignedIp);
|
||||
for (const profileId of client.targetProfileIds) {
|
||||
const profile = this.profiles.get(profileId);
|
||||
if (!profile) continue;
|
||||
|
||||
const matchResult = this.routeMatchesProfileDetailed(route, routeId, profile, routeDomains);
|
||||
if (matchResult === 'full') {
|
||||
fullAccess = true;
|
||||
break; // No need to check more profiles
|
||||
}
|
||||
if (matchResult !== 'none') {
|
||||
for (const d of matchResult.domains) scopedDomains.add(d);
|
||||
}
|
||||
}
|
||||
|
||||
if (fullAccess) {
|
||||
entries.push(client.assignedIp);
|
||||
} else if (scopedDomains.size > 0) {
|
||||
entries.push({ ip: client.assignedIp, domains: [...scopedDomains] });
|
||||
}
|
||||
}
|
||||
|
||||
return ips;
|
||||
return entries;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -216,7 +241,7 @@ export class TargetProfileManager {
|
||||
// Direct target IP entries
|
||||
if (profile.targets?.length) {
|
||||
for (const t of profile.targets) {
|
||||
targetIps.add(t.host);
|
||||
targetIps.add(t.ip);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -257,33 +282,67 @@ export class TargetProfileManager {
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Check if a route matches a profile. A profile matches if ANY condition is true:
|
||||
* 1. Profile's routeRefs contains the route's name or stored route id
|
||||
* 2. Profile's domains overlaps with route.match.domains (wildcard matching)
|
||||
* 3. Profile's targets overlaps with route.action.targets (host + port match)
|
||||
* Check if a route matches a profile (boolean convenience wrapper).
|
||||
*/
|
||||
private routeMatchesProfile(
|
||||
route: IDcRouterRouteConfig,
|
||||
routeId: string | undefined,
|
||||
profile: ITargetProfile,
|
||||
): boolean {
|
||||
// 1. Route reference match
|
||||
const routeDomains: string[] = (route.match as any)?.domains || [];
|
||||
const result = this.routeMatchesProfileDetailed(route, routeId, profile, routeDomains);
|
||||
return result !== 'none';
|
||||
}
|
||||
|
||||
/**
|
||||
* Detailed match: returns 'full' (plain IP, entire route), 'scoped' (domain-limited),
|
||||
* or 'none' (no match).
|
||||
*
|
||||
* - routeRefs / target matches → 'full' (explicit reference = full access)
|
||||
* - domain match where profile domains are a subset of route wildcard → 'scoped'
|
||||
* - domain match where domains are identical or profile is a wildcard → 'full'
|
||||
*/
|
||||
private routeMatchesProfileDetailed(
|
||||
route: IDcRouterRouteConfig,
|
||||
routeId: string | undefined,
|
||||
profile: ITargetProfile,
|
||||
routeDomains: string[],
|
||||
): 'full' | { type: 'scoped'; domains: string[] } | 'none' {
|
||||
// 1. Route reference match → full access
|
||||
if (profile.routeRefs?.length) {
|
||||
if (routeId && profile.routeRefs.includes(routeId)) return true;
|
||||
if (route.name && profile.routeRefs.includes(route.name)) return true;
|
||||
if (routeId && profile.routeRefs.includes(routeId)) return 'full';
|
||||
if (route.name && profile.routeRefs.includes(route.name)) return 'full';
|
||||
}
|
||||
|
||||
// 2. Domain match
|
||||
if (profile.domains?.length) {
|
||||
const routeDomains: string[] = (route.match as any)?.domains || [];
|
||||
if (profile.domains?.length && routeDomains.length) {
|
||||
const matchedProfileDomains: string[] = [];
|
||||
|
||||
for (const profileDomain of profile.domains) {
|
||||
for (const routeDomain of routeDomains) {
|
||||
if (this.domainMatchesPattern(routeDomain, profileDomain)) return true;
|
||||
if (this.domainMatchesPattern(routeDomain, profileDomain) ||
|
||||
this.domainMatchesPattern(profileDomain, routeDomain)) {
|
||||
matchedProfileDomains.push(profileDomain);
|
||||
break; // This profileDomain matched, move to the next
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (matchedProfileDomains.length > 0) {
|
||||
// Check if profile domains cover the route entirely (same wildcards = full access)
|
||||
const isFullCoverage = routeDomains.every((rd) =>
|
||||
matchedProfileDomains.some((pd) =>
|
||||
rd === pd || this.domainMatchesPattern(rd, pd),
|
||||
),
|
||||
);
|
||||
if (isFullCoverage) return 'full';
|
||||
|
||||
// Profile domains are a subset → scoped access to those specific domains
|
||||
return { type: 'scoped', domains: matchedProfileDomains };
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Target match (host + port)
|
||||
// 3. Target match (host + port) → full access (precise by nature)
|
||||
if (profile.targets?.length) {
|
||||
const routeTargets = (route.action as any)?.targets;
|
||||
if (Array.isArray(routeTargets)) {
|
||||
@@ -291,15 +350,15 @@ export class TargetProfileManager {
|
||||
for (const routeTarget of routeTargets) {
|
||||
const routeHost = routeTarget.host;
|
||||
const routePort = routeTarget.port;
|
||||
if (routeHost === profileTarget.host && routePort === profileTarget.port) {
|
||||
return true;
|
||||
if (routeHost === profileTarget.ip && routePort === profileTarget.port) {
|
||||
return 'full';
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
return 'none';
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -39,10 +39,6 @@ export class SourceProfileDoc extends plugins.smartdata.SmartDataDbDoc<SourcePro
|
||||
return await SourceProfileDoc.getInstance({ id });
|
||||
}
|
||||
|
||||
public static async findByName(name: string): Promise<SourceProfileDoc | null> {
|
||||
return await SourceProfileDoc.getInstance({ name });
|
||||
}
|
||||
|
||||
public static async findAll(): Promise<SourceProfileDoc[]> {
|
||||
return await SourceProfileDoc.getInstances({});
|
||||
}
|
||||
|
||||
@@ -42,10 +42,6 @@ export class TargetProfileDoc extends plugins.smartdata.SmartDataDbDoc<TargetPro
|
||||
return await TargetProfileDoc.getInstance({ id });
|
||||
}
|
||||
|
||||
public static async findByName(name: string): Promise<TargetProfileDoc | null> {
|
||||
return await TargetProfileDoc.getInstance({ name });
|
||||
}
|
||||
|
||||
public static async findAll(): Promise<TargetProfileDoc[]> {
|
||||
return await TargetProfileDoc.getInstances({});
|
||||
}
|
||||
|
||||
@@ -39,9 +39,6 @@ export class VpnClientDoc extends plugins.smartdata.SmartDataDbDoc<VpnClientDoc,
|
||||
@plugins.smartdata.svDb()
|
||||
public expiresAt?: string;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public forceDestinationSmartproxy: boolean = true;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public destinationAllowList?: string[];
|
||||
|
||||
@@ -67,15 +64,7 @@ export class VpnClientDoc extends plugins.smartdata.SmartDataDbDoc<VpnClientDoc,
|
||||
super();
|
||||
}
|
||||
|
||||
public static async findByClientId(clientId: string): Promise<VpnClientDoc | null> {
|
||||
return await VpnClientDoc.getInstance({ clientId });
|
||||
}
|
||||
|
||||
public static async findAll(): Promise<VpnClientDoc[]> {
|
||||
return await VpnClientDoc.getInstances({});
|
||||
}
|
||||
|
||||
public static async findEnabled(): Promise<VpnClientDoc[]> {
|
||||
return await VpnClientDoc.getInstances({ enabled: true });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,28 @@ import * as plugins from '../../plugins.js';
|
||||
import type { OpsServer } from '../classes.opsserver.js';
|
||||
import * as interfaces from '../../../ts_interfaces/index.js';
|
||||
import { AcmeCertDoc, ProxyCertDoc } from '../../db/index.js';
|
||||
import { logger } from '../../logger.js';
|
||||
|
||||
/**
|
||||
* Mirrors `SmartacmeCertMatcher.getCertificateDomainNameByDomainName` from
|
||||
* @push.rocks/smartacme. Inlined here because the original is `private` on
|
||||
* SmartAcme. The cert identity ('task.vc' for both 'outline.task.vc' and
|
||||
* '*.task.vc') is what AcmeCertDoc is keyed by, so two route domains with
|
||||
* the same identity share the same underlying ACME cert.
|
||||
*
|
||||
* Returns undefined for domains with 4+ levels (matching smartacme's
|
||||
* "deeper domains not supported" behavior) and for malformed inputs.
|
||||
*
|
||||
* Exported for unit testing.
|
||||
*/
|
||||
export function deriveCertDomainName(domain: string): string | undefined {
|
||||
if (domain.startsWith('*.')) {
|
||||
return domain.slice(2);
|
||||
}
|
||||
const parts = domain.split('.');
|
||||
if (parts.length < 2 || parts.length > 3) return undefined;
|
||||
return parts.slice(-2).join('.');
|
||||
}
|
||||
|
||||
export class CertificateHandler {
|
||||
constructor(private opsServerRef: OpsServer) {
|
||||
@@ -295,7 +317,12 @@ export class CertificateHandler {
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy route-based reprovisioning
|
||||
* Legacy route-based reprovisioning. Kept for backward compatibility with
|
||||
* older clients that send `reprovisionCertificate` typed-requests.
|
||||
*
|
||||
* Like reprovisionCertificateDomain, this triggers the full route apply
|
||||
* pipeline rather than smartProxy.provisionCertificate(routeName) — which
|
||||
* is a no-op when certProvisionFunction is set (Rust ACME disabled).
|
||||
*/
|
||||
private async reprovisionCertificateByRoute(routeName: string): Promise<{ success: boolean; message?: string }> {
|
||||
const dcRouter = this.opsServerRef.dcRouterRef;
|
||||
@@ -305,13 +332,19 @@ export class CertificateHandler {
|
||||
return { success: false, message: 'SmartProxy is not running' };
|
||||
}
|
||||
|
||||
// Clear event-based status for domains in this route so the
|
||||
// certificate-issued event can refresh them
|
||||
for (const [domain, entry] of dcRouter.certificateStatusMap) {
|
||||
if (entry.routeNames.includes(routeName)) {
|
||||
dcRouter.certificateStatusMap.delete(domain);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await smartProxy.provisionCertificate(routeName);
|
||||
// Clear event-based status for domains in this route
|
||||
for (const [domain, entry] of dcRouter.certificateStatusMap) {
|
||||
if (entry.routeNames.includes(routeName)) {
|
||||
dcRouter.certificateStatusMap.delete(domain);
|
||||
}
|
||||
if (dcRouter.routeConfigManager) {
|
||||
await dcRouter.routeConfigManager.applyRoutes();
|
||||
} else {
|
||||
await smartProxy.updateRoutes(smartProxy.routeManager.getRoutes());
|
||||
}
|
||||
return { success: true, message: `Certificate reprovisioning triggered for route '${routeName}'` };
|
||||
} catch (err: unknown) {
|
||||
@@ -320,7 +353,16 @@ export class CertificateHandler {
|
||||
}
|
||||
|
||||
/**
|
||||
* Domain-based reprovisioning — clears backoff first, then triggers provision
|
||||
* Domain-based reprovisioning — clears backoff first, refreshes the smartacme
|
||||
* cert (when forceRenew is set), then re-applies routes so the running Rust
|
||||
* proxy actually picks up the new cert.
|
||||
*
|
||||
* Why applyRoutes (not smartProxy.provisionCertificate)?
|
||||
* smartProxy.provisionCertificate(routeName) routes through the Rust ACME
|
||||
* path, which is forcibly disabled whenever certProvisionFunction is set
|
||||
* (smart-proxy.ts:168-171). The only path that re-invokes
|
||||
* certProvisionFunction → bridge.loadCertificate is updateRoutes(), which
|
||||
* we trigger via routeConfigManager.applyRoutes().
|
||||
*/
|
||||
private async reprovisionCertificateDomain(domain: string, forceRenew?: boolean): Promise<{ success: boolean; message?: string }> {
|
||||
const dcRouter = this.opsServerRef.dcRouterRef;
|
||||
@@ -335,34 +377,145 @@ export class CertificateHandler {
|
||||
await dcRouter.certProvisionScheduler.clearBackoff(domain);
|
||||
}
|
||||
|
||||
// Find routes matching this domain — needed to provision through SmartProxy
|
||||
// Find routes matching this domain — fail early if none exist
|
||||
const routeNames = dcRouter.findRouteNamesForDomain(domain);
|
||||
if (routeNames.length === 0) {
|
||||
return { success: false, message: `No routes found for domain '${domain}'` };
|
||||
}
|
||||
|
||||
// If forceRenew, invalidate SmartAcme's cache so the next provision gets a fresh cert
|
||||
// If forceRenew, order a fresh cert from ACME now so it's already in
|
||||
// AcmeCertDoc by the time certProvisionFunction is invoked below.
|
||||
//
|
||||
// includeWildcard: when forcing a non-wildcard subdomain renewal, we still
|
||||
// want the wildcard SAN in the order so the new cert keeps covering every
|
||||
// sibling. Without this, smartacme defaults to includeWildcard: false and
|
||||
// the re-issued cert would have only the base domain as SAN, breaking every
|
||||
// sibling subdomain that was previously covered by the same wildcard cert.
|
||||
if (forceRenew && dcRouter.smartAcme) {
|
||||
let newCert: plugins.smartacme.Cert;
|
||||
try {
|
||||
await dcRouter.smartAcme.getCertificateForDomain(domain, { forceRenew: true });
|
||||
} catch {
|
||||
// Cache invalidation failed — proceed with provisioning anyway
|
||||
newCert = await dcRouter.smartAcme.getCertificateForDomain(domain, {
|
||||
forceRenew: true,
|
||||
includeWildcard: !domain.startsWith('*.'),
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
return { success: false, message: `Failed to renew certificate for ${domain}: ${(err as Error).message}` };
|
||||
}
|
||||
|
||||
// Propagate the freshly-issued cert PEM to every sibling route domain that
|
||||
// shares the same cert identity. Without this, the rust hot-swap (keyed by
|
||||
// exact domain in `loaded_certs`) only fires for the clicked route via the
|
||||
// fire-and-forget cert provisioning path, leaving siblings serving the
|
||||
// stale in-memory cert until the next background reload completes.
|
||||
try {
|
||||
await this.propagateCertToSiblings(domain, newCert);
|
||||
} catch (err: unknown) {
|
||||
// Best-effort: failure here doesn't undo the cert issuance, just log.
|
||||
logger.log('warn', `Failed to propagate force-renewed cert to siblings of ${domain}: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Clear status map entry so it gets refreshed by the certificate-issued event
|
||||
dcRouter.certificateStatusMap.delete(domain);
|
||||
|
||||
// Provision through SmartProxy — this triggers the full pipeline:
|
||||
// certProvisionFunction → bridge.loadCertificate → certificate-issued event → status map updated
|
||||
// Trigger the full route apply pipeline:
|
||||
// applyRoutes → updateRoutes → provisionCertificatesViaCallback →
|
||||
// certProvisionFunction(domain) → smartAcme.getCertificateForDomain →
|
||||
// bridge.loadCertificate → Rust hot-swaps `loaded_certs` →
|
||||
// certificate-issued event → certificateStatusMap updated
|
||||
try {
|
||||
await smartProxy.provisionCertificate(routeNames[0]);
|
||||
if (dcRouter.routeConfigManager) {
|
||||
await dcRouter.routeConfigManager.applyRoutes();
|
||||
} else {
|
||||
// Fallback when DB is disabled and there is no RouteConfigManager
|
||||
await smartProxy.updateRoutes(smartProxy.routeManager.getRoutes());
|
||||
}
|
||||
return { success: true, message: forceRenew ? `Certificate force-renewed for domain '${domain}'` : `Certificate reprovisioning triggered for domain '${domain}'` };
|
||||
} catch (err: unknown) {
|
||||
return { success: false, message: (err as Error).message || `Failed to reprovision certificate for ${domain}` };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* After a force-renew, walk every route in the smartproxy that resolves to
|
||||
* the same cert identity as `forcedDomain` and write the freshly-issued cert
|
||||
* PEM into ProxyCertDoc for each. This guarantees that the next applyRoutes
|
||||
* → provisionCertificatesViaCallback iteration will hot-swap every sibling's
|
||||
* rust loaded_certs entry with the new (correct) PEM, rather than relying on
|
||||
* the in-memory cert returned by smartacme's per-domain cache.
|
||||
*
|
||||
* Why this is necessary:
|
||||
* Rust's `loaded_certs` is a HashMap<domain, TlsCertConfig>. Each
|
||||
* bridge.loadCertificate(domain, ...) only swaps that one entry. The
|
||||
* fire-and-forget cert provisioning path triggered by updateRoutes does
|
||||
* eventually iterate every auto-cert route, but it returns the cached
|
||||
* (broken pre-fix) cert from smartacme's per-domain mutex. With this
|
||||
* helper, ProxyCertDoc is updated synchronously to the correct PEM before
|
||||
* applyRoutes runs, so even the transient window stays consistent.
|
||||
*/
|
||||
private async propagateCertToSiblings(
|
||||
forcedDomain: string,
|
||||
newCert: plugins.smartacme.Cert,
|
||||
): Promise<void> {
|
||||
const dcRouter = this.opsServerRef.dcRouterRef;
|
||||
const smartProxy = dcRouter.smartProxy;
|
||||
if (!smartProxy) return;
|
||||
|
||||
const certIdentity = deriveCertDomainName(forcedDomain);
|
||||
if (!certIdentity) return;
|
||||
|
||||
// Collect every route domain whose cert identity matches.
|
||||
const affected = new Set<string>();
|
||||
for (const route of smartProxy.routeManager.getRoutes()) {
|
||||
if (!route.match.domains) continue;
|
||||
const routeDomains = Array.isArray(route.match.domains)
|
||||
? route.match.domains
|
||||
: [route.match.domains];
|
||||
for (const routeDomain of routeDomains) {
|
||||
if (deriveCertDomainName(routeDomain) === certIdentity) {
|
||||
affected.add(routeDomain);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (affected.size === 0) return;
|
||||
|
||||
// Parse expiry from PEM (defense-in-depth — same pattern as
|
||||
// ts/classes.dcrouter.ts:988-995 and the existing certStore.save callback).
|
||||
let validUntil = newCert.validUntil;
|
||||
let validFrom: number | undefined;
|
||||
if (newCert.publicKey) {
|
||||
try {
|
||||
const x509 = new plugins.crypto.X509Certificate(newCert.publicKey);
|
||||
validUntil = new Date(x509.validTo).getTime();
|
||||
validFrom = new Date(x509.validFrom).getTime();
|
||||
} catch { /* fall back to smartacme's value */ }
|
||||
}
|
||||
|
||||
// Persist new cert PEM under each affected route domain
|
||||
for (const routeDomain of affected) {
|
||||
let doc = await ProxyCertDoc.findByDomain(routeDomain);
|
||||
if (!doc) {
|
||||
doc = new ProxyCertDoc();
|
||||
doc.domain = routeDomain;
|
||||
}
|
||||
doc.publicKey = newCert.publicKey;
|
||||
doc.privateKey = newCert.privateKey;
|
||||
doc.ca = '';
|
||||
doc.validUntil = validUntil || 0;
|
||||
doc.validFrom = validFrom || 0;
|
||||
await doc.save();
|
||||
|
||||
// Clear status so the next event refresh shows the new cert
|
||||
dcRouter.certificateStatusMap.delete(routeDomain);
|
||||
}
|
||||
|
||||
logger.log(
|
||||
'info',
|
||||
`Propagated force-renewed cert for ${forcedDomain} (cert identity '${certIdentity}') to ${affected.size} sibling route domain(s): ${[...affected].join(', ')}`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete certificate data for a domain from storage
|
||||
*/
|
||||
|
||||
@@ -111,8 +111,8 @@ export class TargetProfileHandler {
|
||||
routeRefs: dataArg.routeRefs,
|
||||
});
|
||||
// Re-apply routes and refresh VPN client security to update access
|
||||
this.opsServerRef.dcRouterRef.routeConfigManager?.applyRoutes();
|
||||
this.opsServerRef.dcRouterRef.vpnManager?.refreshAllClientSecurity();
|
||||
await this.opsServerRef.dcRouterRef.routeConfigManager?.applyRoutes();
|
||||
await this.opsServerRef.dcRouterRef.vpnManager?.refreshAllClientSecurity();
|
||||
return { success: true };
|
||||
},
|
||||
),
|
||||
@@ -131,8 +131,8 @@ export class TargetProfileHandler {
|
||||
const result = await manager.deleteProfile(dataArg.id, dataArg.force);
|
||||
if (result.success) {
|
||||
// Re-apply routes and refresh VPN client security to update access
|
||||
this.opsServerRef.dcRouterRef.routeConfigManager?.applyRoutes();
|
||||
this.opsServerRef.dcRouterRef.vpnManager?.refreshAllClientSecurity();
|
||||
await this.opsServerRef.dcRouterRef.routeConfigManager?.applyRoutes();
|
||||
await this.opsServerRef.dcRouterRef.vpnManager?.refreshAllClientSecurity();
|
||||
}
|
||||
return result;
|
||||
},
|
||||
|
||||
@@ -31,7 +31,6 @@ export class VpnHandler {
|
||||
createdAt: c.createdAt,
|
||||
updatedAt: c.updatedAt,
|
||||
expiresAt: c.expiresAt,
|
||||
forceDestinationSmartproxy: c.forceDestinationSmartproxy ?? true,
|
||||
destinationAllowList: c.destinationAllowList,
|
||||
destinationBlockList: c.destinationBlockList,
|
||||
useHostIp: c.useHostIp,
|
||||
@@ -122,7 +121,6 @@ export class VpnHandler {
|
||||
clientId: dataArg.clientId,
|
||||
targetProfileIds: dataArg.targetProfileIds,
|
||||
description: dataArg.description,
|
||||
forceDestinationSmartproxy: dataArg.forceDestinationSmartproxy,
|
||||
destinationAllowList: dataArg.destinationAllowList,
|
||||
destinationBlockList: dataArg.destinationBlockList,
|
||||
useHostIp: dataArg.useHostIp,
|
||||
@@ -148,7 +146,6 @@ export class VpnHandler {
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
expiresAt: bundle.entry.expiresAt,
|
||||
forceDestinationSmartproxy: persistedClient?.forceDestinationSmartproxy ?? true,
|
||||
destinationAllowList: persistedClient?.destinationAllowList,
|
||||
destinationBlockList: persistedClient?.destinationBlockList,
|
||||
useHostIp: persistedClient?.useHostIp,
|
||||
@@ -180,7 +177,6 @@ export class VpnHandler {
|
||||
await manager.updateClient(dataArg.clientId, {
|
||||
description: dataArg.description,
|
||||
targetProfileIds: dataArg.targetProfileIds,
|
||||
forceDestinationSmartproxy: dataArg.forceDestinationSmartproxy,
|
||||
destinationAllowList: dataArg.destinationAllowList,
|
||||
destinationBlockList: dataArg.destinationBlockList,
|
||||
useHostIp: dataArg.useHostIp,
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
{
|
||||
"order": 2
|
||||
"order": 3
|
||||
}
|
||||
|
||||
@@ -201,7 +201,6 @@ export class VpnManager {
|
||||
clientId: string;
|
||||
targetProfileIds?: string[];
|
||||
description?: string;
|
||||
forceDestinationSmartproxy?: boolean;
|
||||
destinationAllowList?: string[];
|
||||
destinationBlockList?: string[];
|
||||
useHostIp?: boolean;
|
||||
@@ -242,9 +241,6 @@ export class VpnManager {
|
||||
doc.createdAt = Date.now();
|
||||
doc.updatedAt = Date.now();
|
||||
doc.expiresAt = bundle.entry.expiresAt;
|
||||
if (opts.forceDestinationSmartproxy !== undefined) {
|
||||
doc.forceDestinationSmartproxy = opts.forceDestinationSmartproxy;
|
||||
}
|
||||
if (opts.destinationAllowList !== undefined) {
|
||||
doc.destinationAllowList = opts.destinationAllowList;
|
||||
}
|
||||
@@ -267,7 +263,18 @@ export class VpnManager {
|
||||
doc.vlanId = opts.vlanId;
|
||||
}
|
||||
this.clients.set(doc.clientId, doc);
|
||||
await this.persistClient(doc);
|
||||
try {
|
||||
await this.persistClient(doc);
|
||||
} catch (err) {
|
||||
// Rollback: remove from in-memory map and daemon to stay consistent with DB
|
||||
this.clients.delete(doc.clientId);
|
||||
try {
|
||||
await this.vpnServer!.removeClient(doc.clientId);
|
||||
} catch {
|
||||
// best-effort daemon cleanup
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
// Sync per-client security to the running daemon
|
||||
const security = this.buildClientSecurity(doc);
|
||||
@@ -338,7 +345,6 @@ export class VpnManager {
|
||||
public async updateClient(clientId: string, update: {
|
||||
description?: string;
|
||||
targetProfileIds?: string[];
|
||||
forceDestinationSmartproxy?: boolean;
|
||||
destinationAllowList?: string[];
|
||||
destinationBlockList?: string[];
|
||||
useHostIp?: boolean;
|
||||
@@ -351,7 +357,6 @@ export class VpnManager {
|
||||
if (!client) throw new Error(`Client not found: ${clientId}`);
|
||||
if (update.description !== undefined) client.description = update.description;
|
||||
if (update.targetProfileIds !== undefined) client.targetProfileIds = update.targetProfileIds;
|
||||
if (update.forceDestinationSmartproxy !== undefined) client.forceDestinationSmartproxy = update.forceDestinationSmartproxy;
|
||||
if (update.destinationAllowList !== undefined) client.destinationAllowList = update.destinationAllowList;
|
||||
if (update.destinationBlockList !== undefined) client.destinationBlockList = update.destinationBlockList;
|
||||
if (update.useHostIp !== undefined) client.useHostIp = update.useHostIp;
|
||||
@@ -473,12 +478,11 @@ export class VpnManager {
|
||||
|
||||
/**
|
||||
* Build per-client security settings for the smartvpn daemon.
|
||||
* Maps dcrouter-level fields (forceDestinationSmartproxy, allow/block lists)
|
||||
* to smartvpn's IClientSecurity with a destinationPolicy.
|
||||
* All VPN traffic is forced through SmartProxy (forceTarget to 127.0.0.1).
|
||||
* TargetProfile direct IP:port targets bypass SmartProxy via allowList.
|
||||
*/
|
||||
private buildClientSecurity(client: VpnClientDoc): plugins.smartvpn.IClientSecurity {
|
||||
const security: plugins.smartvpn.IClientSecurity = {};
|
||||
const forceSmartproxy = client.forceDestinationSmartproxy ?? true;
|
||||
|
||||
// Collect direct targets from assigned TargetProfiles (bypass forceTarget for these IPs)
|
||||
const profileDirectTargets = this.config.getClientDirectTargets?.(client.targetProfileIds || []) || [];
|
||||
@@ -489,23 +493,12 @@ export class VpnManager {
|
||||
...profileDirectTargets,
|
||||
];
|
||||
|
||||
if (!forceSmartproxy) {
|
||||
// Client traffic goes directly — not forced to SmartProxy
|
||||
security.destinationPolicy = {
|
||||
default: 'allow' as const,
|
||||
blockList: client.destinationBlockList,
|
||||
};
|
||||
} else if (mergedAllowList.length || client.destinationBlockList?.length) {
|
||||
// Client is forced to SmartProxy, but with allow/block overrides
|
||||
// (includes TargetProfile direct targets that bypass SmartProxy)
|
||||
security.destinationPolicy = {
|
||||
default: 'forceTarget' as const,
|
||||
target: '127.0.0.1',
|
||||
allowList: mergedAllowList.length ? mergedAllowList : undefined,
|
||||
blockList: client.destinationBlockList,
|
||||
};
|
||||
}
|
||||
// else: no per-client policy, server-wide applies
|
||||
security.destinationPolicy = {
|
||||
default: 'forceTarget' as const,
|
||||
target: '127.0.0.1',
|
||||
allowList: mergedAllowList.length ? mergedAllowList : undefined,
|
||||
blockList: client.destinationBlockList,
|
||||
};
|
||||
|
||||
return security;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
{
|
||||
"order": 4
|
||||
"order": 5
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* A specific IP:port target within a TargetProfile.
|
||||
*/
|
||||
export interface ITargetProfileTarget {
|
||||
host: string;
|
||||
ip: string;
|
||||
port: number;
|
||||
}
|
||||
|
||||
|
||||
@@ -11,7 +11,6 @@ export interface IVpnClient {
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
expiresAt?: string;
|
||||
forceDestinationSmartproxy: boolean;
|
||||
destinationAllowList?: string[];
|
||||
destinationBlockList?: string[];
|
||||
useHostIp?: boolean;
|
||||
|
||||
@@ -51,7 +51,7 @@ export interface IReq_CreateVpnClient extends plugins.typedrequestInterfaces.imp
|
||||
clientId: string;
|
||||
targetProfileIds?: string[];
|
||||
description?: string;
|
||||
forceDestinationSmartproxy?: boolean;
|
||||
|
||||
destinationAllowList?: string[];
|
||||
destinationBlockList?: string[];
|
||||
useHostIp?: boolean;
|
||||
@@ -82,7 +82,7 @@ export interface IReq_UpdateVpnClient extends plugins.typedrequestInterfaces.imp
|
||||
clientId: string;
|
||||
description?: string;
|
||||
targetProfileIds?: string[];
|
||||
forceDestinationSmartproxy?: boolean;
|
||||
|
||||
destinationAllowList?: string[];
|
||||
destinationBlockList?: string[];
|
||||
useHostIp?: boolean;
|
||||
|
||||
70
ts_migrations/index.ts
Normal file
70
ts_migrations/index.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
/// <reference types="node" />
|
||||
|
||||
/**
|
||||
* dcrouter migration runner.
|
||||
*
|
||||
* Uses @push.rocks/smartmigration via dynamic import so smartmigration's type
|
||||
* chain (which pulls in mongodb 7.x and related types) doesn't leak into
|
||||
* compile-time type checking for this folder.
|
||||
*/
|
||||
|
||||
/** Matches the subset of IMigrationRunResult we actually log. */
|
||||
export interface IMigrationRunResult {
|
||||
stepsApplied: Array<unknown>;
|
||||
wasFreshInstall: boolean;
|
||||
currentVersionBefore: string | null;
|
||||
currentVersionAfter: string;
|
||||
totalDurationMs: number;
|
||||
}
|
||||
|
||||
export interface IMigrationRunner {
|
||||
run(): Promise<IMigrationRunResult>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a configured SmartMigration runner with all dcrouter migration steps registered.
|
||||
*
|
||||
* Call `.run()` on the returned instance at startup (after DcRouterDb is ready,
|
||||
* before any service that reads migrated collections).
|
||||
*
|
||||
* @param db - The initialized SmartdataDb instance from DcRouterDb.getDb()
|
||||
* @param targetVersion - The current app version (from commitinfo.version)
|
||||
*/
|
||||
export async function createMigrationRunner(
|
||||
db: unknown,
|
||||
targetVersion: string,
|
||||
): Promise<IMigrationRunner> {
|
||||
const sm = await import('@push.rocks/smartmigration');
|
||||
const migration = new sm.SmartMigration({
|
||||
targetVersion,
|
||||
db: db as any,
|
||||
// Brand-new installs skip all migrations and stamp directly to the current version.
|
||||
freshInstallVersion: targetVersion,
|
||||
});
|
||||
|
||||
// Register steps in execution order. Each step's .from() must match the
|
||||
// previous step's .to() to form a contiguous chain.
|
||||
migration
|
||||
.step('rename-target-profile-host-to-ip')
|
||||
.from('13.0.11').to('13.1.0')
|
||||
.description('Rename ITargetProfileTarget.host → ip on all target profiles')
|
||||
.up(async (ctx) => {
|
||||
const collection = ctx.mongo!.collection('targetprofiledoc');
|
||||
const cursor = collection.find({ 'targets.host': { $exists: true } });
|
||||
let migrated = 0;
|
||||
for await (const doc of cursor) {
|
||||
const targets = ((doc as any).targets || []).map((t: any) => {
|
||||
if (t && typeof t === 'object' && 'host' in t && !('ip' in t)) {
|
||||
const { host, ...rest } = t;
|
||||
return { ...rest, ip: host };
|
||||
}
|
||||
return t;
|
||||
});
|
||||
await collection.updateOne({ _id: (doc as any)._id }, { $set: { targets } });
|
||||
migrated++;
|
||||
}
|
||||
ctx.log.log('info', `rename-target-profile-host-to-ip: migrated ${migrated} profile(s)`);
|
||||
});
|
||||
|
||||
return migration;
|
||||
}
|
||||
3
ts_migrations/tspublish.json
Normal file
3
ts_migrations/tspublish.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"order": 2
|
||||
}
|
||||
@@ -3,6 +3,6 @@
|
||||
*/
|
||||
export const commitinfo = {
|
||||
name: '@serve.zone/dcrouter',
|
||||
version: '13.0.10',
|
||||
version: '13.4.1',
|
||||
description: 'A multifaceted routing service handling mail and SMS delivery functions.'
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@ export interface IConfigState {
|
||||
|
||||
export interface IUiState {
|
||||
activeView: string;
|
||||
activeSubview: string | null;
|
||||
sidebarCollapsed: boolean;
|
||||
autoRefresh: boolean;
|
||||
refreshInterval: number; // milliseconds
|
||||
@@ -116,16 +117,24 @@ export const configStatePart = await appState.getStatePart<IConfigState>(
|
||||
// Determine initial view from URL path
|
||||
const getInitialView = (): string => {
|
||||
const path = typeof window !== 'undefined' ? window.location.pathname : '/';
|
||||
const validViews = ['overview', 'network', 'emails', 'logs', 'routes', 'apitokens', 'configuration', 'security', 'certificates', 'remoteingress', 'sourceprofiles', 'networktargets', 'targetprofiles'];
|
||||
const validViews = ['overview', 'network', 'email', 'logs', 'access', 'security', 'certificates'];
|
||||
const segments = path.split('/').filter(Boolean);
|
||||
const view = segments[0];
|
||||
return validViews.includes(view) ? view : 'overview';
|
||||
};
|
||||
|
||||
// Determine initial subview (second URL segment) from the path
|
||||
const getInitialSubview = (): string | null => {
|
||||
const path = typeof window !== 'undefined' ? window.location.pathname : '/';
|
||||
const segments = path.split('/').filter(Boolean);
|
||||
return segments[1] ?? null;
|
||||
};
|
||||
|
||||
export const uiStatePart = await appState.getStatePart<IUiState>(
|
||||
'ui',
|
||||
{
|
||||
activeView: getInitialView(),
|
||||
activeSubview: getInitialSubview(),
|
||||
sidebarCollapsed: false,
|
||||
autoRefresh: true,
|
||||
refreshInterval: 1000, // 1 second
|
||||
@@ -435,43 +444,6 @@ export const setActiveViewAction = uiStatePart.createAction<string>(async (state
|
||||
}, 100);
|
||||
}
|
||||
|
||||
// If switching to routes view, ensure we fetch route data
|
||||
if (viewName === 'routes' && currentState.activeView !== 'routes') {
|
||||
setTimeout(() => {
|
||||
routeManagementStatePart.dispatchAction(fetchMergedRoutesAction, null);
|
||||
// Also fetch profiles/targets for the Create Route dropdowns
|
||||
profilesTargetsStatePart.dispatchAction(fetchProfilesAndTargetsAction, null);
|
||||
}, 100);
|
||||
}
|
||||
|
||||
// If switching to apitokens view, ensure we fetch token data
|
||||
if (viewName === 'apitokens' && currentState.activeView !== 'apitokens') {
|
||||
setTimeout(() => {
|
||||
routeManagementStatePart.dispatchAction(fetchApiTokensAction, null);
|
||||
}, 100);
|
||||
}
|
||||
|
||||
// If switching to remoteingress view, ensure we fetch edge data
|
||||
if (viewName === 'remoteingress' && currentState.activeView !== 'remoteingress') {
|
||||
setTimeout(() => {
|
||||
remoteIngressStatePart.dispatchAction(fetchRemoteIngressAction, null);
|
||||
}, 100);
|
||||
}
|
||||
|
||||
// If switching to security profiles or network targets views, fetch profiles/targets data
|
||||
if ((viewName === 'sourceprofiles' || viewName === 'networktargets') && currentState.activeView !== viewName) {
|
||||
setTimeout(() => {
|
||||
profilesTargetsStatePart.dispatchAction(fetchProfilesAndTargetsAction, null);
|
||||
}, 100);
|
||||
}
|
||||
|
||||
// If switching to target profiles view, fetch target profiles data
|
||||
if (viewName === 'targetprofiles' && currentState.activeView !== viewName) {
|
||||
setTimeout(() => {
|
||||
targetProfilesStatePart.dispatchAction(fetchTargetProfilesAction, null);
|
||||
}, 100);
|
||||
}
|
||||
|
||||
return {
|
||||
...currentState,
|
||||
activeView: viewName,
|
||||
@@ -1015,7 +987,7 @@ export const createVpnClientAction = vpnStatePart.createAction<{
|
||||
clientId: string;
|
||||
targetProfileIds?: string[];
|
||||
description?: string;
|
||||
forceDestinationSmartproxy?: boolean;
|
||||
|
||||
destinationAllowList?: string[];
|
||||
destinationBlockList?: string[];
|
||||
useHostIp?: boolean;
|
||||
@@ -1037,7 +1009,7 @@ export const createVpnClientAction = vpnStatePart.createAction<{
|
||||
clientId: dataArg.clientId,
|
||||
targetProfileIds: dataArg.targetProfileIds,
|
||||
description: dataArg.description,
|
||||
forceDestinationSmartproxy: dataArg.forceDestinationSmartproxy,
|
||||
|
||||
destinationAllowList: dataArg.destinationAllowList,
|
||||
destinationBlockList: dataArg.destinationBlockList,
|
||||
useHostIp: dataArg.useHostIp,
|
||||
@@ -1113,7 +1085,7 @@ export const updateVpnClientAction = vpnStatePart.createAction<{
|
||||
clientId: string;
|
||||
description?: string;
|
||||
targetProfileIds?: string[];
|
||||
forceDestinationSmartproxy?: boolean;
|
||||
|
||||
destinationAllowList?: string[];
|
||||
destinationBlockList?: string[];
|
||||
useHostIp?: boolean;
|
||||
@@ -1135,7 +1107,7 @@ export const updateVpnClientAction = vpnStatePart.createAction<{
|
||||
clientId: dataArg.clientId,
|
||||
description: dataArg.description,
|
||||
targetProfileIds: dataArg.targetProfileIds,
|
||||
forceDestinationSmartproxy: dataArg.forceDestinationSmartproxy,
|
||||
|
||||
destinationAllowList: dataArg.destinationAllowList,
|
||||
destinationBlockList: dataArg.destinationBlockList,
|
||||
useHostIp: dataArg.useHostIp,
|
||||
@@ -1223,7 +1195,7 @@ export const createTargetProfileAction = targetProfilesStatePart.createAction<{
|
||||
name: string;
|
||||
description?: string;
|
||||
domains?: string[];
|
||||
targets?: Array<{ host: string; port: number }>;
|
||||
targets?: Array<{ ip: string; port: number }>;
|
||||
routeRefs?: string[];
|
||||
}>(async (statePartArg, dataArg, actionContext): Promise<ITargetProfilesState> => {
|
||||
const context = getActionContext();
|
||||
@@ -1259,7 +1231,7 @@ export const updateTargetProfileAction = targetProfilesStatePart.createAction<{
|
||||
name?: string;
|
||||
description?: string;
|
||||
domains?: string[];
|
||||
targets?: Array<{ host: string; port: number }>;
|
||||
targets?: Array<{ ip: string; port: number }>;
|
||||
routeRefs?: string[];
|
||||
}>(async (statePartArg, dataArg, actionContext): Promise<ITargetProfilesState> => {
|
||||
const context = getActionContext();
|
||||
@@ -1944,6 +1916,7 @@ async function dispatchCombinedRefreshActionInner() {
|
||||
const context = getActionContext();
|
||||
if (!context.identity) return;
|
||||
const currentView = uiStatePart.getState()!.activeView;
|
||||
const currentSubview = uiStatePart.getState()!.activeSubview;
|
||||
|
||||
try {
|
||||
// Always fetch basic stats for dashboard widgets
|
||||
@@ -2055,8 +2028,8 @@ async function dispatchCombinedRefreshActionInner() {
|
||||
}
|
||||
}
|
||||
|
||||
// Refresh remote ingress data if on remoteingress view
|
||||
if (currentView === 'remoteingress') {
|
||||
// Refresh remote ingress data if on the Network → Remote Ingress subview
|
||||
if (currentView === 'network' && currentSubview === 'remoteingress') {
|
||||
try {
|
||||
await remoteIngressStatePart.dispatchAction(fetchRemoteIngressAction, null);
|
||||
} catch (error) {
|
||||
@@ -2064,8 +2037,8 @@ async function dispatchCombinedRefreshActionInner() {
|
||||
}
|
||||
}
|
||||
|
||||
// Refresh VPN data if on vpn view
|
||||
if (currentView === 'vpn') {
|
||||
// Refresh VPN data if on the Network → VPN subview
|
||||
if (currentView === 'network' && currentSubview === 'vpn') {
|
||||
try {
|
||||
await vpnStatePart.dispatchAction(fetchVpnAction, null);
|
||||
} catch (error) {
|
||||
|
||||
1
ts_web/elements/access/index.ts
Normal file
1
ts_web/elements/access/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './ops-view-apitokens.js';
|
||||
@@ -1,6 +1,6 @@
|
||||
import * as appstate from '../appstate.js';
|
||||
import * as interfaces from '../../dist_ts_interfaces/index.js';
|
||||
import { viewHostCss } from './shared/css.js';
|
||||
import * as appstate from '../../appstate.js';
|
||||
import * as interfaces from '../../../dist_ts_interfaces/index.js';
|
||||
import { viewHostCss } from '../shared/css.js';
|
||||
|
||||
import {
|
||||
DeesElement,
|
||||
@@ -109,6 +109,7 @@ export class OpsViewApiTokens extends DeesElement {
|
||||
.data=${apiTokens}
|
||||
.dataName=${'token'}
|
||||
.searchable=${true}
|
||||
.showColumnFilters=${true}
|
||||
.displayFunction=${(token: interfaces.data.IApiTokenInfo) => ({
|
||||
name: token.name,
|
||||
scopes: this.renderScopePills(token.scopes),
|
||||
2
ts_web/elements/email/index.ts
Normal file
2
ts_web/elements/email/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './ops-view-emails.js';
|
||||
export * from './ops-view-email-security.js';
|
||||
160
ts_web/elements/email/ops-view-email-security.ts
Normal file
160
ts_web/elements/email/ops-view-email-security.ts
Normal file
@@ -0,0 +1,160 @@
|
||||
import * as appstate from '../../appstate.js';
|
||||
import { viewHostCss } from '../shared/css.js';
|
||||
|
||||
import {
|
||||
DeesElement,
|
||||
customElement,
|
||||
html,
|
||||
state,
|
||||
css,
|
||||
cssManager,
|
||||
type TemplateResult,
|
||||
} from '@design.estate/dees-element';
|
||||
import { type IStatsTile } from '@design.estate/dees-catalog';
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'ops-view-email-security': OpsViewEmailSecurity;
|
||||
}
|
||||
}
|
||||
|
||||
@customElement('ops-view-email-security')
|
||||
export class OpsViewEmailSecurity extends DeesElement {
|
||||
@state()
|
||||
accessor statsState: appstate.IStatsState = appstate.statsStatePart.getState()!;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
const sub = appstate.statsStatePart
|
||||
.select((s) => s)
|
||||
.subscribe((s) => {
|
||||
this.statsState = s;
|
||||
});
|
||||
this.rxSubscriptions.push(sub);
|
||||
}
|
||||
|
||||
public static styles = [
|
||||
cssManager.defaultStyles,
|
||||
viewHostCss,
|
||||
css`
|
||||
h2 {
|
||||
margin: 32px 0 16px 0;
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
color: ${cssManager.bdTheme('#333', '#ccc')};
|
||||
}
|
||||
dees-statsgrid {
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
.securityCard {
|
||||
background: ${cssManager.bdTheme('#fff', '#222')};
|
||||
border: 1px solid ${cssManager.bdTheme('#e9ecef', '#333')};
|
||||
border-radius: 8px;
|
||||
padding: 24px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
.actionButton {
|
||||
margin-top: 16px;
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
||||
public render(): TemplateResult {
|
||||
const metrics = this.statsState.securityMetrics;
|
||||
|
||||
if (!metrics) {
|
||||
return html`
|
||||
<div class="loadingMessage">
|
||||
<p>Loading security metrics...</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
const tiles: IStatsTile[] = [
|
||||
{
|
||||
id: 'malware',
|
||||
title: 'Malware Detection',
|
||||
value: metrics.malwareDetected,
|
||||
type: 'number',
|
||||
icon: 'lucide:BugOff',
|
||||
color: metrics.malwareDetected > 0 ? '#ef4444' : '#22c55e',
|
||||
description: 'Malware detected',
|
||||
},
|
||||
{
|
||||
id: 'phishing',
|
||||
title: 'Phishing Detection',
|
||||
value: metrics.phishingDetected,
|
||||
type: 'number',
|
||||
icon: 'lucide:Fish',
|
||||
color: metrics.phishingDetected > 0 ? '#ef4444' : '#22c55e',
|
||||
description: 'Phishing attempts detected',
|
||||
},
|
||||
{
|
||||
id: 'suspicious',
|
||||
title: 'Suspicious Activities',
|
||||
value: metrics.suspiciousActivities,
|
||||
type: 'number',
|
||||
icon: 'lucide:TriangleAlert',
|
||||
color: metrics.suspiciousActivities > 5 ? '#ef4444' : '#f59e0b',
|
||||
description: 'Suspicious activities detected',
|
||||
},
|
||||
{
|
||||
id: 'spam',
|
||||
title: 'Spam Detection',
|
||||
value: metrics.spamDetected,
|
||||
type: 'number',
|
||||
icon: 'lucide:Ban',
|
||||
color: '#f59e0b',
|
||||
description: 'Spam emails blocked',
|
||||
},
|
||||
];
|
||||
|
||||
return html`
|
||||
<dees-heading level="hr">Email Security</dees-heading>
|
||||
|
||||
<dees-statsgrid
|
||||
.tiles=${tiles}
|
||||
.minTileWidth=${200}
|
||||
></dees-statsgrid>
|
||||
|
||||
<h2>Email Security Configuration</h2>
|
||||
<div class="securityCard">
|
||||
<dees-form>
|
||||
<dees-input-checkbox
|
||||
.key=${'enableSPF'}
|
||||
.label=${'Enable SPF checking'}
|
||||
.value=${true}
|
||||
></dees-input-checkbox>
|
||||
<dees-input-checkbox
|
||||
.key=${'enableDKIM'}
|
||||
.label=${'Enable DKIM validation'}
|
||||
.value=${true}
|
||||
></dees-input-checkbox>
|
||||
<dees-input-checkbox
|
||||
.key=${'enableDMARC'}
|
||||
.label=${'Enable DMARC policy enforcement'}
|
||||
.value=${true}
|
||||
></dees-input-checkbox>
|
||||
<dees-input-checkbox
|
||||
.key=${'enableSpamFilter'}
|
||||
.label=${'Enable spam filtering'}
|
||||
.value=${true}
|
||||
></dees-input-checkbox>
|
||||
</dees-form>
|
||||
<dees-button
|
||||
class="actionButton"
|
||||
type="highlighted"
|
||||
@click=${() => this.saveEmailSecuritySettings()}
|
||||
>
|
||||
Save Settings
|
||||
</dees-button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private async saveEmailSecuritySettings() {
|
||||
// Config is read-only from the UI for now
|
||||
alert('Email security settings are read-only. Update the dcrouter configuration file to change these settings.');
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
import { DeesElement, property, html, customElement, type TemplateResult, css, state, cssManager } from '@design.estate/dees-element';
|
||||
import * as plugins from '../plugins.js';
|
||||
import * as appstate from '../appstate.js';
|
||||
import * as shared from './shared/index.js';
|
||||
import * as interfaces from '../../dist_ts_interfaces/index.js';
|
||||
import * as plugins from '../../plugins.js';
|
||||
import * as appstate from '../../appstate.js';
|
||||
import * as shared from '../shared/index.js';
|
||||
import * as interfaces from '../../../dist_ts_interfaces/index.js';
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
@@ -1,16 +1,9 @@
|
||||
export * from './ops-dashboard.js';
|
||||
export * from './ops-view-overview.js';
|
||||
export * from './ops-view-network.js';
|
||||
export * from './ops-view-emails.js';
|
||||
export * from './overview/index.js';
|
||||
export * from './network/index.js';
|
||||
export * from './email/index.js';
|
||||
export * from './ops-view-logs.js';
|
||||
export * from './ops-view-config.js';
|
||||
export * from './ops-view-routes.js';
|
||||
export * from './ops-view-apitokens.js';
|
||||
export * from './ops-view-security.js';
|
||||
export * from './access/index.js';
|
||||
export * from './security/index.js';
|
||||
export * from './ops-view-certificates.js';
|
||||
export * from './ops-view-remoteingress.js';
|
||||
export * from './ops-view-vpn.js';
|
||||
export * from './ops-view-sourceprofiles.js';
|
||||
export * from './ops-view-networktargets.js';
|
||||
export * from './ops-view-targetprofiles.js';
|
||||
export * from './shared/index.js';
|
||||
export * from './shared/index.js';
|
||||
|
||||
7
ts_web/elements/network/index.ts
Normal file
7
ts_web/elements/network/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export * from './ops-view-network-activity.js';
|
||||
export * from './ops-view-routes.js';
|
||||
export * from './ops-view-sourceprofiles.js';
|
||||
export * from './ops-view-networktargets.js';
|
||||
export * from './ops-view-targetprofiles.js';
|
||||
export * from './ops-view-remoteingress.js';
|
||||
export * from './ops-view-vpn.js';
|
||||
@@ -1,12 +1,12 @@
|
||||
import { DeesElement, property, html, customElement, type TemplateResult, css, state, cssManager } from '@design.estate/dees-element';
|
||||
import * as appstate from '../appstate.js';
|
||||
import * as interfaces from '../../dist_ts_interfaces/index.js';
|
||||
import { viewHostCss } from './shared/css.js';
|
||||
import * as appstate from '../../appstate.js';
|
||||
import * as interfaces from '../../../dist_ts_interfaces/index.js';
|
||||
import { viewHostCss } from '../shared/css.js';
|
||||
import { type IStatsTile } from '@design.estate/dees-catalog';
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'ops-view-network': OpsViewNetwork;
|
||||
'ops-view-network-activity': OpsViewNetworkActivity;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,14 +26,14 @@ interface INetworkRequest {
|
||||
route?: string;
|
||||
}
|
||||
|
||||
@customElement('ops-view-network')
|
||||
export class OpsViewNetwork extends DeesElement {
|
||||
@customElement('ops-view-network-activity')
|
||||
export class OpsViewNetworkActivity extends DeesElement {
|
||||
/** How far back the traffic chart shows */
|
||||
private static readonly CHART_WINDOW_MS = 5 * 60 * 1000; // 5 minutes
|
||||
/** How often a new data point is added */
|
||||
private static readonly UPDATE_INTERVAL_MS = 1000; // 1 second
|
||||
/** Derived: max data points the buffer holds */
|
||||
private static readonly MAX_DATA_POINTS = OpsViewNetwork.CHART_WINDOW_MS / OpsViewNetwork.UPDATE_INTERVAL_MS;
|
||||
private static readonly MAX_DATA_POINTS = OpsViewNetworkActivity.CHART_WINDOW_MS / OpsViewNetworkActivity.UPDATE_INTERVAL_MS;
|
||||
|
||||
@state()
|
||||
accessor statsState = appstate.statsStatePart.getState()!;
|
||||
@@ -50,10 +50,10 @@ export class OpsViewNetwork extends DeesElement {
|
||||
|
||||
@state()
|
||||
accessor trafficDataOut: Array<{ x: string | number; y: number }> = [];
|
||||
|
||||
|
||||
// Track if we need to update the chart to avoid unnecessary re-renders
|
||||
private lastChartUpdate = 0;
|
||||
private chartUpdateThreshold = OpsViewNetwork.UPDATE_INTERVAL_MS; // Minimum ms between chart updates
|
||||
private chartUpdateThreshold = OpsViewNetworkActivity.UPDATE_INTERVAL_MS; // Minimum ms between chart updates
|
||||
|
||||
private trafficUpdateTimer: any = null;
|
||||
private requestsPerSecHistory: number[] = []; // Track requests/sec over time for trend
|
||||
@@ -101,17 +101,17 @@ export class OpsViewNetwork extends DeesElement {
|
||||
this.updateNetworkData();
|
||||
});
|
||||
this.rxSubscriptions.push(statsUnsubscribe);
|
||||
|
||||
|
||||
const networkUnsubscribe = appstate.networkStatePart.select().subscribe((state) => {
|
||||
this.networkState = state;
|
||||
this.updateNetworkData();
|
||||
});
|
||||
this.rxSubscriptions.push(networkUnsubscribe);
|
||||
}
|
||||
|
||||
|
||||
private initializeTrafficData() {
|
||||
const now = Date.now();
|
||||
const { MAX_DATA_POINTS, UPDATE_INTERVAL_MS } = OpsViewNetwork;
|
||||
const { MAX_DATA_POINTS, UPDATE_INTERVAL_MS } = OpsViewNetworkActivity;
|
||||
|
||||
// Initialize with empty data points for both in and out
|
||||
const emptyData = Array.from({ length: MAX_DATA_POINTS }, (_, i) => {
|
||||
@@ -148,7 +148,7 @@ export class OpsViewNetwork extends DeesElement {
|
||||
y: Math.round((p.out * 8) / 1000000 * 10) / 10,
|
||||
}));
|
||||
|
||||
const { MAX_DATA_POINTS, UPDATE_INTERVAL_MS } = OpsViewNetwork;
|
||||
const { MAX_DATA_POINTS, UPDATE_INTERVAL_MS } = OpsViewNetworkActivity;
|
||||
|
||||
// Use history as the chart data, keeping the most recent points within the window
|
||||
const sliceStart = Math.max(0, historyIn.length - MAX_DATA_POINTS);
|
||||
@@ -285,8 +285,8 @@ export class OpsViewNetwork extends DeesElement {
|
||||
|
||||
public render() {
|
||||
return html`
|
||||
<dees-heading level="2">Network Activity</dees-heading>
|
||||
|
||||
<dees-heading level="hr">Network Activity</dees-heading>
|
||||
|
||||
<div class="networkContainer">
|
||||
<!-- Stats Grid -->
|
||||
${this.renderNetworkStats()}
|
||||
@@ -307,7 +307,7 @@ export class OpsViewNetwork extends DeesElement {
|
||||
}
|
||||
]}
|
||||
.realtimeMode=${true}
|
||||
.rollingWindow=${OpsViewNetwork.CHART_WINDOW_MS}
|
||||
.rollingWindow=${OpsViewNetworkActivity.CHART_WINDOW_MS}
|
||||
.yAxisFormatter=${(val: number) => `${val} Mbit/s`}
|
||||
></dees-chart-area>
|
||||
|
||||
@@ -357,7 +357,7 @@ export class OpsViewNetwork extends DeesElement {
|
||||
|
||||
private async showRequestDetails(request: INetworkRequest) {
|
||||
const { DeesModal } = await import('@design.estate/dees-catalog');
|
||||
|
||||
|
||||
await DeesModal.createAndShow({
|
||||
heading: 'Request Details',
|
||||
content: html`
|
||||
@@ -400,10 +400,10 @@ export class OpsViewNetwork extends DeesElement {
|
||||
if (!statusCode) {
|
||||
return html`<span class="statusBadge warning">N/A</span>`;
|
||||
}
|
||||
|
||||
|
||||
const statusClass = statusCode >= 200 && statusCode < 300 ? 'success' :
|
||||
statusCode >= 400 ? 'error' : 'warning';
|
||||
|
||||
|
||||
return html`<span class="statusBadge ${statusClass}">${statusCode}</span>`;
|
||||
}
|
||||
|
||||
@@ -426,26 +426,26 @@ export class OpsViewNetwork extends DeesElement {
|
||||
const units = ['B', 'KB', 'MB', 'GB'];
|
||||
let size = bytes;
|
||||
let unitIndex = 0;
|
||||
|
||||
|
||||
while (size >= 1024 && unitIndex < units.length - 1) {
|
||||
size /= 1024;
|
||||
unitIndex++;
|
||||
}
|
||||
|
||||
|
||||
return `${size.toFixed(1)} ${units[unitIndex]}`;
|
||||
}
|
||||
|
||||
|
||||
private formatBitsPerSecond(bytesPerSecond: number): string {
|
||||
const bitsPerSecond = bytesPerSecond * 8; // Convert bytes to bits
|
||||
const units = ['bit/s', 'kbit/s', 'Mbit/s', 'Gbit/s'];
|
||||
let size = bitsPerSecond;
|
||||
let unitIndex = 0;
|
||||
|
||||
|
||||
while (size >= 1000 && unitIndex < units.length - 1) {
|
||||
size /= 1000; // Use 1000 for bits (not 1024)
|
||||
unitIndex++;
|
||||
}
|
||||
|
||||
|
||||
return `${size.toFixed(1)} ${units[unitIndex]}`;
|
||||
}
|
||||
|
||||
@@ -520,18 +520,9 @@ export class OpsViewNetwork extends DeesElement {
|
||||
];
|
||||
|
||||
return html`
|
||||
<dees-statsgrid
|
||||
<dees-statsgrid
|
||||
.tiles=${tiles}
|
||||
.minTileWidth=${200}
|
||||
.gridActions=${[
|
||||
{
|
||||
name: 'Export Data',
|
||||
iconName: 'lucide:FileOutput',
|
||||
action: async () => {
|
||||
console.log('Export feature coming soon');
|
||||
},
|
||||
},
|
||||
]}
|
||||
></dees-statsgrid>
|
||||
`;
|
||||
}
|
||||
@@ -732,12 +723,12 @@ export class OpsViewNetwork extends DeesElement {
|
||||
// Only update if connections changed significantly
|
||||
const newConnectionCount = this.networkState.connections.length;
|
||||
const oldConnectionCount = this.networkRequests.length;
|
||||
|
||||
|
||||
// Check if we need to update the network requests array
|
||||
const shouldUpdate = newConnectionCount !== oldConnectionCount ||
|
||||
const shouldUpdate = newConnectionCount !== oldConnectionCount ||
|
||||
newConnectionCount === 0 ||
|
||||
(newConnectionCount > 0 && this.networkRequests.length === 0);
|
||||
|
||||
|
||||
if (shouldUpdate) {
|
||||
// Convert connection data to network requests format
|
||||
if (newConnectionCount > 0) {
|
||||
@@ -760,62 +751,62 @@ export class OpsViewNetwork extends DeesElement {
|
||||
this.networkRequests = [];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Load server-side throughput history into chart (once)
|
||||
if (!this.historyLoaded && this.networkState.throughputHistory && this.networkState.throughputHistory.length > 0) {
|
||||
this.loadThroughputHistory();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private startTrafficUpdateTimer() {
|
||||
this.stopTrafficUpdateTimer(); // Clear any existing timer
|
||||
this.trafficUpdateTimer = setInterval(() => {
|
||||
this.addTrafficDataPoint();
|
||||
}, OpsViewNetwork.UPDATE_INTERVAL_MS);
|
||||
}, OpsViewNetworkActivity.UPDATE_INTERVAL_MS);
|
||||
}
|
||||
|
||||
|
||||
private addTrafficDataPoint() {
|
||||
const now = Date.now();
|
||||
|
||||
|
||||
// Throttle chart updates to avoid excessive re-renders
|
||||
if (now - this.lastChartUpdate < this.chartUpdateThreshold) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
const throughput = this.calculateThroughput();
|
||||
|
||||
|
||||
// Convert to Mbps (bytes * 8 / 1,000,000)
|
||||
const throughputInMbps = (throughput.in * 8) / 1000000;
|
||||
const throughputOutMbps = (throughput.out * 8) / 1000000;
|
||||
|
||||
|
||||
// Add new data points
|
||||
const timestamp = new Date(now).toISOString();
|
||||
|
||||
|
||||
const newDataPointIn = {
|
||||
x: timestamp,
|
||||
y: Math.round(throughputInMbps * 10) / 10
|
||||
};
|
||||
|
||||
|
||||
const newDataPointOut = {
|
||||
x: timestamp,
|
||||
y: Math.round(throughputOutMbps * 10) / 10
|
||||
};
|
||||
|
||||
|
||||
// In-place mutation then reassign for Lit reactivity (avoids 4 intermediate arrays)
|
||||
if (this.trafficDataIn.length >= OpsViewNetwork.MAX_DATA_POINTS) {
|
||||
if (this.trafficDataIn.length >= OpsViewNetworkActivity.MAX_DATA_POINTS) {
|
||||
this.trafficDataIn.shift();
|
||||
this.trafficDataOut.shift();
|
||||
}
|
||||
this.trafficDataIn = [...this.trafficDataIn, newDataPointIn];
|
||||
this.trafficDataOut = [...this.trafficDataOut, newDataPointOut];
|
||||
|
||||
|
||||
this.lastChartUpdate = now;
|
||||
}
|
||||
|
||||
|
||||
private stopTrafficUpdateTimer() {
|
||||
if (this.trafficUpdateTimer) {
|
||||
clearInterval(this.trafficUpdateTimer);
|
||||
this.trafficUpdateTimer = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,9 +7,9 @@ import {
|
||||
state,
|
||||
cssManager,
|
||||
} from '@design.estate/dees-element';
|
||||
import * as appstate from '../appstate.js';
|
||||
import * as interfaces from '../../dist_ts_interfaces/index.js';
|
||||
import { viewHostCss } from './shared/css.js';
|
||||
import * as appstate from '../../appstate.js';
|
||||
import * as interfaces from '../../../dist_ts_interfaces/index.js';
|
||||
import { viewHostCss } from '../shared/css.js';
|
||||
import { type IStatsTile } from '@design.estate/dees-catalog';
|
||||
|
||||
declare global {
|
||||
@@ -64,13 +64,14 @@ export class OpsViewNetworkTargets extends DeesElement {
|
||||
];
|
||||
|
||||
return html`
|
||||
<dees-heading level="2">Network Targets</dees-heading>
|
||||
<dees-heading level="hr">Network Targets</dees-heading>
|
||||
<div class="targetsContainer">
|
||||
<dees-statsgrid .tiles=${statsTiles}></dees-statsgrid>
|
||||
<dees-table
|
||||
.heading1=${'Network Targets'}
|
||||
.heading2=${'Reusable host:port destinations for routes'}
|
||||
.data=${targets}
|
||||
.showColumnFilters=${true}
|
||||
.displayFunction=${(target: interfaces.data.INetworkTarget) => ({
|
||||
Name: target.name,
|
||||
Host: Array.isArray(target.host) ? target.host.join(', ') : target.host,
|
||||
@@ -7,9 +7,9 @@ import {
|
||||
state,
|
||||
cssManager,
|
||||
} from '@design.estate/dees-element';
|
||||
import * as appstate from '../appstate.js';
|
||||
import * as interfaces from '../../dist_ts_interfaces/index.js';
|
||||
import { viewHostCss } from './shared/css.js';
|
||||
import * as appstate from '../../appstate.js';
|
||||
import * as interfaces from '../../../dist_ts_interfaces/index.js';
|
||||
import { viewHostCss } from '../shared/css.js';
|
||||
import { type IStatsTile } from '@design.estate/dees-catalog';
|
||||
|
||||
declare global {
|
||||
@@ -174,7 +174,7 @@ export class OpsViewRemoteIngress extends DeesElement {
|
||||
];
|
||||
|
||||
return html`
|
||||
<dees-heading level="2">Remote Ingress</dees-heading>
|
||||
<dees-heading level="hr">Remote Ingress</dees-heading>
|
||||
|
||||
${this.riState.newEdgeId ? html`
|
||||
<div class="secretDialog">
|
||||
@@ -220,6 +220,7 @@ export class OpsViewRemoteIngress extends DeesElement {
|
||||
.heading1=${'Edge Nodes'}
|
||||
.heading2=${'Manage remote ingress edge registrations'}
|
||||
.data=${this.riState.edges}
|
||||
.showColumnFilters=${true}
|
||||
.displayFunction=${(edge: interfaces.data.IRemoteIngress) => ({
|
||||
name: edge.name,
|
||||
status: this.getEdgeStatusHtml(edge),
|
||||
@@ -1,6 +1,6 @@
|
||||
import * as appstate from '../appstate.js';
|
||||
import * as interfaces from '../../dist_ts_interfaces/index.js';
|
||||
import { viewHostCss } from './shared/css.js';
|
||||
import * as appstate from '../../appstate.js';
|
||||
import * as interfaces from '../../../dist_ts_interfaces/index.js';
|
||||
import { viewHostCss } from '../shared/css.js';
|
||||
import { type IStatsTile } from '@design.estate/dees-catalog';
|
||||
|
||||
import {
|
||||
@@ -200,7 +200,7 @@ export class OpsViewRoutes extends DeesElement {
|
||||
});
|
||||
|
||||
return html`
|
||||
<dees-heading level="2">Route Management</dees-heading>
|
||||
<dees-heading level="hr">Route Management</dees-heading>
|
||||
|
||||
<div class="routesContainer">
|
||||
<dees-statsgrid
|
||||
@@ -7,9 +7,9 @@ import {
|
||||
state,
|
||||
cssManager,
|
||||
} from '@design.estate/dees-element';
|
||||
import * as appstate from '../appstate.js';
|
||||
import * as interfaces from '../../dist_ts_interfaces/index.js';
|
||||
import { viewHostCss } from './shared/css.js';
|
||||
import * as appstate from '../../appstate.js';
|
||||
import * as interfaces from '../../../dist_ts_interfaces/index.js';
|
||||
import { viewHostCss } from '../shared/css.js';
|
||||
import { type IStatsTile } from '@design.estate/dees-catalog';
|
||||
|
||||
declare global {
|
||||
@@ -64,13 +64,14 @@ export class OpsViewSourceProfiles extends DeesElement {
|
||||
];
|
||||
|
||||
return html`
|
||||
<dees-heading level="2">Source Profiles</dees-heading>
|
||||
<dees-heading level="hr">Source Profiles</dees-heading>
|
||||
<div class="profilesContainer">
|
||||
<dees-statsgrid .tiles=${statsTiles}></dees-statsgrid>
|
||||
<dees-table
|
||||
.heading1=${'Source Profiles'}
|
||||
.heading2=${'Reusable source configurations for routes'}
|
||||
.data=${profiles}
|
||||
.showColumnFilters=${true}
|
||||
.displayFunction=${(profile: interfaces.data.ISourceProfile) => ({
|
||||
Name: profile.name,
|
||||
Description: profile.description || '-',
|
||||
@@ -149,7 +150,8 @@ export class OpsViewSourceProfiles extends DeesElement {
|
||||
const data = await form.collectFormData();
|
||||
const ipAllowList: string[] = Array.isArray(data.ipAllowList) ? data.ipAllowList : [];
|
||||
const ipBlockList: string[] = Array.isArray(data.ipBlockList) ? data.ipBlockList : [];
|
||||
const maxConnections = data.maxConnections ? parseInt(String(data.maxConnections)) : undefined;
|
||||
const parsed = data.maxConnections ? parseInt(String(data.maxConnections), 10) : NaN;
|
||||
const maxConnections = Number.isNaN(parsed) ? undefined : parsed;
|
||||
|
||||
await appstate.profilesTargetsStatePart.dispatchAction(appstate.createProfileAction, {
|
||||
name: String(data.name),
|
||||
@@ -190,7 +192,8 @@ export class OpsViewSourceProfiles extends DeesElement {
|
||||
const data = await form.collectFormData();
|
||||
const ipAllowList: string[] = Array.isArray(data.ipAllowList) ? data.ipAllowList : [];
|
||||
const ipBlockList: string[] = Array.isArray(data.ipBlockList) ? data.ipBlockList : [];
|
||||
const maxConnections = data.maxConnections ? parseInt(String(data.maxConnections)) : undefined;
|
||||
const parsed = data.maxConnections ? parseInt(String(data.maxConnections), 10) : NaN;
|
||||
const maxConnections = Number.isNaN(parsed) ? undefined : parsed;
|
||||
|
||||
await appstate.profilesTargetsStatePart.dispatchAction(appstate.updateProfileAction, {
|
||||
id: profile.id,
|
||||
@@ -7,10 +7,10 @@ import {
|
||||
state,
|
||||
cssManager,
|
||||
} from '@design.estate/dees-element';
|
||||
import * as plugins from '../plugins.js';
|
||||
import * as appstate from '../appstate.js';
|
||||
import * as interfaces from '../../dist_ts_interfaces/index.js';
|
||||
import { viewHostCss } from './shared/css.js';
|
||||
import * as plugins from '../../plugins.js';
|
||||
import * as appstate from '../../appstate.js';
|
||||
import * as interfaces from '../../../dist_ts_interfaces/index.js';
|
||||
import { viewHostCss } from '../shared/css.js';
|
||||
import { type IStatsTile } from '@design.estate/dees-catalog';
|
||||
|
||||
declare global {
|
||||
@@ -77,13 +77,14 @@ export class OpsViewTargetProfiles extends DeesElement {
|
||||
];
|
||||
|
||||
return html`
|
||||
<dees-heading level="2">Target Profiles</dees-heading>
|
||||
<dees-heading level="hr">Target Profiles</dees-heading>
|
||||
<div class="profilesContainer">
|
||||
<dees-statsgrid .tiles=${statsTiles}></dees-statsgrid>
|
||||
<dees-table
|
||||
.heading1=${'Target Profiles'}
|
||||
.heading2=${'Define what resources VPN clients can access'}
|
||||
.data=${profiles}
|
||||
.showColumnFilters=${true}
|
||||
.displayFunction=${(profile: interfaces.data.ITargetProfile) => ({
|
||||
Name: profile.name,
|
||||
Description: profile.description || '-',
|
||||
@@ -91,7 +92,7 @@ export class OpsViewTargetProfiles extends DeesElement {
|
||||
? html`${profile.domains.map(d => html`<span class="tagBadge">${d}</span>`)}`
|
||||
: '-',
|
||||
Targets: profile.targets?.length
|
||||
? html`${profile.targets.map(t => html`<span class="tagBadge">${t.host}:${t.port}</span>`)}`
|
||||
? html`${profile.targets.map(t => html`<span class="tagBadge">${t.ip}:${t.port}</span>`)}`
|
||||
: '-',
|
||||
'Route Refs': profile.routeRefs?.length
|
||||
? html`${profile.routeRefs.map(r => html`<span class="tagBadge">${r}</span>`)}`
|
||||
@@ -175,7 +176,7 @@ export class OpsViewTargetProfiles extends DeesElement {
|
||||
<dees-input-text .key=${'name'} .label=${'Name'} .required=${true}></dees-input-text>
|
||||
<dees-input-text .key=${'description'} .label=${'Description'}></dees-input-text>
|
||||
<dees-input-list .key=${'domains'} .label=${'Domains'} .placeholder=${'e.g. *.example.com'} .allowFreeform=${true}></dees-input-list>
|
||||
<dees-input-list .key=${'targets'} .label=${'Targets (host:port)'} .placeholder=${'e.g. 10.0.0.1:443'} .allowFreeform=${true}></dees-input-list>
|
||||
<dees-input-list .key=${'targets'} .label=${'Targets (ip:port)'} .placeholder=${'e.g. 10.0.0.1:443'} .allowFreeform=${true}></dees-input-list>
|
||||
<dees-input-list .key=${'routeRefs'} .label=${'Route Refs'} .placeholder=${'Type to search routes...'} .candidates=${routeCandidates} .allowFreeform=${true}></dees-input-list>
|
||||
</dees-form>
|
||||
`,
|
||||
@@ -197,11 +198,11 @@ export class OpsViewTargetProfiles extends DeesElement {
|
||||
const lastColon = s.lastIndexOf(':');
|
||||
if (lastColon === -1) return null;
|
||||
return {
|
||||
host: s.substring(0, lastColon),
|
||||
ip: s.substring(0, lastColon),
|
||||
port: parseInt(s.substring(lastColon + 1), 10),
|
||||
};
|
||||
})
|
||||
.filter((t): t is { host: string; port: number } => t !== null && !isNaN(t.port));
|
||||
.filter((t): t is { ip: string; port: number } => t !== null && !isNaN(t.port));
|
||||
const routeRefs: string[] = Array.isArray(data.routeRefs) ? data.routeRefs : [];
|
||||
|
||||
await appstate.targetProfilesStatePart.dispatchAction(appstate.createTargetProfileAction, {
|
||||
@@ -220,7 +221,7 @@ export class OpsViewTargetProfiles extends DeesElement {
|
||||
|
||||
private async showEditProfileDialog(profile: interfaces.data.ITargetProfile) {
|
||||
const currentDomains = profile.domains || [];
|
||||
const currentTargets = profile.targets?.map(t => `${t.host}:${t.port}`) || [];
|
||||
const currentTargets = profile.targets?.map(t => `${t.ip}:${t.port}`) || [];
|
||||
const currentRouteRefs = profile.routeRefs || [];
|
||||
|
||||
const { DeesModal } = await import('@design.estate/dees-catalog');
|
||||
@@ -234,7 +235,7 @@ export class OpsViewTargetProfiles extends DeesElement {
|
||||
<dees-input-text .key=${'name'} .label=${'Name'} .value=${profile.name}></dees-input-text>
|
||||
<dees-input-text .key=${'description'} .label=${'Description'} .value=${profile.description || ''}></dees-input-text>
|
||||
<dees-input-list .key=${'domains'} .label=${'Domains'} .placeholder=${'e.g. *.example.com'} .allowFreeform=${true} .value=${currentDomains}></dees-input-list>
|
||||
<dees-input-list .key=${'targets'} .label=${'Targets (host:port)'} .placeholder=${'e.g. 10.0.0.1:443'} .allowFreeform=${true} .value=${currentTargets}></dees-input-list>
|
||||
<dees-input-list .key=${'targets'} .label=${'Targets (ip:port)'} .placeholder=${'e.g. 10.0.0.1:443'} .allowFreeform=${true} .value=${currentTargets}></dees-input-list>
|
||||
<dees-input-list .key=${'routeRefs'} .label=${'Route Refs'} .placeholder=${'Type to search routes...'} .candidates=${routeCandidates} .allowFreeform=${true} .value=${currentRouteRefs}></dees-input-list>
|
||||
</dees-form>
|
||||
`,
|
||||
@@ -255,11 +256,11 @@ export class OpsViewTargetProfiles extends DeesElement {
|
||||
const lastColon = s.lastIndexOf(':');
|
||||
if (lastColon === -1) return null;
|
||||
return {
|
||||
host: s.substring(0, lastColon),
|
||||
ip: s.substring(0, lastColon),
|
||||
port: parseInt(s.substring(lastColon + 1), 10),
|
||||
};
|
||||
})
|
||||
.filter((t): t is { host: string; port: number } => t !== null && !isNaN(t.port));
|
||||
.filter((t): t is { ip: string; port: number } => t !== null && !isNaN(t.port));
|
||||
const routeRefs: string[] = Array.isArray(data.routeRefs) ? data.routeRefs : [];
|
||||
|
||||
await appstate.targetProfilesStatePart.dispatchAction(appstate.updateTargetProfileAction, {
|
||||
@@ -327,7 +328,7 @@ export class OpsViewTargetProfiles extends DeesElement {
|
||||
<div style="font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; color: ${cssManager.bdTheme('#6b7280', '#9ca3af')};">Targets</div>
|
||||
<div style="font-size: 14px; margin-top: 4px;">
|
||||
${profile.targets?.length
|
||||
? profile.targets.map(t => html`<span class="tagBadge">${t.host}:${t.port}</span>`)
|
||||
? profile.targets.map(t => html`<span class="tagBadge">${t.ip}:${t.port}</span>`)
|
||||
: '-'}
|
||||
</div>
|
||||
</div>
|
||||
@@ -7,10 +7,10 @@ import {
|
||||
state,
|
||||
cssManager,
|
||||
} from '@design.estate/dees-element';
|
||||
import * as plugins from '../plugins.js';
|
||||
import * as appstate from '../appstate.js';
|
||||
import * as interfaces from '../../dist_ts_interfaces/index.js';
|
||||
import { viewHostCss } from './shared/css.js';
|
||||
import * as plugins from '../../plugins.js';
|
||||
import * as appstate from '../../appstate.js';
|
||||
import * as interfaces from '../../../dist_ts_interfaces/index.js';
|
||||
import { viewHostCss } from '../shared/css.js';
|
||||
import { type IStatsTile } from '@design.estate/dees-catalog';
|
||||
|
||||
/**
|
||||
@@ -28,7 +28,7 @@ function setupFormVisibility(formEl: any) {
|
||||
const staticIpGroup = contentEl.querySelector('.staticIpGroup') as HTMLElement;
|
||||
const vlanIdGroup = contentEl.querySelector('.vlanIdGroup') as HTMLElement;
|
||||
const aclGroup = contentEl.querySelector('.aclGroup') as HTMLElement;
|
||||
if (hostIpGroup) hostIpGroup.style.display = data.forceDestinationSmartproxy ? 'none' : show;
|
||||
if (hostIpGroup) hostIpGroup.style.display = show; // always show (forceTarget is always on)
|
||||
if (hostIpDetails) hostIpDetails.style.display = data.useHostIp ? show : 'none';
|
||||
if (staticIpGroup) staticIpGroup.style.display = data.useDhcp ? 'none' : show;
|
||||
if (vlanIdGroup) vlanIdGroup.style.display = data.forceVlan ? show : 'none';
|
||||
@@ -223,7 +223,7 @@ export class OpsViewVpn extends DeesElement {
|
||||
];
|
||||
|
||||
return html`
|
||||
<dees-heading level="2">VPN</dees-heading>
|
||||
<dees-heading level="hr">VPN</dees-heading>
|
||||
<div class="vpnContainer">
|
||||
|
||||
${this.vpnState.newClientConfig ? html`
|
||||
@@ -305,6 +305,7 @@ export class OpsViewVpn extends DeesElement {
|
||||
.heading1=${'VPN Clients'}
|
||||
.heading2=${'Manage WireGuard and SmartVPN client registrations'}
|
||||
.data=${clients}
|
||||
.showColumnFilters=${true}
|
||||
.displayFunction=${(client: interfaces.data.IVpnClient) => {
|
||||
const conn = this.getConnectedInfo(client);
|
||||
let statusHtml;
|
||||
@@ -317,9 +318,7 @@ export class OpsViewVpn extends DeesElement {
|
||||
statusHtml = html`<span class="statusBadge enabled" style="background: ${cssManager.bdTheme('#eff6ff', '#172554')}; color: ${cssManager.bdTheme('#1e40af', '#60a5fa')};">offline</span>`;
|
||||
}
|
||||
let routingHtml;
|
||||
if (client.forceDestinationSmartproxy !== false) {
|
||||
routingHtml = html`<span class="statusBadge enabled">SmartProxy</span>`;
|
||||
} else if (client.useHostIp) {
|
||||
if (client.useHostIp) {
|
||||
routingHtml = html`<span class="statusBadge" style="background: ${cssManager.bdTheme('#f3e8ff', '#3b0764')}; color: ${cssManager.bdTheme('#7c3aed', '#c084fc')};">Host IP</span>`;
|
||||
} else {
|
||||
routingHtml = html`<span class="statusBadge" style="background: ${cssManager.bdTheme('#eff6ff', '#172554')}; color: ${cssManager.bdTheme('#1e40af', '#60a5fa')};">Direct</span>`;
|
||||
@@ -355,8 +354,7 @@ export class OpsViewVpn extends DeesElement {
|
||||
<dees-input-text .key=${'clientId'} .label=${'Client ID'} .required=${true}></dees-input-text>
|
||||
<dees-input-text .key=${'description'} .label=${'Description'}></dees-input-text>
|
||||
<dees-input-list .key=${'targetProfileNames'} .label=${'Target Profiles'} .placeholder=${'Type to search profiles...'} .candidates=${profileCandidates} .allowFreeform=${false}></dees-input-list>
|
||||
<dees-input-checkbox .key=${'forceDestinationSmartproxy'} .label=${'Force traffic through SmartProxy'} .value=${true}></dees-input-checkbox>
|
||||
<div class="hostIpGroup" style="display: none; flex-direction: column; gap: 16px;">
|
||||
<div class="hostIpGroup" style="display: flex; flex-direction: column; gap: 16px;">
|
||||
<dees-input-checkbox .key=${'useHostIp'} .label=${'Get Host IP'} .value=${false}></dees-input-checkbox>
|
||||
<div class="hostIpDetails" style="display: none; flex-direction: column; gap: 16px;">
|
||||
<dees-input-checkbox .key=${'useDhcp'} .label=${'Get IP through DHCP'} .value=${false}></dees-input-checkbox>
|
||||
@@ -395,8 +393,7 @@ export class OpsViewVpn extends DeesElement {
|
||||
);
|
||||
|
||||
// Apply conditional logic based on checkbox states
|
||||
const forceSmartproxy = data.forceDestinationSmartproxy ?? true;
|
||||
const useHostIp = !forceSmartproxy && (data.useHostIp ?? false);
|
||||
const useHostIp = data.useHostIp ?? false;
|
||||
const useDhcp = useHostIp && (data.useDhcp ?? false);
|
||||
const staticIp = useHostIp && !useDhcp && data.staticIp ? data.staticIp : undefined;
|
||||
const forceVlan = useHostIp && (data.forceVlan ?? false);
|
||||
@@ -414,7 +411,7 @@ export class OpsViewVpn extends DeesElement {
|
||||
clientId: data.clientId,
|
||||
description: data.description || undefined,
|
||||
targetProfileIds,
|
||||
forceDestinationSmartproxy: forceSmartproxy,
|
||||
|
||||
useHostIp: useHostIp || undefined,
|
||||
useDhcp: useDhcp || undefined,
|
||||
staticIp,
|
||||
@@ -487,7 +484,7 @@ export class OpsViewVpn extends DeesElement {
|
||||
` : ''}
|
||||
<div class="infoItem"><span class="infoLabel">Description</span><span class="infoValue">${client.description || '-'}</span></div>
|
||||
<div class="infoItem"><span class="infoLabel">Target Profiles</span><span class="infoValue">${this.resolveProfileIdsToNames(client.targetProfileIds)?.join(', ') || '-'}</span></div>
|
||||
<div class="infoItem"><span class="infoLabel">Routing</span><span class="infoValue">${client.forceDestinationSmartproxy !== false ? 'SmartProxy' : client.useHostIp ? 'Host IP' : 'Direct'}</span></div>
|
||||
<div class="infoItem"><span class="infoLabel">Routing</span><span class="infoValue">${client.useHostIp ? 'Host IP' : 'SmartProxy'}</span></div>
|
||||
${client.useHostIp ? html`
|
||||
<div class="infoItem"><span class="infoLabel">Host IP</span><span class="infoValue">${client.useDhcp ? 'DHCP' : client.staticIp ? `Static: ${client.staticIp}` : 'Not configured'}</span></div>
|
||||
<div class="infoItem"><span class="infoLabel">VLAN</span><span class="infoValue">${client.forceVlan && client.vlanId != null ? `VLAN ${client.vlanId}` : 'No VLAN'}</span></div>
|
||||
@@ -652,7 +649,6 @@ export class OpsViewVpn extends DeesElement {
|
||||
const currentDescription = client.description ?? '';
|
||||
const currentTargetProfileNames = this.resolveProfileIdsToNames(client.targetProfileIds) || [];
|
||||
const profileCandidates = this.getTargetProfileCandidates();
|
||||
const currentForceSmartproxy = client.forceDestinationSmartproxy ?? true;
|
||||
const currentUseHostIp = client.useHostIp ?? false;
|
||||
const currentUseDhcp = client.useDhcp ?? false;
|
||||
const currentStaticIp = client.staticIp ?? '';
|
||||
@@ -668,8 +664,7 @@ export class OpsViewVpn extends DeesElement {
|
||||
<dees-form>
|
||||
<dees-input-text .key=${'description'} .label=${'Description'} .value=${currentDescription}></dees-input-text>
|
||||
<dees-input-list .key=${'targetProfileNames'} .label=${'Target Profiles'} .placeholder=${'Type to search profiles...'} .candidates=${profileCandidates} .allowFreeform=${false} .value=${currentTargetProfileNames}></dees-input-list>
|
||||
<dees-input-checkbox .key=${'forceDestinationSmartproxy'} .label=${'Force traffic through SmartProxy'} .value=${currentForceSmartproxy}></dees-input-checkbox>
|
||||
<div class="hostIpGroup" style="display: ${currentForceSmartproxy ? 'none' : 'flex'}; flex-direction: column; gap: 16px;">
|
||||
<div class="hostIpGroup" style="display: flex; flex-direction: column; gap: 16px;">
|
||||
<dees-input-checkbox .key=${'useHostIp'} .label=${'Get Host IP'} .value=${currentUseHostIp}></dees-input-checkbox>
|
||||
<div class="hostIpDetails" style="display: ${currentUseHostIp ? 'flex' : 'none'}; flex-direction: column; gap: 16px;">
|
||||
<dees-input-checkbox .key=${'useDhcp'} .label=${'Get IP through DHCP'} .value=${currentUseDhcp}></dees-input-checkbox>
|
||||
@@ -703,8 +698,7 @@ export class OpsViewVpn extends DeesElement {
|
||||
);
|
||||
|
||||
// Apply conditional logic based on checkbox states
|
||||
const forceSmartproxy = data.forceDestinationSmartproxy ?? true;
|
||||
const useHostIp = !forceSmartproxy && (data.useHostIp ?? false);
|
||||
const useHostIp = data.useHostIp ?? false;
|
||||
const useDhcp = useHostIp && (data.useDhcp ?? false);
|
||||
const staticIp = useHostIp && !useDhcp && data.staticIp ? data.staticIp : undefined;
|
||||
const forceVlan = useHostIp && (data.forceVlan ?? false);
|
||||
@@ -722,7 +716,7 @@ export class OpsViewVpn extends DeesElement {
|
||||
clientId: client.clientId,
|
||||
description: data.description || undefined,
|
||||
targetProfileIds,
|
||||
forceDestinationSmartproxy: forceSmartproxy,
|
||||
|
||||
useHostIp: useHostIp || undefined,
|
||||
useDhcp: useDhcp || undefined,
|
||||
staticIp,
|
||||
@@ -11,22 +11,45 @@ import {
|
||||
state,
|
||||
type TemplateResult
|
||||
} from '@design.estate/dees-element';
|
||||
import type { IView } from '@design.estate/dees-catalog';
|
||||
|
||||
// Import view components
|
||||
import { OpsViewOverview } from './ops-view-overview.js';
|
||||
import { OpsViewNetwork } from './ops-view-network.js';
|
||||
import { OpsViewEmails } from './ops-view-emails.js';
|
||||
// Top-level / flat views
|
||||
import { OpsViewLogs } from './ops-view-logs.js';
|
||||
import { OpsViewConfig } from './ops-view-config.js';
|
||||
import { OpsViewRoutes } from './ops-view-routes.js';
|
||||
import { OpsViewApiTokens } from './ops-view-apitokens.js';
|
||||
import { OpsViewSecurity } from './ops-view-security.js';
|
||||
import { OpsViewCertificates } from './ops-view-certificates.js';
|
||||
import { OpsViewRemoteIngress } from './ops-view-remoteingress.js';
|
||||
import { OpsViewVpn } from './ops-view-vpn.js';
|
||||
import { OpsViewSourceProfiles } from './ops-view-sourceprofiles.js';
|
||||
import { OpsViewNetworkTargets } from './ops-view-networktargets.js';
|
||||
import { OpsViewTargetProfiles } from './ops-view-targetprofiles.js';
|
||||
|
||||
// Overview group
|
||||
import { OpsViewOverview } from './overview/ops-view-overview.js';
|
||||
import { OpsViewConfig } from './overview/ops-view-config.js';
|
||||
|
||||
// Network group
|
||||
import { OpsViewNetworkActivity } from './network/ops-view-network-activity.js';
|
||||
import { OpsViewRoutes } from './network/ops-view-routes.js';
|
||||
import { OpsViewSourceProfiles } from './network/ops-view-sourceprofiles.js';
|
||||
import { OpsViewNetworkTargets } from './network/ops-view-networktargets.js';
|
||||
import { OpsViewTargetProfiles } from './network/ops-view-targetprofiles.js';
|
||||
import { OpsViewRemoteIngress } from './network/ops-view-remoteingress.js';
|
||||
import { OpsViewVpn } from './network/ops-view-vpn.js';
|
||||
|
||||
// Email group
|
||||
import { OpsViewEmails } from './email/ops-view-emails.js';
|
||||
import { OpsViewEmailSecurity } from './email/ops-view-email-security.js';
|
||||
|
||||
// Access group
|
||||
import { OpsViewApiTokens } from './access/ops-view-apitokens.js';
|
||||
|
||||
// Security group
|
||||
import { OpsViewSecurityOverview } from './security/ops-view-security-overview.js';
|
||||
import { OpsViewSecurityBlocked } from './security/ops-view-security-blocked.js';
|
||||
import { OpsViewSecurityAuthentication } from './security/ops-view-security-authentication.js';
|
||||
|
||||
/**
|
||||
* Extended IView with explicit URL slug. Without an explicit `slug`, the URL
|
||||
* slug is derived from `name.toLowerCase().replace(/\s+/g, '')`.
|
||||
*/
|
||||
interface ITabbedView extends IView {
|
||||
slug?: string;
|
||||
subViews?: ITabbedView[];
|
||||
}
|
||||
|
||||
@customElement('ops-dashboard')
|
||||
export class OpsDashboard extends DeesElement {
|
||||
@@ -37,6 +60,7 @@ export class OpsDashboard extends DeesElement {
|
||||
|
||||
@state() accessor uiState: appstate.IUiState = {
|
||||
activeView: 'overview',
|
||||
activeSubview: null,
|
||||
sidebarCollapsed: false,
|
||||
autoRefresh: true,
|
||||
refreshInterval: 1000,
|
||||
@@ -49,27 +73,36 @@ export class OpsDashboard extends DeesElement {
|
||||
error: null,
|
||||
};
|
||||
|
||||
// Store viewTabs as a property to maintain object references
|
||||
private viewTabs = [
|
||||
// Store viewTabs as a property to maintain object references (used for === selectedView identity)
|
||||
private viewTabs: ITabbedView[] = [
|
||||
{
|
||||
name: 'Overview',
|
||||
iconName: 'lucide:layoutDashboard',
|
||||
element: OpsViewOverview,
|
||||
},
|
||||
{
|
||||
name: 'Configuration',
|
||||
iconName: 'lucide:settings',
|
||||
element: OpsViewConfig,
|
||||
subViews: [
|
||||
{ slug: 'stats', name: 'Stats', iconName: 'lucide:activity', element: OpsViewOverview },
|
||||
{ slug: 'configuration', name: 'Configuration', iconName: 'lucide:settings', element: OpsViewConfig },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Network',
|
||||
iconName: 'lucide:network',
|
||||
element: OpsViewNetwork,
|
||||
subViews: [
|
||||
{ slug: 'activity', name: 'Network Activity', iconName: 'lucide:activity', element: OpsViewNetworkActivity },
|
||||
{ slug: 'routes', name: 'Routes', iconName: 'lucide:route', element: OpsViewRoutes },
|
||||
{ slug: 'sourceprofiles', name: 'Source Profiles', iconName: 'lucide:shieldCheck', element: OpsViewSourceProfiles },
|
||||
{ slug: 'networktargets', name: 'Network Targets', iconName: 'lucide:server', element: OpsViewNetworkTargets },
|
||||
{ slug: 'targetprofiles', name: 'Target Profiles', iconName: 'lucide:target', element: OpsViewTargetProfiles },
|
||||
{ slug: 'remoteingress', name: 'Remote Ingress', iconName: 'lucide:globe', element: OpsViewRemoteIngress },
|
||||
{ slug: 'vpn', name: 'VPN', iconName: 'lucide:shield', element: OpsViewVpn },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Emails',
|
||||
name: 'Email',
|
||||
iconName: 'lucide:mail',
|
||||
element: OpsViewEmails,
|
||||
subViews: [
|
||||
{ slug: 'log', name: 'Email Log', iconName: 'lucide:scrollText', element: OpsViewEmails },
|
||||
{ slug: 'security', name: 'Email Security', iconName: 'lucide:shieldCheck', element: OpsViewEmailSecurity },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Logs',
|
||||
@@ -77,52 +110,48 @@ export class OpsDashboard extends DeesElement {
|
||||
element: OpsViewLogs,
|
||||
},
|
||||
{
|
||||
name: 'Routes',
|
||||
iconName: 'lucide:route',
|
||||
element: OpsViewRoutes,
|
||||
},
|
||||
{
|
||||
name: 'SourceProfiles',
|
||||
iconName: 'lucide:shieldCheck',
|
||||
element: OpsViewSourceProfiles,
|
||||
},
|
||||
{
|
||||
name: 'NetworkTargets',
|
||||
iconName: 'lucide:server',
|
||||
element: OpsViewNetworkTargets,
|
||||
},
|
||||
{
|
||||
name: 'TargetProfiles',
|
||||
iconName: 'lucide:target',
|
||||
element: OpsViewTargetProfiles,
|
||||
},
|
||||
{
|
||||
name: 'ApiTokens',
|
||||
iconName: 'lucide:key',
|
||||
element: OpsViewApiTokens,
|
||||
name: 'Access',
|
||||
iconName: 'lucide:keyRound',
|
||||
subViews: [
|
||||
{ slug: 'apitokens', name: 'API Tokens', iconName: 'lucide:key', element: OpsViewApiTokens },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Security',
|
||||
iconName: 'lucide:shield',
|
||||
element: OpsViewSecurity,
|
||||
subViews: [
|
||||
{ slug: 'overview', name: 'Overview', iconName: 'lucide:eye', element: OpsViewSecurityOverview },
|
||||
{ slug: 'blocked', name: 'Blocked IPs', iconName: 'lucide:shieldBan', element: OpsViewSecurityBlocked },
|
||||
{ slug: 'authentication', name: 'Authentication', iconName: 'lucide:lock', element: OpsViewSecurityAuthentication },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Certificates',
|
||||
iconName: 'lucide:badgeCheck',
|
||||
element: OpsViewCertificates,
|
||||
},
|
||||
{
|
||||
name: 'RemoteIngress',
|
||||
iconName: 'lucide:globe',
|
||||
element: OpsViewRemoteIngress,
|
||||
},
|
||||
{
|
||||
name: 'VPN',
|
||||
iconName: 'lucide:shield',
|
||||
element: OpsViewVpn,
|
||||
},
|
||||
];
|
||||
|
||||
/** URL slug for a view (explicit `slug` field, or lowercased name with spaces stripped). */
|
||||
private slugFor(view: ITabbedView): string {
|
||||
return view.slug ?? view.name.toLowerCase().replace(/\s+/g, '');
|
||||
}
|
||||
|
||||
/** Find the parent group of a subview, or undefined for top-level views. */
|
||||
private findParent(view: ITabbedView): ITabbedView | undefined {
|
||||
return this.viewTabs.find((v) => v.subViews?.includes(view));
|
||||
}
|
||||
|
||||
/** Look up a view (or subview) by its URL slug pair. */
|
||||
private findViewBySlug(viewSlug: string, subSlug: string | null): ITabbedView | undefined {
|
||||
const top = this.viewTabs.find((v) => this.slugFor(v) === viewSlug);
|
||||
if (!top) return undefined;
|
||||
if (subSlug && top.subViews) {
|
||||
return top.subViews.find((sv) => this.slugFor(sv) === subSlug) ?? top;
|
||||
}
|
||||
return top;
|
||||
}
|
||||
|
||||
private get globalMessages() {
|
||||
const messages: Array<{ id: string; type: string; message: string; dismissible?: boolean }> = [];
|
||||
const config = this.configState.config;
|
||||
@@ -138,17 +167,19 @@ export class OpsDashboard extends DeesElement {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current view tab based on the UI state's activeView.
|
||||
* Get the current view tab based on the UI state's activeView/activeSubview.
|
||||
* Used to pass the correct selectedView to dees-simple-appdash on initial render.
|
||||
*/
|
||||
private get currentViewTab() {
|
||||
return this.viewTabs.find(t => t.name.toLowerCase() === this.uiState.activeView) || this.viewTabs[0];
|
||||
private get currentViewTab(): ITabbedView {
|
||||
return (
|
||||
this.findViewBySlug(this.uiState.activeView, this.uiState.activeSubview) ?? this.viewTabs[0]
|
||||
);
|
||||
}
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
document.title = 'DCRouter OpsServer';
|
||||
|
||||
|
||||
// Subscribe to login state
|
||||
const loginSubscription = appstate.loginStatePart
|
||||
.select((stateArg) => stateArg)
|
||||
@@ -161,7 +192,7 @@ export class OpsDashboard extends DeesElement {
|
||||
}
|
||||
});
|
||||
this.rxSubscriptions.push(loginSubscription);
|
||||
|
||||
|
||||
// Subscribe to config state (for global warnings)
|
||||
const configSubscription = appstate.configStatePart
|
||||
.select((stateArg) => stateArg)
|
||||
@@ -176,38 +207,27 @@ export class OpsDashboard extends DeesElement {
|
||||
.subscribe((uiState) => {
|
||||
this.uiState = uiState;
|
||||
// Sync appdash view when state changes (e.g., from URL navigation)
|
||||
this.syncAppdashView(uiState.activeView);
|
||||
this.syncAppdashView(uiState.activeView, uiState.activeSubview);
|
||||
});
|
||||
this.rxSubscriptions.push(uiSubscription);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync the dees-simple-appdash view selection with the current state.
|
||||
* This is needed when the URL changes and we need to update the UI.
|
||||
* This is needed when the URL changes externally (back/forward, deep link).
|
||||
*/
|
||||
private syncAppdashView(viewName: string): void {
|
||||
private syncAppdashView(viewSlug: string, subviewSlug: string | null): void {
|
||||
const appDash = this.shadowRoot?.querySelector('dees-simple-appdash') as any;
|
||||
if (!appDash) return;
|
||||
|
||||
const targetTab = this.viewTabs.find(t => t.name.toLowerCase() === viewName);
|
||||
if (!targetTab) return;
|
||||
const targetView = this.findViewBySlug(viewSlug, subviewSlug);
|
||||
if (!targetView) return;
|
||||
|
||||
// Check if we need to switch (avoid unnecessary updates)
|
||||
if (appDash.selectedView === targetTab) return;
|
||||
if (appDash.selectedView === targetView) return;
|
||||
|
||||
// Update the selected view programmatically
|
||||
appDash.selectedView = targetTab;
|
||||
|
||||
// Update the displayed content
|
||||
const content = appDash.shadowRoot?.querySelector('.appcontent');
|
||||
if (content) {
|
||||
if (appDash.currentView) {
|
||||
appDash.currentView.remove();
|
||||
}
|
||||
const view = new targetTab.element();
|
||||
content.appendChild(view);
|
||||
appDash.currentView = view;
|
||||
}
|
||||
// Use loadView to update both selectedView and the mounted element.
|
||||
// It will dispatch view-select; our handler skips when state already matches.
|
||||
appDash.loadView(targetView);
|
||||
}
|
||||
|
||||
public static styles = [
|
||||
@@ -249,7 +269,7 @@ export class OpsDashboard extends DeesElement {
|
||||
public async firstUpdated() {
|
||||
const simpleLogin = this.shadowRoot!.querySelector('dees-simple-login') as any;
|
||||
simpleLogin.addEventListener('login', (e: Event) => {
|
||||
// Handle logout event
|
||||
// Handle login event
|
||||
const detail = (e as CustomEvent).detail;
|
||||
this.login(detail.data.username, detail.data.password);
|
||||
});
|
||||
@@ -258,9 +278,24 @@ export class OpsDashboard extends DeesElement {
|
||||
const appDash = this.shadowRoot!.querySelector('dees-simple-appdash');
|
||||
if (appDash) {
|
||||
appDash.addEventListener('view-select', (e: Event) => {
|
||||
const viewName = (e as CustomEvent).detail.view.name.toLowerCase();
|
||||
// Use router for navigation instead of direct state update
|
||||
appRouter.navigateToView(viewName);
|
||||
const view = (e as CustomEvent).detail.view as ITabbedView;
|
||||
const parent = this.findParent(view);
|
||||
const currentState = appstate.uiStatePart.getState();
|
||||
if (parent) {
|
||||
const parentSlug = this.slugFor(parent);
|
||||
const subSlug = this.slugFor(view);
|
||||
// Skip if already on this exact subview — preserves URL on initial mount
|
||||
if (currentState?.activeView === parentSlug && currentState?.activeSubview === subSlug) {
|
||||
return;
|
||||
}
|
||||
appRouter.navigateToView(parentSlug, subSlug);
|
||||
} else {
|
||||
const slug = this.slugFor(view);
|
||||
if (currentState?.activeView === slug && !currentState?.activeSubview) {
|
||||
return;
|
||||
}
|
||||
appRouter.navigateToView(slug);
|
||||
}
|
||||
});
|
||||
|
||||
// Handle logout event
|
||||
@@ -306,12 +341,12 @@ export class OpsDashboard extends DeesElement {
|
||||
const simpleLogin = this.shadowRoot!.querySelector('dees-simple-login') as any;
|
||||
const form = simpleLogin.shadowRoot!.querySelector('dees-form') as any;
|
||||
form.setStatus('pending', 'Logging in...');
|
||||
|
||||
|
||||
const state = await appstate.loginStatePart.dispatchAction(appstate.loginAction, {
|
||||
username,
|
||||
password,
|
||||
});
|
||||
|
||||
|
||||
if (state.identity) {
|
||||
console.log('Login successful');
|
||||
this.loginState = state;
|
||||
@@ -325,4 +360,4 @@ export class OpsDashboard extends DeesElement {
|
||||
form!.reset();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -228,6 +228,7 @@ export class OpsViewCertificates extends DeesElement {
|
||||
return html`
|
||||
<dees-table
|
||||
.data=${this.certState.certificates}
|
||||
.showColumnFilters=${true}
|
||||
.displayFunction=${(cert: interfaces.requests.ICertificateInfo) => ({
|
||||
Domain: cert.domain,
|
||||
Routes: this.renderRoutePills(cert.routeNames),
|
||||
|
||||
@@ -1,543 +0,0 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
import * as shared from './shared/index.js';
|
||||
import * as appstate from '../appstate.js';
|
||||
|
||||
import {
|
||||
DeesElement,
|
||||
customElement,
|
||||
html,
|
||||
state,
|
||||
css,
|
||||
cssManager,
|
||||
} from '@design.estate/dees-element';
|
||||
import { type IStatsTile } from '@design.estate/dees-catalog';
|
||||
|
||||
@customElement('ops-view-security')
|
||||
export class OpsViewSecurity extends DeesElement {
|
||||
@state()
|
||||
accessor statsState: appstate.IStatsState = {
|
||||
serverStats: null,
|
||||
emailStats: null,
|
||||
dnsStats: null,
|
||||
securityMetrics: null,
|
||||
radiusStats: null,
|
||||
vpnStats: null,
|
||||
lastUpdated: 0,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
};
|
||||
|
||||
@state()
|
||||
accessor selectedTab: 'overview' | 'blocked' | 'authentication' | 'email-security' = 'overview';
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
const subscription = appstate.statsStatePart
|
||||
.select((stateArg) => stateArg)
|
||||
.subscribe((statsState) => {
|
||||
this.statsState = statsState;
|
||||
});
|
||||
this.rxSubscriptions.push(subscription);
|
||||
}
|
||||
|
||||
public static styles = [
|
||||
cssManager.defaultStyles,
|
||||
shared.viewHostCss,
|
||||
css`
|
||||
.tabs {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 24px;
|
||||
border-bottom: 2px solid ${cssManager.bdTheme('#e9ecef', '#333')};
|
||||
}
|
||||
|
||||
.tab {
|
||||
padding: 12px 24px;
|
||||
background: none;
|
||||
border: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
color: ${cssManager.bdTheme('#666', '#999')};
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.tab:hover {
|
||||
color: ${cssManager.bdTheme('#333', '#ccc')};
|
||||
}
|
||||
|
||||
.tab.active {
|
||||
color: ${cssManager.bdTheme('#2196F3', '#4a90e2')};
|
||||
border-bottom-color: ${cssManager.bdTheme('#2196F3', '#4a90e2')};
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin: 32px 0 16px 0;
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
color: ${cssManager.bdTheme('#333', '#ccc')};
|
||||
}
|
||||
|
||||
dees-statsgrid {
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.securityCard {
|
||||
background: ${cssManager.bdTheme('#fff', '#222')};
|
||||
border: 1px solid ${cssManager.bdTheme('#e9ecef', '#333')};
|
||||
border-radius: 8px;
|
||||
padding: 24px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.securityCard.alert {
|
||||
border-color: ${cssManager.bdTheme('#f44336', '#ff6666')};
|
||||
background: ${cssManager.bdTheme('#ffebee', '#4a1f1f')};
|
||||
}
|
||||
|
||||
.securityCard.warning {
|
||||
border-color: ${cssManager.bdTheme('#ff9800', '#ffaa33')};
|
||||
background: ${cssManager.bdTheme('#fff3e0', '#4a3a1f')};
|
||||
}
|
||||
|
||||
.securityCard.success {
|
||||
border-color: ${cssManager.bdTheme('#4caf50', '#66cc66')};
|
||||
background: ${cssManager.bdTheme('#e8f5e9', '#1f3f1f')};
|
||||
}
|
||||
|
||||
.cardHeader {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.cardTitle {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: ${cssManager.bdTheme('#333', '#ccc')};
|
||||
}
|
||||
|
||||
.cardStatus {
|
||||
font-size: 14px;
|
||||
padding: 4px 12px;
|
||||
border-radius: 16px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.status-critical {
|
||||
background: ${cssManager.bdTheme('#f44336', '#ff6666')};
|
||||
color: ${cssManager.bdTheme('#fff', '#fff')};
|
||||
}
|
||||
|
||||
.status-warning {
|
||||
background: ${cssManager.bdTheme('#ff9800', '#ffaa33')};
|
||||
color: ${cssManager.bdTheme('#fff', '#fff')};
|
||||
}
|
||||
|
||||
.status-good {
|
||||
background: ${cssManager.bdTheme('#4caf50', '#66cc66')};
|
||||
color: ${cssManager.bdTheme('#fff', '#fff')};
|
||||
}
|
||||
|
||||
.metricValue {
|
||||
font-size: 32px;
|
||||
font-weight: 700;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.metricLabel {
|
||||
font-size: 14px;
|
||||
color: ${cssManager.bdTheme('#666', '#999')};
|
||||
}
|
||||
|
||||
.actionButton {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.blockedIpList {
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.blockedIpItem {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 12px;
|
||||
border-bottom: 1px solid ${cssManager.bdTheme('#e9ecef', '#333')};
|
||||
}
|
||||
|
||||
.blockedIpItem:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.ipAddress {
|
||||
font-family: 'Consolas', 'Monaco', monospace;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.blockReason {
|
||||
font-size: 14px;
|
||||
color: ${cssManager.bdTheme('#666', '#999')};
|
||||
}
|
||||
|
||||
.blockTime {
|
||||
font-size: 12px;
|
||||
color: ${cssManager.bdTheme('#999', '#666')};
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
||||
public render() {
|
||||
return html`
|
||||
<dees-heading level="2">Security</dees-heading>
|
||||
|
||||
<div class="tabs">
|
||||
<button
|
||||
class="tab ${this.selectedTab === 'overview' ? 'active' : ''}"
|
||||
@click=${() => this.selectedTab = 'overview'}
|
||||
>
|
||||
Overview
|
||||
</button>
|
||||
<button
|
||||
class="tab ${this.selectedTab === 'blocked' ? 'active' : ''}"
|
||||
@click=${() => this.selectedTab = 'blocked'}
|
||||
>
|
||||
Blocked IPs
|
||||
</button>
|
||||
<button
|
||||
class="tab ${this.selectedTab === 'authentication' ? 'active' : ''}"
|
||||
@click=${() => this.selectedTab = 'authentication'}
|
||||
>
|
||||
Authentication
|
||||
</button>
|
||||
<button
|
||||
class="tab ${this.selectedTab === 'email-security' ? 'active' : ''}"
|
||||
@click=${() => this.selectedTab = 'email-security'}
|
||||
>
|
||||
Email Security
|
||||
</button>
|
||||
</div>
|
||||
|
||||
${this.renderTabContent()}
|
||||
`;
|
||||
}
|
||||
|
||||
private renderTabContent() {
|
||||
const metrics = this.statsState.securityMetrics;
|
||||
|
||||
if (!metrics) {
|
||||
return html`
|
||||
<div class="loadingMessage">
|
||||
<p>Loading security metrics...</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
switch(this.selectedTab) {
|
||||
case 'overview':
|
||||
return this.renderOverview(metrics);
|
||||
case 'blocked':
|
||||
return this.renderBlockedIPs(metrics);
|
||||
case 'authentication':
|
||||
return this.renderAuthentication(metrics);
|
||||
case 'email-security':
|
||||
return this.renderEmailSecurity(metrics);
|
||||
}
|
||||
}
|
||||
|
||||
private renderOverview(metrics: any) {
|
||||
const threatLevel = this.calculateThreatLevel(metrics);
|
||||
const threatScore = this.getThreatScore(metrics);
|
||||
|
||||
// Derive active sessions from recent successful auth events (last hour)
|
||||
const allEvents: any[] = metrics.recentEvents || [];
|
||||
const oneHourAgo = Date.now() - 3600000;
|
||||
const recentAuthSuccesses = allEvents.filter(
|
||||
(evt: any) => evt.type === 'authentication' && evt.success === true && evt.timestamp >= oneHourAgo
|
||||
).length;
|
||||
|
||||
const tiles: IStatsTile[] = [
|
||||
{
|
||||
id: 'threatLevel',
|
||||
title: 'Threat Level',
|
||||
value: threatScore,
|
||||
type: 'gauge',
|
||||
icon: 'lucide:Shield',
|
||||
gaugeOptions: {
|
||||
min: 0,
|
||||
max: 100,
|
||||
thresholds: [
|
||||
{ value: 0, color: '#ef4444' },
|
||||
{ value: 30, color: '#f59e0b' },
|
||||
{ value: 70, color: '#22c55e' },
|
||||
],
|
||||
},
|
||||
description: `Status: ${threatLevel.toUpperCase()}`,
|
||||
},
|
||||
{
|
||||
id: 'blockedThreats',
|
||||
title: 'Blocked Threats',
|
||||
value: (metrics.blockedIPs?.length || 0) + metrics.spamDetected,
|
||||
type: 'number',
|
||||
icon: 'lucide:ShieldCheck',
|
||||
color: '#ef4444',
|
||||
description: 'Total threats blocked today',
|
||||
},
|
||||
{
|
||||
id: 'activeSessions',
|
||||
title: 'Active Sessions',
|
||||
value: recentAuthSuccesses,
|
||||
type: 'number',
|
||||
icon: 'lucide:Users',
|
||||
color: '#22c55e',
|
||||
description: 'Authenticated in last hour',
|
||||
},
|
||||
{
|
||||
id: 'authFailures',
|
||||
title: 'Auth Failures',
|
||||
value: metrics.authenticationFailures,
|
||||
type: 'number',
|
||||
icon: 'lucide:LockOpen',
|
||||
color: metrics.authenticationFailures > 10 ? '#ef4444' : '#f59e0b',
|
||||
description: 'Failed login attempts today',
|
||||
},
|
||||
];
|
||||
|
||||
return html`
|
||||
<dees-statsgrid
|
||||
.tiles=${tiles}
|
||||
.minTileWidth=${200}
|
||||
></dees-statsgrid>
|
||||
|
||||
<h2>Recent Security Events</h2>
|
||||
<dees-table
|
||||
.heading1=${'Security Events'}
|
||||
.heading2=${'Last 24 hours'}
|
||||
.data=${this.getSecurityEvents(metrics)}
|
||||
.displayFunction=${(item) => ({
|
||||
'Time': new Date(item.timestamp).toLocaleTimeString(),
|
||||
'Event': item.event,
|
||||
'Severity': item.severity,
|
||||
'Details': item.details,
|
||||
})}
|
||||
></dees-table>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderBlockedIPs(metrics: any) {
|
||||
return html`
|
||||
<div class="securityCard">
|
||||
<div class="cardHeader">
|
||||
<h3 class="cardTitle">Blocked IP Addresses</h3>
|
||||
<dees-button @click=${() => this.clearBlockedIPs()}>
|
||||
Clear All
|
||||
</dees-button>
|
||||
</div>
|
||||
|
||||
<div class="blockedIpList">
|
||||
${metrics.blockedIPs && metrics.blockedIPs.length > 0 ? metrics.blockedIPs.map((ipAddress, index) => html`
|
||||
<div class="blockedIpItem">
|
||||
<div>
|
||||
<div class="ipAddress">${ipAddress}</div>
|
||||
<div class="blockReason">Suspicious activity</div>
|
||||
<div class="blockTime">Blocked</div>
|
||||
</div>
|
||||
<dees-button @click=${() => this.unblockIP(ipAddress)}>
|
||||
Unblock
|
||||
</dees-button>
|
||||
</div>
|
||||
`) : html`
|
||||
<p>No blocked IPs</p>
|
||||
`}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderAuthentication(metrics: any) {
|
||||
// Derive auth events from recentEvents
|
||||
const allEvents: any[] = metrics.recentEvents || [];
|
||||
const authEvents = allEvents.filter((evt: any) => evt.type === 'authentication');
|
||||
const successfulLogins = authEvents.filter((evt: any) => evt.success === true).length;
|
||||
|
||||
const tiles: IStatsTile[] = [
|
||||
{
|
||||
id: 'authFailures',
|
||||
title: 'Authentication Failures',
|
||||
value: metrics.authenticationFailures,
|
||||
type: 'number',
|
||||
icon: 'lucide:LockOpen',
|
||||
color: metrics.authenticationFailures > 10 ? '#ef4444' : '#f59e0b',
|
||||
description: 'Failed authentication attempts today',
|
||||
},
|
||||
{
|
||||
id: 'successfulLogins',
|
||||
title: 'Successful Logins',
|
||||
value: successfulLogins,
|
||||
type: 'number',
|
||||
icon: 'lucide:Lock',
|
||||
color: '#22c55e',
|
||||
description: 'Successful logins today',
|
||||
},
|
||||
];
|
||||
|
||||
// Map auth events to login history table data
|
||||
const loginHistory = authEvents.map((evt: any) => ({
|
||||
timestamp: evt.timestamp,
|
||||
username: evt.details?.username || 'unknown',
|
||||
ipAddress: evt.ipAddress || 'unknown',
|
||||
success: evt.success ?? false,
|
||||
reason: evt.success ? '' : evt.message || 'Authentication failed',
|
||||
}));
|
||||
|
||||
return html`
|
||||
<dees-statsgrid
|
||||
.tiles=${tiles}
|
||||
.minTileWidth=${200}
|
||||
></dees-statsgrid>
|
||||
|
||||
<h2>Recent Login Attempts</h2>
|
||||
<dees-table
|
||||
.heading1=${'Login History'}
|
||||
.heading2=${'Recent authentication attempts'}
|
||||
.data=${loginHistory}
|
||||
.displayFunction=${(item) => ({
|
||||
'Time': new Date(item.timestamp).toLocaleString(),
|
||||
'Username': item.username,
|
||||
'IP Address': item.ipAddress,
|
||||
'Status': item.success ? 'Success' : 'Failed',
|
||||
'Reason': item.reason || '-',
|
||||
})}
|
||||
></dees-table>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderEmailSecurity(metrics: any) {
|
||||
const tiles: IStatsTile[] = [
|
||||
{
|
||||
id: 'malware',
|
||||
title: 'Malware Detection',
|
||||
value: metrics.malwareDetected,
|
||||
type: 'number',
|
||||
icon: 'lucide:BugOff',
|
||||
color: metrics.malwareDetected > 0 ? '#ef4444' : '#22c55e',
|
||||
description: 'Malware detected',
|
||||
},
|
||||
{
|
||||
id: 'phishing',
|
||||
title: 'Phishing Detection',
|
||||
value: metrics.phishingDetected,
|
||||
type: 'number',
|
||||
icon: 'lucide:Fish',
|
||||
color: metrics.phishingDetected > 0 ? '#ef4444' : '#22c55e',
|
||||
description: 'Phishing attempts detected',
|
||||
},
|
||||
{
|
||||
id: 'suspicious',
|
||||
title: 'Suspicious Activities',
|
||||
value: metrics.suspiciousActivities,
|
||||
type: 'number',
|
||||
icon: 'lucide:TriangleAlert',
|
||||
color: metrics.suspiciousActivities > 5 ? '#ef4444' : '#f59e0b',
|
||||
description: 'Suspicious activities detected',
|
||||
},
|
||||
{
|
||||
id: 'spam',
|
||||
title: 'Spam Detection',
|
||||
value: metrics.spamDetected,
|
||||
type: 'number',
|
||||
icon: 'lucide:Ban',
|
||||
color: '#f59e0b',
|
||||
description: 'Spam emails blocked',
|
||||
},
|
||||
];
|
||||
|
||||
return html`
|
||||
<dees-statsgrid
|
||||
.tiles=${tiles}
|
||||
.minTileWidth=${200}
|
||||
></dees-statsgrid>
|
||||
|
||||
<h2>Email Security Configuration</h2>
|
||||
<div class="securityCard">
|
||||
<dees-form>
|
||||
<dees-input-checkbox
|
||||
.key=${'enableSPF'}
|
||||
.label=${'Enable SPF checking'}
|
||||
.value=${true}
|
||||
></dees-input-checkbox>
|
||||
<dees-input-checkbox
|
||||
.key=${'enableDKIM'}
|
||||
.label=${'Enable DKIM validation'}
|
||||
.value=${true}
|
||||
></dees-input-checkbox>
|
||||
<dees-input-checkbox
|
||||
.key=${'enableDMARC'}
|
||||
.label=${'Enable DMARC policy enforcement'}
|
||||
.value=${true}
|
||||
></dees-input-checkbox>
|
||||
<dees-input-checkbox
|
||||
.key=${'enableSpamFilter'}
|
||||
.label=${'Enable spam filtering'}
|
||||
.value=${true}
|
||||
></dees-input-checkbox>
|
||||
</dees-form>
|
||||
<dees-button
|
||||
class="actionButton"
|
||||
type="highlighted"
|
||||
@click=${() => this.saveEmailSecuritySettings()}
|
||||
>
|
||||
Save Settings
|
||||
</dees-button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private calculateThreatLevel(metrics: any): string {
|
||||
const score = this.getThreatScore(metrics);
|
||||
if (score < 30) return 'alert';
|
||||
if (score < 70) return 'warning';
|
||||
return 'success';
|
||||
}
|
||||
|
||||
private getThreatScore(metrics: any): number {
|
||||
// Simple scoring algorithm
|
||||
let score = 100;
|
||||
const blockedCount = Array.isArray(metrics.blockedIPs) ? metrics.blockedIPs.length : (metrics.blockedIPs || 0);
|
||||
score -= blockedCount * 2;
|
||||
score -= (metrics.authenticationFailures || 0) * 1;
|
||||
score -= (metrics.spamDetected || 0) * 0.5;
|
||||
score -= (metrics.malwareDetected || 0) * 3;
|
||||
score -= (metrics.phishingDetected || 0) * 3;
|
||||
score -= (metrics.suspiciousActivities || 0) * 2;
|
||||
return Math.max(0, Math.min(100, Math.round(score)));
|
||||
}
|
||||
|
||||
private getSecurityEvents(metrics: any): any[] {
|
||||
const events: any[] = metrics.recentEvents || [];
|
||||
return events.map((evt: any) => ({
|
||||
timestamp: evt.timestamp,
|
||||
event: evt.message,
|
||||
severity: evt.level === 'critical' ? 'critical' : evt.level === 'error' ? 'high' : evt.level === 'warn' ? 'warning' : 'info',
|
||||
details: evt.ipAddress ? `IP: ${evt.ipAddress}` : evt.domain ? `Domain: ${evt.domain}` : evt.type,
|
||||
}));
|
||||
}
|
||||
|
||||
private async clearBlockedIPs() {
|
||||
// SmartProxy manages IP blocking — not yet exposed via API
|
||||
alert('Clearing blocked IPs is not yet supported from the UI.');
|
||||
}
|
||||
|
||||
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 async saveEmailSecuritySettings() {
|
||||
// Config is read-only from the UI for now
|
||||
alert('Email security settings are read-only. Update the dcrouter configuration file to change these settings.');
|
||||
}
|
||||
}
|
||||
2
ts_web/elements/overview/index.ts
Normal file
2
ts_web/elements/overview/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './ops-view-overview.js';
|
||||
export * from './ops-view-config.js';
|
||||
@@ -1,7 +1,7 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
import * as shared from './shared/index.js';
|
||||
import * as appstate from '../appstate.js';
|
||||
import { appRouter } from '../router.js';
|
||||
import * as plugins from '../../plugins.js';
|
||||
import * as shared from '../shared/index.js';
|
||||
import * as appstate from '../../appstate.js';
|
||||
import { appRouter } from '../../router.js';
|
||||
|
||||
import {
|
||||
DeesElement,
|
||||
@@ -86,7 +86,7 @@ export class OpsViewConfig extends DeesElement {
|
||||
infoText="This view displays the current running configuration. DcRouter is configured through code or remote management."
|
||||
@navigate=${(e: CustomEvent) => {
|
||||
if (e.detail?.view) {
|
||||
appRouter.navigateToView(e.detail.view);
|
||||
appRouter.navigateToView(e.detail.view, e.detail.subview);
|
||||
}
|
||||
}}
|
||||
>
|
||||
@@ -149,7 +149,7 @@ export class OpsViewConfig extends DeesElement {
|
||||
}
|
||||
|
||||
const actions: IConfigSectionAction[] = [
|
||||
{ label: 'View Routes', icon: 'lucide:arrow-right', event: 'navigate', detail: { view: 'routes' } },
|
||||
{ label: 'View Routes', icon: 'lucide:arrow-right', event: 'navigate', detail: { view: 'network', subview: 'routes' } },
|
||||
];
|
||||
|
||||
return html`
|
||||
@@ -181,7 +181,7 @@ export class OpsViewConfig extends DeesElement {
|
||||
}
|
||||
|
||||
const actions: IConfigSectionAction[] = [
|
||||
{ label: 'View Emails', icon: 'lucide:arrow-right', event: 'navigate', detail: { view: 'emails' } },
|
||||
{ label: 'View Emails', icon: 'lucide:arrow-right', event: 'navigate', detail: { view: 'email', subview: 'log' } },
|
||||
];
|
||||
|
||||
return html`
|
||||
@@ -305,7 +305,7 @@ export class OpsViewConfig extends DeesElement {
|
||||
];
|
||||
|
||||
const actions: IConfigSectionAction[] = [
|
||||
{ label: 'View Remote Ingress', icon: 'lucide:arrow-right', event: 'navigate', detail: { view: 'remoteingress' } },
|
||||
{ label: 'View Remote Ingress', icon: 'lucide:arrow-right', event: 'navigate', detail: { view: 'network', subview: 'remoteingress' } },
|
||||
];
|
||||
|
||||
return html`
|
||||
@@ -1,6 +1,6 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
import * as shared from './shared/index.js';
|
||||
import * as appstate from '../appstate.js';
|
||||
import * as plugins from '../../plugins.js';
|
||||
import * as shared from '../shared/index.js';
|
||||
import * as appstate from '../../appstate.js';
|
||||
|
||||
import {
|
||||
DeesElement,
|
||||
3
ts_web/elements/security/index.ts
Normal file
3
ts_web/elements/security/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from './ops-view-security-overview.js';
|
||||
export * from './ops-view-security-blocked.js';
|
||||
export * from './ops-view-security-authentication.js';
|
||||
121
ts_web/elements/security/ops-view-security-authentication.ts
Normal file
121
ts_web/elements/security/ops-view-security-authentication.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
import * as appstate from '../../appstate.js';
|
||||
import { viewHostCss } from '../shared/css.js';
|
||||
|
||||
import {
|
||||
DeesElement,
|
||||
customElement,
|
||||
html,
|
||||
state,
|
||||
css,
|
||||
cssManager,
|
||||
type TemplateResult,
|
||||
} from '@design.estate/dees-element';
|
||||
import { type IStatsTile } from '@design.estate/dees-catalog';
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'ops-view-security-authentication': OpsViewSecurityAuthentication;
|
||||
}
|
||||
}
|
||||
|
||||
@customElement('ops-view-security-authentication')
|
||||
export class OpsViewSecurityAuthentication extends DeesElement {
|
||||
@state()
|
||||
accessor statsState: appstate.IStatsState = appstate.statsStatePart.getState()!;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
const sub = appstate.statsStatePart
|
||||
.select((s) => s)
|
||||
.subscribe((s) => {
|
||||
this.statsState = s;
|
||||
});
|
||||
this.rxSubscriptions.push(sub);
|
||||
}
|
||||
|
||||
public static styles = [
|
||||
cssManager.defaultStyles,
|
||||
viewHostCss,
|
||||
css`
|
||||
h2 {
|
||||
margin: 32px 0 16px 0;
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
color: ${cssManager.bdTheme('#333', '#ccc')};
|
||||
}
|
||||
dees-statsgrid {
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
||||
public render(): TemplateResult {
|
||||
const metrics = this.statsState.securityMetrics;
|
||||
|
||||
if (!metrics) {
|
||||
return html`
|
||||
<div class="loadingMessage">
|
||||
<p>Loading security metrics...</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// Derive auth events from recentEvents
|
||||
const allEvents: any[] = metrics.recentEvents || [];
|
||||
const authEvents = allEvents.filter((evt: any) => evt.type === 'authentication');
|
||||
const successfulLogins = authEvents.filter((evt: any) => evt.success === true).length;
|
||||
|
||||
const tiles: IStatsTile[] = [
|
||||
{
|
||||
id: 'authFailures',
|
||||
title: 'Authentication Failures',
|
||||
value: metrics.authenticationFailures,
|
||||
type: 'number',
|
||||
icon: 'lucide:LockOpen',
|
||||
color: metrics.authenticationFailures > 10 ? '#ef4444' : '#f59e0b',
|
||||
description: 'Failed authentication attempts today',
|
||||
},
|
||||
{
|
||||
id: 'successfulLogins',
|
||||
title: 'Successful Logins',
|
||||
value: successfulLogins,
|
||||
type: 'number',
|
||||
icon: 'lucide:Lock',
|
||||
color: '#22c55e',
|
||||
description: 'Successful logins today',
|
||||
},
|
||||
];
|
||||
|
||||
// Map auth events to login history table data
|
||||
const loginHistory = authEvents.map((evt: any) => ({
|
||||
timestamp: evt.timestamp,
|
||||
username: evt.details?.username || 'unknown',
|
||||
ipAddress: evt.ipAddress || 'unknown',
|
||||
success: evt.success ?? false,
|
||||
reason: evt.success ? '' : evt.message || 'Authentication failed',
|
||||
}));
|
||||
|
||||
return html`
|
||||
<dees-heading level="hr">Authentication</dees-heading>
|
||||
|
||||
<dees-statsgrid
|
||||
.tiles=${tiles}
|
||||
.minTileWidth=${200}
|
||||
></dees-statsgrid>
|
||||
|
||||
<h2>Recent Login Attempts</h2>
|
||||
<dees-table
|
||||
.heading1=${'Login History'}
|
||||
.heading2=${'Recent authentication attempts'}
|
||||
.data=${loginHistory}
|
||||
.displayFunction=${(item) => ({
|
||||
'Time': new Date(item.timestamp).toLocaleString(),
|
||||
'Username': item.username,
|
||||
'IP Address': item.ipAddress,
|
||||
'Status': item.success ? 'Success' : 'Failed',
|
||||
'Reason': item.reason || '-',
|
||||
})}
|
||||
></dees-table>
|
||||
`;
|
||||
}
|
||||
}
|
||||
118
ts_web/elements/security/ops-view-security-blocked.ts
Normal file
118
ts_web/elements/security/ops-view-security-blocked.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
import * as appstate from '../../appstate.js';
|
||||
import { viewHostCss } from '../shared/css.js';
|
||||
|
||||
import {
|
||||
DeesElement,
|
||||
customElement,
|
||||
html,
|
||||
state,
|
||||
css,
|
||||
cssManager,
|
||||
type TemplateResult,
|
||||
} from '@design.estate/dees-element';
|
||||
import { type IStatsTile } from '@design.estate/dees-catalog';
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'ops-view-security-blocked': OpsViewSecurityBlocked;
|
||||
}
|
||||
}
|
||||
|
||||
@customElement('ops-view-security-blocked')
|
||||
export class OpsViewSecurityBlocked extends DeesElement {
|
||||
@state()
|
||||
accessor statsState: appstate.IStatsState = appstate.statsStatePart.getState()!;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
const sub = appstate.statsStatePart
|
||||
.select((s) => s)
|
||||
.subscribe((s) => {
|
||||
this.statsState = s;
|
||||
});
|
||||
this.rxSubscriptions.push(sub);
|
||||
}
|
||||
|
||||
public static styles = [
|
||||
cssManager.defaultStyles,
|
||||
viewHostCss,
|
||||
css`
|
||||
dees-statsgrid {
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
||||
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 tiles: IStatsTile[] = [
|
||||
{
|
||||
id: 'totalBlocked',
|
||||
title: 'Blocked IPs',
|
||||
value: blockedIPs.length,
|
||||
type: 'number',
|
||||
icon: 'lucide:ShieldBan',
|
||||
color: blockedIPs.length > 0 ? '#ef4444' : '#22c55e',
|
||||
description: 'Currently blocked addresses',
|
||||
},
|
||||
];
|
||||
|
||||
return html`
|
||||
<dees-heading level="hr">Blocked IPs</dees-heading>
|
||||
|
||||
<dees-statsgrid
|
||||
.tiles=${tiles}
|
||||
.minTileWidth=${200}
|
||||
></dees-statsgrid>
|
||||
|
||||
<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',
|
||||
})}
|
||||
.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();
|
||||
},
|
||||
},
|
||||
]}
|
||||
></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 async unblockIP(ip: string) {
|
||||
// SmartProxy manages IP blocking — not yet exposed via API
|
||||
alert(`Unblocking IP ${ip} is not yet supported from the UI.`);
|
||||
}
|
||||
}
|
||||
172
ts_web/elements/security/ops-view-security-overview.ts
Normal file
172
ts_web/elements/security/ops-view-security-overview.ts
Normal file
@@ -0,0 +1,172 @@
|
||||
import * as appstate from '../../appstate.js';
|
||||
import { viewHostCss } from '../shared/css.js';
|
||||
|
||||
import {
|
||||
DeesElement,
|
||||
customElement,
|
||||
html,
|
||||
state,
|
||||
css,
|
||||
cssManager,
|
||||
type TemplateResult,
|
||||
} from '@design.estate/dees-element';
|
||||
import { type IStatsTile } from '@design.estate/dees-catalog';
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'ops-view-security-overview': OpsViewSecurityOverview;
|
||||
}
|
||||
}
|
||||
|
||||
@customElement('ops-view-security-overview')
|
||||
export class OpsViewSecurityOverview extends DeesElement {
|
||||
@state()
|
||||
accessor statsState: appstate.IStatsState = appstate.statsStatePart.getState()!;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
const sub = appstate.statsStatePart
|
||||
.select((s) => s)
|
||||
.subscribe((s) => {
|
||||
this.statsState = s;
|
||||
});
|
||||
this.rxSubscriptions.push(sub);
|
||||
}
|
||||
|
||||
public static styles = [
|
||||
cssManager.defaultStyles,
|
||||
viewHostCss,
|
||||
css`
|
||||
h2 {
|
||||
margin: 32px 0 16px 0;
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
color: ${cssManager.bdTheme('#333', '#ccc')};
|
||||
}
|
||||
dees-statsgrid {
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
||||
public render(): TemplateResult {
|
||||
const metrics = this.statsState.securityMetrics;
|
||||
|
||||
if (!metrics) {
|
||||
return html`
|
||||
<div class="loadingMessage">
|
||||
<p>Loading security metrics...</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
const threatLevel = this.calculateThreatLevel(metrics);
|
||||
const threatScore = this.getThreatScore(metrics);
|
||||
|
||||
// Derive active sessions from recent successful auth events (last hour)
|
||||
const allEvents: any[] = metrics.recentEvents || [];
|
||||
const oneHourAgo = Date.now() - 3600000;
|
||||
const recentAuthSuccesses = allEvents.filter(
|
||||
(evt: any) => evt.type === 'authentication' && evt.success === true && evt.timestamp >= oneHourAgo
|
||||
).length;
|
||||
|
||||
const tiles: IStatsTile[] = [
|
||||
{
|
||||
id: 'threatLevel',
|
||||
title: 'Threat Level',
|
||||
value: threatScore,
|
||||
type: 'gauge',
|
||||
icon: 'lucide:Shield',
|
||||
gaugeOptions: {
|
||||
min: 0,
|
||||
max: 100,
|
||||
thresholds: [
|
||||
{ value: 0, color: '#ef4444' },
|
||||
{ value: 30, color: '#f59e0b' },
|
||||
{ value: 70, color: '#22c55e' },
|
||||
],
|
||||
},
|
||||
description: `Status: ${threatLevel.toUpperCase()}`,
|
||||
},
|
||||
{
|
||||
id: 'blockedThreats',
|
||||
title: 'Blocked Threats',
|
||||
value: (metrics.blockedIPs?.length || 0) + metrics.spamDetected,
|
||||
type: 'number',
|
||||
icon: 'lucide:ShieldCheck',
|
||||
color: '#ef4444',
|
||||
description: 'Total threats blocked today',
|
||||
},
|
||||
{
|
||||
id: 'activeSessions',
|
||||
title: 'Active Sessions',
|
||||
value: recentAuthSuccesses,
|
||||
type: 'number',
|
||||
icon: 'lucide:Users',
|
||||
color: '#22c55e',
|
||||
description: 'Authenticated in last hour',
|
||||
},
|
||||
{
|
||||
id: 'authFailures',
|
||||
title: 'Auth Failures',
|
||||
value: metrics.authenticationFailures,
|
||||
type: 'number',
|
||||
icon: 'lucide:LockOpen',
|
||||
color: metrics.authenticationFailures > 10 ? '#ef4444' : '#f59e0b',
|
||||
description: 'Failed login attempts today',
|
||||
},
|
||||
];
|
||||
|
||||
return html`
|
||||
<dees-heading level="hr">Overview</dees-heading>
|
||||
|
||||
<dees-statsgrid
|
||||
.tiles=${tiles}
|
||||
.minTileWidth=${200}
|
||||
></dees-statsgrid>
|
||||
|
||||
<h2>Recent Security Events</h2>
|
||||
<dees-table
|
||||
.heading1=${'Security Events'}
|
||||
.heading2=${'Last 24 hours'}
|
||||
.data=${this.getSecurityEvents(metrics)}
|
||||
.displayFunction=${(item) => ({
|
||||
'Time': new Date(item.timestamp).toLocaleTimeString(),
|
||||
'Event': item.event,
|
||||
'Severity': item.severity,
|
||||
'Details': item.details,
|
||||
})}
|
||||
></dees-table>
|
||||
`;
|
||||
}
|
||||
|
||||
private calculateThreatLevel(metrics: any): string {
|
||||
const score = this.getThreatScore(metrics);
|
||||
if (score < 30) return 'alert';
|
||||
if (score < 70) return 'warning';
|
||||
return 'success';
|
||||
}
|
||||
|
||||
private getThreatScore(metrics: any): number {
|
||||
// Simple scoring algorithm
|
||||
let score = 100;
|
||||
const blockedCount = Array.isArray(metrics.blockedIPs) ? metrics.blockedIPs.length : (metrics.blockedIPs || 0);
|
||||
score -= blockedCount * 2;
|
||||
score -= (metrics.authenticationFailures || 0) * 1;
|
||||
score -= (metrics.spamDetected || 0) * 0.5;
|
||||
score -= (metrics.malwareDetected || 0) * 3;
|
||||
score -= (metrics.phishingDetected || 0) * 3;
|
||||
score -= (metrics.suspiciousActivities || 0) * 2;
|
||||
return Math.max(0, Math.min(100, Math.round(score)));
|
||||
}
|
||||
|
||||
private getSecurityEvents(metrics: any): any[] {
|
||||
const events: any[] = metrics.recentEvents || [];
|
||||
return events.map((evt: any) => ({
|
||||
timestamp: evt.timestamp,
|
||||
event: evt.message,
|
||||
severity: evt.level === 'critical' ? 'critical' : evt.level === 'error' ? 'high' : evt.level === 'warn' ? 'warning' : 'info',
|
||||
details: evt.ipAddress ? `IP: ${evt.ipAddress}` : evt.domain ? `Domain: ${evt.domain}` : evt.type,
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -3,9 +3,37 @@ import * as appstate from './appstate.js';
|
||||
|
||||
const SmartRouter = plugins.domtools.plugins.smartrouter.SmartRouter;
|
||||
|
||||
export const validViews = ['overview', 'network', 'emails', 'logs', 'routes', 'apitokens', 'configuration', 'security', 'certificates', 'remoteingress', 'vpn', 'sourceprofiles', 'networktargets', 'targetprofiles'] as const;
|
||||
// Flat top-level views (no subviews)
|
||||
const flatViews = ['logs', 'certificates'] as const;
|
||||
|
||||
export type TValidView = typeof validViews[number];
|
||||
// Tabbed views and their valid subviews
|
||||
const subviewMap: Record<string, readonly string[]> = {
|
||||
overview: ['stats', 'configuration'] as const,
|
||||
network: ['activity', 'routes', 'sourceprofiles', 'networktargets', 'targetprofiles', 'remoteingress', 'vpn'] as const,
|
||||
email: ['log', 'security'] as const,
|
||||
access: ['apitokens'] as const,
|
||||
security: ['overview', 'blocked', 'authentication'] as const,
|
||||
};
|
||||
|
||||
// Default subview when user visits the bare parent URL
|
||||
const defaultSubview: Record<string, string> = {
|
||||
overview: 'stats',
|
||||
network: 'activity',
|
||||
email: 'log',
|
||||
access: 'apitokens',
|
||||
security: 'overview',
|
||||
};
|
||||
|
||||
export const validTopLevelViews = [...flatViews, ...Object.keys(subviewMap)] as const;
|
||||
export type TValidView = typeof validTopLevelViews[number];
|
||||
|
||||
export function isValidView(view: string): boolean {
|
||||
return (validTopLevelViews as readonly string[]).includes(view);
|
||||
}
|
||||
|
||||
export function isValidSubview(view: string, subview: string): boolean {
|
||||
return subviewMap[view]?.includes(subview) ?? false;
|
||||
}
|
||||
|
||||
class AppRouter {
|
||||
private router: InstanceType<typeof SmartRouter>;
|
||||
@@ -25,12 +53,27 @@ class AppRouter {
|
||||
}
|
||||
|
||||
private setupRoutes(): void {
|
||||
for (const view of validViews) {
|
||||
// Flat views
|
||||
for (const view of flatViews) {
|
||||
this.router.on(`/${view}`, async () => {
|
||||
this.updateViewState(view);
|
||||
this.updateViewState(view, null);
|
||||
});
|
||||
}
|
||||
|
||||
// Tabbed views
|
||||
for (const view of Object.keys(subviewMap)) {
|
||||
// Bare parent → redirect to default subview
|
||||
this.router.on(`/${view}`, async () => {
|
||||
this.navigateTo(`/${view}/${defaultSubview[view]}`);
|
||||
});
|
||||
// Each valid subview
|
||||
for (const sub of subviewMap[view]) {
|
||||
this.router.on(`/${view}/${sub}`, async () => {
|
||||
this.updateViewState(view, sub);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Root redirect
|
||||
this.router.on('/', async () => {
|
||||
this.navigateTo('/overview');
|
||||
@@ -42,7 +85,9 @@ class AppRouter {
|
||||
if (this.suppressStateUpdate) return;
|
||||
|
||||
const currentPath = window.location.pathname;
|
||||
const expectedPath = `/${uiState.activeView}`;
|
||||
const expectedPath = uiState.activeSubview
|
||||
? `/${uiState.activeView}/${uiState.activeSubview}`
|
||||
: `/${uiState.activeView}`;
|
||||
|
||||
if (currentPath !== expectedPath) {
|
||||
this.suppressStateUpdate = true;
|
||||
@@ -57,25 +102,38 @@ class AppRouter {
|
||||
|
||||
if (!path || path === '/') {
|
||||
this.router.pushUrl('/overview');
|
||||
} else {
|
||||
const segments = path.split('/').filter(Boolean);
|
||||
const view = segments[0];
|
||||
return;
|
||||
}
|
||||
|
||||
if (validViews.includes(view as TValidView)) {
|
||||
this.updateViewState(view as TValidView);
|
||||
const segments = path.split('/').filter(Boolean);
|
||||
const view = segments[0];
|
||||
const sub = segments[1];
|
||||
|
||||
if (!isValidView(view)) {
|
||||
this.router.pushUrl('/overview');
|
||||
return;
|
||||
}
|
||||
|
||||
if (subviewMap[view]) {
|
||||
if (sub && isValidSubview(view, sub)) {
|
||||
this.updateViewState(view, sub);
|
||||
} else {
|
||||
this.router.pushUrl('/overview');
|
||||
// Bare parent or invalid sub → default subview
|
||||
this.router.pushUrl(`/${view}/${defaultSubview[view]}`);
|
||||
}
|
||||
} else {
|
||||
this.updateViewState(view, null);
|
||||
}
|
||||
}
|
||||
|
||||
private updateViewState(view: string): void {
|
||||
private updateViewState(view: string, subview: string | null): void {
|
||||
this.suppressStateUpdate = true;
|
||||
const currentState = appstate.uiStatePart.getState()!;
|
||||
if (currentState.activeView !== view) {
|
||||
if (currentState.activeView !== view || currentState.activeSubview !== subview) {
|
||||
appstate.uiStatePart.setState({
|
||||
...currentState,
|
||||
activeView: view,
|
||||
activeSubview: subview,
|
||||
} as appstate.IUiState);
|
||||
}
|
||||
this.suppressStateUpdate = false;
|
||||
@@ -85,11 +143,17 @@ class AppRouter {
|
||||
this.router.pushUrl(path);
|
||||
}
|
||||
|
||||
public navigateToView(view: string): void {
|
||||
if (validViews.includes(view as TValidView)) {
|
||||
this.navigateTo(`/${view}`);
|
||||
} else {
|
||||
public navigateToView(view: string, subview?: string): void {
|
||||
if (!isValidView(view)) {
|
||||
this.navigateTo('/overview');
|
||||
return;
|
||||
}
|
||||
if (subview && isValidSubview(view, subview)) {
|
||||
this.navigateTo(`/${view}/${subview}`);
|
||||
} else if (subviewMap[view]) {
|
||||
this.navigateTo(`/${view}/${defaultSubview[view]}`);
|
||||
} else {
|
||||
this.navigateTo(`/${view}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
{
|
||||
"order": 3
|
||||
"order": 4
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user