Compare commits

...

12 Commits

Author SHA1 Message Date
jkunz ac118397f9 v13.34.0
Docker (tags) / release (push) Failing after 0s
2026-05-21 23:45:34 +00:00
jkunz 8188b4712c feat(vpn): allow target profiles to grant non-vpnOnly routes by live client source IP 2026-05-21 23:44:01 +00:00
jkunz 27d077feed v13.33.0
Docker (tags) / release (push) Failing after 0s
2026-05-21 01:56:32 +00:00
jkunz 98913c1977 feat(security): add queued IP intelligence observation and filtered retrieval for network and security views 2026-05-21 01:56:17 +00:00
jkunz ca5c57a329 v13.32.1
Docker (tags) / release (push) Failing after 1s
2026-05-20 16:24:44 +00:00
jkunz 707fbc2413 fix(opsserver,vpn): tighten admin bootstrap behavior when the database is unavailable and include wildcard VPN profile matches in route access rules 2026-05-20 16:24:30 +00:00
jkunz a0c9d40e87 fix(deps): update smartproxy for Alpine compatibility 2026-05-20 15:15:34 +00:00
jkunz 2a73973eda fix(deps): update smartdb for Alpine compatibility 2026-05-20 13:46:01 +00:00
jkunz f0069f87e2 v13.32.0
Docker (tags) / release (push) Failing after 1s
2026-05-19 22:24:40 +00:00
jkunz 77c1738390 feat(ops-auth): add scoped API token auth across ops endpoints 2026-05-19 22:24:37 +00:00
jkunz 53d7c5350e v13.31.0
Docker (tags) / release (push) Failing after 1s
2026-05-19 17:06:52 +00:00
jkunz 7986d01245 feat(opsserver): add admin user create/delete management and default hosted idp.global auth support 2026-05-19 17:06:50 +00:00
71 changed files with 2672 additions and 679 deletions
+51
View File
@@ -3,7 +3,58 @@
## Pending
## 2026-05-21 - 13.34.0
### Features
- allow VPN target profiles to grant routes by live client source IP (vpn)
- Add an opt-in target profile flag that evaluates non-vpnOnly route source security against the VPN client's real connecting IP.
- Track live VPN client source IPs from smartvpn remote addresses and WireGuard peer endpoints, refreshing routes when they change.
- Expose the setting and current source IPs in the Ops UI with regression coverage for source-IP matching behavior.
- allow target profiles to grant non-vpnOnly routes by live client source IP (vpn)
- add an opt-in target profile flag to match route source security against a VPN client's real connecting IP
- track live client source IPs from VPN remote addresses and WireGuard peer endpoints and re-apply routes when they change
- expose source IP access settings and current client source IPs through the ops API and UI
- add regression tests for source-IP route matching, block-list handling, vpnOnly exclusions, and WireGuard endpoint refresh
## 2026-05-21 - 13.33.0
### Features
- add queued IP intelligence observation and filtered retrieval for network and security views (security)
- Queue observed public IPs from network metrics with throttled background enrichment instead of awaiting lookups during stats collection.
- Allow listing IP intelligence records by specific IP addresses and limit through the security handler and request interface.
- Update web app state to refresh IP intelligence asynchronously in the background and preserve current UI state during refreshes.
- Improve security policy manager observation handling so forced refresh waits for in-flight lookups before fetching updated intelligence.
## 2026-05-20 - 13.32.1
### Fixes
- tighten admin bootstrap behavior when the database is unavailable and include wildcard VPN profile matches in route access rules (opsserver,vpn)
- Block ephemeral admin bootstrap login and user listing until the configured database is ready, and report bootstrap availability accurately in admin status responses.
- Preserve persisted admin accounts across OpsServer restarts with added regression coverage.
- Merge matching VPN client IPs into restricted non-vpnOnly route allow lists without duplicating entries.
- Handle string and wildcard route domains consistently when resolving target profile access and VPN client matches.
## 2026-05-19 - 13.32.0
### Features
- add scoped API token auth across ops endpoints (ops-auth)
- introduces a shared requireOpsAuth helper that validates JWT identities and API tokens with scope and admin-policy checks
- applies explicit per-endpoint authorization across config, logs, stats, security, VPN, RADIUS, remote ingress, users, API tokens, and related ops handlers
- extends request interfaces and UI scope definitions to support apiToken-based access and adds tests for auth behavior and migration bridging
## 2026-05-19 - 13.31.0
### Features
- add admin user create/delete management and default hosted idp.global auth support (opsserver)
- adds admin-only createUser and deleteUser typed requests with safeguards against deleting the current user or last active admin
- updates the ops users UI to create and delete users, show richer account details, and support optional idp.global login during account creation
- treats idp.global as available by default via the hosted https://idp.global endpoint while keeping URL settings as optional overrides
- adds VPN-only route controls and indicators in the ops routes UI
## 2026-05-18 - 13.30.0
+6 -6
View File
@@ -1,7 +1,7 @@
{
"name": "@serve.zone/dcrouter",
"private": false,
"version": "13.30.0",
"version": "13.34.0",
"description": "A multifaceted routing service handling mail and SMS delivery functions.",
"type": "module",
"exports": {
@@ -38,25 +38,25 @@
"@apiclient.xyz/cloudflare": "^7.1.0",
"@design.estate/dees-catalog": "^3.81.0",
"@design.estate/dees-element": "^2.2.4",
"@idp.global/sdk": "^1.3.0",
"@idp.global/sdk": "^1.3.1",
"@push.rocks/lik": "^6.4.1",
"@push.rocks/projectinfo": "^5.1.0",
"@push.rocks/qenv": "^6.1.4",
"@push.rocks/smartacme": "^9.5.0",
"@push.rocks/smartdata": "^7.1.7",
"@push.rocks/smartdb": "^2.10.0",
"@push.rocks/smartdb": "^2.10.1",
"@push.rocks/smartdns": "^7.9.2",
"@push.rocks/smartfs": "^1.5.1",
"@push.rocks/smartguard": "^3.1.0",
"@push.rocks/smartjwt": "^2.2.2",
"@push.rocks/smartlog": "^3.2.2",
"@push.rocks/smartmetrics": "^3.0.3",
"@push.rocks/smartmigration": "1.3.1",
"@push.rocks/smartmigration": "1.4.1",
"@push.rocks/smartmta": "^5.3.3",
"@push.rocks/smartnetwork": "^4.7.1",
"@push.rocks/smartnetwork": "^4.7.2",
"@push.rocks/smartpath": "^6.0.0",
"@push.rocks/smartpromise": "^4.2.4",
"@push.rocks/smartproxy": "^27.10.2",
"@push.rocks/smartproxy": "^27.10.3",
"@push.rocks/smartradius": "^1.1.2",
"@push.rocks/smartrequest": "^5.0.3",
"@push.rocks/smartrx": "^3.0.10",
+28 -28
View File
@@ -30,8 +30,8 @@ importers:
specifier: ^2.2.4
version: 2.2.4
'@idp.global/sdk':
specifier: ^1.3.0
version: 1.3.0(@push.rocks/smartserve@2.0.4)(socks@2.8.8)
specifier: ^1.3.1
version: 1.3.1(@push.rocks/smartserve@2.0.4)(socks@2.8.8)
'@push.rocks/lik':
specifier: ^6.4.1
version: 6.4.1
@@ -48,8 +48,8 @@ importers:
specifier: ^7.1.7
version: 7.1.7(socks@2.8.8)
'@push.rocks/smartdb':
specifier: ^2.10.0
version: 2.10.0(@tiptap/pm@2.27.2)(socks@2.8.8)
specifier: ^2.10.1
version: 2.10.1(@tiptap/pm@2.27.2)(socks@2.8.8)
'@push.rocks/smartdns':
specifier: ^7.9.2
version: 7.9.2
@@ -69,14 +69,14 @@ importers:
specifier: ^3.0.3
version: 3.0.3
'@push.rocks/smartmigration':
specifier: 1.3.1
version: 1.3.1(@push.rocks/smartbucket@4.6.1)(@push.rocks/smartdata@7.1.7(socks@2.8.8))
specifier: 1.4.1
version: 1.4.1(@push.rocks/smartbucket@4.6.1)(@push.rocks/smartdata@7.1.7(socks@2.8.8))
'@push.rocks/smartmta':
specifier: ^5.3.3
version: 5.3.3
'@push.rocks/smartnetwork':
specifier: ^4.7.1
version: 4.7.1
specifier: ^4.7.2
version: 4.7.2
'@push.rocks/smartpath':
specifier: ^6.0.0
version: 6.0.0
@@ -84,8 +84,8 @@ importers:
specifier: ^4.2.4
version: 4.2.4
'@push.rocks/smartproxy':
specifier: ^27.10.2
version: 27.10.2
specifier: ^27.10.3
version: 27.10.3
'@push.rocks/smartradius':
specifier: ^1.1.2
version: 1.1.2
@@ -753,8 +753,8 @@ packages:
'@idp.global/interfaces@1.0.1':
resolution: {integrity: sha512-PEA538+V2VUnKTkLbt66OWxsRZxWHuhC1nduzeFvBaOlH7EIXIZ0rA9D20JKtUZyucZJlXj1hFp2WCMUmgqPwQ==}
'@idp.global/sdk@1.3.0':
resolution: {integrity: sha512-ADxra57bBVHXrsOCrh6c82PhiJoxWZQ9LMPqkfWiQ/jspufo2WhSZIjm7G7C5SvFtGRmiYYJStKfwYBpCKO01w==}
'@idp.global/sdk@1.3.1':
resolution: {integrity: sha512-mZ7cRhbyaE7PoGo9WRJLNwQLIjs9mZxLK1HhVQntvf7hN+OrCRjpYfDn5/G3ub8tpTNq1trtROYNjlhbD/4GLw==}
'@img/colour@1.1.0':
resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==}
@@ -1279,8 +1279,8 @@ packages:
'@push.rocks/smartdata@7.1.7':
resolution: {integrity: sha512-HDI/Q9dKybfsJ68oCzlE+S63Xpij9qXnMfi28yznKP0Li1ECVZZMDDGIW5IjsXlHjO+Q+RJMcVd72Pjt3QLY5Q==}
'@push.rocks/smartdb@2.10.0':
resolution: {integrity: sha512-f7Sm861LJqBxgpX3ybNeRSShothSTLJsFETh1Vfj0WdC+oUZSOgIDqfQcR/gy25hc3eSnk1Bd5zz4cbWh9wosg==}
'@push.rocks/smartdb@2.10.1':
resolution: {integrity: sha512-m33HbSZdvUjCIucHWuJRK4ly7c0fsnL1hJAjZdjf6WqaFlWAjR0SztZp/V/u1yGP7IIcaXMXaWAijB9BC91Dvg==}
'@push.rocks/smartdelay@3.1.0':
resolution: {integrity: sha512-59xveBMbWmbFhh/rqhQnYG/klg/VONG9hV8+RQ7ftqsNRkcmUT+VM5etAbODgAUvsF4lxK+xVR0tbZOo0kGhRQ==}
@@ -1366,8 +1366,8 @@ packages:
'@push.rocks/smartmetrics@3.0.3':
resolution: {integrity: sha512-RYY4NOla3kraZYVF9TBHgIz4/hSkqVDVNP7tLwhLK5mGBPBy8I/9NWXX6txZKQw6QihP85YD8mWUuUu2xS4D6Q==}
'@push.rocks/smartmigration@1.3.1':
resolution: {integrity: sha512-qU3vc4yCLn8vJQIEMQwS2Lq6Ra8ixSfjutnbR1L/hauCzFRCic3o/DnFKB7pjj5jWaqSDG5nlyeIliLmC5aGsg==}
'@push.rocks/smartmigration@1.4.1':
resolution: {integrity: sha512-kBvWuqBIIgkK2QskjHl0/MPLXYu4CDJDyuPc1KBDPBNejYIJp6hOZtbsmj4DYoNKsgFTpAALJn9JmUEdLe9E4g==}
peerDependencies:
'@push.rocks/smartbucket': ^4.6.1
'@push.rocks/smartdata': ^7.1.7
@@ -1395,8 +1395,8 @@ packages:
'@push.rocks/smartmustache@3.0.2':
resolution: {integrity: sha512-G3LyRXoJhyM+iQhkvP/MR/2WYMvC9U7zc2J44JxUM5tPdkQ+o3++FbfRtnZj6rz5X/A7q03//vsxPitVQwoi2Q==}
'@push.rocks/smartnetwork@4.7.1':
resolution: {integrity: sha512-x9SolGn8lU3oh+fKL26dR5dIhsus5f0p/Xiaut2pK5Wamgwrvt5y5To8F+pzF1pQr6yA0XwWZ0Dgoppp2E+ziQ==}
'@push.rocks/smartnetwork@4.7.2':
resolution: {integrity: sha512-OwT8kwQeEO+E3RuCyCfgQEBz+FyydUVaTBivZzzVchdJCUDgoDkXSnRkbIuGoHd1BfRFkUg9DQlSzt0uDfsIbw==}
'@push.rocks/smartnftables@1.2.0':
resolution: {integrity: sha512-VTRHnxHrJj9VOq2MaCOqxiA4JLGRnzEaZ7kXxA7v3ljX+Y2wWK9VYpwKKBEbjgjoTpQyOf+I0gEG9wkR/jtUvQ==}
@@ -1422,8 +1422,8 @@ packages:
'@push.rocks/smartpromise@4.2.4':
resolution: {integrity: sha512-8FUyYt94hOIY9mqHjitn4h69u0jbEtTF2RKKw2DpiTVFjpDTk9gXbVHZ/V+xEcBrN4mrzdQES0OiDmkNPoddEQ==}
'@push.rocks/smartproxy@27.10.2':
resolution: {integrity: sha512-ycTJ3OZ/LUAO0OY06O2al41bhm3s6mT9D5LcL7RepLyShjHBsaC26FNEApIVh9tll7OMHtsOa9ejOWQ8zuA4pA==}
'@push.rocks/smartproxy@27.10.3':
resolution: {integrity: sha512-2TvjgXUHtV0s8WH2RbtCS5+yjnFjbvQQ2ROmtVme1lgt2GUaAbekozUJNTE1ZMLEXc4xcZRdXIOfgBcQ6j/dmQ==}
'@push.rocks/smartpuppeteer@2.0.6':
resolution: {integrity: sha512-G+8cyDERvbXQcb9Sd8lnYdWYz8b3Mv2LfFf1ULmucDqQhcRHvxrWX/dKsvBZrwKPR4Wg+795Dyd+E1iOOh3tHw==}
@@ -5306,7 +5306,7 @@ snapshots:
'@push.rocks/smartjson': 6.0.1
'@push.rocks/smartlog': 3.2.2
'@push.rocks/smartmongo': 7.0.0(socks@2.8.8)
'@push.rocks/smartnetwork': 4.7.1
'@push.rocks/smartnetwork': 4.7.2
'@push.rocks/smartpath': 6.0.0
'@push.rocks/smartpromise': 4.2.4
'@push.rocks/smartrequest': 5.0.3
@@ -5381,7 +5381,7 @@ snapshots:
'@api.global/typedrequest-interfaces': 3.0.19
'@tsclass/tsclass': 9.5.1
'@idp.global/sdk@1.3.0(@push.rocks/smartserve@2.0.4)(socks@2.8.8)':
'@idp.global/sdk@1.3.1(@push.rocks/smartserve@2.0.4)(socks@2.8.8)':
dependencies:
'@api.global/typedrequest': 3.3.1
'@api.global/typedsocket': 4.1.3(@push.rocks/smartserve@2.0.4)
@@ -6115,7 +6115,7 @@ snapshots:
'@push.rocks/smartdelay': 3.1.0
'@push.rocks/smartdns': 7.9.2
'@push.rocks/smartlog': 3.2.2
'@push.rocks/smartnetwork': 4.7.1
'@push.rocks/smartnetwork': 4.7.2
'@push.rocks/smartstring': 4.1.1
'@push.rocks/smarttime': 4.2.3
'@push.rocks/smartunique': 3.0.9
@@ -6293,7 +6293,7 @@ snapshots:
- supports-color
- vue
'@push.rocks/smartdb@2.10.0(@tiptap/pm@2.27.2)(socks@2.8.8)':
'@push.rocks/smartdb@2.10.1(@tiptap/pm@2.27.2)(socks@2.8.8)':
dependencies:
'@api.global/typedserver': 8.4.6(@tiptap/pm@2.27.2)
'@design.estate/dees-element': 2.2.4
@@ -6508,7 +6508,7 @@ snapshots:
'@push.rocks/smartdelay': 3.1.0
'@push.rocks/smartlog': 3.2.2
'@push.rocks/smartmigration@1.3.1(@push.rocks/smartbucket@4.6.1)(@push.rocks/smartdata@7.1.7(socks@2.8.8))':
'@push.rocks/smartmigration@1.4.1(@push.rocks/smartbucket@4.6.1)(@push.rocks/smartdata@7.1.7(socks@2.8.8))':
dependencies:
'@push.rocks/smartlog': 3.2.2
'@push.rocks/smartversion': 3.1.0
@@ -6591,7 +6591,7 @@ snapshots:
dependencies:
handlebars: 4.7.9
'@push.rocks/smartnetwork@4.7.1':
'@push.rocks/smartnetwork@4.7.2':
dependencies:
'@push.rocks/smartdns': 7.9.2
'@push.rocks/smartrust': 1.4.0
@@ -6654,7 +6654,7 @@ snapshots:
'@push.rocks/smartdelay': 3.1.0
'@push.rocks/smartfs': 1.5.1
'@push.rocks/smartjimp': 1.2.1
'@push.rocks/smartnetwork': 4.7.1
'@push.rocks/smartnetwork': 4.7.2
'@push.rocks/smartpath': 6.0.0
'@push.rocks/smartpromise': 4.2.4
'@push.rocks/smartpuppeteer': 2.0.6(typescript@6.0.3)
@@ -6675,7 +6675,7 @@ snapshots:
'@push.rocks/smartpromise@4.2.4': {}
'@push.rocks/smartproxy@27.10.2':
'@push.rocks/smartproxy@27.10.3':
dependencies:
'@push.rocks/smartcrypto': 2.0.4
'@push.rocks/smartlog': 3.2.2
+14 -1
View File
@@ -86,7 +86,7 @@ Bootstrap behavior:
- `getAdminBootstrapStatus` reports whether persistence is ready and whether a first admin is required.
- The temporary env/config admin identity is only used to authorize bootstrap access while no persisted admin exists.
- `createInitialAdminUser` creates the first persisted admin with normalized email and local password authentication.
- Optional `idp.global` authentication can be enabled for that local account; the local dcrouter role remains authoritative and the IdP email must match the local account email.
- Optional `idp.global` authentication can be enabled for that local account. The hosted `https://idp.global` endpoint is used by default, `adminAuth.idpGlobalUrl` or `DCROUTER_IDP_GLOBAL_URL` only override it, and the local dcrouter role remains authoritative.
- After a persisted admin exists, temporary bootstrap admin login is rejected and normal persisted-account authentication is used.
## Configuration Model
@@ -196,6 +196,19 @@ const router = new DcRouter({
await router.start();
```
## VPN Target Profiles
Target profiles define what a VPN client can reach through `domains`, direct `targets`, and `routeRefs`. Set `allowRoutesByClientSourceIp: true` on a target profile when a VPN client should also reach non-`vpnOnly` routes that would have allowed the client's real connecting IP without the VPN.
dcrouter evaluates the live source IP reported by the VPN transport, such as `remoteAddr` or the WireGuard peer endpoint. If the route source policy allows that real IP, dcrouter injects the client's assigned VPN IP into SmartProxy for that route. The source-IP grant is live-only and is removed or updated when the VPN client disconnects or changes peer endpoint.
```typescript
const targetProfile = {
name: 'ops laptop source access',
allowRoutesByClientSourceIp: true,
};
```
## Automation API
The OpsServer exposes TypedRequest handlers at `/typedrequest`. You can use raw contracts or the object-oriented API client.
+170 -30
View File
@@ -14,8 +14,10 @@ let previousAdminPassword: string | undefined;
let opsServer: OpsServer;
let testDb: DcRouterDb;
let storagePath: string;
let dbName: string;
let bootstrapIdentity: interfaces.data.IIdentity;
let persistedIdentity: interfaces.data.IIdentity;
let createdUserId: string;
const createStatusRequest = () => new TypedRequest<interfaces.requests.IReq_GetAdminBootstrapStatus>(
baseUrl,
@@ -27,6 +29,40 @@ const createLoginRequest = () => new TypedRequest<interfaces.requests.IReq_Admin
'adminLoginWithUsernameAndPassword',
);
const createFakeDcRouter = (portArg: number, dcRouterDbArg?: DcRouterDb) => ({
options: {
opsServerPort: portArg,
dbConfig: { enabled: true },
adminAuth: {
idpClient: {
loginWithEmailAndPassword: async () => ({
jwt: 'idp-jwt',
refreshToken: 'idp-refresh-token',
user: {
id: 'idp-user-1',
data: {
name: 'Wrong IdP User',
username: 'wrong@example.com',
email: 'wrong@example.com',
status: 'active',
connectedOrgs: [],
},
},
}),
stop: async () => {},
},
},
},
typedrouter: new plugins.typedrequest.TypedRouter(),
dcRouterDb: dcRouterDbArg,
});
const restartOpsServer = async () => {
await opsServer.stop();
opsServer = new OpsServer(createFakeDcRouter(testPort, testDb) as any);
await opsServer.start();
};
tap.test('setup db-backed OpsServer admin bootstrap test', async () => {
previousAdminPassword = process.env.DCROUTER_ADMIN_PASSWORD;
process.env.DCROUTER_ADMIN_PASSWORD = bootstrapPassword;
@@ -37,42 +73,15 @@ tap.test('setup db-backed OpsServer admin bootstrap test', async () => {
);
DcRouterDb.resetInstance();
dbName = `dcrouter-admin-bootstrap-${Date.now()}-${Math.random().toString(16).slice(2)}`;
testDb = DcRouterDb.getInstance({
storagePath,
dbName: `dcrouter-admin-bootstrap-${Date.now()}-${Math.random().toString(16).slice(2)}`,
dbName,
});
await testDb.start();
await testDb.getDb().mongoDb.createCollection('__test_init');
const fakeDcRouter = {
options: {
opsServerPort: testPort,
dbConfig: { enabled: true },
adminAuth: {
idpClient: {
loginWithEmailAndPassword: async () => ({
jwt: 'idp-jwt',
refreshToken: 'idp-refresh-token',
user: {
id: 'idp-user-1',
data: {
name: 'Wrong IdP User',
username: 'wrong@example.com',
email: 'wrong@example.com',
status: 'active',
connectedOrgs: [],
},
},
}),
stop: async () => {},
},
},
},
typedrouter: new plugins.typedrequest.TypedRouter(),
dcRouterDb: testDb,
};
opsServer = new OpsServer(fakeDcRouter as any);
opsServer = new OpsServer(createFakeDcRouter(testPort, testDb) as any);
await opsServer.start();
});
@@ -84,6 +93,7 @@ tap.test('reports bootstrap required without auto-persisting an admin', async ()
expect(status.hasPersistentAdmin).toEqual(false);
expect(status.needsBootstrap).toEqual(true);
expect(status.ephemeralAdminAvailable).toEqual(true);
expect(status.idpGlobalConfigured).toEqual(true);
});
tap.test('allows temporary bootstrap admin login before persisted admin exists', async () => {
@@ -168,6 +178,30 @@ tap.test('authenticates the persisted admin locally by normalized email', async
expect(response.identity.userId).toEqual(persistedIdentity.userId);
});
tap.test('persists users across OpsServer restart', async () => {
const oldPersistedIdentity = persistedIdentity;
await restartOpsServer();
const verifyRequest = new TypedRequest<interfaces.requests.IReq_VerifyIdentity>(
baseUrl,
'verifyIdentity',
);
const verifyResponse = await verifyRequest.fire({ identity: oldPersistedIdentity });
expect(verifyResponse.valid).toEqual(false);
const loginResponse = await createLoginRequest().fire({
username: 'admin@example.com',
password: persistedPassword,
authSource: 'local',
});
if (!loginResponse.identity) {
throw new Error('Expected persisted admin login identity after restart');
}
expect(loginResponse.identity.userId).toEqual(oldPersistedIdentity.userId);
persistedIdentity = loginResponse.identity;
});
tap.test('rejects idp.global login when IdP email does not match local account', async () => {
let rejected = false;
try {
@@ -183,6 +217,45 @@ tap.test('rejects idp.global login when IdP email does not match local account',
expect(rejected).toEqual(true);
});
tap.test('creates a persisted non-admin user explicitly', async () => {
const request = new TypedRequest<interfaces.requests.IReq_CreateUser>(baseUrl, 'createUser');
const response = await request.fire({
identity: persistedIdentity,
email: 'operator@example.com',
name: 'Operator User',
role: 'user',
password: 'operator-password',
});
expect(response.success).toEqual(true);
expect(response.user?.role).toEqual('user');
expect(response.user?.email).toEqual('operator@example.com');
if (!response.user?.id) {
throw new Error('Expected created user id');
}
createdUserId = response.user.id;
});
tap.test('rejects deleting the current persisted admin user', async () => {
const request = new TypedRequest<interfaces.requests.IReq_DeleteUser>(baseUrl, 'deleteUser');
const response = await request.fire({
identity: persistedIdentity,
id: persistedIdentity.userId,
});
expect(response.success).toEqual(false);
});
tap.test('deletes a persisted non-current user', async () => {
const request = new TypedRequest<interfaces.requests.IReq_DeleteUser>(baseUrl, 'deleteUser');
const response = await request.fire({
identity: persistedIdentity,
id: createdUserId,
});
expect(response.success).toEqual(true);
});
tap.test('lists persisted users without password material', async () => {
const request = new TypedRequest<interfaces.requests.IReq_ListUsers>(baseUrl, 'listUsers');
const response = await request.fire({ identity: persistedIdentity });
@@ -192,6 +265,28 @@ tap.test('lists persisted users without password material', async () => {
expect((response.users[0] as any).password).toBeUndefined();
});
tap.test('rejects temporary bootstrap admin when persisted-user database is unavailable', async () => {
await testDb.stop();
const status = await createStatusRequest().fire({});
expect(status.dbEnabled).toEqual(true);
expect(status.dbReady).toEqual(false);
expect(status.needsBootstrap).toEqual(false);
expect(status.ephemeralAdminAvailable).toEqual(false);
let rejected = false;
try {
await createLoginRequest().fire({
username: 'admin',
password: bootstrapPassword,
});
} catch {
rejected = true;
}
expect(rejected).toEqual(true);
});
tap.test('cleanup db-backed OpsServer admin bootstrap test', async () => {
await opsServer.stop();
await testDb.stop();
@@ -205,4 +300,49 @@ tap.test('cleanup db-backed OpsServer admin bootstrap test', async () => {
}
});
tap.test('does not offer bootstrap while configured database is unavailable', async () => {
const unavailablePort = 3111;
const unavailableBaseUrl = `http://localhost:${unavailablePort}/typedrequest`;
const previousUnavailableAdminPassword = process.env.DCROUTER_ADMIN_PASSWORD;
process.env.DCROUTER_ADMIN_PASSWORD = 'unavailable-bootstrap-password';
DcRouterDb.resetInstance();
const unavailableOpsServer = new OpsServer(createFakeDcRouter(unavailablePort) as any);
try {
await unavailableOpsServer.start();
const status = await new TypedRequest<interfaces.requests.IReq_GetAdminBootstrapStatus>(
unavailableBaseUrl,
'getAdminBootstrapStatus',
).fire({});
expect(status.dbEnabled).toEqual(true);
expect(status.dbReady).toEqual(false);
expect(status.needsBootstrap).toEqual(false);
expect(status.ephemeralAdminAvailable).toEqual(false);
let rejected = false;
try {
await new TypedRequest<interfaces.requests.IReq_AdminLoginWithUsernameAndPassword>(
unavailableBaseUrl,
'adminLoginWithUsernameAndPassword',
).fire({
username: 'admin',
password: 'unavailable-bootstrap-password',
});
} catch {
rejected = true;
}
expect(rejected).toEqual(true);
} finally {
await unavailableOpsServer.stop();
DcRouterDb.resetInstance();
if (previousUnavailableAdminPassword === undefined) {
delete process.env.DCROUTER_ADMIN_PASSWORD;
} else {
process.env.DCROUTER_ADMIN_PASSWORD = previousUnavailableAdminPassword;
}
}
});
export default tap.start();
+1
View File
@@ -56,6 +56,7 @@ const setupHandler = (scopes: TScope[], options?: {
const opsServerRef: any = {
typedrouter,
adminHandler: {
validateIdentity: async () => null,
adminIdentityGuard: {
exec: async () => false,
},
+79
View File
@@ -0,0 +1,79 @@
import { expect, tap } from '@git.zone/tstest/tapbundle';
import { ConfigHandler } from '../ts/opsserver/handlers/config.handler.js';
import * as plugins from '../ts/plugins.js';
import * as interfaces from '../ts_interfaces/index.js';
const fireTypedRequest = async (
router: plugins.typedrequest.TypedRouter,
method: string,
request: Record<string, any>,
) => {
return await router.routeAndAddResponse({
method,
request,
response: {},
correlation: {
id: `${method}-${Date.now()}-${Math.random().toString(16).slice(2)}`,
phase: 'request',
},
} as any, { localRequest: true, skipHooks: true }) as any;
};
const makeOpsServer = (scopes: interfaces.data.TApiTokenScope[]) => {
const router = new plugins.typedrequest.TypedRouter();
const token = {
id: 'token-1',
name: 'config-token',
tokenHash: 'hash',
scopes,
createdBy: 'token-user',
createdAt: Date.now(),
expiresAt: null,
lastUsedAt: null,
enabled: true,
} as interfaces.data.IStoredApiToken;
const opsServerRef = {
viewRouter: router,
adminHandler: {
validateIdentity: async () => null,
},
dcRouterRef: {
options: {
dbConfig: { enabled: false },
},
resolvedPaths: {
dcrouterHomeDir: '/tmp/dcrouter-home',
dataDir: '/tmp/dcrouter-data',
defaultTsmDbPath: '/tmp/dcrouter-data/db',
},
detectedPublicIp: null,
apiTokenManager: {
validateToken: async (rawTokenArg: string) => rawTokenArg === 'valid-token' ? token : null,
hasScope: (storedTokenArg: interfaces.data.IStoredApiToken, scopeArg: interfaces.data.TApiTokenScope) => storedTokenArg.scopes.includes(scopeArg),
},
},
} as any;
new ConfigHandler(opsServerRef);
return router;
};
tap.test('ConfigHandler accepts API token with config:read', async () => {
const router = makeOpsServer(['config:read']);
const result = await fireTypedRequest(router, 'getConfiguration', {
apiToken: 'valid-token',
});
expect(result.error).toBeUndefined();
expect(result.response.config.system.baseDir).toEqual('/tmp/dcrouter-home');
});
tap.test('ConfigHandler rejects API token without config:read', async () => {
const router = makeOpsServer(['logs:read']);
const result = await fireTypedRequest(router, 'getConfiguration', {
apiToken: 'valid-token',
});
expect(result.error?.text).toEqual('insufficient scope');
});
export default tap.start();
+43 -3
View File
@@ -22,14 +22,21 @@ function createProxyMetrics(args: {
backendMetrics?: Map<string, any>;
protocolCache?: any[];
requestsTotal?: number;
connectionsByIP?: Map<string, number>;
throughputByIP?: Map<string, { in: number; out: number }>;
}) {
const connectionsByIP = args.connectionsByIP || new Map<string, number>();
const throughputByIP = args.throughputByIP || new Map<string, { in: number; out: number }>();
return {
connections: {
active: () => 0,
total: () => 0,
byRoute: () => args.connectionsByRoute,
byIP: () => new Map<string, number>(),
topIPs: () => [],
byIP: () => connectionsByIP,
topIPs: (limit = 10) => Array.from(connectionsByIP.entries())
.sort((a, b) => b[1] - a[1])
.slice(0, limit)
.map(([ip, count]) => ({ ip, count })),
domainRequestsByIP: () => args.domainRequestsByIP,
topDomainRequests: () => [],
frontendProtocols: () => emptyProtocolDistribution,
@@ -42,7 +49,7 @@ function createProxyMetrics(args: {
custom: () => ({ in: 0, out: 0 }),
history: () => [],
byRoute: () => args.throughputByRoute,
byIP: () => new Map<string, { in: number; out: number }>(),
byIP: () => throughputByIP,
},
requests: {
perSecond: () => 0,
@@ -239,4 +246,37 @@ tap.test('MetricsManager does not duplicate backend active counts onto protocol
expect(cacheRows.every((item) => item.activeConnections === 0)).toBeTrue();
});
tap.test('MetricsManager queues IP intelligence without awaiting enrichment', async () => {
const proxyMetrics = createProxyMetrics({
connectionsByRoute: new Map(),
throughputByRoute: new Map(),
domainRequestsByIP: new Map(),
connectionsByIP: new Map([
['8.8.8.8', 4],
['1.1.1.1', 2],
]),
throughputByIP: new Map([
['8.8.8.8', { in: 500, out: 250 }],
['1.1.1.1', { in: 1500, out: 1000 }],
]),
});
const queuedIps: string[][] = [];
const manager = new MetricsManager({
smartProxy: {
getMetrics: () => proxyMetrics,
routeManager: { getRoutes: () => [] },
},
securityPolicyManager: {
queueObservedIps: (ips: string[]) => queuedIps.push(ips),
},
} as any);
await manager.getNetworkStats();
expect(queuedIps).toHaveLength(1);
expect(queuedIps[0]).toContain('8.8.8.8');
expect(queuedIps[0]).toContain('1.1.1.1');
});
export default tap.start();
+69
View File
@@ -0,0 +1,69 @@
import { expect, tap } from '@git.zone/tstest/tapbundle';
import { createMigrationRunner } from '../ts_migrations/index.js';
function setPath(target: Record<string, any>, path: string, value: unknown): void {
const parts = path.split('.');
let cursor = target;
for (const part of parts.slice(0, -1)) {
cursor[part] = cursor[part] || {};
cursor = cursor[part];
}
cursor[parts[parts.length - 1]] = value;
}
function applySet(document: Record<string, any>, set: Record<string, unknown>): void {
for (const [key, value] of Object.entries(set)) {
setPath(document, key, value);
}
}
function createFakeDb(currentVersion: string) {
const ledgerDocument = {
nameId: 'smartmigration:smartmigration',
data: {
currentVersion,
steps: {},
lock: { holder: null, acquiredAt: null, expiresAt: null },
checkpoints: {},
},
};
const emptyCollection = {
find: () => ({
async *[Symbol.asyncIterator]() {},
}),
updateMany: async () => ({ modifiedCount: 0 }),
};
const ledgerCollection = {
createIndex: async () => undefined,
findOne: async () => structuredClone(ledgerDocument),
findOneAndUpdate: async (_query: unknown, update: any) => {
applySet(ledgerDocument, update.$set || {});
return structuredClone(ledgerDocument);
},
updateOne: async (_query: unknown, update: any) => {
applySet(ledgerDocument, update.$set || {});
return { matchedCount: 1, modifiedCount: 1, upsertedCount: 0 };
},
};
return {
mongoDb: {
collection: (name: string) =>
name === 'SmartdataEasyStore' ? ledgerCollection : emptyCollection,
},
};
}
tap.test('migration runner bridges old package-version targets without real schema steps', async () => {
const runner = await createMigrationRunner(createFakeDb('13.16.0'), '13.31.0');
const result = await runner.run();
expect(result.currentVersionBefore).toEqual('13.16.0');
expect(result.currentVersionAfter).toEqual('13.31.0');
expect(result.stepsApplied).toHaveLength(3);
});
export default tap.start();
+126
View File
@@ -0,0 +1,126 @@
import { expect, tap } from '@git.zone/tstest/tapbundle';
import { requireOpsAuth } from '../ts/opsserver/helpers/auth.js';
import * as interfaces from '../ts_interfaces/index.js';
type TScope = interfaces.data.TApiTokenScope;
const makeIdentity = (role: string = 'user'): interfaces.data.IIdentity => ({
jwt: `jwt-${role}`,
userId: `${role}-user`,
name: role,
expiresAt: Date.now() + 3600000,
role,
});
const makeOpsServer = (options: {
identityRole?: string | null;
tokenScopes?: TScope[];
tokenPolicy?: interfaces.data.IApiTokenPolicy;
}) => {
const token = {
id: 'token-1',
name: 'test-token',
tokenHash: 'hash',
scopes: options.tokenScopes || [],
policy: options.tokenPolicy,
createdAt: Date.now(),
expiresAt: null,
lastUsedAt: null,
createdBy: 'token-user',
enabled: true,
} as interfaces.data.IStoredApiToken;
return {
adminHandler: {
validateIdentity: async (identityArg?: interfaces.data.IIdentity) => {
if (!identityArg || options.identityRole === null) return null;
return { ...identityArg, role: options.identityRole || identityArg.role || 'user' };
},
},
dcRouterRef: {
apiTokenManager: {
validateToken: async (rawTokenArg: string) => rawTokenArg === 'valid-token' ? token : null,
hasScope: (storedTokenArg: interfaces.data.IStoredApiToken, scopeArg: TScope) => {
if (storedTokenArg.policy?.role === 'admin') return true;
return storedTokenArg.scopes.includes('*') || storedTokenArg.scopes.includes(scopeArg) || Boolean(storedTokenArg.policy?.scopes?.includes(scopeArg));
},
},
},
} as any;
};
const getErrorText = (errorArg: unknown) => {
return (errorArg as any).errorText || (errorArg as any).text || (errorArg as Error).message;
};
tap.test('requireOpsAuth accepts valid JWT identity for read endpoints', async () => {
const auth = await requireOpsAuth(
makeOpsServer({ identityRole: 'user' }),
{ identity: makeIdentity('user') },
{ scope: 'config:read' },
);
expect(auth.type).toEqual('identity');
expect(auth.userId).toEqual('user-user');
expect(auth.isAdmin).toEqual(false);
});
tap.test('requireOpsAuth rejects non-admin JWT identity for admin identity requirements', async () => {
let errorText = '';
try {
await requireOpsAuth(
makeOpsServer({ identityRole: 'user' }),
{ identity: makeIdentity('user') },
{ scope: 'routes:write', requireAdminIdentity: true },
);
} catch (error) {
errorText = getErrorText(error);
}
expect(errorText).toEqual('admin identity required');
});
tap.test('requireOpsAuth accepts scoped API tokens', async () => {
const auth = await requireOpsAuth(
makeOpsServer({ identityRole: null, tokenScopes: ['logs:read'] }),
{ apiToken: 'valid-token' },
{ scope: 'logs:read' },
);
expect(auth.type).toEqual('apiToken');
expect(auth.userId).toEqual('token-user');
});
tap.test('requireOpsAuth rejects API tokens without the required scope', async () => {
let errorText = '';
try {
await requireOpsAuth(
makeOpsServer({ identityRole: null, tokenScopes: ['logs:read'] }),
{ apiToken: 'valid-token' },
{ scope: 'stats:read' },
);
} catch (error) {
errorText = getErrorText(error);
}
expect(errorText).toEqual('insufficient scope');
});
tap.test('requireOpsAuth requires admin policy for sensitive API-token operations', async () => {
let errorText = '';
try {
await requireOpsAuth(
makeOpsServer({ identityRole: null, tokenScopes: ['tokens:manage'] }),
{ apiToken: 'valid-token' },
{ scope: 'tokens:manage', requireAdminToken: true },
);
} catch (error) {
errorText = getErrorText(error);
}
expect(errorText).toEqual('admin API token required');
const auth = await requireOpsAuth(
makeOpsServer({ identityRole: null, tokenPolicy: { role: 'admin' } }),
{ apiToken: 'valid-token' },
{ scope: 'tokens:manage', requireAdminToken: true },
);
expect(auth.isAdmin).toEqual(true);
});
export default tap.start();
+71
View File
@@ -40,6 +40,23 @@ const clearTestState = async () => {
}
};
const createIntelligenceResult = (asn: number) => ({
asn,
asnOrg: `ASN ${asn}`,
registrantOrg: null,
registrantCountry: null,
networkRange: null,
networkCidrs: null,
abuseContact: null,
country: null,
countryCode: 'US',
city: null,
latitude: null,
longitude: null,
accuracyRadius: null,
timezone: null,
});
tap.test('SecurityPolicyManager compiles start-end CIDR rules for edge firewall snapshots', async () => {
await testDbPromise;
await clearTestState();
@@ -120,6 +137,60 @@ tap.test('SecurityPolicyManager returns an explicit empty edge firewall snapshot
expect(firewall).toEqual({ blockedIps: [] });
});
tap.test('SecurityPolicyManager filters listed IP intelligence records', async () => {
await testDbPromise;
await clearTestState();
const manager = new SecurityPolicyManager();
for (const [ipAddress, asn] of [['8.8.8.8', 15169], ['1.1.1.1', 13335]] as const) {
const intelligenceDoc = new IpIntelligenceDoc();
intelligenceDoc.ipAddress = ipAddress;
intelligenceDoc.asn = asn;
intelligenceDoc.asnOrg = `ASN ${asn}`;
intelligenceDoc.firstSeenAt = Date.now();
intelligenceDoc.lastSeenAt = Date.now();
intelligenceDoc.updatedAt = Date.now();
intelligenceDoc.seenCount = 1;
await intelligenceDoc.save();
}
const records = await manager.listIpIntelligence({ ipAddresses: ['1.1.1.1'] });
expect(records).toHaveLength(1);
expect(records[0].ipAddress).toEqual('1.1.1.1');
});
tap.test('SecurityPolicyManager force refresh waits for an in-flight background observation', async () => {
await testDbPromise;
await clearTestState();
const manager = new SecurityPolicyManager({ intelligenceRefreshMs: 0 });
let releaseFirstLookup!: () => void;
let lookupCount = 0;
(manager as any).smartNetwork = {
getIpIntelligence: async () => {
lookupCount++;
if (lookupCount === 1) {
await new Promise<void>((resolve) => { releaseFirstLookup = resolve; });
return createIntelligenceResult(64500);
}
return createIntelligenceResult(64501);
},
stop: async () => {},
};
const backgroundObservation = manager.observeIp('8.8.8.8');
await new Promise((resolve) => setTimeout(resolve, 10));
const forcedRefresh = manager.refreshIpIntelligence('8.8.8.8');
releaseFirstLookup();
const record = await forcedRefresh;
await backgroundObservation;
expect(lookupCount).toEqual(2);
expect(record?.asn).toEqual(64501);
});
tap.test('cleanup security policy test db', async () => {
const dbHandle = await testDbPromise;
await clearTestState();
+297
View File
@@ -2,6 +2,7 @@ import { expect, tap } from '@git.zone/tstest/tapbundle';
import { DcRouter } from '../ts/classes.dcrouter.js';
import { VpnManager } from '../ts/vpn/classes.vpn-manager.js';
import { RouteConfigManager } from '../ts/config/classes.route-config-manager.js';
import { TargetProfileManager } from '../ts/config/classes.target-profile-manager.js';
tap.test('VpnManager downgrades back to socket mode when no host-IP clients remain', async () => {
const manager = new VpnManager({ forwardingMode: 'socket' });
@@ -147,6 +148,302 @@ tap.test('RouteConfigManager replaces public allow lists for vpnOnly routes', as
expect(prepared.security.ipBlockList).toEqual(['198.51.100.5']);
});
tap.test('RouteConfigManager adds matching VPN clients to restricted non-vpnOnly routes', async () => {
const manager = new RouteConfigManager(
() => undefined,
undefined,
() => ['10.8.0.2'],
);
const route = {
name: 'shared-private-route',
match: { domains: ['app.example.com'] },
action: { type: 'forward', targets: [{ host: '127.0.0.1', port: 8080 }] },
security: {
ipAllowList: ['203.0.113.10'],
ipBlockList: ['198.51.100.5'],
},
} as any;
const prepared = (manager as any).injectVpnSecurity(route);
expect(prepared.security.ipAllowList).toEqual(['203.0.113.10', '10.8.0.2']);
expect(prepared.security.ipBlockList).toEqual(['198.51.100.5']);
});
tap.test('TargetProfileManager matches wildcard profiles against string route domains', async () => {
const manager = new TargetProfileManager();
(manager as any).profiles.set('profile-1', {
id: 'profile-1',
name: 'hagen.team VPN access',
domains: ['*.hagen.team'],
createdAt: 1,
updatedAt: 1,
createdBy: 'test',
});
const entries = manager.getMatchingClientIps(
{
name: 'hagen-app',
match: { domains: 'app.hagen.team', ports: [443] },
action: { type: 'forward', targets: [{ host: '10.0.0.5', port: 443 }] },
} as any,
'route-1',
[{ enabled: true, assignedIp: '10.8.0.2', targetProfileIds: ['profile-1'] }] as any,
);
expect(entries).toEqual(['10.8.0.2']);
});
tap.test('TargetProfileManager expands wildcard profile domains to matching concrete route domains', async () => {
const manager = new TargetProfileManager();
(manager as any).profiles.set('profile-1', {
id: 'profile-1',
name: 'hagen.team VPN access',
domains: ['*.hagen.team'],
createdAt: 1,
updatedAt: 1,
createdBy: 'test',
});
const routes = new Map([
['route-1', {
id: 'route-1',
enabled: true,
createdAt: 1,
updatedAt: 1,
createdBy: 'test',
origin: 'api',
route: {
name: 'hagen-app',
match: { domains: 'app.hagen.team', ports: [443] },
action: { type: 'forward', targets: [{ host: '10.0.0.5', port: 443 }] },
},
}],
]) as any;
const accessSpec = manager.getClientAccessSpec(['profile-1'], routes);
expect(accessSpec.domains).toContain('*.hagen.team');
expect(accessSpec.domains).toContain('app.hagen.team');
});
tap.test('TargetProfileManager allows source-IP reachable routes for opted-in profiles', async () => {
const manager = new TargetProfileManager();
(manager as any).profiles.set('profile-1', {
id: 'profile-1',
name: 'source-ip access',
allowRoutesByClientSourceIp: true,
createdAt: 1,
updatedAt: 1,
createdBy: 'test',
});
const entries = manager.getMatchingClientIps(
{
name: 'restricted-public-route',
match: { domains: 'app.example.com', ports: [443] },
action: { type: 'forward', targets: [{ host: '10.0.0.5', port: 443 }] },
security: { ipAllowList: ['203.0.113.10'] },
} as any,
'route-1',
[{ clientId: 'client-1', enabled: true, assignedIp: '10.8.0.2', targetProfileIds: ['profile-1'] }] as any,
new Map(),
new Map([['client-1', '203.0.113.10']]),
);
expect(entries).toEqual(['10.8.0.2']);
});
tap.test('TargetProfileManager does not allow non-matching client source IPs', async () => {
const manager = new TargetProfileManager();
(manager as any).profiles.set('profile-1', {
id: 'profile-1',
name: 'source-ip access',
allowRoutesByClientSourceIp: true,
createdAt: 1,
updatedAt: 1,
createdBy: 'test',
});
const entries = manager.getMatchingClientIps(
{
name: 'restricted-public-route',
match: { domains: 'app.example.com', ports: [443] },
action: { type: 'forward', targets: [{ host: '10.0.0.5', port: 443 }] },
security: { ipAllowList: ['203.0.113.10'] },
} as any,
'route-1',
[{ clientId: 'client-1', enabled: true, assignedIp: '10.8.0.2', targetProfileIds: ['profile-1'] }] as any,
new Map(),
new Map([['client-1', '198.51.100.10']]),
);
expect(entries).toEqual([]);
});
tap.test('TargetProfileManager source-IP matching respects route block lists', async () => {
const manager = new TargetProfileManager();
(manager as any).profiles.set('profile-1', {
id: 'profile-1',
name: 'source-ip access',
allowRoutesByClientSourceIp: true,
createdAt: 1,
updatedAt: 1,
createdBy: 'test',
});
const entries = manager.getMatchingClientIps(
{
name: 'blocked-route',
match: { domains: 'app.example.com', ports: [443] },
action: { type: 'forward', targets: [{ host: '10.0.0.5', port: 443 }] },
security: {
ipAllowList: ['203.0.113.0/24'],
ipBlockList: ['203.0.113.10'],
},
} as any,
'route-1',
[{ clientId: 'client-1', enabled: true, assignedIp: '10.8.0.2', targetProfileIds: ['profile-1'] }] as any,
new Map(),
new Map([['client-1', '203.0.113.10']]),
);
expect(entries).toEqual([]);
});
tap.test('TargetProfileManager treats public non-vpnOnly routes as source-IP reachable', async () => {
const manager = new TargetProfileManager();
(manager as any).profiles.set('profile-1', {
id: 'profile-1',
name: 'source-ip access',
allowRoutesByClientSourceIp: true,
createdAt: 1,
updatedAt: 1,
createdBy: 'test',
});
const entries = manager.getMatchingClientIps(
{
name: 'public-route',
match: { domains: 'public.example.com', ports: [443] },
action: { type: 'forward', targets: [{ host: '10.0.0.5', port: 443 }] },
} as any,
'route-1',
[{ clientId: 'client-1', enabled: true, assignedIp: '10.8.0.2', targetProfileIds: ['profile-1'] }] as any,
new Map(),
new Map([['client-1', '203.0.113.10']]),
);
expect(entries).toEqual(['10.8.0.2']);
});
tap.test('TargetProfileManager does not grant vpnOnly routes through source-IP matching alone', async () => {
const manager = new TargetProfileManager();
(manager as any).profiles.set('profile-1', {
id: 'profile-1',
name: 'source-ip access',
allowRoutesByClientSourceIp: true,
createdAt: 1,
updatedAt: 1,
createdBy: 'test',
});
const entries = manager.getMatchingClientIps(
{
name: 'vpn-only-route',
vpnOnly: true,
match: { domains: 'private.example.com', ports: [443] },
action: { type: 'forward', targets: [{ host: '10.0.0.5', port: 443 }] },
security: { ipAllowList: ['203.0.113.10'] },
} as any,
'route-1',
[{ clientId: 'client-1', enabled: true, assignedIp: '10.8.0.2', targetProfileIds: ['profile-1'] }] as any,
new Map(),
new Map([['client-1', '203.0.113.10']]),
);
expect(entries).toEqual([]);
});
tap.test('TargetProfileManager includes source-IP reachable route domains in client access specs', async () => {
const manager = new TargetProfileManager();
(manager as any).profiles.set('profile-1', {
id: 'profile-1',
name: 'source-ip access',
allowRoutesByClientSourceIp: true,
createdAt: 1,
updatedAt: 1,
createdBy: 'test',
});
const routes = new Map([
['route-1', {
id: 'route-1',
enabled: true,
createdAt: 1,
updatedAt: 1,
createdBy: 'test',
origin: 'api',
route: {
name: 'source-reachable-app',
match: { domains: 'app.example.com', ports: [443] },
action: { type: 'forward', targets: [{ host: '10.0.0.5', port: 443 }] },
security: { ipAllowList: ['203.0.113.0/24'] },
},
}],
]) as any;
const accessSpec = manager.getClientAccessSpec(['profile-1'], routes, '203.0.113.10');
expect(accessSpec.domains).toContain('app.example.com');
});
tap.test('VpnManager normalizes real remote addresses', async () => {
expect(VpnManager.normalizeRemoteAddress('203.0.113.10:51234')).toEqual('203.0.113.10');
expect(VpnManager.normalizeRemoteAddress('[2001:db8::1]:51234')).toEqual('2001:db8::1');
expect(VpnManager.normalizeRemoteAddress('2001:db8::1')).toEqual('2001:db8::1');
});
tap.test('VpnManager refreshes live source IPs from WireGuard peer endpoints', async () => {
const manager = new VpnManager({});
let sourceIpChangeCalls = 0;
(manager as any).config.onClientSourceIpsChanged = () => {
sourceIpChangeCalls++;
};
(manager as any).clients = new Map([
['client-1', { clientId: 'client-1', wgPublicKey: 'wg-public-key' }],
]);
(manager as any).vpnServer = {
listClients: async () => ([
{
clientId: 'runtime-client-1',
registeredClientId: 'client-1',
assignedIp: '10.8.0.2',
transportType: 'wireguard',
},
]),
listWgPeers: async () => ([
{
publicKey: 'wg-public-key',
allowedIps: ['10.8.0.2/32'],
endpoint: '198.51.100.44:61234',
bytesSent: 0,
bytesReceived: 0,
packetsSent: 0,
packetsReceived: 0,
},
]),
};
const changed = await manager.refreshClientSourceIps();
const changedAgain = await manager.refreshClientSourceIps();
expect(changed).toEqual(true);
expect(changedAgain).toEqual(false);
expect(manager.getClientSourceIp('client-1')).toEqual('198.51.100.44');
expect(sourceIpChangeCalls).toEqual(1);
});
tap.test('VpnManager rewrites WireGuard AllowedIPs after key rotation', async () => {
const manager = new VpnManager({
serverEndpoint: 'vpn.example.com',
+3
View File
@@ -136,6 +136,9 @@ const setupHandler = (options: {
const opsServerRef: any = {
typedrouter,
adminHandler: {
validateIdentity: async (identity: interfaces.data.IIdentity) => options.isAdmin
? { ...identity, role: 'admin' }
: identity,
adminIdentityGuard: {
exec: async () => Boolean(options.isAdmin),
},
+1 -1
View File
@@ -3,6 +3,6 @@
*/
export const commitinfo = {
name: '@serve.zone/dcrouter',
version: '13.30.0',
version: '13.34.0',
description: 'A multifaceted routing service handling mail and SMS delivery functions.'
}
+11 -3
View File
@@ -169,7 +169,7 @@ export interface IDcRouterOptions {
/** Optional OpsServer account authentication settings. */
adminAuth?: {
/** Base URL for idp.global password authentication. Can also be set through DCROUTER_IDP_GLOBAL_URL. */
/** Optional idp.global password-authentication URL override. Defaults to the SDK's hosted https://idp.global endpoint. Can also be set through DCROUTER_IDP_GLOBAL_URL. */
idpGlobalUrl?: string;
/** Test/integration hook for injecting an idp.global-compatible password client. */
idpClient?: Pick<plugins.idpSdkServer.IdpGlobalServerClient, 'loginWithEmailAndPassword' | 'stop'>;
@@ -2421,6 +2421,7 @@ export class DcRouter {
routeId,
this.vpnManager.listClients(),
this.routeConfigManager?.getRoutes() || new Map(),
this.vpnManager.getClientSourceIpMap(),
);
};
}
@@ -2458,11 +2459,16 @@ export class DcRouter {
logger.log('warn', `Failed to re-apply routes after VPN client change: ${err?.message || err}`);
});
},
onClientSourceIpsChanged: () => {
this.routeConfigManager?.applyRoutes().catch((err) => {
logger.log('warn', `Failed to re-apply routes after VPN client source IP change: ${err?.message || err}`);
});
},
getClientDirectTargets: (targetProfileIds: string[]) => {
if (!this.targetProfileManager) return [];
return this.targetProfileManager.getDirectTargetIps(targetProfileIds);
},
getClientAllowedIPs: async (targetProfileIds: string[]) => {
getClientAllowedIPs: async (targetProfileIds: string[], clientId?: string, sourceIp?: string) => {
const subnet = this.options.vpnConfig?.subnet || '10.8.0.0/24';
const ips = new Set<string>([subnet]);
@@ -2471,7 +2477,9 @@ export class DcRouter {
const allRoutes = this.routeConfigManager?.getRoutes() || new Map();
const { domains, targetIps } = this.targetProfileManager.getClientAccessSpec(
targetProfileIds, allRoutes,
targetProfileIds,
allRoutes,
sourceIp,
);
// Add target IPs directly
+35 -2
View File
@@ -608,9 +608,23 @@ export class RouteConfigManager {
routeId?: string,
): plugins.smartproxy.IRouteConfig {
const dcRoute = route as IDcRouterRouteConfig;
if (!dcRoute.vpnOnly) return route;
const vpnEntries = this.getVpnClientIpsForRoute?.(dcRoute, routeId) || [];
if (!dcRoute.vpnOnly) {
const existingAllowList = route.security?.ipAllowList;
if (!Array.isArray(existingAllowList) || existingAllowList.length === 0 || vpnEntries.length === 0) {
return route;
}
return {
...route,
security: {
...route.security,
ipAllowList: this.mergeIpAllowEntries(existingAllowList as TIpAllowEntry[], vpnEntries),
},
};
}
const existingBlockList = route.security?.ipBlockList || [];
const ipBlockList = vpnEntries.length
? existingBlockList
@@ -625,4 +639,23 @@ export class RouteConfigManager {
},
};
}
private mergeIpAllowEntries(
existingEntries: TIpAllowEntry[],
vpnEntries: TIpAllowEntry[],
): TIpAllowEntry[] {
const merged: TIpAllowEntry[] = [];
const seen = new Set<string>();
for (const entry of [...existingEntries, ...vpnEntries]) {
const key = typeof entry === 'string'
? `ip:${entry}`
: `domain:${entry.ip}:${[...entry.domains].sort().join(',')}`;
if (seen.has(key)) continue;
seen.add(key);
merged.push(entry);
}
return merged;
}
}
+238 -11
View File
@@ -5,6 +5,8 @@ import type { ITargetProfile, ITargetProfileTarget } from '../../ts_interfaces/d
import type { IDcRouterRouteConfig } from '../../ts_interfaces/data/remoteingress.js';
import type { IRoute } from '../../ts_interfaces/data/route-management.js';
type TIpAllowEntry = string | { ip: string; domains?: string[] };
/**
* Manages TargetProfiles (target-side: what can be accessed).
* TargetProfiles define what resources a VPN client can reach:
@@ -35,6 +37,7 @@ export class TargetProfileManager {
domains?: string[];
targets?: ITargetProfileTarget[];
routeRefs?: string[];
allowRoutesByClientSourceIp?: boolean;
createdBy: string;
}): Promise<string> {
// Enforce unique profile names
@@ -55,6 +58,7 @@ export class TargetProfileManager {
domains: data.domains,
targets: data.targets,
routeRefs,
allowRoutesByClientSourceIp: data.allowRoutesByClientSourceIp === true,
createdAt: now,
updatedAt: now,
createdBy: data.createdBy,
@@ -88,6 +92,9 @@ export class TargetProfileManager {
if (patch.domains !== undefined) profile.domains = patch.domains;
if (patch.targets !== undefined) profile.targets = patch.targets;
if (patch.routeRefs !== undefined) profile.routeRefs = this.normalizeRouteRefs(patch.routeRefs);
if (patch.allowRoutesByClientSourceIp !== undefined) {
profile.allowRoutesByClientSourceIp = patch.allowRoutesByClientSourceIp === true;
}
profile.updatedAt = Date.now();
await this.persistProfile(profile);
@@ -208,16 +215,18 @@ export class TargetProfileManager {
*
* 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.
* or when profile domains exactly equal the route's domains. Profiles can also opt
* into source-IP matching against non-vpnOnly route security.
*/
public getMatchingClientIps(
route: IDcRouterRouteConfig,
routeId: string | undefined,
clients: VpnClientDoc[],
allRoutes: Map<string, IRoute> = new Map(),
clientSourceIps: Map<string, string> = new Map(),
): Array<string | { ip: string; domains: string[] }> {
const entries: Array<string | { ip: string; domains: string[] }> = [];
const routeDomains: string[] = (route.match as any)?.domains || [];
const routeDomains = this.getRouteDomains(route);
const routeNameIndex = this.buildRouteNameIndex(allRoutes);
for (const client of clients) {
@@ -227,6 +236,7 @@ export class TargetProfileManager {
// Collect scoped domains from all matching profiles for this client
let fullAccess = false;
const scopedDomains = new Set<string>();
const clientSourceIp = clientSourceIps.get(client.clientId);
for (const profileId of client.targetProfileIds) {
const profile = this.profiles.get(profileId);
@@ -246,6 +256,16 @@ export class TargetProfileManager {
if (matchResult !== 'none') {
for (const d of matchResult.domains) scopedDomains.add(d);
}
if (
!route.vpnOnly
&& profile.allowRoutesByClientSourceIp === true
&& clientSourceIp
&& this.routeAllowsSourceIp(route, clientSourceIp, routeDomains)
) {
fullAccess = true;
break;
}
}
if (fullAccess) {
@@ -265,6 +285,7 @@ export class TargetProfileManager {
public getClientAccessSpec(
targetProfileIds: string[],
allRoutes: Map<string, IRoute>,
clientSourceIp?: string,
): { domains: string[]; targetIps: string[] } {
const domains = new Set<string>();
const targetIps = new Set<string>();
@@ -292,17 +313,21 @@ export class TargetProfileManager {
// Route references: scan all routes
for (const [routeId, route] of allRoutes) {
if (!route.enabled) continue;
if (this.routeMatchesProfile(
route.route as IDcRouterRouteConfig,
const dcRoute = route.route as IDcRouterRouteConfig;
const routeDomains = this.getRouteDomains(dcRoute);
const profileMatchesRoute = this.routeMatchesProfile(
dcRoute,
routeId,
profile,
routeNameIndex,
)) {
const routeDomains = (route.route.match as any)?.domains;
if (Array.isArray(routeDomains)) {
for (const d of routeDomains) {
domains.add(d);
}
);
const sourceIpMatchesRoute = profile.allowRoutesByClientSourceIp === true
&& clientSourceIp
&& !dcRoute.vpnOnly
&& this.routeAllowsSourceIp(dcRoute, clientSourceIp, routeDomains);
if (profileMatchesRoute || sourceIpMatchesRoute) {
for (const d of routeDomains) {
domains.add(d);
}
}
}
@@ -327,7 +352,7 @@ export class TargetProfileManager {
profile: ITargetProfile,
routeNameIndex: Map<string, string[]>,
): boolean {
const routeDomains: string[] = (route.match as any)?.domains || [];
const routeDomains = this.getRouteDomains(route);
const result = this.routeMatchesProfileDetailed(
route,
routeId,
@@ -425,6 +450,205 @@ export class TargetProfileManager {
return false;
}
private routeAllowsSourceIp(
route: IDcRouterRouteConfig,
sourceIp: string,
routeDomains: string[],
): boolean {
const security = (route as any).security;
const ipAllowList = this.normalizeIpEntries(security?.ipAllowList);
const ipBlockList = this.normalizeIpEntries(security?.ipBlockList);
if (this.ipEntriesMatchSource(ipBlockList, sourceIp, routeDomains)) {
return false;
}
if (!ipAllowList.length) {
return true;
}
return this.ipEntriesMatchSource(ipAllowList, sourceIp, routeDomains);
}
private normalizeIpEntries(entries: unknown): TIpAllowEntry[] {
if (!entries) return [];
if (Array.isArray(entries)) return entries as TIpAllowEntry[];
return [entries as TIpAllowEntry];
}
private ipEntriesMatchSource(
entries: TIpAllowEntry[],
sourceIp: string,
routeDomains: string[],
): boolean {
return entries.some((entry) => this.ipEntryMatchesSource(entry, sourceIp, routeDomains));
}
private ipEntryMatchesSource(
entry: TIpAllowEntry,
sourceIp: string,
routeDomains: string[],
): boolean {
const ipPattern = typeof entry === 'string' ? entry : entry.ip;
if (typeof ipPattern !== 'string') return false;
if (!this.ipPatternMatchesSource(ipPattern, sourceIp)) {
return false;
}
if (typeof entry === 'string' || !entry.domains?.length) {
return true;
}
if (!routeDomains.length) {
return false;
}
return routeDomains.some((routeDomain) =>
entry.domains!.some((entryDomain) =>
this.domainMatchesPattern(routeDomain, entryDomain)
|| this.domainMatchesPattern(entryDomain, routeDomain),
),
);
}
private ipPatternMatchesSource(pattern: string, sourceIp: string): boolean {
const trimmedPattern = pattern.trim();
const trimmedSourceIp = sourceIp.trim();
if (!trimmedPattern || !trimmedSourceIp) return false;
if (trimmedPattern === '*') return true;
if (trimmedPattern === trimmedSourceIp) return true;
if (trimmedPattern.includes('/')) {
return this.ipMatchesCidr(trimmedSourceIp, trimmedPattern);
}
if (trimmedPattern.includes('-')) {
return this.ipMatchesRange(trimmedSourceIp, trimmedPattern);
}
if (trimmedPattern.includes('*')) {
return this.ipMatchesWildcard(trimmedSourceIp, trimmedPattern);
}
return false;
}
private ipMatchesCidr(sourceIp: string, cidr: string): boolean {
const [networkIp, prefixString] = cidr.split('/');
if (!networkIp || !prefixString) return false;
const source = this.ipToComparable(sourceIp);
const network = this.ipToComparable(networkIp);
const prefix = Number(prefixString);
if (!source || !network || source.version !== network.version) return false;
const bitCount = source.version === 4 ? 32 : 128;
if (!Number.isInteger(prefix) || prefix < 0 || prefix > bitCount) return false;
if (prefix === 0) return true;
const shift = BigInt(bitCount - prefix);
return (source.value >> shift) === (network.value >> shift);
}
private ipMatchesRange(sourceIp: string, range: string): boolean {
const [startIp, endIp] = range.split('-').map((part) => part.trim());
if (!startIp || !endIp) return false;
const source = this.ipToComparable(sourceIp);
const start = this.ipToComparable(startIp);
const end = this.ipToComparable(endIp);
if (!source || !start || !end) return false;
if (source.version !== start.version || source.version !== end.version) return false;
return source.value >= start.value && source.value <= end.value;
}
private ipMatchesWildcard(sourceIp: string, pattern: string): boolean {
const sourceParts = sourceIp.split('.');
const patternParts = pattern.split('.');
if (sourceParts.length !== 4 || patternParts.length !== 4) return false;
return patternParts.every((patternPart, index) => {
if (patternPart === '*') return true;
return patternPart === sourceParts[index];
});
}
private ipToComparable(ip: string): { version: 4 | 6; value: bigint } | undefined {
const normalizedIp = this.normalizeIpLiteral(ip);
const ipVersion = plugins.net.isIP(normalizedIp);
if (ipVersion === 4) {
const parts = normalizedIp.split('.').map((part) => Number(part));
if (parts.length !== 4 || parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) {
return undefined;
}
return {
version: 4,
value: parts.reduce((value, part) => (value << 8n) + BigInt(part), 0n),
};
}
if (ipVersion === 6) {
const parts = this.expandIpv6(normalizedIp);
if (!parts) return undefined;
return {
version: 6,
value: parts.reduce((value, part) => (value << 16n) + BigInt(part), 0n),
};
}
return undefined;
}
private normalizeIpLiteral(ip: string): string {
const trimmed = ip.trim().replace(/^\[|\]$/g, '');
const zoneIndex = trimmed.indexOf('%');
const withoutZone = zoneIndex === -1 ? trimmed : trimmed.slice(0, zoneIndex);
const ipv4MappedPrefix = '::ffff:';
if (withoutZone.toLowerCase().startsWith(ipv4MappedPrefix)) {
const mappedIpv4 = withoutZone.slice(ipv4MappedPrefix.length);
if (plugins.net.isIP(mappedIpv4) === 4) return mappedIpv4;
}
return withoutZone;
}
private expandIpv6(ip: string): number[] | undefined {
let normalizedIp = ip.toLowerCase();
if (normalizedIp.includes('.')) {
const lastColonIndex = normalizedIp.lastIndexOf(':');
const ipv4Part = normalizedIp.slice(lastColonIndex + 1);
const ipv4Comparable = this.ipToComparable(ipv4Part);
if (!ipv4Comparable || ipv4Comparable.version !== 4) return undefined;
const high = Number((ipv4Comparable.value >> 16n) & 0xffffn).toString(16);
const low = Number(ipv4Comparable.value & 0xffffn).toString(16);
normalizedIp = `${normalizedIp.slice(0, lastColonIndex)}:${high}:${low}`;
}
const doubleColonParts = normalizedIp.split('::');
if (doubleColonParts.length > 2) return undefined;
const head = doubleColonParts[0] ? doubleColonParts[0].split(':') : [];
const tail = doubleColonParts[1] ? doubleColonParts[1].split(':') : [];
const missingCount = 8 - head.length - tail.length;
if (missingCount < 0 || (doubleColonParts.length === 1 && missingCount !== 0)) return undefined;
const parts = [
...head,
...Array(missingCount).fill('0'),
...tail,
];
if (parts.length !== 8) return undefined;
const numbers = parts.map((part) => Number.parseInt(part || '0', 16));
if (numbers.some((part) => !Number.isInteger(part) || part < 0 || part > 0xffff)) {
return undefined;
}
return numbers;
}
private getRouteDomains(route: IDcRouterRouteConfig): string[] {
const domains = (route.match as any)?.domains;
if (!domains) return [];
return Array.isArray(domains) ? domains : [domains];
}
private normalizeRouteRefs(routeRefs?: string[]): string[] | undefined {
const allRoutes = this.getAllRoutes?.() || new Map<string, IRoute>();
return this.normalizeRouteRefsAgainstRoutes(routeRefs, allRoutes, 'strict');
@@ -500,6 +724,7 @@ export class TargetProfileManager {
domains: doc.domains,
targets: doc.targets,
routeRefs: doc.routeRefs,
allowRoutesByClientSourceIp: doc.allowRoutesByClientSourceIp === true,
createdAt: doc.createdAt,
updatedAt: doc.updatedAt,
createdBy: doc.createdBy,
@@ -519,6 +744,7 @@ export class TargetProfileManager {
existingDoc.domains = profile.domains;
existingDoc.targets = profile.targets;
existingDoc.routeRefs = profile.routeRefs;
existingDoc.allowRoutesByClientSourceIp = profile.allowRoutesByClientSourceIp === true;
existingDoc.updatedAt = profile.updatedAt;
await existingDoc.save();
} else {
@@ -529,6 +755,7 @@ export class TargetProfileManager {
doc.domains = profile.domains;
doc.targets = profile.targets;
doc.routeRefs = profile.routeRefs;
doc.allowRoutesByClientSourceIp = profile.allowRoutesByClientSourceIp === true;
doc.createdAt = profile.createdAt;
doc.updatedAt = profile.updatedAt;
doc.createdBy = profile.createdBy;
@@ -25,6 +25,9 @@ export class TargetProfileDoc extends plugins.smartdata.SmartDataDbDoc<TargetPro
@plugins.smartdata.svDb()
public routeRefs?: string[];
@plugins.smartdata.svDb()
public allowRoutesByClientSourceIp?: boolean;
@plugins.smartdata.svDb()
public createdAt!: number;
+4 -1
View File
@@ -725,7 +725,10 @@ export class MetricsManager {
.slice(0, 10)
.map(([ip, data]) => ({ ip, count: data.count, bwIn: data.bwIn, bwOut: data.bwOut }));
void this.dcRouter.securityPolicyManager?.observeIps([...allIPData.keys()]);
this.dcRouter.securityPolicyManager?.queueObservedIps([
...topIPs.map((item) => item.ip),
...topIPsByBandwidth.map((item) => item.ip),
]);
// Build domain activity using per-IP domain request counts from Rust engine
const connectionsByRoute = proxyMetrics.connections.byRoute();
+3 -14
View File
@@ -3,7 +3,6 @@ import * as plugins from '../plugins.js';
import * as paths from '../paths.js';
import * as handlers from './handlers/index.js';
import * as interfaces from '../../ts_interfaces/index.js';
import { requireValidIdentity, requireAdminIdentity } from './helpers/guards.js';
export class OpsServer {
public dcRouterRef: DcRouter;
@@ -12,9 +11,9 @@ export class OpsServer {
// Main TypedRouter — unauthenticated endpoints (login/logout/verify) and own-auth handlers
public typedrouter = new plugins.typedrequest.TypedRouter();
// Auth-enforced routers — middleware validates identity before any handler runs
public viewRouter = new plugins.typedrequest.TypedRouter<{ request: { identity: interfaces.data.IIdentity } }>();
public adminRouter = new plugins.typedrequest.TypedRouter<{ request: { identity: interfaces.data.IIdentity } }>();
// Grouped routers. Handlers enforce auth explicitly with per-endpoint scopes.
public viewRouter = new plugins.typedrequest.TypedRouter<{ request: { identity?: interfaces.data.IIdentity; apiToken?: string } }>();
public adminRouter = new plugins.typedrequest.TypedRouter<{ request: { identity?: interfaces.data.IIdentity; apiToken?: string } }>();
// Handler instances
public adminHandler!: handlers.AdminHandler;
@@ -72,16 +71,6 @@ export class OpsServer {
this.adminHandler = new handlers.AdminHandler(this);
await this.adminHandler.initialize();
// viewRouter middleware: requires valid identity (any logged-in user)
this.viewRouter.addMiddleware(async (typedRequest) => {
await requireValidIdentity(this.adminHandler, typedRequest.request);
});
// adminRouter middleware: requires admin identity
this.adminRouter.addMiddleware(async (typedRequest) => {
await requireAdminIdentity(this.adminHandler, typedRequest.request);
});
// Connect auth routers to the main typedrouter
this.typedrouter.addTypedRouter(this.viewRouter);
this.typedrouter.addTypedRouter(this.adminRouter);
+6 -23
View File
@@ -1,6 +1,7 @@
import * as plugins from '../../plugins.js';
import type { OpsServer } from '../classes.opsserver.js';
import * as interfaces from '../../../ts_interfaces/index.js';
import { requireOpsAuth } from '../helpers/auth.js';
/**
* CRUD handler for the singleton `AcmeConfigDoc`.
@@ -20,29 +21,11 @@ export class AcmeConfigHandler {
request: { identity?: interfaces.data.IIdentity; apiToken?: string },
requiredScope?: interfaces.data.TApiTokenScope,
): Promise<string> {
if (request.identity?.jwt) {
try {
const isAdmin = await this.opsServerRef.adminHandler.adminIdentityGuard.exec({
identity: request.identity,
});
if (isAdmin) return request.identity.userId;
} catch { /* fall through */ }
}
if (request.apiToken) {
const tokenManager = this.opsServerRef.dcRouterRef.apiTokenManager;
if (tokenManager) {
const token = await tokenManager.validateToken(request.apiToken);
if (token) {
if (!requiredScope || tokenManager.hasScope(token, requiredScope)) {
return token.createdBy;
}
throw new plugins.typedrequest.TypedResponseError('insufficient scope');
}
}
}
throw new plugins.typedrequest.TypedResponseError('unauthorized');
const auth = await requireOpsAuth(this.opsServerRef, request, {
scope: requiredScope,
requireAdminIdentity: requiredScope?.endsWith(':write'),
});
return auth.userId;
}
private registerHandlers(): void {
+200 -133
View File
@@ -24,7 +24,8 @@ export class AdminHandler {
// JWT instance
public smartjwtInstance!: plugins.smartjwt.SmartJwt<IJwtData>;
// Ephemeral bootstrap users. Persisted accounts take over once an active admin exists.
// Ephemeral bootstrap users. DB-backed instances may use these only until the
// database is ready and the first persistent admin account has been created.
private users = new Map<string, {
id: string;
username: string;
@@ -87,9 +88,12 @@ export class AdminHandler {
* Used by UsersHandler to serve the admin-only listUsers endpoint.
*/
public async listUsers(): Promise<interfaces.requests.IAdminUserProjection[]> {
if (await this.hasPersistentAdminAccount()) {
const store = this.getAccountStore();
const accounts = await store!.listAccounts();
const accountState = await this.getPersistentAccountState();
if (accountState.dbEnabled && !accountState.dbReady) {
throw new plugins.typedrequest.TypedResponseError('database is not ready');
}
if (accountState.hasPersistentAdmin) {
const accounts = await accountState.store!.listAccounts();
return accounts.map((accountArg) => this.accountToUser(accountArg));
}
@@ -101,16 +105,14 @@ export class AdminHandler {
}
public async getBootstrapStatus(): Promise<interfaces.requests.IReq_GetAdminBootstrapStatus['response']> {
const dbEnabled = this.opsServerRef.dcRouterRef.options.dbConfig?.enabled !== false;
const store = this.getAccountStore();
const dbReady = !!store;
const hasPersistentAdmin = dbReady ? await store.hasActiveAdminAccount() : false;
const accountState = await this.getPersistentAccountState();
const bootstrapAvailable = !accountState.dbEnabled || (accountState.dbReady && !accountState.hasPersistentAdmin);
return {
dbEnabled,
dbReady,
hasPersistentAdmin,
needsBootstrap: dbEnabled && dbReady && !hasPersistentAdmin,
ephemeralAdminAvailable: !hasPersistentAdmin,
dbEnabled: accountState.dbEnabled,
dbReady: accountState.dbReady,
hasPersistentAdmin: accountState.hasPersistentAdmin,
needsBootstrap: accountState.dbEnabled && accountState.dbReady && !accountState.hasPersistentAdmin,
ephemeralAdminAvailable: bootstrapAvailable,
idpGlobalConfigured: this.isIdpGlobalConfigured(),
};
}
@@ -159,6 +161,93 @@ export class AdminHandler {
throw new plugins.typedrequest.TypedResponseError((error as Error).message || 'failed to create initial admin');
}
}
public async createUser(optionsArg: {
email: string;
name?: string;
role: interfaces.requests.TUserManagementRole;
password: string;
enableIdpGlobalAuth?: boolean;
}): Promise<interfaces.requests.IReq_CreateUser['response']> {
const store = this.getAccountStore();
if (!store) {
return { success: false, message: 'database is not ready' };
}
if (!(await store.hasActiveAdminAccount())) {
return { success: false, message: 'initial admin bootstrap is required before creating users' };
}
const role = optionsArg.role;
if (role !== 'admin' && role !== 'user') {
return { success: false, message: 'role must be admin or user' };
}
const password = String(optionsArg.password || '');
if (!password) {
return { success: false, message: 'password is required' };
}
const authSources: Array<'local' | 'idp.global'> = ['local'];
if (optionsArg.enableIdpGlobalAuth) {
authSources.push('idp.global');
}
try {
const email = String(optionsArg.email || '').trim();
const account = await store.createAccount({
email,
name: String(optionsArg.name || '').trim() || email,
role,
authSources,
password,
});
return { success: true, user: this.accountToUser(account) };
} catch (error) {
return { success: false, message: (error as Error).message || 'failed to create user' };
}
}
public async deleteUser(optionsArg: {
id: string;
requestingUserId: string;
}): Promise<interfaces.requests.IReq_DeleteUser['response']> {
const store = this.getAccountStore();
if (!store) {
return { success: false, message: 'database is not ready' };
}
if (!(await store.hasActiveAdminAccount())) {
return { success: false, message: 'initial admin bootstrap is required before deleting users' };
}
const id = String(optionsArg.id || '').trim();
if (!id) {
return { success: false, message: 'user id is required' };
}
if (id === optionsArg.requestingUserId) {
return { success: false, message: 'cannot delete the current user' };
}
const account = await store.getAccountById(id);
if (!account) {
return { success: false, message: 'user not found' };
}
if (account.role === 'admin' && account.status === 'active') {
const activeAdmins = (await store.listAccounts()).filter(
(accountArg) => accountArg.role === 'admin' && accountArg.status === 'active',
);
if (activeAdmins.length <= 1) {
return { success: false, message: 'cannot delete the last active admin' };
}
}
const doc = await plugins.idpSdkServer.IdpSdkAccountDoc.findById(id);
if (!doc) {
return { success: false, message: 'user not found' };
}
await doc.delete();
return { success: true };
}
private registerHandlers(): void {
this.typedrouter.addTypedHandler(
@@ -171,12 +260,18 @@ export class AdminHandler {
this.opsServerRef.adminRouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_CreateInitialAdminUser>(
'createInitialAdminUser',
async (dataArg) => this.createInitialAdminUser({
email: dataArg.email,
name: dataArg.name,
password: dataArg.password,
enableIdpGlobalAuth: dataArg.enableIdpGlobalAuth,
})
async (dataArg) => {
const isAdmin = await this.adminIdentityGuard.exec({ identity: dataArg.identity });
if (!isAdmin) {
throw new plugins.typedrequest.TypedResponseError('admin identity required');
}
return this.createInitialAdminUser({
email: dataArg.email,
name: dataArg.name,
password: dataArg.password,
enableIdpGlobalAuth: dataArg.enableIdpGlobalAuth,
});
}
)
);
@@ -213,8 +308,10 @@ export class AdminHandler {
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_AdminLogout>(
'adminLogout',
async (dataArg) => {
// In a real implementation, you might want to blacklist the JWT
// For now, just return success
const identity = await this.validateIdentity(dataArg.identity);
if (!identity) {
throw new plugins.typedrequest.TypedResponseError('identity is not valid');
}
return {
success: true,
};
@@ -227,52 +324,8 @@ export class AdminHandler {
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_VerifyIdentity>(
'verifyIdentity',
async (dataArg) => {
if (!dataArg.identity?.jwt) {
return {
valid: false,
};
}
try {
const jwtData = await this.smartjwtInstance.verifyJWTAndGetData(dataArg.identity.jwt);
// Check if expired
if (jwtData.expiresAt < Date.now()) {
return {
valid: false,
};
}
// Check if logged in
if (jwtData.status !== 'loggedIn') {
return {
valid: false,
};
}
const user = await this.resolveUser(jwtData.userId);
if (!user) {
return {
valid: false,
};
}
return {
valid: true,
identity: {
jwt: dataArg.identity.jwt,
userId: user.id,
name: user.name || user.username,
expiresAt: jwtData.expiresAt,
role: user.role,
type: 'user',
},
};
} catch (error) {
return {
valid: false,
};
}
const identity = await this.validateIdentity(dataArg.identity);
return identity ? { valid: true, identity } : { valid: false };
}
)
);
@@ -285,45 +338,7 @@ export class AdminHandler {
identity: interfaces.data.IIdentity;
}>(
async (dataArg) => {
if (!dataArg.identity?.jwt) {
return false;
}
try {
const jwtData = await this.smartjwtInstance.verifyJWTAndGetData(dataArg.identity.jwt);
// Check expiration
if (jwtData.expiresAt < Date.now()) {
return false;
}
// Check status
if (jwtData.status !== 'loggedIn') {
return false;
}
// Verify data hasn't been tampered with
if (dataArg.identity.expiresAt !== jwtData.expiresAt) {
return false;
}
if (dataArg.identity.userId !== jwtData.userId) {
return false;
}
const user = await this.resolveUser(jwtData.userId);
if (!user) {
return false;
}
if (dataArg.identity.role && dataArg.identity.role !== user.role) {
return false;
}
return true;
} catch (error) {
return false;
}
return Boolean(await this.validateIdentity(dataArg.identity));
},
{
failedHint: 'identity is not valid',
@@ -338,14 +353,8 @@ export class AdminHandler {
identity: interfaces.data.IIdentity;
}>(
async (dataArg) => {
// First check if identity is valid
const isValid = await this.validIdentityGuard.exec(dataArg);
if (!isValid) {
return false;
}
// Check if user has admin role
return dataArg.identity.role === 'admin';
const identity = await this.validateIdentity(dataArg.identity);
return identity?.role === 'admin';
},
{
failedHint: 'user is not admin',
@@ -353,15 +362,62 @@ export class AdminHandler {
}
);
public async validateIdentity(
identityArg?: interfaces.data.IIdentity,
): Promise<interfaces.data.IIdentity | null> {
if (!identityArg?.jwt) {
return null;
}
try {
const jwtData = await this.smartjwtInstance.verifyJWTAndGetData(identityArg.jwt);
if (jwtData.expiresAt < Date.now()) {
return null;
}
if (jwtData.status !== 'loggedIn') {
return null;
}
if (identityArg.expiresAt !== jwtData.expiresAt) {
return null;
}
if (identityArg.userId !== jwtData.userId) {
return null;
}
const user = await this.resolveUser(jwtData.userId);
if (!user) {
return null;
}
if (identityArg.role && identityArg.role !== user.role) {
return null;
}
return {
jwt: identityArg.jwt,
userId: user.id,
name: user.name || user.username,
expiresAt: jwtData.expiresAt,
role: user.role,
type: 'user',
};
} catch {
return null;
}
}
private async authenticateUser(optionsArg: {
username: string;
password: string;
authSource?: interfaces.requests.TAdminLoginAuthSource;
}): Promise<TAdminUser | null> {
if (await this.hasPersistentAdminAccount()) {
const store = this.getAccountStore();
const accountState = await this.getPersistentAccountState();
if (accountState.dbEnabled && !accountState.dbReady) {
throw new plugins.typedrequest.TypedResponseError('database is not ready');
}
if (accountState.hasPersistentAdmin) {
const authService = new plugins.idpSdkServer.AccountAuthService({
store: store!,
store: accountState.store!,
idpClient: this.getIdpClient() as plugins.idpSdkServer.IdpGlobalServerClient | undefined,
});
const result = await authService.authenticate({
@@ -381,8 +437,13 @@ export class AdminHandler {
}
private async resolveUser(userIdArg: string): Promise<TAdminUser | null> {
if (await this.hasPersistentAdminAccount()) {
const account = await this.getAccountStore()!.getAccountById(userIdArg);
const accountState = await this.getPersistentAccountState();
if (accountState.dbEnabled && !accountState.dbReady) {
return null;
}
if (accountState.hasPersistentAdmin) {
const account = await accountState.store!.getAccountById(userIdArg);
if (!account || account.status !== 'active') {
return null;
}
@@ -392,13 +453,25 @@ export class AdminHandler {
return this.users.get(userIdArg) || null;
}
private async hasPersistentAdminAccount(): Promise<boolean> {
const store = this.getAccountStore();
return store ? store.hasActiveAdminAccount() : false;
private async getPersistentAccountState(): Promise<{
dbEnabled: boolean;
dbReady: boolean;
store: plugins.idpSdkServer.SmartdataAccountStore | null;
hasPersistentAdmin: boolean;
}> {
const dbEnabled = this.isPersistenceEnabled();
const store = dbEnabled ? this.getAccountStore() : null;
const dbReady = !!store;
const hasPersistentAdmin = store ? await store.hasActiveAdminAccount() : false;
return { dbEnabled, dbReady, store, hasPersistentAdmin };
}
private isPersistenceEnabled(): boolean {
return this.opsServerRef.dcRouterRef.options.dbConfig?.enabled !== false;
}
private getAccountStore(): plugins.idpSdkServer.SmartdataAccountStore | null {
if (this.opsServerRef.dcRouterRef.options.dbConfig?.enabled === false) {
if (!this.isPersistenceEnabled()) {
return null;
}
const dcRouterDb = this.opsServerRef.dcRouterRef.dcRouterDb;
@@ -420,23 +493,17 @@ export class AdminHandler {
}
const baseUrl = this.opsServerRef.dcRouterRef.options.adminAuth?.idpGlobalUrl || process.env.DCROUTER_IDP_GLOBAL_URL;
if (!baseUrl) {
return undefined;
}
if (!this.idpClient) {
this.idpClient = new plugins.idpSdkServer.IdpGlobalServerClient({ baseUrl });
this.idpClient = baseUrl
? new plugins.idpSdkServer.IdpGlobalServerClient({ baseUrl })
: new plugins.idpSdkServer.IdpGlobalServerClient({} as plugins.idpSdkServer.IIdpGlobalServerClientOptions);
this.ownsIdpClient = true;
}
return this.idpClient;
}
private isIdpGlobalConfigured(): boolean {
return !!(
this.opsServerRef.dcRouterRef.options.adminAuth?.idpClient ||
this.opsServerRef.dcRouterRef.options.adminAuth?.idpGlobalUrl ||
process.env.DCROUTER_IDP_GLOBAL_URL
);
return true;
}
private accountToUser(accountArg: plugins.idpSdkServer.IIdpSdkAccount): TAdminUser {
+27 -1
View File
@@ -1,6 +1,7 @@
import * as plugins from '../../plugins.js';
import type { OpsServer } from '../classes.opsserver.js';
import * as interfaces from '../../../ts_interfaces/index.js';
import { requireOpsAuth } from '../helpers/auth.js';
export class ApiTokenHandler {
constructor(private opsServerRef: OpsServer) {
@@ -17,6 +18,11 @@ export class ApiTokenHandler {
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_CreateApiToken>(
'createApiToken',
async (dataArg) => {
const auth = await requireOpsAuth(this.opsServerRef, dataArg, {
scope: 'tokens:manage',
requireAdminIdentity: true,
requireAdminToken: true,
});
const manager = this.opsServerRef.dcRouterRef.apiTokenManager;
if (!manager) {
return { success: false, message: 'Token management not initialized' };
@@ -25,7 +31,7 @@ export class ApiTokenHandler {
dataArg.name,
dataArg.scopes,
dataArg.expiresInDays ?? null,
dataArg.identity.userId,
auth.userId,
dataArg.policy,
);
return { success: true, tokenId: result.id, tokenValue: result.rawToken };
@@ -38,6 +44,11 @@ export class ApiTokenHandler {
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ListApiTokens>(
'listApiTokens',
async (dataArg) => {
await requireOpsAuth(this.opsServerRef, dataArg, {
scope: 'tokens:read',
requireAdminIdentity: true,
requireAdminToken: true,
});
const manager = this.opsServerRef.dcRouterRef.apiTokenManager;
if (!manager) {
return { tokens: [] };
@@ -52,6 +63,11 @@ export class ApiTokenHandler {
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_RevokeApiToken>(
'revokeApiToken',
async (dataArg) => {
await requireOpsAuth(this.opsServerRef, dataArg, {
scope: 'tokens:manage',
requireAdminIdentity: true,
requireAdminToken: true,
});
const manager = this.opsServerRef.dcRouterRef.apiTokenManager;
if (!manager) {
return { success: false, message: 'Token management not initialized' };
@@ -67,6 +83,11 @@ export class ApiTokenHandler {
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_RollApiToken>(
'rollApiToken',
async (dataArg) => {
await requireOpsAuth(this.opsServerRef, dataArg, {
scope: 'tokens:manage',
requireAdminIdentity: true,
requireAdminToken: true,
});
const manager = this.opsServerRef.dcRouterRef.apiTokenManager;
if (!manager) {
return { success: false, message: 'Token management not initialized' };
@@ -85,6 +106,11 @@ export class ApiTokenHandler {
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ToggleApiToken>(
'toggleApiToken',
async (dataArg) => {
await requireOpsAuth(this.opsServerRef, dataArg, {
scope: 'tokens:manage',
requireAdminIdentity: true,
requireAdminToken: true,
});
const manager = this.opsServerRef.dcRouterRef.apiTokenManager;
if (!manager) {
return { success: false, message: 'Token management not initialized' };
+6 -23
View File
@@ -3,6 +3,7 @@ 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';
import { requireOpsAuth } from '../helpers/auth.js';
/**
* Mirrors `SmartacmeCertMatcher.getCertificateDomainNameByDomainName` from
@@ -37,29 +38,11 @@ export class CertificateHandler {
request: { identity?: interfaces.data.IIdentity; apiToken?: string },
requiredScope?: interfaces.data.TApiTokenScope,
): Promise<string> {
if (request.identity?.jwt) {
try {
const isAdmin = await this.opsServerRef.adminHandler.adminIdentityGuard.exec({
identity: request.identity,
});
if (isAdmin) return request.identity.userId;
} catch { /* fall through */ }
}
if (request.apiToken) {
const tokenManager = this.opsServerRef.dcRouterRef.apiTokenManager;
if (tokenManager) {
const token = await tokenManager.validateToken(request.apiToken);
if (token) {
if (!requiredScope || tokenManager.hasScope(token, requiredScope)) {
return token.createdBy;
}
throw new plugins.typedrequest.TypedResponseError('insufficient scope');
}
}
}
throw new plugins.typedrequest.TypedResponseError('unauthorized');
const auth = await requireOpsAuth(this.opsServerRef, request, {
scope: requiredScope,
requireAdminIdentity: requiredScope?.endsWith(':write'),
});
return auth.userId;
}
private registerHandlers(): void {
+2
View File
@@ -2,6 +2,7 @@ import * as plugins from '../../plugins.js';
import * as paths from '../../paths.js';
import type { OpsServer } from '../classes.opsserver.js';
import * as interfaces from '../../../ts_interfaces/index.js';
import { requireOpsAuth } from '../helpers/auth.js';
export class ConfigHandler {
constructor(private opsServerRef: OpsServer) {
@@ -17,6 +18,7 @@ export class ConfigHandler {
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetConfiguration>(
'getConfiguration',
async (dataArg, toolsArg) => {
await requireOpsAuth(this.opsServerRef, dataArg, { scope: 'config:read' });
const config = await this.getConfiguration();
return {
config,
+6 -23
View File
@@ -1,6 +1,7 @@
import * as plugins from '../../plugins.js';
import type { OpsServer } from '../classes.opsserver.js';
import * as interfaces from '../../../ts_interfaces/index.js';
import { requireOpsAuth } from '../helpers/auth.js';
/**
* CRUD + connection-test handlers for DnsProviderDoc.
@@ -20,29 +21,11 @@ export class DnsProviderHandler {
request: { identity?: interfaces.data.IIdentity; apiToken?: string },
requiredScope?: interfaces.data.TApiTokenScope,
): Promise<string> {
if (request.identity?.jwt) {
try {
const isAdmin = await this.opsServerRef.adminHandler.adminIdentityGuard.exec({
identity: request.identity,
});
if (isAdmin) return request.identity.userId;
} catch { /* fall through */ }
}
if (request.apiToken) {
const tokenManager = this.opsServerRef.dcRouterRef.apiTokenManager;
if (tokenManager) {
const token = await tokenManager.validateToken(request.apiToken);
if (token) {
if (!requiredScope || tokenManager.hasScope(token, requiredScope)) {
return token.createdBy;
}
throw new plugins.typedrequest.TypedResponseError('insufficient scope');
}
}
}
throw new plugins.typedrequest.TypedResponseError('unauthorized');
const auth = await requireOpsAuth(this.opsServerRef, request, {
scope: requiredScope,
requireAdminIdentity: requiredScope?.endsWith(':write'),
});
return auth.userId;
}
private registerHandlers(): void {
+6 -23
View File
@@ -1,6 +1,7 @@
import * as plugins from '../../plugins.js';
import type { OpsServer } from '../classes.opsserver.js';
import * as interfaces from '../../../ts_interfaces/index.js';
import { requireOpsAuth } from '../helpers/auth.js';
/**
* CRUD handlers for DnsRecordDoc.
@@ -17,29 +18,11 @@ export class DnsRecordHandler {
request: { identity?: interfaces.data.IIdentity; apiToken?: string },
requiredScope?: interfaces.data.TApiTokenScope,
): Promise<string> {
if (request.identity?.jwt) {
try {
const isAdmin = await this.opsServerRef.adminHandler.adminIdentityGuard.exec({
identity: request.identity,
});
if (isAdmin) return request.identity.userId;
} catch { /* fall through */ }
}
if (request.apiToken) {
const tokenManager = this.opsServerRef.dcRouterRef.apiTokenManager;
if (tokenManager) {
const token = await tokenManager.validateToken(request.apiToken);
if (token) {
if (!requiredScope || tokenManager.hasScope(token, requiredScope)) {
return token.createdBy;
}
throw new plugins.typedrequest.TypedResponseError('insufficient scope');
}
}
}
throw new plugins.typedrequest.TypedResponseError('unauthorized');
const auth = await requireOpsAuth(this.opsServerRef, request, {
scope: requiredScope,
requireAdminIdentity: requiredScope?.endsWith(':write'),
});
return auth.userId;
}
private registerHandlers(): void {
+6 -23
View File
@@ -1,6 +1,7 @@
import * as plugins from '../../plugins.js';
import type { OpsServer } from '../classes.opsserver.js';
import * as interfaces from '../../../ts_interfaces/index.js';
import { requireOpsAuth } from '../helpers/auth.js';
/**
* CRUD handlers for DomainDoc.
@@ -17,29 +18,11 @@ export class DomainHandler {
request: { identity?: interfaces.data.IIdentity; apiToken?: string },
requiredScope?: interfaces.data.TApiTokenScope,
): Promise<string> {
if (request.identity?.jwt) {
try {
const isAdmin = await this.opsServerRef.adminHandler.adminIdentityGuard.exec({
identity: request.identity,
});
if (isAdmin) return request.identity.userId;
} catch { /* fall through */ }
}
if (request.apiToken) {
const tokenManager = this.opsServerRef.dcRouterRef.apiTokenManager;
if (tokenManager) {
const token = await tokenManager.validateToken(request.apiToken);
if (token) {
if (!requiredScope || tokenManager.hasScope(token, requiredScope)) {
return token.createdBy;
}
throw new plugins.typedrequest.TypedResponseError('insufficient scope');
}
}
}
throw new plugins.typedrequest.TypedResponseError('unauthorized');
const auth = await requireOpsAuth(this.opsServerRef, request, {
scope: requiredScope,
requireAdminIdentity: requiredScope?.endsWith(':write'),
});
return auth.userId;
}
private registerHandlers(): void {
+6 -23
View File
@@ -1,6 +1,7 @@
import * as plugins from '../../plugins.js';
import type { OpsServer } from '../classes.opsserver.js';
import * as interfaces from '../../../ts_interfaces/index.js';
import { requireOpsAuth } from '../helpers/auth.js';
/**
* CRUD + DNS provisioning handler for email domains.
@@ -19,29 +20,11 @@ export class EmailDomainHandler {
request: { identity?: interfaces.data.IIdentity; apiToken?: string },
requiredScope?: interfaces.data.TApiTokenScope,
): Promise<string> {
if (request.identity?.jwt) {
try {
const isAdmin = await this.opsServerRef.adminHandler.adminIdentityGuard.exec({
identity: request.identity,
});
if (isAdmin) return request.identity.userId;
} catch { /* fall through */ }
}
if (request.apiToken) {
const tokenManager = this.opsServerRef.dcRouterRef.apiTokenManager;
if (tokenManager) {
const token = await tokenManager.validateToken(request.apiToken);
if (token) {
if (!requiredScope || tokenManager.hasScope(token, requiredScope)) {
return token.createdBy;
}
throw new plugins.typedrequest.TypedResponseError('insufficient scope');
}
}
}
throw new plugins.typedrequest.TypedResponseError('unauthorized');
const auth = await requireOpsAuth(this.opsServerRef, request, {
scope: requiredScope,
requireAdminIdentity: requiredScope?.endsWith(':write'),
});
return auth.userId;
}
private get manager() {
@@ -1,6 +1,7 @@
import * as plugins from '../../plugins.js';
import type { OpsServer } from '../classes.opsserver.js';
import * as interfaces from '../../../ts_interfaces/index.js';
import { requireOpsAuth } from '../helpers/auth.js';
export class EmailOpsHandler {
constructor(private opsServerRef: OpsServer) {
@@ -18,6 +19,7 @@ export class EmailOpsHandler {
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetAllEmails>(
'getAllEmails',
async (dataArg) => {
await requireOpsAuth(this.opsServerRef, dataArg, { scope: 'emails:read' });
const emails = this.getAllQueueEmails();
return { emails };
}
@@ -29,6 +31,7 @@ export class EmailOpsHandler {
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetEmailDetail>(
'getEmailDetail',
async (dataArg) => {
await requireOpsAuth(this.opsServerRef, dataArg, { scope: 'emails:read' });
const email = this.getEmailDetail(dataArg.emailId);
return { email };
}
@@ -42,6 +45,10 @@ export class EmailOpsHandler {
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ResendEmail>(
'resendEmail',
async (dataArg) => {
await requireOpsAuth(this.opsServerRef, dataArg, {
scope: 'emails:write',
requireAdminIdentity: true,
});
const emailServer = this.opsServerRef.dcRouterRef.emailServer;
if (!emailServer?.deliveryQueue) {
return { success: false, error: 'Email server not available' };
+3
View File
@@ -2,6 +2,7 @@ import * as plugins from '../../plugins.js';
import type { OpsServer } from '../classes.opsserver.js';
import * as interfaces from '../../../ts_interfaces/index.js';
import { logBuffer, baseLogger } from '../../logger.js';
import { requireOpsAuth } from '../helpers/auth.js';
// Module-level singleton: the log push destination is added once and reuses
// the current OpsServer reference so it survives OpsServer restarts without
@@ -40,6 +41,7 @@ export class LogsHandler {
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetRecentLogs>(
'getRecentLogs',
async (dataArg, toolsArg) => {
await requireOpsAuth(this.opsServerRef, dataArg, { scope: 'logs:read' });
const logs = await this.getRecentLogs(
dataArg.level,
dataArg.category,
@@ -63,6 +65,7 @@ export class LogsHandler {
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetLogStream>(
'getLogStream',
async (dataArg, toolsArg) => {
await requireOpsAuth(this.opsServerRef, dataArg, { scope: 'logs:read' });
// Create a virtual stream for log streaming
const virtualStream = new plugins.typedrequest.VirtualStream<Uint8Array>();
@@ -1,6 +1,7 @@
import * as plugins from '../../plugins.js';
import type { OpsServer } from '../classes.opsserver.js';
import * as interfaces from '../../../ts_interfaces/index.js';
import { requireOpsAuth } from '../helpers/auth.js';
export class NetworkTargetHandler {
public typedrouter = new plugins.typedrequest.TypedRouter();
@@ -14,29 +15,11 @@ export class NetworkTargetHandler {
request: { identity?: interfaces.data.IIdentity; apiToken?: string },
requiredScope?: interfaces.data.TApiTokenScope,
): Promise<string> {
if (request.identity?.jwt) {
try {
const isAdmin = await this.opsServerRef.adminHandler.adminIdentityGuard.exec({
identity: request.identity,
});
if (isAdmin) return request.identity.userId;
} catch { /* fall through */ }
}
if (request.apiToken) {
const tokenManager = this.opsServerRef.dcRouterRef.apiTokenManager;
if (tokenManager) {
const token = await tokenManager.validateToken(request.apiToken);
if (token) {
if (!requiredScope || tokenManager.hasScope(token, requiredScope)) {
return token.createdBy;
}
throw new plugins.typedrequest.TypedResponseError('insufficient scope');
}
}
}
throw new plugins.typedrequest.TypedResponseError('unauthorized');
const auth = await requireOpsAuth(this.opsServerRef, request, {
scope: requiredScope,
requireAdminIdentity: requiredScope?.endsWith(':write'),
});
return auth.userId;
}
private registerHandlers(): void {
+31
View File
@@ -1,6 +1,7 @@
import * as plugins from '../../plugins.js';
import type { OpsServer } from '../classes.opsserver.js';
import * as interfaces from '../../../ts_interfaces/index.js';
import { requireOpsAuth } from '../helpers/auth.js';
export class RadiusHandler {
constructor(private opsServerRef: OpsServer) {
@@ -19,6 +20,7 @@ export class RadiusHandler {
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetRadiusClients>(
'getRadiusClients',
async (dataArg, toolsArg) => {
await requireOpsAuth(this.opsServerRef, dataArg, { scope: 'radius:read' });
const radiusServer = this.opsServerRef.dcRouterRef.radiusServer;
if (!radiusServer) {
@@ -43,6 +45,10 @@ export class RadiusHandler {
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_SetRadiusClient>(
'setRadiusClient',
async (dataArg, toolsArg) => {
await requireOpsAuth(this.opsServerRef, dataArg, {
scope: 'radius:write',
requireAdminIdentity: true,
});
const radiusServer = this.opsServerRef.dcRouterRef.radiusServer;
if (!radiusServer) {
@@ -64,6 +70,10 @@ export class RadiusHandler {
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_RemoveRadiusClient>(
'removeRadiusClient',
async (dataArg, toolsArg) => {
await requireOpsAuth(this.opsServerRef, dataArg, {
scope: 'radius:write',
requireAdminIdentity: true,
});
const radiusServer = this.opsServerRef.dcRouterRef.radiusServer;
if (!radiusServer) {
@@ -88,6 +98,7 @@ export class RadiusHandler {
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetVlanMappings>(
'getVlanMappings',
async (dataArg, toolsArg) => {
await requireOpsAuth(this.opsServerRef, dataArg, { scope: 'radius:read' });
const radiusServer = this.opsServerRef.dcRouterRef.radiusServer;
if (!radiusServer) {
@@ -124,6 +135,10 @@ export class RadiusHandler {
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_SetVlanMapping>(
'setVlanMapping',
async (dataArg, toolsArg) => {
await requireOpsAuth(this.opsServerRef, dataArg, {
scope: 'radius:write',
requireAdminIdentity: true,
});
const radiusServer = this.opsServerRef.dcRouterRef.radiusServer;
if (!radiusServer) {
@@ -156,6 +171,10 @@ export class RadiusHandler {
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_RemoveVlanMapping>(
'removeVlanMapping',
async (dataArg, toolsArg) => {
await requireOpsAuth(this.opsServerRef, dataArg, {
scope: 'radius:write',
requireAdminIdentity: true,
});
const radiusServer = this.opsServerRef.dcRouterRef.radiusServer;
if (!radiusServer) {
@@ -177,6 +196,10 @@ export class RadiusHandler {
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_UpdateVlanConfig>(
'updateVlanConfig',
async (dataArg, toolsArg) => {
await requireOpsAuth(this.opsServerRef, dataArg, {
scope: 'radius:write',
requireAdminIdentity: true,
});
const radiusServer = this.opsServerRef.dcRouterRef.radiusServer;
if (!radiusServer) {
@@ -209,6 +232,7 @@ export class RadiusHandler {
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_TestVlanAssignment>(
'testVlanAssignment',
async (dataArg, toolsArg) => {
await requireOpsAuth(this.opsServerRef, dataArg, { scope: 'radius:read' });
const radiusServer = this.opsServerRef.dcRouterRef.radiusServer;
if (!radiusServer) {
@@ -243,6 +267,7 @@ export class RadiusHandler {
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetRadiusSessions>(
'getRadiusSessions',
async (dataArg, toolsArg) => {
await requireOpsAuth(this.opsServerRef, dataArg, { scope: 'radius:read' });
const radiusServer = this.opsServerRef.dcRouterRef.radiusServer;
if (!radiusServer) {
@@ -292,6 +317,10 @@ export class RadiusHandler {
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_DisconnectRadiusSession>(
'disconnectRadiusSession',
async (dataArg, toolsArg) => {
await requireOpsAuth(this.opsServerRef, dataArg, {
scope: 'radius:write',
requireAdminIdentity: true,
});
const radiusServer = this.opsServerRef.dcRouterRef.radiusServer;
if (!radiusServer) {
@@ -317,6 +346,7 @@ export class RadiusHandler {
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetRadiusAccountingSummary>(
'getRadiusAccountingSummary',
async (dataArg, toolsArg) => {
await requireOpsAuth(this.opsServerRef, dataArg, { scope: 'radius:read' });
const radiusServer = this.opsServerRef.dcRouterRef.radiusServer;
if (!radiusServer) {
@@ -354,6 +384,7 @@ export class RadiusHandler {
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetRadiusStatistics>(
'getRadiusStatistics',
async (dataArg, toolsArg) => {
await requireOpsAuth(this.opsServerRef, dataArg, { scope: 'radius:read' });
const radiusServer = this.opsServerRef.dcRouterRef.radiusServer;
if (!radiusServer) {
@@ -1,6 +1,7 @@
import * as plugins from '../../plugins.js';
import type { OpsServer } from '../classes.opsserver.js';
import * as interfaces from '../../../ts_interfaces/index.js';
import { requireOpsAuth } from '../helpers/auth.js';
export class RemoteIngressHandler {
constructor(private opsServerRef: OpsServer) {
@@ -18,6 +19,7 @@ export class RemoteIngressHandler {
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetRemoteIngresses>(
'getRemoteIngresses',
async (dataArg, toolsArg) => {
await requireOpsAuth(this.opsServerRef, dataArg, { scope: 'remote-ingress:read' });
const manager = this.opsServerRef.dcRouterRef.remoteIngressManager;
if (!manager) {
return { edges: [] };
@@ -46,6 +48,10 @@ export class RemoteIngressHandler {
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_CreateRemoteIngress>(
'createRemoteIngress',
async (dataArg, toolsArg) => {
await requireOpsAuth(this.opsServerRef, dataArg, {
scope: 'remote-ingress:write',
requireAdminIdentity: true,
});
const manager = this.opsServerRef.dcRouterRef.remoteIngressManager;
const tunnelManager = this.opsServerRef.dcRouterRef.tunnelManager;
@@ -78,6 +84,10 @@ export class RemoteIngressHandler {
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_DeleteRemoteIngress>(
'deleteRemoteIngress',
async (dataArg, toolsArg) => {
await requireOpsAuth(this.opsServerRef, dataArg, {
scope: 'remote-ingress:write',
requireAdminIdentity: true,
});
const manager = this.opsServerRef.dcRouterRef.remoteIngressManager;
const tunnelManager = this.opsServerRef.dcRouterRef.tunnelManager;
@@ -103,6 +113,10 @@ export class RemoteIngressHandler {
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_UpdateRemoteIngress>(
'updateRemoteIngress',
async (dataArg, toolsArg) => {
await requireOpsAuth(this.opsServerRef, dataArg, {
scope: 'remote-ingress:write',
requireAdminIdentity: true,
});
const manager = this.opsServerRef.dcRouterRef.remoteIngressManager;
const tunnelManager = this.opsServerRef.dcRouterRef.tunnelManager;
@@ -148,6 +162,10 @@ export class RemoteIngressHandler {
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_RegenerateRemoteIngressSecret>(
'regenerateRemoteIngressSecret',
async (dataArg, toolsArg) => {
await requireOpsAuth(this.opsServerRef, dataArg, {
scope: 'remote-ingress:write',
requireAdminIdentity: true,
});
const manager = this.opsServerRef.dcRouterRef.remoteIngressManager;
const tunnelManager = this.opsServerRef.dcRouterRef.tunnelManager;
@@ -175,6 +193,7 @@ export class RemoteIngressHandler {
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetRemoteIngressStatus>(
'getRemoteIngressStatus',
async (dataArg, toolsArg) => {
await requireOpsAuth(this.opsServerRef, dataArg, { scope: 'remote-ingress:read' });
const tunnelManager = this.opsServerRef.dcRouterRef.tunnelManager;
if (!tunnelManager) {
return { statuses: [] };
@@ -189,6 +208,10 @@ export class RemoteIngressHandler {
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetRemoteIngressConnectionToken>(
'getRemoteIngressConnectionToken',
async (dataArg, toolsArg) => {
await requireOpsAuth(this.opsServerRef, dataArg, {
scope: 'remote-ingress:write',
requireAdminIdentity: true,
});
const manager = this.opsServerRef.dcRouterRef.remoteIngressManager;
if (!manager) {
return { success: false, message: 'RemoteIngress not configured' };
@@ -1,6 +1,7 @@
import * as plugins from '../../plugins.js';
import type { OpsServer } from '../classes.opsserver.js';
import * as interfaces from '../../../ts_interfaces/index.js';
import { requireOpsAuth } from '../helpers/auth.js';
export class RouteManagementHandler {
public typedrouter = new plugins.typedrequest.TypedRouter();
@@ -18,31 +19,11 @@ export class RouteManagementHandler {
request: { identity?: interfaces.data.IIdentity; apiToken?: string },
requiredScope?: interfaces.data.TApiTokenScope,
): Promise<string> {
// Try JWT identity first
if (request.identity?.jwt) {
try {
const isAdmin = await this.opsServerRef.adminHandler.adminIdentityGuard.exec({
identity: request.identity,
});
if (isAdmin) return request.identity.userId;
} catch { /* fall through */ }
}
// Try API token
if (request.apiToken) {
const tokenManager = this.opsServerRef.dcRouterRef.apiTokenManager;
if (tokenManager) {
const token = await tokenManager.validateToken(request.apiToken);
if (token) {
if (!requiredScope || tokenManager.hasScope(token, requiredScope)) {
return token.createdBy;
}
throw new plugins.typedrequest.TypedResponseError('insufficient scope');
}
}
}
throw new plugins.typedrequest.TypedResponseError('unauthorized');
const auth = await requireOpsAuth(this.opsServerRef, request, {
scope: requiredScope,
requireAdminIdentity: requiredScope?.endsWith(':write'),
});
return auth.userId;
}
private registerHandlers(): void {
+39 -7
View File
@@ -2,6 +2,7 @@ import * as plugins from '../../plugins.js';
import type { OpsServer } from '../classes.opsserver.js';
import * as interfaces from '../../../ts_interfaces/index.js';
import { MetricsManager } from '../../monitoring/index.js';
import { requireOpsAuth } from '../helpers/auth.js';
export class SecurityHandler {
constructor(private opsServerRef: OpsServer) {
@@ -17,6 +18,7 @@ export class SecurityHandler {
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetSecurityMetrics>(
'getSecurityMetrics',
async (dataArg, toolsArg) => {
await requireOpsAuth(this.opsServerRef, dataArg, { scope: 'security:read' });
const metrics = await this.collectSecurityMetrics();
return {
metrics: {
@@ -43,6 +45,7 @@ export class SecurityHandler {
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetActiveConnections>(
'getActiveConnections',
async (dataArg, toolsArg) => {
await requireOpsAuth(this.opsServerRef, dataArg, { scope: 'stats:read' });
const connections = await this.getActiveConnections(dataArg.protocol, dataArg.state);
const connectionInfos: interfaces.data.IConnectionInfo[] = connections.map(conn => ({
id: conn.id,
@@ -82,6 +85,7 @@ export class SecurityHandler {
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetNetworkStats>(
'getNetworkStats',
async (dataArg, toolsArg) => {
await requireOpsAuth(this.opsServerRef, dataArg, { scope: 'stats:read' });
// Get network stats from MetricsManager if available
if (this.opsServerRef.dcRouterRef.metricsManager) {
const networkStats = await this.opsServerRef.dcRouterRef.metricsManager.getNetworkStats();
@@ -136,6 +140,7 @@ export class SecurityHandler {
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetRateLimitStatus>(
'getRateLimitStatus',
async (dataArg, toolsArg) => {
await requireOpsAuth(this.opsServerRef, dataArg, { scope: 'security:read' });
const status = await this.getRateLimitStatus(dataArg.domain, dataArg.ip);
const limits: interfaces.data.IRateLimitInfo[] = status.limits.map(limit => ({
domain: limit.identifier,
@@ -161,7 +166,8 @@ export class SecurityHandler {
router.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ListSecurityBlockRules>(
'listSecurityBlockRules',
async () => {
async (dataArg) => {
await requireOpsAuth(this.opsServerRef, dataArg, { scope: 'security:read' });
const manager = this.opsServerRef.dcRouterRef.securityPolicyManager;
return { rules: manager ? await manager.listBlockRules() : [] };
},
@@ -171,9 +177,17 @@ export class SecurityHandler {
router.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ListIpIntelligence>(
'listIpIntelligence',
async () => {
async (dataArg) => {
await requireOpsAuth(this.opsServerRef, dataArg, { scope: 'security:read' });
const manager = this.opsServerRef.dcRouterRef.securityPolicyManager;
return { records: manager ? await manager.listIpIntelligence() : [] };
return {
records: manager
? await manager.listIpIntelligence({
ipAddresses: dataArg.ipAddresses,
limit: dataArg.limit,
})
: [],
};
},
),
);
@@ -181,7 +195,8 @@ export class SecurityHandler {
router.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetCompiledSecurityPolicy>(
'getCompiledSecurityPolicy',
async () => {
async (dataArg) => {
await requireOpsAuth(this.opsServerRef, dataArg, { scope: 'security:read' });
const manager = this.opsServerRef.dcRouterRef.securityPolicyManager;
return {
policy: manager
@@ -196,6 +211,7 @@ export class SecurityHandler {
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ListSecurityPolicyAudit>(
'listSecurityPolicyAudit',
async (dataArg) => {
await requireOpsAuth(this.opsServerRef, dataArg, { scope: 'security:read' });
const manager = this.opsServerRef.dcRouterRef.securityPolicyManager;
return { events: manager ? await manager.listAuditEvents(dataArg.limit || 100) : [] };
},
@@ -208,6 +224,10 @@ export class SecurityHandler {
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_CreateSecurityBlockRule>(
'createSecurityBlockRule',
async (dataArg) => {
const auth = await requireOpsAuth(this.opsServerRef, dataArg, {
scope: 'security:write',
requireAdminIdentity: true,
});
const manager = this.opsServerRef.dcRouterRef.securityPolicyManager;
if (!manager) return { success: false, message: 'Security policy manager not initialized' };
const rule = await manager.createBlockRule({
@@ -216,7 +236,7 @@ export class SecurityHandler {
matchMode: dataArg.matchMode,
reason: dataArg.reason,
enabled: dataArg.enabled,
}, dataArg.identity.userId);
}, auth.userId);
return { success: true, rule };
},
),
@@ -226,6 +246,10 @@ export class SecurityHandler {
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_UpdateSecurityBlockRule>(
'updateSecurityBlockRule',
async (dataArg) => {
const auth = await requireOpsAuth(this.opsServerRef, dataArg, {
scope: 'security:write',
requireAdminIdentity: true,
});
const manager = this.opsServerRef.dcRouterRef.securityPolicyManager;
if (!manager) return { success: false, message: 'Security policy manager not initialized' };
const rule = await manager.updateBlockRule(dataArg.id, {
@@ -233,7 +257,7 @@ export class SecurityHandler {
matchMode: dataArg.matchMode,
reason: dataArg.reason,
enabled: dataArg.enabled,
}, dataArg.identity.userId);
}, auth.userId);
return rule ? { success: true, rule } : { success: false, message: 'Rule not found' };
},
),
@@ -243,9 +267,13 @@ export class SecurityHandler {
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_DeleteSecurityBlockRule>(
'deleteSecurityBlockRule',
async (dataArg) => {
const auth = await requireOpsAuth(this.opsServerRef, dataArg, {
scope: 'security:write',
requireAdminIdentity: true,
});
const manager = this.opsServerRef.dcRouterRef.securityPolicyManager;
if (!manager) return { success: false, message: 'Security policy manager not initialized' };
const success = await manager.deleteBlockRule(dataArg.id, dataArg.identity.userId);
const success = await manager.deleteBlockRule(dataArg.id, auth.userId);
return { success, message: success ? undefined : 'Rule not found' };
},
),
@@ -255,6 +283,10 @@ export class SecurityHandler {
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_RefreshIpIntelligence>(
'refreshIpIntelligence',
async (dataArg) => {
await requireOpsAuth(this.opsServerRef, dataArg, {
scope: 'security:write',
requireAdminIdentity: true,
});
const manager = this.opsServerRef.dcRouterRef.securityPolicyManager;
if (!manager) return { success: false, message: 'Security policy manager not initialized' };
const record = await manager.refreshIpIntelligence(dataArg.ipAddress);
@@ -1,6 +1,7 @@
import * as plugins from '../../plugins.js';
import type { OpsServer } from '../classes.opsserver.js';
import * as interfaces from '../../../ts_interfaces/index.js';
import { requireOpsAuth } from '../helpers/auth.js';
export class SourceProfileHandler {
public typedrouter = new plugins.typedrequest.TypedRouter();
@@ -14,29 +15,11 @@ export class SourceProfileHandler {
request: { identity?: interfaces.data.IIdentity; apiToken?: string },
requiredScope?: interfaces.data.TApiTokenScope,
): Promise<string> {
if (request.identity?.jwt) {
try {
const isAdmin = await this.opsServerRef.adminHandler.adminIdentityGuard.exec({
identity: request.identity,
});
if (isAdmin) return request.identity.userId;
} catch { /* fall through */ }
}
if (request.apiToken) {
const tokenManager = this.opsServerRef.dcRouterRef.apiTokenManager;
if (tokenManager) {
const token = await tokenManager.validateToken(request.apiToken);
if (token) {
if (!requiredScope || tokenManager.hasScope(token, requiredScope)) {
return token.createdBy;
}
throw new plugins.typedrequest.TypedResponseError('insufficient scope');
}
}
}
throw new plugins.typedrequest.TypedResponseError('unauthorized');
const auth = await requireOpsAuth(this.opsServerRef, request, {
scope: requiredScope,
requireAdminIdentity: requiredScope?.endsWith(':write'),
});
return auth.userId;
}
private registerHandlers(): void {
+7
View File
@@ -4,6 +4,7 @@ import * as interfaces from '../../../ts_interfaces/index.js';
import { MetricsManager } from '../../monitoring/index.js';
import { SecurityLogger } from '../../security/classes.securitylogger.js';
import { commitinfo } from '../../00_commitinfo_data.js';
import { requireOpsAuth } from '../helpers/auth.js';
export class StatsHandler {
constructor(private opsServerRef: OpsServer) {
@@ -19,6 +20,7 @@ export class StatsHandler {
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetServerStatistics>(
'getServerStatistics',
async (dataArg, toolsArg) => {
await requireOpsAuth(this.opsServerRef, dataArg, { scope: 'stats:read' });
const stats = await this.collectServerStats();
return {
stats: {
@@ -42,6 +44,7 @@ export class StatsHandler {
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetEmailStatistics>(
'getEmailStatistics',
async (dataArg, toolsArg) => {
await requireOpsAuth(this.opsServerRef, dataArg, { scope: 'stats:read' });
const emailServer = this.opsServerRef.dcRouterRef.emailServer;
if (!emailServer) {
return {
@@ -81,6 +84,7 @@ export class StatsHandler {
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetDnsStatistics>(
'getDnsStatistics',
async (dataArg, toolsArg) => {
await requireOpsAuth(this.opsServerRef, dataArg, { scope: 'stats:read' });
const dnsServer = this.opsServerRef.dcRouterRef.dnsServer;
if (!dnsServer) {
return {
@@ -118,6 +122,7 @@ export class StatsHandler {
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetQueueStatus>(
'getQueueStatus',
async (dataArg, toolsArg) => {
await requireOpsAuth(this.opsServerRef, dataArg, { scope: 'stats:read' });
const emailServer = this.opsServerRef.dcRouterRef.emailServer;
const queues: interfaces.data.IQueueStatus[] = [];
@@ -146,6 +151,7 @@ export class StatsHandler {
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetHealthStatus>(
'getHealthStatus',
async (dataArg, toolsArg) => {
await requireOpsAuth(this.opsServerRef, dataArg, { scope: 'stats:read' });
const health = await this.checkHealthStatus();
return {
health: {
@@ -171,6 +177,7 @@ export class StatsHandler {
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetCombinedMetrics>(
'getCombinedMetrics',
async (dataArg, toolsArg) => {
await requireOpsAuth(this.opsServerRef, dataArg, { scope: 'stats:read' });
const sections = dataArg.sections || {
server: true,
email: true,
@@ -1,6 +1,7 @@
import * as plugins from '../../plugins.js';
import type { OpsServer } from '../classes.opsserver.js';
import * as interfaces from '../../../ts_interfaces/index.js';
import { requireOpsAuth } from '../helpers/auth.js';
export class TargetProfileHandler {
public typedrouter = new plugins.typedrequest.TypedRouter();
@@ -14,29 +15,11 @@ export class TargetProfileHandler {
request: { identity?: interfaces.data.IIdentity; apiToken?: string },
requiredScope?: interfaces.data.TApiTokenScope,
): Promise<string> {
if (request.identity?.jwt) {
try {
const isAdmin = await this.opsServerRef.adminHandler.adminIdentityGuard.exec({
identity: request.identity,
});
if (isAdmin) return request.identity.userId;
} catch { /* fall through */ }
}
if (request.apiToken) {
const tokenManager = this.opsServerRef.dcRouterRef.apiTokenManager;
if (tokenManager) {
const token = await tokenManager.validateToken(request.apiToken);
if (token) {
if (!requiredScope || tokenManager.hasScope(token, requiredScope)) {
return token.createdBy;
}
throw new plugins.typedrequest.TypedResponseError('insufficient scope');
}
}
}
throw new plugins.typedrequest.TypedResponseError('unauthorized');
const auth = await requireOpsAuth(this.opsServerRef, request, {
scope: requiredScope,
requireAdminIdentity: requiredScope?.endsWith(':write'),
});
return auth.userId;
}
private registerHandlers(): void {
@@ -86,6 +69,7 @@ export class TargetProfileHandler {
domains: dataArg.domains,
targets: dataArg.targets,
routeRefs: dataArg.routeRefs,
allowRoutesByClientSourceIp: dataArg.allowRoutesByClientSourceIp,
createdBy: userId,
});
await this.opsServerRef.dcRouterRef.routeConfigManager?.applyRoutes();
@@ -111,6 +95,7 @@ export class TargetProfileHandler {
domains: dataArg.domains,
targets: dataArg.targets,
routeRefs: dataArg.routeRefs,
allowRoutesByClientSourceIp: dataArg.allowRoutesByClientSourceIp,
});
// Re-apply routes and refresh VPN client security to update access
await this.opsServerRef.dcRouterRef.routeConfigManager?.applyRoutes();
+46 -3
View File
@@ -1,9 +1,10 @@
import * as plugins from '../../plugins.js';
import type { OpsServer } from '../classes.opsserver.js';
import * as interfaces from '../../../ts_interfaces/index.js';
import { requireOpsAuth } from '../helpers/auth.js';
/**
* Read-only handler for OpsServer user accounts. Registers on adminRouter,
* Handler for OpsServer user accounts. Registers on adminRouter,
* so admin middleware enforces auth + role check before the handler runs.
* User data is owned by AdminHandler; this handler just exposes a safe
* projection of it via TypedRequest.
@@ -16,15 +17,57 @@ export class UsersHandler {
private registerHandlers(): void {
const router = this.opsServerRef.adminRouter;
// List users (admin-only, read-only)
// List users (admin-only)
router.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ListUsers>(
'listUsers',
async (_dataArg) => {
async (dataArg) => {
await requireOpsAuth(this.opsServerRef, dataArg, {
scope: 'users:read',
requireAdminIdentity: true,
requireAdminToken: true,
});
const users = await this.opsServerRef.adminHandler.listUsers();
return { users };
},
),
);
router.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_CreateUser>(
'createUser',
async (dataArg) => {
await requireOpsAuth(this.opsServerRef, dataArg, {
scope: 'users:manage',
requireAdminIdentity: true,
requireAdminToken: true,
});
return this.opsServerRef.adminHandler.createUser({
email: dataArg.email,
name: dataArg.name,
role: dataArg.role,
password: dataArg.password,
enableIdpGlobalAuth: dataArg.enableIdpGlobalAuth,
});
},
),
);
router.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_DeleteUser>(
'deleteUser',
async (dataArg) => {
const auth = await requireOpsAuth(this.opsServerRef, dataArg, {
scope: 'users:manage',
requireAdminIdentity: true,
requireAdminToken: true,
});
return this.opsServerRef.adminHandler.deleteUser({
id: dataArg.id,
requestingUserId: auth.userId,
});
},
),
);
}
}
+35
View File
@@ -1,6 +1,7 @@
import * as plugins from '../../plugins.js';
import type { OpsServer } from '../classes.opsserver.js';
import * as interfaces from '../../../ts_interfaces/index.js';
import { requireOpsAuth } from '../helpers/auth.js';
export class VpnHandler {
constructor(private opsServerRef: OpsServer) {
@@ -18,6 +19,7 @@ export class VpnHandler {
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetVpnClients>(
'getVpnClients',
async (dataArg, toolsArg) => {
await requireOpsAuth(this.opsServerRef, dataArg, { scope: 'vpn:read' });
const manager = this.opsServerRef.dcRouterRef.vpnManager;
if (!manager) {
return { clients: [] };
@@ -49,6 +51,7 @@ export class VpnHandler {
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetVpnStatus>(
'getVpnStatus',
async (dataArg, toolsArg) => {
await requireOpsAuth(this.opsServerRef, dataArg, { scope: 'vpn:read' });
const manager = this.opsServerRef.dcRouterRef.vpnManager;
const vpnConfig = this.opsServerRef.dcRouterRef.options.vpnConfig;
if (!manager) {
@@ -84,6 +87,7 @@ export class VpnHandler {
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetVpnConnectedClients>(
'getVpnConnectedClients',
async (dataArg, toolsArg) => {
await requireOpsAuth(this.opsServerRef, dataArg, { scope: 'vpn:read' });
const manager = this.opsServerRef.dcRouterRef.vpnManager;
if (!manager) {
return { connectedClients: [] };
@@ -98,6 +102,8 @@ export class VpnHandler {
bytesSent: c.bytesSent,
bytesReceived: c.bytesReceived,
transport: c.transportType,
remoteAddr: c.remoteAddr,
sourceIp: manager.getClientSourceIp(c.registeredClientId || c.clientId),
})),
};
},
@@ -111,6 +117,10 @@ export class VpnHandler {
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_CreateVpnClient>(
'createVpnClient',
async (dataArg, toolsArg) => {
await requireOpsAuth(this.opsServerRef, dataArg, {
scope: 'vpn:write',
requireAdminIdentity: true,
});
const manager = this.opsServerRef.dcRouterRef.vpnManager;
if (!manager) {
return { success: false, message: 'VPN not configured' };
@@ -168,6 +178,10 @@ export class VpnHandler {
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_UpdateVpnClient>(
'updateVpnClient',
async (dataArg, toolsArg) => {
await requireOpsAuth(this.opsServerRef, dataArg, {
scope: 'vpn:write',
requireAdminIdentity: true,
});
const manager = this.opsServerRef.dcRouterRef.vpnManager;
if (!manager) {
return { success: false, message: 'VPN not configured' };
@@ -198,6 +212,10 @@ export class VpnHandler {
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_DeleteVpnClient>(
'deleteVpnClient',
async (dataArg, toolsArg) => {
await requireOpsAuth(this.opsServerRef, dataArg, {
scope: 'vpn:write',
requireAdminIdentity: true,
});
const manager = this.opsServerRef.dcRouterRef.vpnManager;
if (!manager) {
return { success: false, message: 'VPN not configured' };
@@ -218,6 +236,10 @@ export class VpnHandler {
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_EnableVpnClient>(
'enableVpnClient',
async (dataArg, toolsArg) => {
await requireOpsAuth(this.opsServerRef, dataArg, {
scope: 'vpn:write',
requireAdminIdentity: true,
});
const manager = this.opsServerRef.dcRouterRef.vpnManager;
if (!manager) {
return { success: false, message: 'VPN not configured' };
@@ -238,6 +260,10 @@ export class VpnHandler {
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_DisableVpnClient>(
'disableVpnClient',
async (dataArg, toolsArg) => {
await requireOpsAuth(this.opsServerRef, dataArg, {
scope: 'vpn:write',
requireAdminIdentity: true,
});
const manager = this.opsServerRef.dcRouterRef.vpnManager;
if (!manager) {
return { success: false, message: 'VPN not configured' };
@@ -258,6 +284,10 @@ export class VpnHandler {
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_RotateVpnClientKey>(
'rotateVpnClientKey',
async (dataArg, toolsArg) => {
await requireOpsAuth(this.opsServerRef, dataArg, {
scope: 'vpn:write',
requireAdminIdentity: true,
});
const manager = this.opsServerRef.dcRouterRef.vpnManager;
if (!manager) {
return { success: false, message: 'VPN not configured' };
@@ -281,6 +311,10 @@ export class VpnHandler {
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ExportVpnClientConfig>(
'exportVpnClientConfig',
async (dataArg, toolsArg) => {
await requireOpsAuth(this.opsServerRef, dataArg, {
scope: 'vpn:write',
requireAdminIdentity: true,
});
const manager = this.opsServerRef.dcRouterRef.vpnManager;
if (!manager) {
return { success: false, message: 'VPN not configured' };
@@ -301,6 +335,7 @@ export class VpnHandler {
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetVpnClientTelemetry>(
'getVpnClientTelemetry',
async (dataArg, toolsArg) => {
await requireOpsAuth(this.opsServerRef, dataArg, { scope: 'vpn:read' });
const manager = this.opsServerRef.dcRouterRef.vpnManager;
if (!manager) {
return { success: false, message: 'VPN not configured' };
+18 -33
View File
@@ -1,6 +1,7 @@
import * as plugins from '../../plugins.js';
import type { OpsServer } from '../classes.opsserver.js';
import * as interfaces from '../../../ts_interfaces/index.js';
import { requireOpsAuth } from '../helpers/auth.js';
type TAuthContext = {
userId: string;
@@ -20,39 +21,23 @@ export class WorkHosterHandler {
request: { identity?: interfaces.data.IIdentity; apiToken?: string },
requiredScope?: interfaces.data.TApiTokenScope,
): Promise<TAuthContext> {
if (request.identity?.jwt) {
try {
const isAdmin = await this.opsServerRef.adminHandler.adminIdentityGuard.exec({
identity: request.identity,
});
if (isAdmin) return { userId: request.identity.userId, isAdmin: true };
} catch { /* fall through */ }
}
if (request.apiToken) {
const tokenManager = this.opsServerRef.dcRouterRef.apiTokenManager;
if (tokenManager) {
const token = await tokenManager.validateToken(request.apiToken);
if (token) {
if (!requiredScope || tokenManager.hasScope(token, requiredScope)) {
return { userId: token.createdBy, isAdmin: token.policy?.role === 'admin', token };
}
throw new plugins.typedrequest.TypedResponseError('insufficient scope');
}
}
}
throw new plugins.typedrequest.TypedResponseError('unauthorized');
const auth = await requireOpsAuth(this.opsServerRef, request, {
scope: requiredScope,
requireAdminIdentity: requiredScope?.endsWith(':write'),
});
return { userId: auth.userId, isAdmin: auth.isAdmin, token: auth.token };
}
private async requireAdmin(request: { identity?: interfaces.data.IIdentity }): Promise<string> {
if (request.identity?.jwt) {
const isAdmin = await this.opsServerRef.adminHandler.adminIdentityGuard.exec({
identity: request.identity,
});
if (isAdmin) return request.identity.userId;
}
throw new plugins.typedrequest.TypedResponseError('admin identity required');
private async requireAdmin(
request: { identity?: interfaces.data.IIdentity; apiToken?: string },
scope: interfaces.data.TApiTokenScope = 'gateway-clients:write',
): Promise<string> {
const auth = await requireOpsAuth(this.opsServerRef, request, {
scope,
requireAdminIdentity: true,
requireAdminToken: true,
});
return auth.userId;
}
private registerHandlers(): void {
@@ -83,7 +68,7 @@ export class WorkHosterHandler {
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ListGatewayClients>(
'listGatewayClients',
async (dataArg) => {
await this.requireAdmin(dataArg);
await this.requireAdmin(dataArg, 'gateway-clients:read');
return { gatewayClients: await this.listManagedGatewayClients() };
},
),
@@ -154,7 +139,7 @@ export class WorkHosterHandler {
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_CreateGatewayClientToken>(
'createGatewayClientToken',
async (dataArg) => {
const userId = await this.requireAdmin(dataArg);
const userId = await this.requireAdmin(dataArg, 'tokens:manage');
const gatewayClient = await this.opsServerRef.dcRouterRef.gatewayClientManager?.getClient(dataArg.gatewayClientId);
const tokenManager = this.opsServerRef.dcRouterRef.apiTokenManager;
if (!gatewayClient || !gatewayClient.enabled) {
+91
View File
@@ -0,0 +1,91 @@
import * as plugins from '../../plugins.js';
import type { OpsServer } from '../classes.opsserver.js';
import * as interfaces from '../../../ts_interfaces/index.js';
export interface IAuthRequest {
identity?: interfaces.data.IIdentity;
apiToken?: string;
}
export interface IAuthRequirement {
scope?: interfaces.data.TApiTokenScope;
requireAdminIdentity?: boolean;
requireAdminToken?: boolean;
}
export interface IAuthContext {
type: 'identity' | 'apiToken';
userId: string;
role?: string;
isAdmin: boolean;
scopes: interfaces.data.TApiTokenScope[];
identity?: interfaces.data.IIdentity;
token?: interfaces.data.IStoredApiToken;
}
const typedAuthError = (messageArg: string) => {
return new plugins.typedrequest.TypedResponseError(messageArg);
};
export async function requireOpsAuth(
opsServerRefArg: OpsServer,
requestArg: IAuthRequest,
requirementArg: IAuthRequirement = {},
): Promise<IAuthContext> {
let identityNeedsAdmin = false;
let tokenNeedsAdmin = false;
let tokenNeedsScope = false;
if (requestArg.identity?.jwt) {
const identity = await opsServerRefArg.adminHandler.validateIdentity(requestArg.identity);
if (identity) {
const isAdmin = identity.role === 'admin';
if (!requirementArg.requireAdminIdentity || isAdmin) {
return {
type: 'identity',
userId: identity.userId,
role: identity.role,
isAdmin,
scopes: [],
identity,
};
}
identityNeedsAdmin = true;
}
}
if (requestArg.apiToken) {
const tokenManager = opsServerRefArg.dcRouterRef.apiTokenManager;
const token = tokenManager ? await tokenManager.validateToken(requestArg.apiToken) : null;
if (token) {
if (requirementArg.requireAdminToken && token.policy?.role !== 'admin') {
tokenNeedsAdmin = true;
} else if (requirementArg.scope && !tokenManager!.hasScope(token, requirementArg.scope)) {
tokenNeedsScope = true;
} else {
const scopes = token.policy?.role === 'admin'
? ['*' as interfaces.data.TApiTokenScope]
: Array.from(new Set([...(token.scopes || []), ...(token.policy?.scopes || [])]));
return {
type: 'apiToken',
userId: token.createdBy,
role: token.policy?.role || 'operator',
isAdmin: token.policy?.role === 'admin',
scopes,
token,
};
}
}
}
if (tokenNeedsScope) {
throw typedAuthError('insufficient scope');
}
if (tokenNeedsAdmin) {
throw typedAuthError('admin API token required');
}
if (identityNeedsAdmin) {
throw typedAuthError('admin identity required');
}
throw typedAuthError('unauthorized');
}
+115 -7
View File
@@ -19,12 +19,24 @@ export interface IRemoteIngressFirewallSnapshot {
blockedIps: string[];
}
const OBSERVED_IP_QUEUE_LIMIT = 512;
const OBSERVED_IP_BATCH_LIMIT = 20;
const OBSERVED_IP_QUEUE_CONCURRENCY = 2;
const OBSERVED_IP_REQUEUE_THROTTLE_MS = 60_000;
export class SecurityPolicyManager {
private readonly smartNetwork = new plugins.smartnetwork.SmartNetwork({
cacheTtl: 24 * 60 * 60 * 1000,
ipIntelligenceTimeout: 5_000,
});
private readonly intelligenceRefreshMs: number;
private readonly inFlightObservations = new Set<string>();
private readonly inFlightObservations = new Map<string, Promise<void>>();
private readonly queuedObservations = new Set<string>();
private readonly observationQueue: string[] = [];
private readonly lastQueuedAt = new Map<string, number>();
private activeQueuedObservations = 0;
private queueDrainScheduled = false;
private isStopping = false;
private readonly onPolicyChanged?: () => void | Promise<void>;
constructor(options: ISecurityPolicyManagerOptions = {}) {
@@ -37,6 +49,9 @@ export class SecurityPolicyManager {
}
public async stop(): Promise<void> {
this.isStopping = true;
this.observationQueue.length = 0;
this.queuedObservations.clear();
await this.smartNetwork.stop();
}
@@ -45,13 +60,55 @@ export class SecurityPolicyManager {
await Promise.allSettled(uniqueIps.map((ip) => this.observeIp(ip)));
}
public queueObservedIps(ips: string[]): void {
if (this.isStopping) return;
const now = Date.now();
const uniqueIps = [...new Set(ips.map((ip) => this.normalizeIp(ip)).filter(Boolean) as string[])];
for (const ip of uniqueIps.slice(0, OBSERVED_IP_BATCH_LIMIT)) {
if (!this.isPublicIp(ip)) continue;
if (this.inFlightObservations.has(ip) || this.queuedObservations.has(ip)) continue;
const lastQueuedAt = this.lastQueuedAt.get(ip);
if (lastQueuedAt && now - lastQueuedAt < OBSERVED_IP_REQUEUE_THROTTLE_MS) continue;
if (this.observationQueue.length >= OBSERVED_IP_QUEUE_LIMIT) {
const droppedIp = this.observationQueue.shift();
if (droppedIp) this.queuedObservations.delete(droppedIp);
}
this.observationQueue.push(ip);
this.queuedObservations.add(ip);
this.lastQueuedAt.set(ip, now);
}
this.pruneQueuedIpMemory(now);
this.scheduleQueueDrain();
}
public async observeIp(ipAddress: string, options: { force?: boolean } = {}): Promise<void> {
const ip = this.normalizeIp(ipAddress);
if (!ip || !this.isPublicIp(ip) || this.inFlightObservations.has(ip)) {
if (!ip || !this.isPublicIp(ip)) {
return;
}
this.inFlightObservations.add(ip);
const existingObservation = this.inFlightObservations.get(ip);
if (existingObservation) {
await existingObservation;
if (!options.force) return;
}
const observationPromise = this.performObserveIp(ip, options).finally(() => {
if (this.inFlightObservations.get(ip) === observationPromise) {
this.inFlightObservations.delete(ip);
}
});
this.inFlightObservations.set(ip, observationPromise);
await observationPromise;
}
private async performObserveIp(ip: string, options: { force?: boolean } = {}): Promise<void> {
try {
const now = Date.now();
let doc = await IpIntelligenceDoc.findByIp(ip);
@@ -81,8 +138,6 @@ export class SecurityPolicyManager {
}
} catch (err) {
logger.log('warn', `Failed to enrich IP ${ip}: ${(err as Error).message}`);
} finally {
this.inFlightObservations.delete(ip);
}
}
@@ -90,8 +145,22 @@ export class SecurityPolicyManager {
return (await SecurityBlockRuleDoc.findAll()).map((doc) => this.ruleFromDoc(doc));
}
public async listIpIntelligence(): Promise<IIpIntelligenceRecord[]> {
return (await IpIntelligenceDoc.findAll()).map((doc) => this.intelligenceFromDoc(doc));
public async listIpIntelligence(options: { ipAddresses?: string[]; limit?: number } = {}): Promise<IIpIntelligenceRecord[]> {
const limit = Number.isInteger(options.limit) && options.limit! > 0
? Math.min(options.limit!, 500)
: undefined;
let docs: IpIntelligenceDoc[];
if (options.ipAddresses?.length) {
const ips = [...new Set(options.ipAddresses.map((ip) => this.normalizeIp(ip)).filter(Boolean) as string[])];
const results = await Promise.all(ips.map((ip) => IpIntelligenceDoc.findByIp(ip)));
docs = results.filter(Boolean) as IpIntelligenceDoc[];
} else {
docs = await IpIntelligenceDoc.findAll();
}
const sortedDocs = docs.sort((a, b) => (b.lastSeenAt || 0) - (a.lastSeenAt || 0));
return (limit ? sortedDocs.slice(0, limit) : sortedDocs).map((doc) => this.intelligenceFromDoc(doc));
}
public async refreshIpIntelligence(ipAddress: string): Promise<IIpIntelligenceRecord | null> {
@@ -104,6 +173,45 @@ export class SecurityPolicyManager {
return doc ? this.intelligenceFromDoc(doc) : null;
}
private scheduleQueueDrain(): void {
if (this.queueDrainScheduled || this.isStopping) return;
this.queueDrainScheduled = true;
setTimeout(() => {
this.queueDrainScheduled = false;
this.drainObservationQueue();
}, 0);
}
private drainObservationQueue(): void {
if (this.isStopping) return;
while (
this.activeQueuedObservations < OBSERVED_IP_QUEUE_CONCURRENCY &&
this.observationQueue.length > 0
) {
const ip = this.observationQueue.shift()!;
this.queuedObservations.delete(ip);
this.activeQueuedObservations++;
void this.observeIp(ip)
.catch(() => undefined)
.finally(() => {
this.activeQueuedObservations--;
if (this.observationQueue.length > 0) {
this.scheduleQueueDrain();
}
});
}
}
private pruneQueuedIpMemory(now: number): void {
if (this.lastQueuedAt.size <= OBSERVED_IP_QUEUE_LIMIT * 2) return;
for (const [ip, lastQueuedAt] of this.lastQueuedAt) {
if (now - lastQueuedAt > OBSERVED_IP_REQUEUE_THROTTLE_MS * 2) {
this.lastQueuedAt.delete(ip);
}
}
}
public async listAuditEvents(limit = 100): Promise<ISecurityPolicyAuditEvent[]> {
return (await SecurityPolicyAuditDoc.findRecent(limit)).map((doc) => ({
id: doc.id,
+158 -3
View File
@@ -19,6 +19,10 @@ export interface IVpnManagerConfig {
}>;
/** Called when clients are created/deleted/toggled — triggers route re-application */
onClientChanged?: () => void;
/** Called when a live VPN client's real source IP changes. */
onClientSourceIpsChanged?: () => void;
/** Poll interval for live VPN client real source IP updates. Default: 10 seconds. */
clientSourceIpPollIntervalMs?: number;
/** Destination routing policy override. Default: forceTarget to 127.0.0.1 */
destinationPolicy?: {
default: 'forceTarget' | 'block' | 'allow';
@@ -29,7 +33,7 @@ export interface IVpnManagerConfig {
/** Compute per-client AllowedIPs based on the client's target profile IDs.
* Called at config generation time (create/export). Returns CIDRs for WireGuard AllowedIPs.
* When not set, defaults to [subnet]. */
getClientAllowedIPs?: (targetProfileIds: string[]) => Promise<string[]>;
getClientAllowedIPs?: (targetProfileIds: string[], clientId?: string, sourceIp?: string) => Promise<string[]>;
/** Resolve per-client destination allow-list IPs from target profile IDs.
* Returns IP strings that should bypass forceTarget and go direct to the real destination. */
getClientDirectTargets?: (targetProfileIds: string[]) => string[];
@@ -57,6 +61,9 @@ export class VpnManager {
private serverKeys?: VpnServerKeysDoc;
private resolvedForwardingMode?: 'socket' | 'bridge' | 'hybrid';
private forwardingModeOverride?: 'socket' | 'bridge' | 'hybrid';
private clientSourceIps = new Map<string, string>();
private clientSourceIpPollTimer?: ReturnType<typeof setInterval>;
private clientSourceIpRefreshInFlight = false;
constructor(config: IVpnManagerConfig) {
this.config = config;
@@ -173,6 +180,9 @@ export class VpnManager {
}
}
await this.refreshClientSourceIps(false);
this.startClientSourceIpPolling();
logger.log('info', `VPN server started: subnet=${subnet}, wg=:${wgListenPort}, clients=${this.clients.size}`);
}
@@ -180,6 +190,7 @@ export class VpnManager {
* Stop the VPN server.
*/
public async stop(): Promise<void> {
this.stopClientSourceIpPolling();
if (this.vpnServer) {
try {
await this.vpnServer.stopServer();
@@ -189,6 +200,11 @@ export class VpnManager {
await this.vpnServer.stop();
this.vpnServer = undefined;
}
const hadClientSourceIps = this.clientSourceIps.size > 0;
this.clientSourceIps.clear();
if (hadClientSourceIps) {
this.config.onClientSourceIpsChanged?.();
}
this.resolvedForwardingMode = undefined;
logger.log('info', 'VPN server stopped');
}
@@ -246,6 +262,7 @@ export class VpnManager {
bundle.wireguardConfig = await this.rewriteWireGuardAllowedIPs(
bundle.wireguardConfig,
doc.targetProfileIds || [],
doc.clientId,
);
// Persist client entry (including WG private key for export/QR)
@@ -287,6 +304,7 @@ export class VpnManager {
await this.vpnServer.removeClient(clientId);
const doc = this.clients.get(clientId);
this.clients.delete(clientId);
this.clientSourceIps.delete(clientId);
if (doc) {
await doc.delete();
}
@@ -328,6 +346,7 @@ export class VpnManager {
client.updatedAt = Date.now();
await this.persistClient(client);
}
this.clientSourceIps.delete(clientId);
this.config.onClientChanged?.();
}
@@ -380,6 +399,7 @@ export class VpnManager {
bundle.wireguardConfig = await this.rewriteWireGuardAllowedIPs(
bundle.wireguardConfig,
client?.targetProfileIds || [],
clientId,
);
// Update persisted entry with new keys (including private key for export/QR)
@@ -413,7 +433,11 @@ export class VpnManager {
);
}
config = await this.rewriteWireGuardAllowedIPs(config, persisted?.targetProfileIds || []);
config = await this.rewriteWireGuardAllowedIPs(
config,
persisted?.targetProfileIds || [],
clientId,
);
}
return config;
@@ -445,6 +469,107 @@ export class VpnManager {
return this.vpnServer.listClients();
}
public getClientSourceIp(clientId: string): string | undefined {
return this.clientSourceIps.get(clientId);
}
public getClientSourceIpMap(): Map<string, string> {
return new Map(this.clientSourceIps);
}
public async refreshClientSourceIps(notifyOnChange = true): Promise<boolean> {
if (!this.vpnServer || this.clientSourceIpRefreshInFlight) {
return false;
}
this.clientSourceIpRefreshInFlight = true;
try {
const connectedClients = await this.vpnServer.listClients();
const nextSourceIps = new Map<string, string>();
const wireguardClientIds = new Set<string>();
for (const connectedClient of connectedClients) {
const clientId = connectedClient.registeredClientId || connectedClient.clientId;
if (!clientId) continue;
if (connectedClient.transportType === 'wireguard') {
wireguardClientIds.add(clientId);
}
const sourceIp = VpnManager.normalizeRemoteAddress(connectedClient.remoteAddr);
if (sourceIp) {
nextSourceIps.set(clientId, sourceIp);
}
}
if (wireguardClientIds.size > 0 && typeof (this.vpnServer as any).listWgPeers === 'function') {
try {
const wgPeers = await this.vpnServer.listWgPeers();
const endpointByPublicKey = new Map<string, string>();
for (const peer of wgPeers) {
const endpointIp = VpnManager.normalizeRemoteAddress(peer.endpoint);
if (peer.publicKey && endpointIp) {
endpointByPublicKey.set(peer.publicKey, endpointIp);
}
}
for (const client of this.clients.values()) {
if (nextSourceIps.has(client.clientId)) continue;
if (!wireguardClientIds.has(client.clientId)) continue;
if (!client.wgPublicKey) continue;
const endpointIp = endpointByPublicKey.get(client.wgPublicKey);
if (endpointIp) {
nextSourceIps.set(client.clientId, endpointIp);
}
}
} catch (err) {
logger.log('warn', `VPN: Failed to refresh WireGuard peer endpoints: ${(err as Error).message}`);
}
}
if (this.sameSourceIpMap(this.clientSourceIps, nextSourceIps)) {
return false;
}
this.clientSourceIps = nextSourceIps;
if (notifyOnChange) {
this.config.onClientSourceIpsChanged?.();
}
return true;
} catch (err) {
logger.log('warn', `VPN: Failed to refresh client source IPs: ${(err as Error).message}`);
return false;
} finally {
this.clientSourceIpRefreshInFlight = false;
}
}
public static normalizeRemoteAddress(remoteAddress?: string): string | undefined {
const remoteAddressString = remoteAddress?.trim();
if (!remoteAddressString) return undefined;
if (remoteAddressString.startsWith('[')) {
const closingBracketIndex = remoteAddressString.indexOf(']');
if (closingBracketIndex > 0) {
const bracketedIp = remoteAddressString.slice(1, closingBracketIndex);
return plugins.net.isIP(bracketedIp) ? bracketedIp : undefined;
}
}
if (plugins.net.isIP(remoteAddressString)) {
return remoteAddressString;
}
const lastColonIndex = remoteAddressString.lastIndexOf(':');
if (lastColonIndex > -1 && remoteAddressString.indexOf(':') === lastColonIndex) {
const host = remoteAddressString.slice(0, lastColonIndex);
if (plugins.net.isIP(host)) {
return host;
}
}
return undefined;
}
/**
* Get telemetry for a specific client.
*/
@@ -533,10 +658,15 @@ export class VpnManager {
private async rewriteWireGuardAllowedIPs(
wireguardConfig: string,
targetProfileIds: string[],
clientId?: string,
): Promise<string> {
if (!this.config.getClientAllowedIPs) return wireguardConfig;
const allowedIPs = await this.config.getClientAllowedIPs(targetProfileIds);
const allowedIPs = await this.config.getClientAllowedIPs(
targetProfileIds,
clientId,
clientId ? this.getClientSourceIp(clientId) : undefined,
);
const effectiveAllowedIPs = allowedIPs.length ? allowedIPs : [this.getSubnet()];
const allowedLine = `AllowedIPs = ${effectiveAllowedIPs.join(', ')}`;
@@ -587,6 +717,31 @@ export class VpnManager {
}
}
private startClientSourceIpPolling(): void {
this.stopClientSourceIpPolling();
const pollIntervalMs = Math.max(1000, this.config.clientSourceIpPollIntervalMs ?? 10_000);
this.clientSourceIpPollTimer = setInterval(() => {
void this.refreshClientSourceIps().catch((err) => {
logger.log('warn', `VPN: Client source IP polling failed: ${err?.message || err}`);
});
}, pollIntervalMs);
this.clientSourceIpPollTimer.unref?.();
}
private stopClientSourceIpPolling(): void {
if (!this.clientSourceIpPollTimer) return;
clearInterval(this.clientSourceIpPollTimer);
this.clientSourceIpPollTimer = undefined;
}
private sameSourceIpMap(left: Map<string, string>, right: Map<string, string>): boolean {
if (left.size !== right.size) return false;
for (const [clientId, sourceIp] of left) {
if (right.get(clientId) !== sourceIp) return false;
}
return true;
}
private getResolvedForwardingMode(): 'socket' | 'bridge' | 'hybrid' {
return this.resolvedForwardingMode
?? this.forwardingModeOverride
+46 -16
View File
@@ -8,22 +8,52 @@ export type IRouteSecurity = NonNullable<IRouteConfig['security']>;
// Route Management Data Types
// ============================================================================
export type TApiTokenScope =
| '*'
| 'routes:read' | 'routes:write'
| 'config:read'
| 'certificates:read' | 'certificates:write'
| 'tokens:read' | 'tokens:manage'
| 'source-profiles:read' | 'source-profiles:write'
| 'target-profiles:read' | 'target-profiles:write'
| 'targets:read' | 'targets:write'
| 'dns-providers:read' | 'dns-providers:write'
| 'domains:read' | 'domains:write'
| 'dns-records:read' | 'dns-records:write'
| 'acme-config:read' | 'acme-config:write'
| 'email-domains:read' | 'email-domains:write'
| 'gateway-clients:read' | 'gateway-clients:write'
| 'workhosters:read' | 'workhosters:write';
export const apiTokenScopes = [
'*',
'routes:read',
'routes:write',
'config:read',
'stats:read',
'logs:read',
'security:read',
'security:write',
'emails:read',
'emails:write',
'certificates:read',
'certificates:write',
'tokens:read',
'tokens:manage',
'users:read',
'users:manage',
'source-profiles:read',
'source-profiles:write',
'target-profiles:read',
'target-profiles:write',
'targets:read',
'targets:write',
'dns-providers:read',
'dns-providers:write',
'domains:read',
'domains:write',
'dns-records:read',
'dns-records:write',
'acme-config:read',
'acme-config:write',
'email-domains:read',
'email-domains:write',
'remote-ingress:read',
'remote-ingress:write',
'vpn:read',
'vpn:write',
'radius:read',
'radius:write',
'gateway-clients:read',
'gateway-clients:write',
'workhosters:read',
'workhosters:write',
] as const;
export type TApiTokenScope = typeof apiTokenScopes[number];
export type TGatewayClientType = 'onebox' | 'cloudly' | 'custom';
/** @deprecated Use TGatewayClientType. */
+2
View File
@@ -23,6 +23,8 @@ export interface ITargetProfile {
targets?: ITargetProfileTarget[];
/** Route references by stored route ID. Legacy route names are normalized when unique. */
routeRefs?: string[];
/** Also allow routes whose source security would allow the VPN client's real connecting IP. */
allowRoutesByClientSourceIp?: boolean;
createdAt: number;
updatedAt: number;
createdBy: string;
+4
View File
@@ -45,6 +45,10 @@ export interface IVpnConnectedClient {
bytesSent: number;
bytesReceived: number;
transport: string;
/** Real client IP:port reported by the VPN transport, when available. */
remoteAddr?: string;
/** Parsed real client IP reported by the VPN transport, when available. */
sourceIp?: string;
}
/**
+1 -1
View File
@@ -68,7 +68,7 @@ for (const route of response.routes) {
## Bootstrap Contracts
The auth request group includes `getAdminBootstrapStatus` and `createInitialAdminUser`. These exist so a fresh DB-backed dcrouter can require explicit first-admin creation instead of auto-persisting a default account. `createInitialAdminUser` requires the temporary bootstrap identity and can optionally enable `idp.global` authentication for the same normalized local email.
The auth request group includes `getAdminBootstrapStatus` and `createInitialAdminUser`. These exist so a fresh DB-backed dcrouter can require explicit first-admin creation instead of auto-persisting a default account. `createInitialAdminUser` requires the temporary bootstrap identity and can optionally enable `idp.global` authentication for the same normalized local email. The SDK defaults to hosted `https://idp.global`; dcrouter URL settings are overrides only.
## When To Use It
+10 -5
View File
@@ -16,7 +16,8 @@ export interface IReq_CreateApiToken extends plugins.typedrequestInterfaces.impl
> {
method: 'createApiToken';
request: {
identity: authInterfaces.IIdentity;
identity?: authInterfaces.IIdentity;
apiToken?: string;
name: string;
scopes: TApiTokenScope[];
policy?: IApiTokenPolicy;
@@ -39,7 +40,8 @@ export interface IReq_ListApiTokens extends plugins.typedrequestInterfaces.imple
> {
method: 'listApiTokens';
request: {
identity: authInterfaces.IIdentity;
identity?: authInterfaces.IIdentity;
apiToken?: string;
};
response: {
tokens: IApiTokenInfo[];
@@ -55,7 +57,8 @@ export interface IReq_RevokeApiToken extends plugins.typedrequestInterfaces.impl
> {
method: 'revokeApiToken';
request: {
identity: authInterfaces.IIdentity;
identity?: authInterfaces.IIdentity;
apiToken?: string;
id: string;
};
response: {
@@ -74,7 +77,8 @@ export interface IReq_RollApiToken extends plugins.typedrequestInterfaces.implem
> {
method: 'rollApiToken';
request: {
identity: authInterfaces.IIdentity;
identity?: authInterfaces.IIdentity;
apiToken?: string;
id: string;
};
response: {
@@ -93,7 +97,8 @@ export interface IReq_ToggleApiToken extends plugins.typedrequestInterfaces.impl
> {
method: 'toggleApiToken';
request: {
identity: authInterfaces.IIdentity;
identity?: authInterfaces.IIdentity;
apiToken?: string;
id: string;
enabled: boolean;
};
+3 -2
View File
@@ -3,7 +3,8 @@ import type * as data from '../data/index.js';
export interface IReq_GetCombinedMetrics {
method: 'getCombinedMetrics';
request: {
identity: data.IIdentity;
identity?: data.IIdentity;
apiToken?: string;
sections?: {
server?: boolean;
email?: boolean;
@@ -26,4 +27,4 @@ export interface IReq_GetCombinedMetrics {
};
timestamp: number;
};
}
}
+2 -1
View File
@@ -82,7 +82,8 @@ export interface IReq_GetConfiguration extends plugins.typedrequestInterfaces.im
> {
method: 'getConfiguration';
request: {
identity: authInterfaces.IIdentity;
identity?: authInterfaces.IIdentity;
apiToken?: string;
section?: string;
};
response: {
+6 -3
View File
@@ -68,7 +68,8 @@ export interface IReq_GetAllEmails extends plugins.typedrequestInterfaces.implem
> {
method: 'getAllEmails';
request: {
identity: authInterfaces.IIdentity;
identity?: authInterfaces.IIdentity;
apiToken?: string;
};
response: {
emails: IEmail[];
@@ -84,7 +85,8 @@ export interface IReq_GetEmailDetail extends plugins.typedrequestInterfaces.impl
> {
method: 'getEmailDetail';
request: {
identity: authInterfaces.IIdentity;
identity?: authInterfaces.IIdentity;
apiToken?: string;
emailId: string;
};
response: {
@@ -101,7 +103,8 @@ export interface IReq_ResendEmail extends plugins.typedrequestInterfaces.impleme
> {
method: 'resendEmail';
request: {
identity: authInterfaces.IIdentity;
identity?: authInterfaces.IIdentity;
apiToken?: string;
emailId: string;
};
response: {
+5 -3
View File
@@ -9,7 +9,8 @@ export interface IReq_GetRecentLogs extends plugins.typedrequestInterfaces.imple
> {
method: 'getRecentLogs';
request: {
identity: authInterfaces.IIdentity;
identity?: authInterfaces.IIdentity;
apiToken?: string;
level?: 'debug' | 'info' | 'warn' | 'error';
category?: 'smtp' | 'dns' | 'security' | 'system' | 'email';
limit?: number;
@@ -31,7 +32,8 @@ export interface IReq_GetLogStream extends plugins.typedrequestInterfaces.implem
> {
method: 'getLogStream';
request: {
identity: authInterfaces.IIdentity;
identity?: authInterfaces.IIdentity;
apiToken?: string;
follow?: boolean;
filters?: {
level?: string[];
@@ -53,4 +55,4 @@ export interface IReq_PushLogEntry extends plugins.typedrequestInterfaces.implem
entry: statsInterfaces.ILogEntry;
};
response: {};
}
}
+24 -12
View File
@@ -14,7 +14,8 @@ export interface IReq_GetRadiusClients extends plugins.typedrequestInterfaces.im
> {
method: 'getRadiusClients';
request: {
identity: authInterfaces.IIdentity;
identity?: authInterfaces.IIdentity;
apiToken?: string;
};
response: {
clients: Array<{
@@ -35,7 +36,8 @@ export interface IReq_SetRadiusClient extends plugins.typedrequestInterfaces.imp
> {
method: 'setRadiusClient';
request: {
identity: authInterfaces.IIdentity;
identity?: authInterfaces.IIdentity;
apiToken?: string;
client: {
name: string;
ipRange: string;
@@ -59,7 +61,8 @@ export interface IReq_RemoveRadiusClient extends plugins.typedrequestInterfaces.
> {
method: 'removeRadiusClient';
request: {
identity: authInterfaces.IIdentity;
identity?: authInterfaces.IIdentity;
apiToken?: string;
name: string;
};
response: {
@@ -81,7 +84,8 @@ export interface IReq_GetVlanMappings extends plugins.typedrequestInterfaces.imp
> {
method: 'getVlanMappings';
request: {
identity: authInterfaces.IIdentity;
identity?: authInterfaces.IIdentity;
apiToken?: string;
};
response: {
mappings: Array<{
@@ -108,7 +112,8 @@ export interface IReq_SetVlanMapping extends plugins.typedrequestInterfaces.impl
> {
method: 'setVlanMapping';
request: {
identity: authInterfaces.IIdentity;
identity?: authInterfaces.IIdentity;
apiToken?: string;
mapping: {
mac: string;
vlan: number;
@@ -139,7 +144,8 @@ export interface IReq_RemoveVlanMapping extends plugins.typedrequestInterfaces.i
> {
method: 'removeVlanMapping';
request: {
identity: authInterfaces.IIdentity;
identity?: authInterfaces.IIdentity;
apiToken?: string;
mac: string;
};
response: {
@@ -157,7 +163,8 @@ export interface IReq_UpdateVlanConfig extends plugins.typedrequestInterfaces.im
> {
method: 'updateVlanConfig';
request: {
identity: authInterfaces.IIdentity;
identity?: authInterfaces.IIdentity;
apiToken?: string;
defaultVlan?: number;
allowUnknownMacs?: boolean;
};
@@ -179,7 +186,8 @@ export interface IReq_TestVlanAssignment extends plugins.typedrequestInterfaces.
> {
method: 'testVlanAssignment';
request: {
identity: authInterfaces.IIdentity;
identity?: authInterfaces.IIdentity;
apiToken?: string;
mac: string;
};
response: {
@@ -207,7 +215,8 @@ export interface IReq_GetRadiusSessions extends plugins.typedrequestInterfaces.i
> {
method: 'getRadiusSessions';
request: {
identity: authInterfaces.IIdentity;
identity?: authInterfaces.IIdentity;
apiToken?: string;
filter?: {
username?: string;
nasIpAddress?: string;
@@ -243,7 +252,8 @@ export interface IReq_DisconnectRadiusSession extends plugins.typedrequestInterf
> {
method: 'disconnectRadiusSession';
request: {
identity: authInterfaces.IIdentity;
identity?: authInterfaces.IIdentity;
apiToken?: string;
sessionId: string;
reason?: string;
};
@@ -262,7 +272,8 @@ export interface IReq_GetRadiusAccountingSummary extends plugins.typedrequestInt
> {
method: 'getRadiusAccountingSummary';
request: {
identity: authInterfaces.IIdentity;
identity?: authInterfaces.IIdentity;
apiToken?: string;
startTime: number;
endTime: number;
};
@@ -296,7 +307,8 @@ export interface IReq_GetRadiusStatistics extends plugins.typedrequestInterfaces
> {
method: 'getRadiusStatistics';
request: {
identity: authInterfaces.IIdentity;
identity?: authInterfaces.IIdentity;
apiToken?: string;
};
response: {
stats: {
+14 -7
View File
@@ -15,7 +15,8 @@ export interface IReq_CreateRemoteIngress extends plugins.typedrequestInterfaces
> {
method: 'createRemoteIngress';
request: {
identity: authInterfaces.IIdentity;
identity?: authInterfaces.IIdentity;
apiToken?: string;
name: string;
listenPorts?: number[];
autoDerivePorts?: boolean;
@@ -36,7 +37,8 @@ export interface IReq_DeleteRemoteIngress extends plugins.typedrequestInterfaces
> {
method: 'deleteRemoteIngress';
request: {
identity: authInterfaces.IIdentity;
identity?: authInterfaces.IIdentity;
apiToken?: string;
id: string;
};
response: {
@@ -54,7 +56,8 @@ export interface IReq_UpdateRemoteIngress extends plugins.typedrequestInterfaces
> {
method: 'updateRemoteIngress';
request: {
identity: authInterfaces.IIdentity;
identity?: authInterfaces.IIdentity;
apiToken?: string;
id: string;
name?: string;
listenPorts?: number[];
@@ -77,7 +80,8 @@ export interface IReq_RegenerateRemoteIngressSecret extends plugins.typedrequest
> {
method: 'regenerateRemoteIngressSecret';
request: {
identity: authInterfaces.IIdentity;
identity?: authInterfaces.IIdentity;
apiToken?: string;
id: string;
};
response: {
@@ -95,7 +99,8 @@ export interface IReq_GetRemoteIngresses extends plugins.typedrequestInterfaces.
> {
method: 'getRemoteIngresses';
request: {
identity: authInterfaces.IIdentity;
identity?: authInterfaces.IIdentity;
apiToken?: string;
};
response: {
edges: IRemoteIngress[];
@@ -111,7 +116,8 @@ export interface IReq_GetRemoteIngressStatus extends plugins.typedrequestInterfa
> {
method: 'getRemoteIngressStatus';
request: {
identity: authInterfaces.IIdentity;
identity?: authInterfaces.IIdentity;
apiToken?: string;
};
response: {
statuses: IRemoteIngressStatus[];
@@ -128,7 +134,8 @@ export interface IReq_GetRemoteIngressConnectionToken extends plugins.typedreque
> {
method: 'getRemoteIngressConnectionToken';
request: {
identity: authInterfaces.IIdentity;
identity?: authInterfaces.IIdentity;
apiToken?: string;
edgeId: string;
hubHost?: string;
};
+18 -8
View File
@@ -15,7 +15,8 @@ export interface IReq_ListSecurityBlockRules extends plugins.typedrequestInterfa
> {
method: 'listSecurityBlockRules';
request: {
identity: authInterfaces.IIdentity;
identity?: authInterfaces.IIdentity;
apiToken?: string;
};
response: {
rules: ISecurityBlockRule[];
@@ -28,7 +29,8 @@ export interface IReq_CreateSecurityBlockRule extends plugins.typedrequestInterf
> {
method: 'createSecurityBlockRule';
request: {
identity: authInterfaces.IIdentity;
identity?: authInterfaces.IIdentity;
apiToken?: string;
type: TSecurityBlockRuleType;
value: string;
matchMode?: TSecurityBlockRuleMatchMode;
@@ -48,7 +50,8 @@ export interface IReq_UpdateSecurityBlockRule extends plugins.typedrequestInterf
> {
method: 'updateSecurityBlockRule';
request: {
identity: authInterfaces.IIdentity;
identity?: authInterfaces.IIdentity;
apiToken?: string;
id: string;
value?: string;
matchMode?: TSecurityBlockRuleMatchMode;
@@ -68,7 +71,8 @@ export interface IReq_DeleteSecurityBlockRule extends plugins.typedrequestInterf
> {
method: 'deleteSecurityBlockRule';
request: {
identity: authInterfaces.IIdentity;
identity?: authInterfaces.IIdentity;
apiToken?: string;
id: string;
};
response: {
@@ -83,7 +87,10 @@ export interface IReq_ListIpIntelligence extends plugins.typedrequestInterfaces.
> {
method: 'listIpIntelligence';
request: {
identity: authInterfaces.IIdentity;
identity?: authInterfaces.IIdentity;
apiToken?: string;
ipAddresses?: string[];
limit?: number;
};
response: {
records: IIpIntelligenceRecord[];
@@ -96,7 +103,8 @@ export interface IReq_GetCompiledSecurityPolicy extends plugins.typedrequestInte
> {
method: 'getCompiledSecurityPolicy';
request: {
identity: authInterfaces.IIdentity;
identity?: authInterfaces.IIdentity;
apiToken?: string;
};
response: {
policy: ISecurityCompiledPolicy;
@@ -109,7 +117,8 @@ export interface IReq_ListSecurityPolicyAudit extends plugins.typedrequestInterf
> {
method: 'listSecurityPolicyAudit';
request: {
identity: authInterfaces.IIdentity;
identity?: authInterfaces.IIdentity;
apiToken?: string;
limit?: number;
};
response: {
@@ -123,7 +132,8 @@ export interface IReq_RefreshIpIntelligence extends plugins.typedrequestInterfac
> {
method: 'refreshIpIntelligence';
request: {
identity: authInterfaces.IIdentity;
identity?: authInterfaces.IIdentity;
apiToken?: string;
ipAddress: string;
};
response: {
+19 -10
View File
@@ -9,7 +9,8 @@ export interface IReq_GetServerStatistics extends plugins.typedrequestInterfaces
> {
method: 'getServerStatistics';
request: {
identity: authInterfaces.IIdentity;
identity?: authInterfaces.IIdentity;
apiToken?: string;
includeHistory?: boolean;
timeRange?: '1h' | '6h' | '24h' | '7d' | '30d';
};
@@ -29,7 +30,8 @@ export interface IReq_GetEmailStatistics extends plugins.typedrequestInterfaces.
> {
method: 'getEmailStatistics';
request: {
identity: authInterfaces.IIdentity;
identity?: authInterfaces.IIdentity;
apiToken?: string;
timeRange?: '1h' | '6h' | '24h' | '7d' | '30d';
domain?: string;
includeDetails?: boolean;
@@ -49,7 +51,8 @@ export interface IReq_GetDnsStatistics extends plugins.typedrequestInterfaces.im
> {
method: 'getDnsStatistics';
request: {
identity: authInterfaces.IIdentity;
identity?: authInterfaces.IIdentity;
apiToken?: string;
timeRange?: '1h' | '6h' | '24h' | '7d' | '30d';
domain?: string;
includeQueryTypes?: boolean;
@@ -69,7 +72,8 @@ export interface IReq_GetRateLimitStatus extends plugins.typedrequestInterfaces.
> {
method: 'getRateLimitStatus';
request: {
identity: authInterfaces.IIdentity;
identity?: authInterfaces.IIdentity;
apiToken?: string;
domain?: string;
ip?: string;
includeBlocked?: boolean;
@@ -91,7 +95,8 @@ export interface IReq_GetSecurityMetrics extends plugins.typedrequestInterfaces.
> {
method: 'getSecurityMetrics';
request: {
identity: authInterfaces.IIdentity;
identity?: authInterfaces.IIdentity;
apiToken?: string;
timeRange?: '1h' | '6h' | '24h' | '7d' | '30d';
includeDetails?: boolean;
};
@@ -112,7 +117,8 @@ export interface IReq_GetActiveConnections extends plugins.typedrequestInterface
> {
method: 'getActiveConnections';
request: {
identity: authInterfaces.IIdentity;
identity?: authInterfaces.IIdentity;
apiToken?: string;
protocol?: 'smtp' | 'smtps' | 'http' | 'https';
state?: string;
};
@@ -137,7 +143,8 @@ export interface IReq_GetQueueStatus extends plugins.typedrequestInterfaces.impl
> {
method: 'getQueueStatus';
request: {
identity: authInterfaces.IIdentity;
identity?: authInterfaces.IIdentity;
apiToken?: string;
queueName?: string;
};
response: {
@@ -153,7 +160,8 @@ export interface IReq_GetHealthStatus extends plugins.typedrequestInterfaces.imp
> {
method: 'getHealthStatus';
request: {
identity: authInterfaces.IIdentity;
identity?: authInterfaces.IIdentity;
apiToken?: string;
detailed?: boolean;
};
response: {
@@ -168,7 +176,8 @@ export interface IReq_GetNetworkStats extends plugins.typedrequestInterfaces.imp
> {
method: 'getNetworkStats';
request: {
identity: authInterfaces.IIdentity;
identity?: authInterfaces.IIdentity;
apiToken?: string;
};
response: {
connectionsByIP: Array<{ ip: string; count: number }>;
@@ -185,4 +194,4 @@ export interface IReq_GetNetworkStats extends plugins.typedrequestInterfaces.imp
frontendProtocols?: statsInterfaces.IProtocolDistribution | null;
backendProtocols?: statsInterfaces.IProtocolDistribution | null;
};
}
}
@@ -57,6 +57,7 @@ export interface IReq_CreateTargetProfile extends plugins.typedrequestInterfaces
domains?: string[];
targets?: ITargetProfileTarget[];
routeRefs?: string[];
allowRoutesByClientSourceIp?: boolean;
};
response: {
success: boolean;
@@ -82,6 +83,7 @@ export interface IReq_UpdateTargetProfile extends plugins.typedrequestInterfaces
domains?: string[];
targets?: ITargetProfileTarget[];
routeRefs?: string[];
allowRoutesByClientSourceIp?: boolean;
};
response: {
success: boolean;
+48 -2
View File
@@ -2,8 +2,10 @@ import * as plugins from '../plugins.js';
import * as authInterfaces from '../data/auth.js';
import type { IAdminUserProjection } from './admin.js';
export type TUserManagementRole = 'admin' | 'user';
/**
* List all OpsServer users (admin-only, read-only).
* List all OpsServer users (admin-only).
* Deliberately omits password/secret fields from the response.
*/
export interface IReq_ListUsers extends plugins.typedrequestInterfaces.implementsTR<
@@ -12,9 +14,53 @@ export interface IReq_ListUsers extends plugins.typedrequestInterfaces.implement
> {
method: 'listUsers';
request: {
identity: authInterfaces.IIdentity;
identity?: authInterfaces.IIdentity;
apiToken?: string;
};
response: {
users: IAdminUserProjection[];
};
}
/**
* Create a persisted OpsServer user account (admin-only).
*/
export interface IReq_CreateUser extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_CreateUser
> {
method: 'createUser';
request: {
identity?: authInterfaces.IIdentity;
apiToken?: string;
email: string;
name?: string;
role: TUserManagementRole;
password: string;
enableIdpGlobalAuth?: boolean;
};
response: {
success: boolean;
user?: IAdminUserProjection;
message?: string;
};
}
/**
* Delete a persisted OpsServer user account (admin-only).
*/
export interface IReq_DeleteUser extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_DeleteUser
> {
method: 'deleteUser';
request: {
identity?: authInterfaces.IIdentity;
apiToken?: string;
id: string;
};
response: {
success: boolean;
message?: string;
};
}
+22 -11
View File
@@ -15,7 +15,8 @@ export interface IReq_GetVpnClients extends plugins.typedrequestInterfaces.imple
> {
method: 'getVpnClients';
request: {
identity: authInterfaces.IIdentity;
identity?: authInterfaces.IIdentity;
apiToken?: string;
};
response: {
clients: IVpnClient[];
@@ -31,7 +32,8 @@ export interface IReq_GetVpnStatus extends plugins.typedrequestInterfaces.implem
> {
method: 'getVpnStatus';
request: {
identity: authInterfaces.IIdentity;
identity?: authInterfaces.IIdentity;
apiToken?: string;
};
response: {
status: IVpnServerStatus;
@@ -47,7 +49,8 @@ export interface IReq_CreateVpnClient extends plugins.typedrequestInterfaces.imp
> {
method: 'createVpnClient';
request: {
identity: authInterfaces.IIdentity;
identity?: authInterfaces.IIdentity;
apiToken?: string;
clientId: string;
targetProfileIds?: string[];
description?: string;
@@ -78,7 +81,8 @@ export interface IReq_UpdateVpnClient extends plugins.typedrequestInterfaces.imp
> {
method: 'updateVpnClient';
request: {
identity: authInterfaces.IIdentity;
identity?: authInterfaces.IIdentity;
apiToken?: string;
clientId: string;
description?: string;
targetProfileIds?: string[];
@@ -106,7 +110,8 @@ export interface IReq_GetVpnConnectedClients extends plugins.typedrequestInterfa
> {
method: 'getVpnConnectedClients';
request: {
identity: authInterfaces.IIdentity;
identity?: authInterfaces.IIdentity;
apiToken?: string;
};
response: {
connectedClients: IVpnConnectedClient[];
@@ -122,7 +127,8 @@ export interface IReq_DeleteVpnClient extends plugins.typedrequestInterfaces.imp
> {
method: 'deleteVpnClient';
request: {
identity: authInterfaces.IIdentity;
identity?: authInterfaces.IIdentity;
apiToken?: string;
clientId: string;
};
response: {
@@ -140,7 +146,8 @@ export interface IReq_EnableVpnClient extends plugins.typedrequestInterfaces.imp
> {
method: 'enableVpnClient';
request: {
identity: authInterfaces.IIdentity;
identity?: authInterfaces.IIdentity;
apiToken?: string;
clientId: string;
};
response: {
@@ -158,7 +165,8 @@ export interface IReq_DisableVpnClient extends plugins.typedrequestInterfaces.im
> {
method: 'disableVpnClient';
request: {
identity: authInterfaces.IIdentity;
identity?: authInterfaces.IIdentity;
apiToken?: string;
clientId: string;
};
response: {
@@ -176,7 +184,8 @@ export interface IReq_RotateVpnClientKey extends plugins.typedrequestInterfaces.
> {
method: 'rotateVpnClientKey';
request: {
identity: authInterfaces.IIdentity;
identity?: authInterfaces.IIdentity;
apiToken?: string;
clientId: string;
};
response: {
@@ -196,7 +205,8 @@ export interface IReq_ExportVpnClientConfig extends plugins.typedrequestInterfac
> {
method: 'exportVpnClientConfig';
request: {
identity: authInterfaces.IIdentity;
identity?: authInterfaces.IIdentity;
apiToken?: string;
clientId: string;
format: 'smartvpn' | 'wireguard';
};
@@ -216,7 +226,8 @@ export interface IReq_GetVpnClientTelemetry extends plugins.typedrequestInterfac
> {
method: 'getVpnClientTelemetry';
request: {
identity: authInterfaces.IIdentity;
identity?: authInterfaces.IIdentity;
apiToken?: string;
clientId: string;
};
response: {
+10 -5
View File
@@ -53,7 +53,8 @@ export interface IReq_ListGatewayClients extends plugins.typedrequestInterfaces.
> {
method: 'listGatewayClients';
request: {
identity: authInterfaces.IIdentity;
identity?: authInterfaces.IIdentity;
apiToken?: string;
};
response: {
gatewayClients: IGatewayClient[];
@@ -66,7 +67,8 @@ export interface IReq_CreateGatewayClient extends plugins.typedrequestInterfaces
> {
method: 'createGatewayClient';
request: {
identity: authInterfaces.IIdentity;
identity?: authInterfaces.IIdentity;
apiToken?: string;
id?: string;
type: IGatewayClient['type'];
name: string;
@@ -88,7 +90,8 @@ export interface IReq_UpdateGatewayClient extends plugins.typedrequestInterfaces
> {
method: 'updateGatewayClient';
request: {
identity: authInterfaces.IIdentity;
identity?: authInterfaces.IIdentity;
apiToken?: string;
id: string;
name?: string;
description?: string;
@@ -110,7 +113,8 @@ export interface IReq_DeleteGatewayClient extends plugins.typedrequestInterfaces
> {
method: 'deleteGatewayClient';
request: {
identity: authInterfaces.IIdentity;
identity?: authInterfaces.IIdentity;
apiToken?: string;
id: string;
};
response: {
@@ -125,7 +129,8 @@ export interface IReq_CreateGatewayClientToken extends plugins.typedrequestInter
> {
method: 'createGatewayClientToken';
request: {
identity: authInterfaces.IIdentity;
identity?: authInterfaces.IIdentity;
apiToken?: string;
gatewayClientId: string;
name?: string;
expiresInDays?: number | null;
+2
View File
@@ -89,6 +89,8 @@ export async function createMigrationRunner(
db: db as any,
// Brand-new installs skip all migrations and stamp directly to the current version.
freshInstallVersion: targetVersion,
// dcrouter uses the package version as targetVersion; bridge releases without DB changes.
targetVersionStrategy: 'bridge',
});
// Register steps in execution order. Each step's .from() must match the
+1 -1
View File
@@ -3,6 +3,6 @@
*/
export const commitinfo = {
name: '@serve.zone/dcrouter',
version: '13.30.0',
version: '13.34.0',
description: 'A multifaceted routing service handling mail and SMS delivery functions.'
}
+155 -49
View File
@@ -582,6 +582,52 @@ export const setActiveViewAction = uiStatePart.createAction<string>(async (state
};
});
const backgroundRefreshesInFlight = new Set<string>();
function runBackgroundRefresh(key: string, errorMessage: string, task: () => Promise<void>): void {
if (backgroundRefreshesInFlight.has(key)) return;
backgroundRefreshesInFlight.add(key);
void task()
.catch((error) => console.error(errorMessage, error))
.finally(() => backgroundRefreshesInFlight.delete(key));
}
function refreshNetworkIpIntelligence(identity: interfaces.data.IIdentity, ipAddresses: string[]): void {
const ips = [...new Set(ipAddresses.filter(Boolean))].slice(0, 100);
if (ips.length === 0) return;
runBackgroundRefresh('networkIpIntelligence', 'IP intelligence refresh failed:', async () => {
const intelligenceRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_ListIpIntelligence
>('/typedrequest', 'listIpIntelligence');
const intelligenceResponse = await intelligenceRequest.fire({
identity,
ipAddresses: ips,
limit: Math.max(100, ips.length),
});
networkStatePart.setState({
...networkStatePart.getState()!,
ipIntelligence: intelligenceResponse.records || [],
});
});
}
function refreshSecurityIpIntelligence(identity: interfaces.data.IIdentity): void {
runBackgroundRefresh('securityIpIntelligence', 'Security IP intelligence refresh failed:', async () => {
const intelligenceRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_ListIpIntelligence
>('/typedrequest', 'listIpIntelligence');
const intelligenceResponse = await intelligenceRequest.fire({
identity,
limit: 500,
});
securityPolicyStatePart.setState({
...securityPolicyStatePart.getState()!,
ipIntelligence: intelligenceResponse.records || [],
});
});
}
// Fetch Network Stats Action
export const fetchNetworkStatsAction = networkStatePart.createAction(async (statePartArg): Promise<INetworkState> => {
const context = getActionContext();
@@ -594,18 +640,9 @@ export const fetchNetworkStatsAction = networkStatePart.createAction(async (stat
interfaces.requests.IReq_GetNetworkStats
>('/typedrequest', 'getNetworkStats');
const ipIntelligenceRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_ListIpIntelligence
>('/typedrequest', 'listIpIntelligence');
const [networkStatsResponse, ipIntelligenceResponse] = await Promise.all([
networkStatsRequest.fire({
identity: context.identity,
}),
ipIntelligenceRequest.fire({
identity: context.identity,
}),
]);
const networkStatsResponse = await networkStatsRequest.fire({
identity: context.identity,
});
// Use the connections data for the connection list
// and network stats for throughput and IP analytics
@@ -637,6 +674,12 @@ export const fetchNetworkStatsAction = networkStatePart.createAction(async (stat
};
});
refreshNetworkIpIntelligence(context.identity, [
...Object.keys(connectionsByIP),
...(networkStatsResponse.topIPs || []).map((item) => item.ip),
...(networkStatsResponse.topIPsByBandwidth || []).map((item) => item.ip),
]);
return {
connections,
connectionsByIP,
@@ -647,7 +690,7 @@ export const fetchNetworkStatsAction = networkStatePart.createAction(async (stat
topIPs: networkStatsResponse.topIPs || [],
topIPsByBandwidth: networkStatsResponse.topIPsByBandwidth || [],
throughputByIP: networkStatsResponse.throughputByIP || [],
ipIntelligence: ipIntelligenceResponse.records || [],
ipIntelligence: currentState.ipIntelligence,
domainActivity: networkStatsResponse.domainActivity || [],
throughputHistory: networkStatsResponse.throughputHistory || [],
requestsPerSecond: networkStatsResponse.requestsPerSecond || 0,
@@ -683,9 +726,6 @@ export const fetchSecurityPolicyAction = securityPolicyStatePart.createAction(
const rulesRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_ListSecurityBlockRules
>('/typedrequest', 'listSecurityBlockRules');
const intelligenceRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_ListIpIntelligence
>('/typedrequest', 'listIpIntelligence');
const compiledPolicyRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_GetCompiledSecurityPolicy
>('/typedrequest', 'getCompiledSecurityPolicy');
@@ -693,16 +733,17 @@ export const fetchSecurityPolicyAction = securityPolicyStatePart.createAction(
interfaces.requests.IReq_ListSecurityPolicyAudit
>('/typedrequest', 'listSecurityPolicyAudit');
const [rulesResponse, intelligenceResponse, compiledPolicyResponse, auditResponse] = await Promise.all([
const [rulesResponse, compiledPolicyResponse, auditResponse] = await Promise.all([
rulesRequest.fire({ identity: context.identity }),
intelligenceRequest.fire({ identity: context.identity }),
compiledPolicyRequest.fire({ identity: context.identity }),
auditRequest.fire({ identity: context.identity, limit: 100 }),
]);
refreshSecurityIpIntelligence(context.identity);
return {
rules: rulesResponse.rules || [],
ipIntelligence: intelligenceResponse.records || [],
ipIntelligence: currentState.ipIntelligence,
compiledPolicy: compiledPolicyResponse.policy,
auditEvents: auditResponse.events || [],
isLoading: false,
@@ -835,7 +876,15 @@ export const refreshIpIntelligenceAction = securityPolicyStatePart.createAction<
if (!response.success) {
return { ...currentState, error: response.message || 'Failed to refresh IP intelligence' };
}
return await actionContext!.dispatch(fetchSecurityPolicyAction, null);
const refreshedState = await actionContext!.dispatch(fetchSecurityPolicyAction, null);
if (!response.record) return refreshedState;
return {
...refreshedState,
ipIntelligence: [
response.record,
...refreshedState.ipIntelligence.filter((record) => record.ipAddress !== response.record!.ipAddress),
],
};
} catch (error: unknown) {
return {
...currentState,
@@ -1520,6 +1569,7 @@ export const createTargetProfileAction = targetProfilesStatePart.createAction<{
domains?: string[];
targets?: Array<{ ip: string; port: number }>;
routeRefs?: string[];
allowRoutesByClientSourceIp?: boolean;
}>(async (statePartArg, dataArg, actionContext): Promise<ITargetProfilesState> => {
const context = getActionContext();
try {
@@ -1533,6 +1583,7 @@ export const createTargetProfileAction = targetProfilesStatePart.createAction<{
domains: dataArg.domains,
targets: dataArg.targets,
routeRefs: dataArg.routeRefs,
allowRoutesByClientSourceIp: dataArg.allowRoutesByClientSourceIp,
});
if (!response.success) {
return {
@@ -1556,6 +1607,7 @@ export const updateTargetProfileAction = targetProfilesStatePart.createAction<{
domains?: string[];
targets?: Array<{ ip: string; port: number }>;
routeRefs?: string[];
allowRoutesByClientSourceIp?: boolean;
}>(async (statePartArg, dataArg, actionContext): Promise<ITargetProfilesState> => {
const context = getActionContext();
try {
@@ -1570,6 +1622,7 @@ export const updateTargetProfileAction = targetProfilesStatePart.createAction<{
domains: dataArg.domains,
targets: dataArg.targets,
routeRefs: dataArg.routeRefs,
allowRoutesByClientSourceIp: dataArg.allowRoutesByClientSourceIp,
});
if (!response.success) {
return {
@@ -2637,7 +2690,7 @@ export async function createGatewayClientToken(
});
}
// Users (read-only list)
// Users
export const fetchUsersAction = usersStatePart.createAction(async (statePartArg): Promise<IUsersState> => {
const context = getActionContext();
const currentState = statePartArg.getState()!;
@@ -2666,6 +2719,74 @@ export const fetchUsersAction = usersStatePart.createAction(async (statePartArg)
}
});
export const createUserAction = usersStatePart.createAction<{
email: string;
name?: string;
role: interfaces.requests.TUserManagementRole;
password: string;
enableIdpGlobalAuth?: boolean;
}>(async (statePartArg, dataArg, actionContext): Promise<IUsersState> => {
const context = getActionContext();
const currentState = statePartArg.getState()!;
if (!context.identity) return currentState;
try {
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_CreateUser
>('/typedrequest', 'createUser');
const response = await request.fire({
identity: context.identity,
email: dataArg.email,
name: dataArg.name,
role: dataArg.role,
password: dataArg.password,
enableIdpGlobalAuth: dataArg.enableIdpGlobalAuth,
});
if (!response.success) {
throw new Error(response.message || 'Failed to create user');
}
return await actionContext!.dispatch(fetchUsersAction, null);
} catch (error) {
return {
...currentState,
error: error instanceof Error ? error.message : 'Failed to create user',
};
}
});
export const deleteUserAction = usersStatePart.createAction<string>(
async (statePartArg, userIdArg, actionContext): Promise<IUsersState> => {
const context = getActionContext();
const currentState = statePartArg.getState()!;
if (!context.identity) return currentState;
try {
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_DeleteUser
>('/typedrequest', 'deleteUser');
const response = await request.fire({
identity: context.identity,
id: userIdArg,
});
if (!response.success) {
throw new Error(response.message || 'Failed to delete user');
}
return await actionContext!.dispatch(fetchUsersAction, null);
} catch (error) {
return {
...currentState,
error: error instanceof Error ? error.message : 'Failed to delete user',
};
}
},
);
export async function createApiToken(
name: string,
scopes: interfaces.data.TApiTokenScope[],
@@ -3044,53 +3165,38 @@ async function dispatchCombinedRefreshActionInner() {
error: null,
});
try {
const intelligenceRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_ListIpIntelligence
>('/typedrequest', 'listIpIntelligence');
const intelligenceResponse = await intelligenceRequest.fire({ identity: context.identity });
networkStatePart.setState({
...networkStatePart.getState()!,
ipIntelligence: intelligenceResponse.records || [],
});
} catch (error) {
console.error('IP intelligence refresh failed:', error);
}
refreshNetworkIpIntelligence(context.identity, [
...network.connectionDetails.map((conn) => conn.remoteAddress),
...network.topEndpoints.map((endpoint) => endpoint.endpoint),
...(network.topEndpointsByBandwidth || []).map((endpoint) => endpoint.endpoint),
]);
}
if (currentView === 'security') {
try {
runBackgroundRefresh('securityPolicy', 'Security policy refresh failed:', async () => {
await securityPolicyStatePart.dispatchAction(fetchSecurityPolicyAction, null);
} catch (error) {
console.error('Security policy refresh failed:', error);
}
});
}
// Refresh certificate data if on Domains > Certificates subview
if (currentView === 'domains' && currentSubview === 'certificates') {
try {
runBackgroundRefresh('certificates', 'Certificate refresh failed:', async () => {
await certificateStatePart.dispatchAction(fetchCertificateOverviewAction, null);
} catch (error) {
console.error('Certificate refresh failed:', error);
}
});
}
// Refresh remote ingress data if on the Network → Remote Ingress subview
if (currentView === 'network' && currentSubview === 'remoteingress') {
try {
runBackgroundRefresh('remoteIngress', 'Remote ingress refresh failed:', async () => {
await remoteIngressStatePart.dispatchAction(fetchRemoteIngressAction, null);
} catch (error) {
console.error('Remote ingress refresh failed:', error);
}
});
}
// Refresh VPN data if on the Network → VPN subview
if (currentView === 'network' && currentSubview === 'vpn') {
try {
runBackgroundRefresh('vpn', 'VPN refresh failed:', async () => {
await vpnStatePart.dispatchAction(fetchVpnAction, null);
} catch (error) {
console.error('VPN refresh failed:', error);
}
});
}
} catch (error) {
console.error('Combined refresh failed:', error);
+1 -20
View File
@@ -200,26 +200,7 @@ export class OpsViewApiTokens extends DeesElement {
private async showCreateTokenDialog() {
const { DeesModal } = await import('@design.estate/dees-catalog');
const allScopes = [
'*',
'routes:read',
'routes:write',
'config:read',
'certificates:read',
'certificates:write',
'tokens:read',
'tokens:manage',
'domains:read',
'domains:write',
'dns-records:read',
'dns-records:write',
'email-domains:read',
'email-domains:write',
'gateway-clients:read',
'gateway-clients:write',
'workhosters:read',
'workhosters:write',
];
const allScopes = [...interfaces.data.apiTokenScopes];
await DeesModal.createAndShow({
heading: 'Create API Token',
+139 -1
View File
@@ -116,12 +116,31 @@ export class OpsViewUsers extends DeesElement {
.showColumnFilters=${true}
.displayFunction=${(user: appstate.IUser) => ({
ID: html`<span class="userIdCell">${user.id}</span>`,
Username: user.username,
Email: user.email || user.username,
Name: user.name || '',
Role: this.renderRoleBadge(user.role),
Status: user.status || 'active',
Auth: (user.authSources || []).join(', ') || 'bootstrap',
Session: user.id === currentUserId
? html`<span class="sessionBadge">current</span>`
: '',
})}
.dataActions=${[
{
name: 'Create User',
iconName: 'lucide:userPlus',
type: ['header'],
actionFunc: async () => await this.showCreateUserDialog(),
},
{
name: 'Delete',
iconName: 'lucide:trash2',
type: ['inRow', 'contextmenu'] as any,
actionFunc: async (actionData: any) => {
await this.showDeleteUserDialog(actionData.item as appstate.IUser);
},
},
]}
></dees-table>
</div>
`;
@@ -132,6 +151,125 @@ export class OpsViewUsers extends DeesElement {
return html`<span class="roleBadge ${cls}">${role}</span>`;
}
private async showCreateUserDialog(): Promise<void> {
const { DeesModal, DeesToast } = await import('@design.estate/dees-catalog');
await DeesModal.createAndShow({
heading: 'Create User',
content: html`
<dees-form>
<dees-input-text .key=${'email'} .label=${'Email'} .required=${true}></dees-input-text>
<dees-input-text .key=${'name'} .label=${'Display name'}></dees-input-text>
<dees-input-dropdown
.key=${'role'}
.label=${'Role'}
.options=${[
{ option: 'User', key: 'user' },
{ option: 'Admin', key: 'admin' },
]}
.selectedOption=${{ option: 'User', key: 'user' }}
.required=${true}
></dees-input-dropdown>
<dees-input-text .key=${'password'} .label=${'Password'} .required=${true} .isPasswordBool=${true}></dees-input-text>
<dees-input-text .key=${'passwordConfirm'} .label=${'Confirm password'} .required=${true} .isPasswordBool=${true}></dees-input-text>
<dees-input-checkbox
.key=${'enableIdpGlobalAuth'}
.label=${'Allow idp.global login for this email'}
.description=${'Uses https://idp.global by default; the local dcrouter account and role remain authoritative.'}
></dees-input-checkbox>
</dees-form>
`,
menuOptions: [
{
name: 'Cancel',
iconName: 'lucide:x',
action: async (modalArg: any) => await modalArg.destroy(),
},
{
name: 'Create',
iconName: 'lucide:userPlus',
action: async (modalArg: any) => {
const form = modalArg.shadowRoot?.querySelector('.content')?.querySelector('dees-form') as any;
if (!form) return;
const data = await form.collectFormData();
const email = String(data.email || '').trim();
const name = String(data.name || '').trim();
const password = String(data.password || '');
const passwordConfirm = String(data.passwordConfirm || '');
const roleValue = String(data.role?.key ?? data.role ?? 'user');
if (!email || !password) {
form.setStatus?.('error', 'Email and password are required.');
return;
}
if (password !== passwordConfirm) {
form.setStatus?.('error', 'Passwords do not match.');
return;
}
form.setStatus?.('pending', 'Creating user...');
await appstate.usersStatePart.dispatchAction(appstate.createUserAction, {
email,
name,
role: roleValue === 'admin' ? 'admin' : 'user',
password,
enableIdpGlobalAuth: Boolean(data.enableIdpGlobalAuth),
});
const state = appstate.usersStatePart.getState();
if (state?.error) {
form.setStatus?.('error', state.error);
return;
}
DeesToast.show({ message: `User created for ${email}`, type: 'success', duration: 3000 });
await modalArg.destroy();
},
},
],
});
}
private async showDeleteUserDialog(userArg: appstate.IUser): Promise<void> {
const { DeesModal, DeesToast } = await import('@design.estate/dees-catalog');
const currentUserId = this.loginState.identity?.userId;
if (userArg.id === currentUserId) {
DeesToast.show({ message: 'You cannot delete the current user.', type: 'error', duration: 4000 });
return;
}
await DeesModal.createAndShow({
heading: 'Delete User',
content: html`
<div style="padding: 8px 0; font-size: 14px; line-height: 1.5;">
<p>Delete <strong>${userArg.email || userArg.username}</strong>?</p>
<p style="color: #f59e0b; margin-top: 12px;">This removes the local dcrouter account and cannot be undone.</p>
</div>
`,
menuOptions: [
{
name: 'Cancel',
iconName: 'lucide:x',
action: async (modalArg: any) => await modalArg.destroy(),
},
{
name: 'Delete',
iconName: 'lucide:trash2',
action: async (modalArg: any) => {
await appstate.usersStatePart.dispatchAction(appstate.deleteUserAction, userArg.id);
const state = appstate.usersStatePart.getState();
if (state?.error) {
DeesToast.show({ message: state.error, type: 'error', duration: 4000 });
return;
}
DeesToast.show({ message: 'User deleted.', type: 'success', duration: 3000 });
await modalArg.destroy();
},
},
],
});
}
async firstUpdated() {
if (this.loginState.isLoggedIn) {
await appstate.usersStatePart.dispatchAction(appstate.fetchUsersAction, null);
@@ -271,6 +271,7 @@ export class OpsViewRoutes extends DeesElement {
const tags = [...(mr.route.tags || [])];
tags.push(mr.origin);
if (!mr.enabled) tags.push('disabled');
if (mr.route.vpnOnly) tags.push('vpn-only');
return {
...mr.route,
@@ -360,6 +361,7 @@ export class OpsViewRoutes extends DeesElement {
<div style="color: #ccc; padding: 8px 0;">
<p>Origin: <strong style="color: #0af;">${merged.origin}</strong></p>
<p>Status: <strong>${merged.enabled ? 'Enabled' : 'Disabled'}</strong></p>
${merged.route.vpnOnly ? html`<p>Access: <strong style="color: #22c55e;">VPN only</strong></p>` : ''}
<p>ID: <code style="color: #888;">${merged.id}</code></p>
${isSystemManaged ? html`<p>This route is system-managed. Change its source config to modify it directly.</p>` : ''}
${meta?.sourceProfileName ? html`<p>Source Profile: <strong style="color: #a78bfa;">${meta.sourceProfileName}</strong></p>` : ''}
@@ -491,6 +493,7 @@ export class OpsViewRoutes extends DeesElement {
? (Array.isArray(firstTarget.host) ? firstTarget.host[0] : firstTarget.host)
: '';
const currentTargetPort = typeof firstTarget?.port === 'number' ? String(firstTarget.port) : '';
const currentVpnOnly = route.vpnOnly === true;
const currentRemoteIngressEnabled = route.remoteIngress?.enabled === true;
const currentEdgeFilter = route.remoteIngress?.edgeFilter || [];
@@ -518,6 +521,7 @@ export class OpsViewRoutes extends DeesElement {
<dees-input-text .key=${'targetHost'} .label=${'Target Host'} .description=${'Used when no network target is selected'} .value=${currentTargetHost}></dees-input-text>
<dees-input-text .key=${'targetPort'} .label=${'Target Port'} .description=${'Used when no network target is selected'} .value=${currentTargetPort}></dees-input-text>
<dees-input-checkbox .key=${'preserveMatchPort'} .label=${'Preserve incoming port'} .value=${currentPreserveMatchPort}></dees-input-checkbox>
<dees-input-checkbox .key=${'vpnOnly'} .label=${'VPN only'} .description=${'Only VPN clients with matching target profiles can access this route'} .value=${currentVpnOnly}></dees-input-checkbox>
<dees-input-checkbox .key=${'remoteIngressEnabled'} .label=${'Enable Remote Ingress'} .value=${currentRemoteIngressEnabled}></dees-input-checkbox>
<div class="remoteIngressGroup" style="display: ${currentRemoteIngressEnabled ? 'flex' : 'none'}; flex-direction: column; gap: 16px;">
<dees-input-list .key=${'remoteIngressEdgeFilter'} .label=${'Edge Filter'} .description=${'Optional edge IDs or tags. Leave empty to allow all edges.'} .placeholder=${'Add edge ID or tag...'} .value=${currentEdgeFilter}></dees-input-list>
@@ -570,6 +574,7 @@ export class OpsViewRoutes extends DeesElement {
const remoteIngressEdgeFilter: string[] = Array.isArray(formData.remoteIngressEdgeFilter)
? formData.remoteIngressEdgeFilter.filter(Boolean)
: [];
const vpnOnly = Boolean(formData.vpnOnly);
const updatedRoute: any = {
name: formData.name,
@@ -586,6 +591,7 @@ export class OpsViewRoutes extends DeesElement {
},
],
},
vpnOnly: vpnOnly ? true : null,
remoteIngress: remoteIngressEnabled
? {
enabled: true,
@@ -684,6 +690,7 @@ export class OpsViewRoutes extends DeesElement {
<dees-input-text .key=${'targetHost'} .label=${'Target Host'} .description=${'Used when no network target is selected'} .value=${'localhost'}></dees-input-text>
<dees-input-text .key=${'targetPort'} .label=${'Target Port'} .description=${'Used when no network target is selected'}></dees-input-text>
<dees-input-checkbox .key=${'preserveMatchPort'} .label=${'Preserve incoming port'} .value=${false}></dees-input-checkbox>
<dees-input-checkbox .key=${'vpnOnly'} .label=${'VPN only'} .description=${'Only VPN clients with matching target profiles can access this route'} .value=${false}></dees-input-checkbox>
<dees-input-checkbox .key=${'remoteIngressEnabled'} .label=${'Enable Remote Ingress'} .value=${false}></dees-input-checkbox>
<div class="remoteIngressGroup" style="display: none; flex-direction: column; gap: 16px;">
<dees-input-list .key=${'remoteIngressEdgeFilter'} .label=${'Edge Filter'} .description=${'Optional edge IDs or tags. Leave empty to allow all edges.'} .placeholder=${'Add edge ID or tag...'}></dees-input-list>
@@ -736,6 +743,7 @@ export class OpsViewRoutes extends DeesElement {
const remoteIngressEdgeFilter: string[] = Array.isArray(formData.remoteIngressEdgeFilter)
? formData.remoteIngressEdgeFilter.filter(Boolean)
: [];
const vpnOnly = Boolean(formData.vpnOnly);
const route: any = {
name: formData.name,
@@ -752,6 +760,7 @@ export class OpsViewRoutes extends DeesElement {
},
],
},
...(vpnOnly ? { vpnOnly: true } : {}),
...(remoteIngressEnabled
? {
remoteIngress: {
@@ -97,6 +97,7 @@ export class OpsViewTargetProfiles extends DeesElement {
'Route Refs': profile.routeRefs?.length
? html`${profile.routeRefs.map(r => html`<span class="tagBadge">${this.formatRouteRef(r)}</span>`)}`
: '-',
'Client Source IP Routes': profile.allowRoutesByClientSourceIp ? 'Yes' : 'No',
Created: new Date(profile.createdAt).toLocaleDateString(),
})}
.dataActions=${[
@@ -223,6 +224,7 @@ export class OpsViewTargetProfiles extends DeesElement {
<dees-input-list .key=${'domains'} .label=${'Domains'} .placeholder=${'e.g. *.example.com'} .allowFreeform=${true}></dees-input-list>
<dees-input-list .key=${'targets'} .label=${'Targets'} .description=${'Format: ip:port, e.g. 10.0.0.1:443'} .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-input-checkbox .key=${'allowRoutesByClientSourceIp'} .label=${'Allow routes by VPN client source IP'} .description=${'Also grant access to non-VPN-only routes that would allow the client\'s real connecting IP'} .value=${false}></dees-input-checkbox>
</dees-form>
`,
menuOptions: [
@@ -258,6 +260,7 @@ export class OpsViewTargetProfiles extends DeesElement {
domains: domains.length > 0 ? domains : undefined,
targets: targets.length > 0 ? targets : undefined,
routeRefs: routeRefs.length > 0 ? routeRefs : undefined,
allowRoutesByClientSourceIp: data.allowRoutesByClientSourceIp === true,
});
modalArg.destroy();
},
@@ -284,6 +287,7 @@ export class OpsViewTargetProfiles extends DeesElement {
<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'} .description=${'Format: ip:port, e.g. 10.0.0.1:443'} .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-input-checkbox .key=${'allowRoutesByClientSourceIp'} .label=${'Allow routes by VPN client source IP'} .description=${'Also grant access to non-VPN-only routes that would allow the client\'s real connecting IP'} .value=${profile.allowRoutesByClientSourceIp === true}></dees-input-checkbox>
</dees-form>
`,
menuOptions: [
@@ -319,6 +323,7 @@ export class OpsViewTargetProfiles extends DeesElement {
domains,
targets,
routeRefs,
allowRoutesByClientSourceIp: data.allowRoutesByClientSourceIp === true,
});
modalArg.destroy();
},
@@ -389,6 +394,10 @@ export class OpsViewTargetProfiles extends DeesElement {
: '-'}
</div>
</div>
<div>
<div style="font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; color: ${cssManager.bdTheme('#6b7280', '#9ca3af')};">Client Source IP Routes</div>
<div style="font-size: 14px; margin-top: 4px;">${profile.allowRoutesByClientSourceIp ? 'Enabled' : 'Disabled'}</div>
</div>
<div>
<div style="font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; color: ${cssManager.bdTheme('#6b7280', '#9ca3af')};">Created</div>
<div style="font-size: 14px; margin-top: 4px;">${new Date(profile.createdAt).toLocaleString()} by ${profile.createdBy}</div>
+2
View File
@@ -339,6 +339,7 @@ export class OpsViewVpn extends DeesElement {
'Status': statusHtml,
'Routing': routingHtml,
'VPN IP': client.assignedIp || '-',
'Source IP': conn?.sourceIp || '-',
'Target Profiles': this.renderTargetProfileBadges(client.targetProfileIds),
'Description': client.description || '-',
'Created': new Date(client.createdAt).toLocaleDateString(),
@@ -487,6 +488,7 @@ export class OpsViewVpn extends DeesElement {
${conn ? html`
<div class="infoItem"><span class="infoLabel">Connected Since</span><span class="infoValue">${new Date(conn.connectedSince).toLocaleString()}</span></div>
<div class="infoItem"><span class="infoLabel">Transport</span><span class="infoValue">${conn.transport}</span></div>
<div class="infoItem"><span class="infoLabel">Source IP</span><span class="infoValue">${conn.sourceIp || '-'}</span></div>
` : ''}
<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.resolveProfileIdsToLabels(client.targetProfileIds)?.join(', ') || '-'}</span></div>
+1 -3
View File
@@ -426,9 +426,7 @@ export class OpsDashboard extends DeesElement {
<dees-input-checkbox
.key=${'enableIdpGlobalAuth'}
.label=${'Allow idp.global login for this email'}
.description=${statusArg.idpGlobalConfigured
? 'The local account remains authoritative; idp.global only verifies identity.'
: 'Requires DCROUTER_IDP_GLOBAL_URL before idp.global logins can work.'}
.description=${'Uses https://idp.global by default; the local dcrouter account and role remain authoritative.'}
></dees-input-checkbox>
</dees-form>
</div>