Compare commits
19 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ac118397f9 | |||
| 8188b4712c | |||
| 27d077feed | |||
| 98913c1977 | |||
| ca5c57a329 | |||
| 707fbc2413 | |||
| a0c9d40e87 | |||
| 2a73973eda | |||
| f0069f87e2 | |||
| 77c1738390 | |||
| 53d7c5350e | |||
| 7986d01245 | |||
| 0b01a4c26b | |||
| 407c8eef8a | |||
| aa0ef2f033 | |||
| 7819f09625 | |||
| 3f8c0c4219 | |||
| 70fcd46d52 | |||
| 47a1f5d7db |
+32
-10
@@ -23,11 +23,14 @@
|
||||
"outputMode": "bundle",
|
||||
"bundler": "esbuild",
|
||||
"production": true,
|
||||
"includeFiles": ["./html/**/*.html"]
|
||||
"includeFiles": [
|
||||
"./html/**/*.html"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"@git.zone/cli": {
|
||||
"schemaVersion": 2,
|
||||
"projectType": "service",
|
||||
"module": {
|
||||
"githost": "code.foss.global",
|
||||
@@ -60,18 +63,37 @@
|
||||
]
|
||||
},
|
||||
"release": {
|
||||
"registries": [
|
||||
"https://verdaccio.lossless.digital",
|
||||
"https://registry.npmjs.org"
|
||||
],
|
||||
"accessLevel": "public"
|
||||
"targets": {
|
||||
"git": {
|
||||
"enabled": true,
|
||||
"remote": "origin"
|
||||
},
|
||||
"npm": {
|
||||
"enabled": true,
|
||||
"registries": [
|
||||
"https://verdaccio.lossless.digital",
|
||||
"https://registry.npmjs.org"
|
||||
],
|
||||
"accessLevel": "public"
|
||||
},
|
||||
"docker": {
|
||||
"enabled": true,
|
||||
"engine": "tsdocker"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"@git.zone/tsdocker": {
|
||||
"registries": ["code.foss.global"],
|
||||
"registries": [
|
||||
"code.foss.global"
|
||||
],
|
||||
"registryRepoMap": {
|
||||
"code.foss.global": "serve.zone/dcrouter"
|
||||
},
|
||||
"platforms": ["linux/amd64", "linux/arm64"]
|
||||
}
|
||||
}
|
||||
"platforms": [
|
||||
"linux/amd64",
|
||||
"linux/arm64"
|
||||
]
|
||||
},
|
||||
"@ship.zone/szci": {}
|
||||
}
|
||||
+93
-1
@@ -1,5 +1,97 @@
|
||||
# Changelog
|
||||
|
||||
## 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
|
||||
|
||||
### Features
|
||||
|
||||
- document first-admin bootstrap flow and update authentication examples (docs)
|
||||
- Add README guidance for explicit initial admin creation on DB-backed instances across the main package, API client, interfaces, and web dashboard docs.
|
||||
- Update authentication examples to use persisted admin email/password credentials instead of the old default admin login.
|
||||
- Refresh dependency versions in package.json to align documentation with current package releases.
|
||||
|
||||
## 2026-05-14 - 13.29.1
|
||||
|
||||
### Fixes
|
||||
|
||||
- enable npm publishing in smartconfig (smartconfig)
|
||||
- Sets the npm integration flag to true in .smartconfig.json
|
||||
- Keeps the configured Verdaccio and npmjs registries unchanged
|
||||
|
||||
## 2026-05-14 - 13.29.0
|
||||
|
||||
### Fixes
|
||||
|
||||
- harden VPN route access and wireguard client configuration handling (vpn)
|
||||
- Fail closed for vpnOnly routes when no VPN client IPs are available by replacing allow lists and enforcing a block-all fallback
|
||||
- Refresh route application and VPN client security after target profile creation so profile changes take effect immediately
|
||||
- Validate vpnConfig.serverEndpoint, require persisted config managers for VPN startup, and normalize WireGuard AllowedIPs during client creation, export, and key rotation
|
||||
- Switch smartvpn server setup to wireguard transport with a localhost-only listener and await async server stop operations consistently
|
||||
|
||||
### Features
|
||||
|
||||
- add persisted admin bootstrap flow with optional idp.global authentication (opsserver-admin)
|
||||
- introduces bootstrap status and initial admin creation endpoints for OpsServer
|
||||
- switches admin authentication from ephemeral-only users to database-backed accounts when a persistent admin exists
|
||||
- adds optional idp.global login support for admin accounts and exposes auth source metadata in user listings
|
||||
- updates the web dashboard to prompt creation of the first persisted admin account
|
||||
- adds integration coverage for bootstrap, persisted login, identity invalidation, and user listing behavior
|
||||
|
||||
## 2026-05-09 - 13.28.0 - feat(gateway-clients)
|
||||
add managed gateway client administration and token-bound route ownership
|
||||
|
||||
@@ -2612,4 +2704,4 @@ Applied a core fix.
|
||||
- Fixed core functionality for version 1.0.1
|
||||
|
||||
–––––––––––––––––––––––
|
||||
Note: Versions that only contained version bumps (for example, 1.0.11 and the plain "1.0.x" commits) have been omitted from individual entries and are implicitly included in the version ranges above.
|
||||
Note: Versions that only contained version bumps (for example, 1.0.11 and the plain "1.0.x" commits) have been omitted from individual entries and are implicitly included in the version ranges above.
|
||||
|
||||
+18
-17
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@serve.zone/dcrouter",
|
||||
"private": false,
|
||||
"version": "13.28.0",
|
||||
"version": "13.34.0",
|
||||
"description": "A multifaceted routing service handling mail and SMS delivery functions.",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
@@ -22,53 +22,54 @@
|
||||
"watch": "tswatch"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@git.zone/tsbuild": "^4.4.0",
|
||||
"@git.zone/tsbundle": "^2.10.1",
|
||||
"@git.zone/tsdocker": "^2.2.5",
|
||||
"@git.zone/tsrun": "^2.0.3",
|
||||
"@git.zone/tstest": "^3.6.3",
|
||||
"@git.zone/tswatch": "^3.3.3",
|
||||
"@types/node": "^25.6.1"
|
||||
"@git.zone/tsbuild": "^4.4.1",
|
||||
"@git.zone/tsbundle": "^2.10.4",
|
||||
"@git.zone/tsdocker": "^2.3.0",
|
||||
"@git.zone/tsrun": "^2.0.4",
|
||||
"@git.zone/tstest": "^3.6.6",
|
||||
"@git.zone/tswatch": "^3.3.5",
|
||||
"@types/node": "^25.9.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@api.global/typedrequest": "^3.3.0",
|
||||
"@api.global/typedrequest": "^3.3.1",
|
||||
"@api.global/typedrequest-interfaces": "^3.0.19",
|
||||
"@api.global/typedserver": "^8.4.6",
|
||||
"@api.global/typedsocket": "^4.1.2",
|
||||
"@api.global/typedsocket": "^4.1.3",
|
||||
"@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.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.0",
|
||||
"@push.rocks/smartproxy": "^27.10.3",
|
||||
"@push.rocks/smartradius": "^1.1.2",
|
||||
"@push.rocks/smartrequest": "^5.0.3",
|
||||
"@push.rocks/smartrx": "^3.0.10",
|
||||
"@push.rocks/smartstate": "^2.3.1",
|
||||
"@push.rocks/smartunique": "^3.0.9",
|
||||
"@push.rocks/smartvpn": "1.19.2",
|
||||
"@push.rocks/smartvpn": "1.19.4",
|
||||
"@push.rocks/taskbuffer": "^8.0.2",
|
||||
"@serve.zone/catalog": "^2.12.4",
|
||||
"@serve.zone/interfaces": "^5.5.0",
|
||||
"@serve.zone/interfaces": "^5.8.0",
|
||||
"@serve.zone/remoteingress": "^4.17.1",
|
||||
"@tsclass/tsclass": "^9.5.1",
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"lru-cache": "^11.3.6",
|
||||
"lru-cache": "^11.4.0",
|
||||
"qrcode": "^1.5.4",
|
||||
"uuid": "^14.0.0"
|
||||
},
|
||||
|
||||
Generated
+589
-355
File diff suppressed because it is too large
Load Diff
@@ -73,10 +73,22 @@ await router.start();
|
||||
After startup:
|
||||
|
||||
- open the dashboard at `http://localhost:3000`
|
||||
- log in with the current built-in development credentials `admin` / `admin`
|
||||
- complete the first-admin bootstrap flow if no persisted admin account exists yet
|
||||
- send proxied traffic to `http://localhost:18080`
|
||||
- stop gracefully with `await router.stop()`
|
||||
|
||||
## Initial Admin Bootstrap
|
||||
|
||||
When DB-backed persistence is enabled and no persisted admin exists, dcrouter does not auto-create an admin account. The Ops dashboard exposes a non-cancelable first-admin bootstrap flow that must be completed explicitly.
|
||||
|
||||
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 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
|
||||
|
||||
`DcRouter` is configured with `IDcRouterOptions` from `@serve.zone/dcrouter`.
|
||||
@@ -184,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.
|
||||
@@ -199,7 +224,7 @@ const client = new DcRouterApiClient({
|
||||
baseUrl: 'https://dcrouter.example.com',
|
||||
});
|
||||
|
||||
await client.login('admin', 'admin');
|
||||
await client.login('admin@example.com', 'strong-password');
|
||||
|
||||
const route = await client.routes.build()
|
||||
.setName('api-gateway')
|
||||
@@ -279,7 +304,7 @@ Use of these trademarks must comply with Task Venture Capital GmbH's Trademark G
|
||||
|
||||
### Company Information
|
||||
|
||||
Task Venture Capital GmbH
|
||||
Task Venture Capital GmbH
|
||||
Registered at District Court Bremen HRB 35230 HB, Germany
|
||||
|
||||
For any legal inquiries or further information, please contact us via email at hello@task.vc.
|
||||
|
||||
@@ -0,0 +1,348 @@
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
import { TypedRequest } from '@api.global/typedrequest';
|
||||
import { OpsServer } from '../ts/opsserver/index.js';
|
||||
import { DcRouterDb } from '../ts/db/index.js';
|
||||
import * as plugins from '../ts/plugins.js';
|
||||
import * as interfaces from '../ts_interfaces/index.js';
|
||||
|
||||
const testPort = 3110;
|
||||
const baseUrl = `http://localhost:${testPort}/typedrequest`;
|
||||
const bootstrapPassword = 'temporary-bootstrap-password';
|
||||
const persistedPassword = 'persisted-admin-password';
|
||||
|
||||
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,
|
||||
'getAdminBootstrapStatus',
|
||||
);
|
||||
|
||||
const createLoginRequest = () => new TypedRequest<interfaces.requests.IReq_AdminLoginWithUsernameAndPassword>(
|
||||
baseUrl,
|
||||
'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;
|
||||
|
||||
storagePath = plugins.path.join(
|
||||
plugins.os.tmpdir(),
|
||||
`dcrouter-admin-bootstrap-${Date.now()}-${Math.random().toString(16).slice(2)}`,
|
||||
);
|
||||
|
||||
DcRouterDb.resetInstance();
|
||||
dbName = `dcrouter-admin-bootstrap-${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||
testDb = DcRouterDb.getInstance({
|
||||
storagePath,
|
||||
dbName,
|
||||
});
|
||||
await testDb.start();
|
||||
await testDb.getDb().mongoDb.createCollection('__test_init');
|
||||
|
||||
opsServer = new OpsServer(createFakeDcRouter(testPort, testDb) as any);
|
||||
await opsServer.start();
|
||||
});
|
||||
|
||||
tap.test('reports bootstrap required without auto-persisting an admin', async () => {
|
||||
const status = await createStatusRequest().fire({});
|
||||
|
||||
expect(status.dbEnabled).toEqual(true);
|
||||
expect(status.dbReady).toEqual(true);
|
||||
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 () => {
|
||||
const response = await createLoginRequest().fire({
|
||||
username: 'admin',
|
||||
password: bootstrapPassword,
|
||||
});
|
||||
|
||||
if (!response.identity) {
|
||||
throw new Error('Expected bootstrap login identity');
|
||||
}
|
||||
bootstrapIdentity = response.identity;
|
||||
expect(bootstrapIdentity.role).toEqual('admin');
|
||||
});
|
||||
|
||||
tap.test('creates the initial persisted admin explicitly', async () => {
|
||||
const request = new TypedRequest<interfaces.requests.IReq_CreateInitialAdminUser>(
|
||||
baseUrl,
|
||||
'createInitialAdminUser',
|
||||
);
|
||||
|
||||
const response = await request.fire({
|
||||
identity: bootstrapIdentity,
|
||||
email: 'Admin@Example.com',
|
||||
name: 'Persisted Admin',
|
||||
password: persistedPassword,
|
||||
enableIdpGlobalAuth: true,
|
||||
});
|
||||
|
||||
expect(response.success).toEqual(true);
|
||||
expect(response.user?.role).toEqual('admin');
|
||||
expect(response.user?.authSources).toContain('local');
|
||||
expect(response.user?.authSources).toContain('idp.global');
|
||||
if (!response.identity) {
|
||||
throw new Error('Expected persisted admin identity');
|
||||
}
|
||||
persistedIdentity = response.identity;
|
||||
});
|
||||
|
||||
tap.test('disables bootstrap mode after persisted admin exists', async () => {
|
||||
const status = await createStatusRequest().fire({});
|
||||
|
||||
expect(status.hasPersistentAdmin).toEqual(true);
|
||||
expect(status.needsBootstrap).toEqual(false);
|
||||
expect(status.ephemeralAdminAvailable).toEqual(false);
|
||||
});
|
||||
|
||||
tap.test('rejects the old temporary admin after persisted admin creation', async () => {
|
||||
let rejected = false;
|
||||
try {
|
||||
await createLoginRequest().fire({
|
||||
username: 'admin',
|
||||
password: bootstrapPassword,
|
||||
});
|
||||
} catch {
|
||||
rejected = true;
|
||||
}
|
||||
|
||||
expect(rejected).toEqual(true);
|
||||
});
|
||||
|
||||
tap.test('rejects the old temporary admin identity after persisted admin creation', async () => {
|
||||
const request = new TypedRequest<interfaces.requests.IReq_VerifyIdentity>(
|
||||
baseUrl,
|
||||
'verifyIdentity',
|
||||
);
|
||||
const response = await request.fire({ identity: bootstrapIdentity });
|
||||
|
||||
expect(response.valid).toEqual(false);
|
||||
});
|
||||
|
||||
tap.test('authenticates the persisted admin locally by normalized email', async () => {
|
||||
const response = await createLoginRequest().fire({
|
||||
username: 'admin@example.com',
|
||||
password: persistedPassword,
|
||||
authSource: 'local',
|
||||
});
|
||||
|
||||
if (!response.identity) {
|
||||
throw new Error('Expected persisted admin login identity');
|
||||
}
|
||||
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 {
|
||||
await createLoginRequest().fire({
|
||||
username: 'admin@example.com',
|
||||
password: 'idp-password',
|
||||
authSource: 'idp.global',
|
||||
});
|
||||
} catch {
|
||||
rejected = true;
|
||||
}
|
||||
|
||||
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 });
|
||||
|
||||
expect(response.users.length).toEqual(1);
|
||||
expect(response.users[0].email).toEqual('Admin@Example.com');
|
||||
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();
|
||||
DcRouterDb.resetInstance();
|
||||
await plugins.fs.promises.rm(storagePath, { recursive: true, force: true });
|
||||
|
||||
if (previousAdminPassword === undefined) {
|
||||
delete process.env.DCROUTER_ADMIN_PASSWORD;
|
||||
} else {
|
||||
process.env.DCROUTER_ADMIN_PASSWORD = previousAdminPassword;
|
||||
}
|
||||
});
|
||||
|
||||
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();
|
||||
@@ -56,6 +56,7 @@ const setupHandler = (scopes: TScope[], options?: {
|
||||
const opsServerRef: any = {
|
||||
typedrouter,
|
||||
adminHandler: {
|
||||
validateIdentity: async () => null,
|
||||
adminIdentityGuard: {
|
||||
exec: async () => false,
|
||||
},
|
||||
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
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' });
|
||||
@@ -107,4 +109,366 @@ tap.test('DcRouter.updateVpnConfig swaps the runtime VPN resolver and restarts V
|
||||
expect(dcRouter.vpnManager).toBeUndefined();
|
||||
});
|
||||
|
||||
tap.test('RouteConfigManager makes vpnOnly routes fail closed without VPN clients', async () => {
|
||||
const manager = new RouteConfigManager(() => undefined);
|
||||
const route = {
|
||||
name: 'private-route',
|
||||
vpnOnly: true,
|
||||
match: { domains: ['private.example.com'] },
|
||||
action: { type: 'forward', targets: [{ host: '127.0.0.1', port: 8080 }] },
|
||||
security: { ipAllowList: ['*'] },
|
||||
} as any;
|
||||
|
||||
const prepared = (manager as any).injectVpnSecurity(route);
|
||||
|
||||
expect(prepared.security.ipAllowList).toEqual([]);
|
||||
expect(prepared.security.ipBlockList).toContain('*');
|
||||
});
|
||||
|
||||
tap.test('RouteConfigManager replaces public allow lists for vpnOnly routes', async () => {
|
||||
const manager = new RouteConfigManager(
|
||||
() => undefined,
|
||||
undefined,
|
||||
() => ['10.8.0.2'],
|
||||
);
|
||||
const route = {
|
||||
name: 'private-route',
|
||||
vpnOnly: true,
|
||||
match: { domains: ['private.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(['10.8.0.2']);
|
||||
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',
|
||||
getClientAllowedIPs: async () => ['10.8.0.0/24', '203.0.113.10/32'],
|
||||
});
|
||||
|
||||
(manager as any).vpnServer = {
|
||||
rotateClientKey: async () => ({
|
||||
entry: {
|
||||
clientId: 'client-1',
|
||||
publicKey: 'noise-public-key',
|
||||
wgPublicKey: 'wg-public-key',
|
||||
},
|
||||
wireguardConfig: '[Interface]\nPrivateKey = old\nAddress = 10.8.0.2/24\n[Peer]\nAllowedIPs = 0.0.0.0/0\nEndpoint = vpn.example.com:51820\n',
|
||||
secrets: { noisePrivateKey: 'noise-private-key', wgPrivateKey: 'wg-private-key' },
|
||||
}),
|
||||
};
|
||||
(manager as any).clients = new Map([
|
||||
['client-1', { clientId: 'client-1', targetProfileIds: ['profile-1'] }],
|
||||
]);
|
||||
(manager as any).persistClient = async () => {};
|
||||
|
||||
const bundle = await manager.rotateClientKey('client-1');
|
||||
|
||||
expect(bundle.wireguardConfig).toContain('AllowedIPs = 10.8.0.0/24, 203.0.113.10/32');
|
||||
});
|
||||
|
||||
export default tap.start()
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
|
||||
@@ -3,6 +3,6 @@
|
||||
*/
|
||||
export const commitinfo = {
|
||||
name: '@serve.zone/dcrouter',
|
||||
version: '13.28.0',
|
||||
version: '13.34.0',
|
||||
description: 'A multifaceted routing service handling mail and SMS delivery functions.'
|
||||
}
|
||||
|
||||
+30
-3
@@ -167,6 +167,14 @@ export interface IDcRouterOptions {
|
||||
/** Port for the OpsServer web UI (default: 3000) */
|
||||
opsServerPort?: number;
|
||||
|
||||
/** Optional OpsServer account authentication settings. */
|
||||
adminAuth?: {
|
||||
/** 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'>;
|
||||
};
|
||||
|
||||
remoteIngressConfig?: {
|
||||
/** Enable remote ingress hub (default: false) */
|
||||
enabled?: boolean;
|
||||
@@ -740,10 +748,14 @@ export class DcRouter {
|
||||
|
||||
// VPN Server: optional, depends on SmartProxy
|
||||
if (this.options.vpnConfig?.enabled) {
|
||||
const vpnServiceDeps = ['SmartProxy'];
|
||||
if (this.options.dbConfig?.enabled !== false) {
|
||||
vpnServiceDeps.push('ConfigManagers');
|
||||
}
|
||||
this.serviceManager.addService(
|
||||
new plugins.taskbuffer.Service('VpnServer')
|
||||
.optional()
|
||||
.dependsOn('SmartProxy')
|
||||
.dependsOn(...vpnServiceDeps)
|
||||
.withStart(async () => {
|
||||
await this.setupVpnServer();
|
||||
})
|
||||
@@ -2409,6 +2421,7 @@ export class DcRouter {
|
||||
routeId,
|
||||
this.vpnManager.listClients(),
|
||||
this.routeConfigManager?.getRoutes() || new Map(),
|
||||
this.vpnManager.getClientSourceIpMap(),
|
||||
);
|
||||
};
|
||||
}
|
||||
@@ -2418,6 +2431,13 @@ export class DcRouter {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.options.dbConfig?.enabled === false) {
|
||||
throw new Error('VPN requires dbConfig.enabled because clients, keys, routes, and target profiles are persisted in DcRouterDb');
|
||||
}
|
||||
if (!this.routeConfigManager || !this.targetProfileManager) {
|
||||
throw new Error('VPN requires initialized route and target profile managers');
|
||||
}
|
||||
|
||||
logger.log('info', 'Setting up VPN server...');
|
||||
|
||||
this.vpnManager = new VpnManager({
|
||||
@@ -2439,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]);
|
||||
|
||||
@@ -2452,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
|
||||
|
||||
@@ -607,20 +607,55 @@ export class RouteConfigManager {
|
||||
route: plugins.smartproxy.IRouteConfig,
|
||||
routeId?: string,
|
||||
): plugins.smartproxy.IRouteConfig {
|
||||
const vpnCallback = this.getVpnClientIpsForRoute;
|
||||
if (!vpnCallback) return route;
|
||||
|
||||
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
|
||||
: [...new Set([...existingBlockList, '*'])];
|
||||
|
||||
const vpnEntries = vpnCallback(dcRoute, routeId);
|
||||
const existingEntries = route.security?.ipAllowList || [];
|
||||
return {
|
||||
...route,
|
||||
security: {
|
||||
...route.security,
|
||||
ipAllowList: [...existingEntries, ...vpnEntries],
|
||||
ipAllowList: vpnEntries,
|
||||
ipBlockList,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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,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);
|
||||
@@ -113,6 +102,9 @@ export class OpsServer {
|
||||
}
|
||||
|
||||
public async stop() {
|
||||
if (this.adminHandler) {
|
||||
await this.adminHandler.stop();
|
||||
}
|
||||
// Clean up log handler streams and push destination before stopping the server
|
||||
if (this.logsHandler) {
|
||||
this.logsHandler.cleanup();
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -8,19 +8,34 @@ export interface IJwtData {
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
type TAdminUser = {
|
||||
id: string;
|
||||
username: string;
|
||||
email?: string;
|
||||
name?: string;
|
||||
role: string;
|
||||
status?: 'active' | 'disabled';
|
||||
authSources?: Array<'local' | 'idp.global'>;
|
||||
};
|
||||
|
||||
export class AdminHandler {
|
||||
public typedrouter = new plugins.typedrequest.TypedRouter();
|
||||
|
||||
// JWT instance
|
||||
public smartjwtInstance!: plugins.smartjwt.SmartJwt<IJwtData>;
|
||||
|
||||
// Simple in-memory user storage (in production, use proper database)
|
||||
// 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;
|
||||
password: string;
|
||||
role: string;
|
||||
}>();
|
||||
|
||||
private accountStore?: plugins.idpSdkServer.SmartdataAccountStore;
|
||||
private idpClient?: plugins.idpSdkServer.IdpGlobalServerClient;
|
||||
private ownsIdpClient = false;
|
||||
|
||||
constructor(private opsServerRef: OpsServer) {
|
||||
// Add this handler's router to the parent
|
||||
@@ -32,6 +47,14 @@ export class AdminHandler {
|
||||
this.initializeDefaultUsers();
|
||||
this.registerHandlers();
|
||||
}
|
||||
|
||||
public async stop(): Promise<void> {
|
||||
if (this.ownsIdpClient) {
|
||||
await this.idpClient?.stop();
|
||||
}
|
||||
this.idpClient = undefined;
|
||||
this.ownsIdpClient = false;
|
||||
}
|
||||
|
||||
private async initializeJwt(): Promise<void> {
|
||||
this.smartjwtInstance = new plugins.smartjwt.SmartJwt();
|
||||
@@ -61,54 +84,214 @@ export class AdminHandler {
|
||||
}
|
||||
|
||||
/**
|
||||
* Return a safe projection of the users Map — excludes password fields.
|
||||
* Return a safe projection of the active user source — excludes password fields.
|
||||
* Used by UsersHandler to serve the admin-only listUsers endpoint.
|
||||
*/
|
||||
public listUsers(): Array<{ id: string; username: string; role: string }> {
|
||||
public async listUsers(): Promise<interfaces.requests.IAdminUserProjection[]> {
|
||||
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));
|
||||
}
|
||||
|
||||
return Array.from(this.users.values()).map((user) => ({
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
role: user.role,
|
||||
}));
|
||||
}
|
||||
|
||||
public async getBootstrapStatus(): Promise<interfaces.requests.IReq_GetAdminBootstrapStatus['response']> {
|
||||
const accountState = await this.getPersistentAccountState();
|
||||
const bootstrapAvailable = !accountState.dbEnabled || (accountState.dbReady && !accountState.hasPersistentAdmin);
|
||||
return {
|
||||
dbEnabled: accountState.dbEnabled,
|
||||
dbReady: accountState.dbReady,
|
||||
hasPersistentAdmin: accountState.hasPersistentAdmin,
|
||||
needsBootstrap: accountState.dbEnabled && accountState.dbReady && !accountState.hasPersistentAdmin,
|
||||
ephemeralAdminAvailable: bootstrapAvailable,
|
||||
idpGlobalConfigured: this.isIdpGlobalConfigured(),
|
||||
};
|
||||
}
|
||||
|
||||
public async createInitialAdminUser(optionsArg: {
|
||||
email: string;
|
||||
name?: string;
|
||||
password: string;
|
||||
enableIdpGlobalAuth?: boolean;
|
||||
}): Promise<interfaces.requests.IReq_CreateInitialAdminUser['response']> {
|
||||
const store = this.getAccountStore();
|
||||
if (!store) {
|
||||
throw new plugins.typedrequest.TypedResponseError('database is not ready');
|
||||
}
|
||||
|
||||
if (await store.hasActiveAdminAccount()) {
|
||||
throw new plugins.typedrequest.TypedResponseError('initial admin already exists');
|
||||
}
|
||||
|
||||
const password = String(optionsArg.password || '');
|
||||
if (!password) {
|
||||
throw new plugins.typedrequest.TypedResponseError('password is required');
|
||||
}
|
||||
|
||||
const email = String(optionsArg.email || '').trim();
|
||||
const authSources: Array<'local' | 'idp.global'> = ['local'];
|
||||
if (optionsArg.enableIdpGlobalAuth) {
|
||||
authSources.push('idp.global');
|
||||
}
|
||||
|
||||
try {
|
||||
const account = await store.createAccount({
|
||||
email,
|
||||
name: String(optionsArg.name || '').trim() || email,
|
||||
role: 'admin',
|
||||
authSources,
|
||||
password,
|
||||
});
|
||||
const user = this.accountToUser(account);
|
||||
return {
|
||||
success: true,
|
||||
identity: await this.createIdentityForUser(user),
|
||||
user,
|
||||
};
|
||||
} catch (error) {
|
||||
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(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetAdminBootstrapStatus>(
|
||||
'getAdminBootstrapStatus',
|
||||
async (_dataArg) => this.getBootstrapStatus()
|
||||
)
|
||||
);
|
||||
|
||||
this.opsServerRef.adminRouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_CreateInitialAdminUser>(
|
||||
'createInitialAdminUser',
|
||||
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,
|
||||
});
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
// Admin Login Handler
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_AdminLoginWithUsernameAndPassword>(
|
||||
'adminLoginWithUsernameAndPassword',
|
||||
async (dataArg) => {
|
||||
try {
|
||||
// Find user by username and password
|
||||
let user: { id: string; username: string; password: string; role: string } | null = null;
|
||||
for (const [_, userData] of this.users) {
|
||||
if (userData.username === dataArg.username && userData.password === dataArg.password) {
|
||||
user = userData;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const user = await this.authenticateUser({
|
||||
username: dataArg.username,
|
||||
password: dataArg.password,
|
||||
authSource: dataArg.authSource,
|
||||
});
|
||||
if (!user) {
|
||||
throw new plugins.typedrequest.TypedResponseError('login failed');
|
||||
}
|
||||
|
||||
const expiresAtTimestamp = Date.now() + 3600 * 1000 * 24; // 24 hours
|
||||
|
||||
const jwt = await this.smartjwtInstance.createJWT({
|
||||
userId: user.id,
|
||||
status: 'loggedIn',
|
||||
expiresAt: expiresAtTimestamp,
|
||||
});
|
||||
|
||||
|
||||
return {
|
||||
identity: {
|
||||
jwt,
|
||||
userId: user.id,
|
||||
name: user.username,
|
||||
expiresAt: expiresAtTimestamp,
|
||||
role: user.role,
|
||||
type: 'user',
|
||||
},
|
||||
identity: await this.createIdentityForUser(user),
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof plugins.typedrequest.TypedResponseError) {
|
||||
@@ -125,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,
|
||||
};
|
||||
@@ -139,53 +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,
|
||||
};
|
||||
}
|
||||
|
||||
// Find user
|
||||
const user = this.users.get(jwtData.userId);
|
||||
if (!user) {
|
||||
return {
|
||||
valid: false,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
valid: true,
|
||||
identity: {
|
||||
jwt: dataArg.identity.jwt,
|
||||
userId: user.id,
|
||||
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 };
|
||||
}
|
||||
)
|
||||
);
|
||||
@@ -198,36 +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;
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
return Boolean(await this.validateIdentity(dataArg.identity));
|
||||
},
|
||||
{
|
||||
failedHint: 'identity is not valid',
|
||||
@@ -242,18 +353,186 @@ 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',
|
||||
name: 'adminIdentityGuard',
|
||||
}
|
||||
);
|
||||
|
||||
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> {
|
||||
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: accountState.store!,
|
||||
idpClient: this.getIdpClient() as plugins.idpSdkServer.IdpGlobalServerClient | undefined,
|
||||
});
|
||||
const result = await authService.authenticate({
|
||||
email: optionsArg.username,
|
||||
password: optionsArg.password,
|
||||
authSource: optionsArg.authSource || 'auto',
|
||||
});
|
||||
return result ? this.accountToUser(result.account) : null;
|
||||
}
|
||||
|
||||
for (const [_, userData] of this.users) {
|
||||
if (userData.username === optionsArg.username && userData.password === optionsArg.password) {
|
||||
return userData;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private async resolveUser(userIdArg: string): Promise<TAdminUser | null> {
|
||||
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;
|
||||
}
|
||||
return this.accountToUser(account);
|
||||
}
|
||||
|
||||
return this.users.get(userIdArg) || null;
|
||||
}
|
||||
|
||||
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.isPersistenceEnabled()) {
|
||||
return null;
|
||||
}
|
||||
const dcRouterDb = this.opsServerRef.dcRouterRef.dcRouterDb;
|
||||
if (!dcRouterDb?.isReady()) {
|
||||
return null;
|
||||
}
|
||||
if (!this.accountStore) {
|
||||
this.accountStore = new plugins.idpSdkServer.SmartdataAccountStore({
|
||||
smartdataDb: dcRouterDb.getDb(),
|
||||
});
|
||||
}
|
||||
return this.accountStore;
|
||||
}
|
||||
|
||||
private getIdpClient(): Pick<plugins.idpSdkServer.IdpGlobalServerClient, 'loginWithEmailAndPassword' | 'stop'> | undefined {
|
||||
const configuredClient = this.opsServerRef.dcRouterRef.options.adminAuth?.idpClient;
|
||||
if (configuredClient) {
|
||||
return configuredClient;
|
||||
}
|
||||
|
||||
const baseUrl = this.opsServerRef.dcRouterRef.options.adminAuth?.idpGlobalUrl || process.env.DCROUTER_IDP_GLOBAL_URL;
|
||||
if (!this.idpClient) {
|
||||
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 true;
|
||||
}
|
||||
|
||||
private accountToUser(accountArg: plugins.idpSdkServer.IIdpSdkAccount): TAdminUser {
|
||||
return {
|
||||
id: accountArg.id,
|
||||
username: accountArg.email,
|
||||
email: accountArg.email,
|
||||
name: accountArg.name,
|
||||
role: accountArg.role,
|
||||
status: accountArg.status,
|
||||
authSources: accountArg.authSources,
|
||||
};
|
||||
}
|
||||
|
||||
private async createIdentityForUser(userArg: TAdminUser): Promise<interfaces.data.IIdentity> {
|
||||
const expiresAtTimestamp = Date.now() + 3600 * 1000 * 24; // 24 hours
|
||||
const jwt = await this.smartjwtInstance.createJWT({
|
||||
userId: userArg.id,
|
||||
status: 'loggedIn',
|
||||
expiresAt: expiresAtTimestamp,
|
||||
});
|
||||
|
||||
return {
|
||||
jwt,
|
||||
userId: userArg.id,
|
||||
name: userArg.name || userArg.username,
|
||||
expiresAt: expiresAtTimestamp,
|
||||
role: userArg.role,
|
||||
type: 'user',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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' };
|
||||
|
||||
@@ -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,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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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' };
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,8 +69,11 @@ export class TargetProfileHandler {
|
||||
domains: dataArg.domains,
|
||||
targets: dataArg.targets,
|
||||
routeRefs: dataArg.routeRefs,
|
||||
allowRoutesByClientSourceIp: dataArg.allowRoutesByClientSourceIp,
|
||||
createdBy: userId,
|
||||
});
|
||||
await this.opsServerRef.dcRouterRef.routeConfigManager?.applyRoutes();
|
||||
await this.opsServerRef.dcRouterRef.vpnManager?.refreshAllClientSecurity();
|
||||
return { success: true, id };
|
||||
},
|
||||
),
|
||||
@@ -109,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();
|
||||
|
||||
@@ -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) => {
|
||||
const users = this.opsServerRef.adminHandler.listUsers();
|
||||
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,
|
||||
});
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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' };
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
@@ -41,6 +41,13 @@ export {
|
||||
typedsocket,
|
||||
}
|
||||
|
||||
// @idp.global scope
|
||||
import * as idpSdkServer from '@idp.global/sdk/server';
|
||||
|
||||
export {
|
||||
idpSdkServer,
|
||||
}
|
||||
|
||||
// @push.rocks scope
|
||||
import * as projectinfo from '@push.rocks/projectinfo';
|
||||
import * as qenv from '@push.rocks/qenv';
|
||||
|
||||
+1
-1
@@ -91,7 +91,7 @@ Use of these trademarks must comply with Task Venture Capital GmbH's Trademark G
|
||||
|
||||
### Company Information
|
||||
|
||||
Task Venture Capital GmbH
|
||||
Task Venture Capital GmbH
|
||||
Registered at District Court Bremen HRB 35230 HB, Germany
|
||||
|
||||
For any legal inquiries or further information, please contact us via email at hello@task.vc.
|
||||
|
||||
@@ -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,
|
||||
|
||||
+212
-26
@@ -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;
|
||||
@@ -111,6 +118,7 @@ export class VpnManager {
|
||||
|
||||
const subnet = this.getSubnet();
|
||||
const wgListenPort = this.config.wgListenPort ?? 51820;
|
||||
const serverEndpoint = this.getWireGuardServerEndpoint();
|
||||
|
||||
const desiredForwardingMode = this.getDesiredForwardingMode(anyClientUsesHostIp);
|
||||
if (anyClientUsesHostIp && desiredForwardingMode === 'hybrid') {
|
||||
@@ -133,21 +141,19 @@ export class VpnManager {
|
||||
: { default: 'forceTarget' as const, target: '127.0.0.1' };
|
||||
|
||||
const serverConfig: plugins.smartvpn.IVpnServerConfig = {
|
||||
listenAddr: '0.0.0.0:0', // WS listener not strictly needed but required field
|
||||
listenAddr: '127.0.0.1:0', // Required by smartvpn, unused in wireguard-only mode
|
||||
privateKey: this.serverKeys.noisePrivateKey,
|
||||
publicKey: this.serverKeys.noisePublicKey,
|
||||
subnet,
|
||||
dns: this.config.dns,
|
||||
forwardingMode: forwardingMode as any,
|
||||
transportMode: 'all',
|
||||
transportMode: 'wireguard',
|
||||
wgPrivateKey: this.serverKeys.wgPrivateKey,
|
||||
wgListenPort,
|
||||
clients: clientEntries,
|
||||
socketForwardProxyProtocol: !isBridge,
|
||||
destinationPolicy: this.getServerDestinationPolicy(forwardingMode, defaultDestinationPolicy),
|
||||
serverEndpoint: this.config.serverEndpoint
|
||||
? `${this.config.serverEndpoint}:${wgListenPort}`
|
||||
: undefined,
|
||||
serverEndpoint,
|
||||
clientAllowedIPs: [subnet],
|
||||
// Bridge-specific config
|
||||
...(isBridge ? {
|
||||
@@ -174,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}`);
|
||||
}
|
||||
|
||||
@@ -181,15 +190,21 @@ export class VpnManager {
|
||||
* Stop the VPN server.
|
||||
*/
|
||||
public async stop(): Promise<void> {
|
||||
this.stopClientSourceIpPolling();
|
||||
if (this.vpnServer) {
|
||||
try {
|
||||
await this.vpnServer.stopServer();
|
||||
} catch {
|
||||
// Ignore stop errors
|
||||
}
|
||||
this.vpnServer.stop();
|
||||
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');
|
||||
}
|
||||
@@ -244,14 +259,11 @@ export class VpnManager {
|
||||
vlanId: doc.vlanId,
|
||||
});
|
||||
|
||||
// Override AllowedIPs with per-client values based on target profiles
|
||||
if (this.config.getClientAllowedIPs && bundle.wireguardConfig) {
|
||||
const allowedIPs = await this.config.getClientAllowedIPs(doc.targetProfileIds || []);
|
||||
bundle.wireguardConfig = bundle.wireguardConfig.replace(
|
||||
/AllowedIPs\s*=\s*.+/,
|
||||
`AllowedIPs = ${allowedIPs.join(', ')}`,
|
||||
);
|
||||
}
|
||||
bundle.wireguardConfig = await this.rewriteWireGuardAllowedIPs(
|
||||
bundle.wireguardConfig,
|
||||
doc.targetProfileIds || [],
|
||||
doc.clientId,
|
||||
);
|
||||
|
||||
// Persist client entry (including WG private key for export/QR)
|
||||
doc.clientId = bundle.entry.clientId;
|
||||
@@ -292,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();
|
||||
}
|
||||
@@ -333,6 +346,7 @@ export class VpnManager {
|
||||
client.updatedAt = Date.now();
|
||||
await this.persistClient(client);
|
||||
}
|
||||
this.clientSourceIps.delete(clientId);
|
||||
this.config.onClientChanged?.();
|
||||
}
|
||||
|
||||
@@ -381,9 +395,14 @@ export class VpnManager {
|
||||
public async rotateClientKey(clientId: string): Promise<plugins.smartvpn.IClientConfigBundle> {
|
||||
if (!this.vpnServer) throw new Error('VPN server not running');
|
||||
const bundle = await this.vpnServer.rotateClientKey(clientId);
|
||||
const client = this.clients.get(clientId);
|
||||
bundle.wireguardConfig = await this.rewriteWireGuardAllowedIPs(
|
||||
bundle.wireguardConfig,
|
||||
client?.targetProfileIds || [],
|
||||
clientId,
|
||||
);
|
||||
|
||||
// Update persisted entry with new keys (including private key for export/QR)
|
||||
const client = this.clients.get(clientId);
|
||||
if (client) {
|
||||
client.noisePublicKey = bundle.entry.publicKey;
|
||||
client.wgPublicKey = bundle.entry.wgPublicKey || '';
|
||||
@@ -414,15 +433,11 @@ export class VpnManager {
|
||||
);
|
||||
}
|
||||
|
||||
// Override AllowedIPs with per-client values based on target profiles
|
||||
if (this.config.getClientAllowedIPs) {
|
||||
const profileIds = persisted?.targetProfileIds || [];
|
||||
const allowedIPs = await this.config.getClientAllowedIPs(profileIds);
|
||||
config = config.replace(
|
||||
/AllowedIPs\s*=\s*.+/,
|
||||
`AllowedIPs = ${allowedIPs.join(', ')}`,
|
||||
);
|
||||
}
|
||||
config = await this.rewriteWireGuardAllowedIPs(
|
||||
config,
|
||||
persisted?.targetProfileIds || [],
|
||||
clientId,
|
||||
);
|
||||
}
|
||||
|
||||
return config;
|
||||
@@ -454,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.
|
||||
*/
|
||||
@@ -515,6 +631,51 @@ export class VpnManager {
|
||||
}
|
||||
}
|
||||
|
||||
private getWireGuardServerEndpoint(): string {
|
||||
const endpoint = this.config.serverEndpoint?.trim();
|
||||
if (!endpoint) {
|
||||
throw new Error('vpnConfig.serverEndpoint is required when VPN is enabled');
|
||||
}
|
||||
if (endpoint.includes('://') || endpoint.includes('/')) {
|
||||
throw new Error('vpnConfig.serverEndpoint must be a host or host:port, not a URL');
|
||||
}
|
||||
|
||||
const host = endpoint.includes(':') ? endpoint.split(':')[0] : endpoint;
|
||||
const lowerHost = host.toLowerCase();
|
||||
if (
|
||||
lowerHost === 'localhost'
|
||||
|| lowerHost === '0.0.0.0'
|
||||
|| lowerHost.startsWith('127.')
|
||||
) {
|
||||
throw new Error('vpnConfig.serverEndpoint must be reachable by VPN clients');
|
||||
}
|
||||
|
||||
return endpoint.includes(':')
|
||||
? endpoint
|
||||
: `${endpoint}:${this.config.wgListenPort ?? 51820}`;
|
||||
}
|
||||
|
||||
private async rewriteWireGuardAllowedIPs(
|
||||
wireguardConfig: string,
|
||||
targetProfileIds: string[],
|
||||
clientId?: string,
|
||||
): Promise<string> {
|
||||
if (!this.config.getClientAllowedIPs) return wireguardConfig;
|
||||
|
||||
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(', ')}`;
|
||||
|
||||
if (/^AllowedIPs\s*=.*$/m.test(wireguardConfig)) {
|
||||
return wireguardConfig.replace(/^AllowedIPs\s*=.*$/m, allowedLine);
|
||||
}
|
||||
return `${wireguardConfig.trimEnd()}\n${allowedLine}\n`;
|
||||
}
|
||||
|
||||
// ── Private helpers ────────────────────────────────────────────────────
|
||||
|
||||
private async loadOrGenerateServerKeys(): Promise<VpnServerKeysDoc> {
|
||||
@@ -532,7 +693,7 @@ export class VpnManager {
|
||||
|
||||
const noiseKeys = await tempServer.generateKeypair();
|
||||
const wgKeys = await tempServer.generateWgKeypair();
|
||||
tempServer.stop();
|
||||
await tempServer.stop();
|
||||
|
||||
const doc = stored || new VpnServerKeysDoc();
|
||||
doc.noisePrivateKey = noiseKeys.privateKey;
|
||||
@@ -556,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
|
||||
|
||||
@@ -27,7 +27,7 @@ const client = new DcRouterApiClient({
|
||||
baseUrl: 'https://dcrouter.example.com',
|
||||
});
|
||||
|
||||
await client.login('admin', 'admin');
|
||||
await client.login('admin@example.com', 'strong-password');
|
||||
|
||||
const { routes, warnings } = await client.routes.list();
|
||||
console.log(routes.length, warnings.length);
|
||||
@@ -43,13 +43,13 @@ await route.toggle(true);
|
||||
|
||||
## Authentication
|
||||
|
||||
The client supports session login and API-token authentication.
|
||||
The client supports persisted-admin session login and API-token authentication. Initial admin creation is a bootstrap flow exposed by the Ops dashboard and raw TypedRequest contracts; after a persisted admin exists, use that account with `login()`.
|
||||
|
||||
```typescript
|
||||
const sessionClient = new DcRouterApiClient({
|
||||
baseUrl: 'https://dcrouter.example.com',
|
||||
});
|
||||
await sessionClient.login('admin', 'admin');
|
||||
await sessionClient.login('admin@example.com', 'strong-password');
|
||||
|
||||
const tokenClient = new DcRouterApiClient({
|
||||
baseUrl: 'https://dcrouter.example.com',
|
||||
@@ -153,7 +153,7 @@ Use of these trademarks must comply with Task Venture Capital GmbH's Trademark G
|
||||
|
||||
### Company Information
|
||||
|
||||
Task Venture Capital GmbH
|
||||
Task Venture Capital GmbH
|
||||
Registered at District Court Bremen HRB 35230 HB, Germany
|
||||
|
||||
For any legal inquiries or further information, please contact us via email at hello@task.vc.
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -30,7 +30,7 @@ import { data, requests } from '@serve.zone/dcrouter/interfaces';
|
||||
|
||||
| Area | Examples |
|
||||
| --- | --- |
|
||||
| Auth | admin login, logout, identity verification, users |
|
||||
| Auth | admin login, first-admin bootstrap status/creation, logout, identity verification, users |
|
||||
| Routes | merged route listing, API route CRUD, toggles, warnings, ownership metadata |
|
||||
| Access | API tokens, source profiles, target profiles, network targets |
|
||||
| DNS and domains | DNS providers, domains, DNS records, ACME config |
|
||||
@@ -66,6 +66,10 @@ 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 SDK defaults to hosted `https://idp.global`; dcrouter URL settings are overrides only.
|
||||
|
||||
## When To Use It
|
||||
|
||||
- Use it in custom CLIs that call dcrouter's TypedRequest API directly.
|
||||
@@ -98,7 +102,7 @@ Use of these trademarks must comply with Task Venture Capital GmbH's Trademark G
|
||||
|
||||
### Company Information
|
||||
|
||||
Task Venture Capital GmbH
|
||||
Task Venture Capital GmbH
|
||||
Registered at District Court Bremen HRB 35230 HB, Germany
|
||||
|
||||
For any legal inquiries or further information, please contact us via email at hello@task.vc.
|
||||
|
||||
@@ -1,6 +1,18 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
import * as authInterfaces from '../data/auth.js';
|
||||
|
||||
export type TAdminLoginAuthSource = 'auto' | 'local' | 'idp.global';
|
||||
|
||||
export interface IAdminUserProjection {
|
||||
id: string;
|
||||
username: string;
|
||||
email?: string;
|
||||
name?: string;
|
||||
role: string;
|
||||
status?: 'active' | 'disabled';
|
||||
authSources?: Array<'local' | 'idp.global'>;
|
||||
}
|
||||
|
||||
// Admin Login
|
||||
export interface IReq_AdminLoginWithUsernameAndPassword extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
@@ -10,12 +22,50 @@ export interface IReq_AdminLoginWithUsernameAndPassword extends plugins.typedreq
|
||||
request: {
|
||||
username: string;
|
||||
password: string;
|
||||
authSource?: TAdminLoginAuthSource;
|
||||
};
|
||||
response: {
|
||||
identity?: authInterfaces.IIdentity;
|
||||
};
|
||||
}
|
||||
|
||||
// Admin bootstrap status
|
||||
export interface IReq_GetAdminBootstrapStatus extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_GetAdminBootstrapStatus
|
||||
> {
|
||||
method: 'getAdminBootstrapStatus';
|
||||
request: {};
|
||||
response: {
|
||||
dbEnabled: boolean;
|
||||
dbReady: boolean;
|
||||
hasPersistentAdmin: boolean;
|
||||
needsBootstrap: boolean;
|
||||
ephemeralAdminAvailable: boolean;
|
||||
idpGlobalConfigured: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
// Create the first persisted admin account. Requires the bootstrap/ephemeral admin identity.
|
||||
export interface IReq_CreateInitialAdminUser extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_CreateInitialAdminUser
|
||||
> {
|
||||
method: 'createInitialAdminUser';
|
||||
request: {
|
||||
identity: authInterfaces.IIdentity;
|
||||
email: string;
|
||||
name?: string;
|
||||
password: string;
|
||||
enableIdpGlobalAuth?: boolean;
|
||||
};
|
||||
response: {
|
||||
success: boolean;
|
||||
identity?: authInterfaces.IIdentity;
|
||||
user?: IAdminUserProjection;
|
||||
};
|
||||
}
|
||||
|
||||
// Admin Logout
|
||||
export interface IReq_AdminLogout extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
@@ -43,4 +93,4 @@ export interface IReq_VerifyIdentity extends plugins.typedrequestInterfaces.impl
|
||||
valid: boolean;
|
||||
identity?: authInterfaces.IIdentity;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,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;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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: {};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
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<
|
||||
@@ -11,13 +14,53 @@ export interface IReq_ListUsers extends plugins.typedrequestInterfaces.implement
|
||||
> {
|
||||
method: 'listUsers';
|
||||
request: {
|
||||
identity: authInterfaces.IIdentity;
|
||||
identity?: authInterfaces.IIdentity;
|
||||
apiToken?: string;
|
||||
};
|
||||
response: {
|
||||
users: Array<{
|
||||
id: string;
|
||||
username: string;
|
||||
role: string;
|
||||
}>;
|
||||
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;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -95,7 +95,7 @@ Use of these trademarks must comply with Task Venture Capital GmbH's Trademark G
|
||||
|
||||
### Company Information
|
||||
|
||||
Task Venture Capital GmbH
|
||||
Task Venture Capital GmbH
|
||||
Registered at District Court Bremen HRB 35230 HB, Germany
|
||||
|
||||
For any legal inquiries or further information, please contact us via email at hello@task.vc.
|
||||
|
||||
@@ -3,6 +3,6 @@
|
||||
*/
|
||||
export const commitinfo = {
|
||||
name: '@serve.zone/dcrouter',
|
||||
version: '13.28.0',
|
||||
version: '13.34.0',
|
||||
description: 'A multifaceted routing service handling mail and SMS delivery functions.'
|
||||
}
|
||||
|
||||
+204
-49
@@ -10,6 +10,8 @@ export interface ILoginState {
|
||||
isLoggedIn: boolean;
|
||||
}
|
||||
|
||||
export type IAdminBootstrapStatus = interfaces.requests.IReq_GetAdminBootstrapStatus['response'];
|
||||
|
||||
export interface IStatsState {
|
||||
serverStats: interfaces.data.IServerStats | null;
|
||||
emailStats: interfaces.data.IEmailStats | null;
|
||||
@@ -312,7 +314,11 @@ export const routeManagementStatePart = await appState.getStatePart<IRouteManage
|
||||
export interface IUser {
|
||||
id: string;
|
||||
username: string;
|
||||
email?: string;
|
||||
name?: string;
|
||||
role: string;
|
||||
status?: 'active' | 'disabled';
|
||||
authSources?: Array<'local' | 'idp.global'>;
|
||||
}
|
||||
|
||||
export interface IUsersState {
|
||||
@@ -351,6 +357,7 @@ const getActionContext = (): IActionContext => {
|
||||
export const loginAction = loginStatePart.createAction<{
|
||||
username: string;
|
||||
password: string;
|
||||
authSource?: interfaces.requests.TAdminLoginAuthSource;
|
||||
}>(async (statePartArg, dataArg): Promise<ILoginState> => {
|
||||
const typedRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||
interfaces.requests.IReq_AdminLoginWithUsernameAndPassword
|
||||
@@ -360,6 +367,7 @@ export const loginAction = loginStatePart.createAction<{
|
||||
const response = await typedRequest.fire({
|
||||
username: dataArg.username,
|
||||
password: dataArg.password,
|
||||
authSource: dataArg.authSource,
|
||||
});
|
||||
|
||||
if (response.identity) {
|
||||
@@ -375,6 +383,47 @@ export const loginAction = loginStatePart.createAction<{
|
||||
}
|
||||
});
|
||||
|
||||
export async function getAdminBootstrapStatus(): Promise<IAdminBootstrapStatus> {
|
||||
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||
interfaces.requests.IReq_GetAdminBootstrapStatus
|
||||
>('/typedrequest', 'getAdminBootstrapStatus');
|
||||
|
||||
return request.fire({});
|
||||
}
|
||||
|
||||
export async function createInitialAdminUser(optionsArg: {
|
||||
email: string;
|
||||
name?: string;
|
||||
password: string;
|
||||
enableIdpGlobalAuth?: boolean;
|
||||
}) {
|
||||
const context = getActionContext();
|
||||
if (!context.identity) {
|
||||
throw new Error('No identity available for admin bootstrap');
|
||||
}
|
||||
|
||||
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||
interfaces.requests.IReq_CreateInitialAdminUser
|
||||
>('/typedrequest', 'createInitialAdminUser');
|
||||
|
||||
const response = await request.fire({
|
||||
identity: context.identity,
|
||||
email: optionsArg.email,
|
||||
name: optionsArg.name,
|
||||
password: optionsArg.password,
|
||||
enableIdpGlobalAuth: optionsArg.enableIdpGlobalAuth,
|
||||
});
|
||||
|
||||
if (response.identity) {
|
||||
loginStatePart.setState({
|
||||
identity: response.identity,
|
||||
isLoggedIn: true,
|
||||
});
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
// Logout Action — always clears state, even if identity is expired/missing
|
||||
export const logoutAction = loginStatePart.createAction(async (statePartArg) => {
|
||||
const context = getActionContext();
|
||||
@@ -533,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();
|
||||
@@ -545,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
|
||||
@@ -588,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,
|
||||
@@ -598,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,
|
||||
@@ -634,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');
|
||||
@@ -644,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,
|
||||
@@ -786,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,
|
||||
@@ -1471,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 {
|
||||
@@ -1484,6 +1583,7 @@ export const createTargetProfileAction = targetProfilesStatePart.createAction<{
|
||||
domains: dataArg.domains,
|
||||
targets: dataArg.targets,
|
||||
routeRefs: dataArg.routeRefs,
|
||||
allowRoutesByClientSourceIp: dataArg.allowRoutesByClientSourceIp,
|
||||
});
|
||||
if (!response.success) {
|
||||
return {
|
||||
@@ -1507,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 {
|
||||
@@ -1521,6 +1622,7 @@ export const updateTargetProfileAction = targetProfilesStatePart.createAction<{
|
||||
domains: dataArg.domains,
|
||||
targets: dataArg.targets,
|
||||
routeRefs: dataArg.routeRefs,
|
||||
allowRoutesByClientSourceIp: dataArg.allowRoutesByClientSourceIp,
|
||||
});
|
||||
if (!response.success) {
|
||||
return {
|
||||
@@ -2588,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()!;
|
||||
@@ -2617,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[],
|
||||
@@ -2995,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);
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -66,6 +66,9 @@ export class OpsDashboard extends DeesElement {
|
||||
isLoggedIn: false,
|
||||
};
|
||||
|
||||
private bootstrapStepper?: any;
|
||||
private bootstrapCheckPromise?: Promise<void>;
|
||||
|
||||
@state() accessor uiState: appstate.IUiState = {
|
||||
activeView: 'overview',
|
||||
activeSubview: null,
|
||||
@@ -336,6 +339,7 @@ export class OpsDashboard extends DeesElement {
|
||||
await (simpleLogin as any).switchToSlottedContent();
|
||||
await appstate.statsStatePart.dispatchAction(appstate.fetchAllStatsAction, null);
|
||||
await appstate.configStatePart.dispatchAction(appstate.fetchConfigurationAction, null);
|
||||
await this.ensureAdminBootstrap();
|
||||
} else {
|
||||
// Server rejected the JWT — clear state, show login
|
||||
await appstate.loginStatePart.dispatchAction(appstate.logoutAction, null);
|
||||
@@ -370,10 +374,104 @@ export class OpsDashboard extends DeesElement {
|
||||
await simpleLogin!.switchToSlottedContent();
|
||||
await appstate.statsStatePart.dispatchAction(appstate.fetchAllStatsAction, null);
|
||||
await appstate.configStatePart.dispatchAction(appstate.fetchConfigurationAction, null);
|
||||
await this.ensureAdminBootstrap();
|
||||
} else {
|
||||
form!.setStatus('error', 'Login failed!');
|
||||
await domtools.convenience.smartdelay.delayFor(2000);
|
||||
form!.reset();
|
||||
}
|
||||
}
|
||||
|
||||
private async ensureAdminBootstrap(): Promise<void> {
|
||||
if (!this.loginState.identity || this.bootstrapStepper?.isConnected) {
|
||||
return;
|
||||
}
|
||||
if (this.bootstrapCheckPromise) {
|
||||
return this.bootstrapCheckPromise;
|
||||
}
|
||||
|
||||
this.bootstrapCheckPromise = (async () => {
|
||||
try {
|
||||
const status = await appstate.getAdminBootstrapStatus();
|
||||
if (status.needsBootstrap) {
|
||||
await this.showAdminBootstrapStepper(status);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Admin bootstrap status check failed:', error);
|
||||
} finally {
|
||||
this.bootstrapCheckPromise = undefined;
|
||||
}
|
||||
})();
|
||||
|
||||
return this.bootstrapCheckPromise;
|
||||
}
|
||||
|
||||
private async showAdminBootstrapStepper(statusArg: appstate.IAdminBootstrapStatus): Promise<void> {
|
||||
const { DeesStepper } = await import('@design.estate/dees-catalog');
|
||||
this.bootstrapStepper = await DeesStepper.createAndShow({
|
||||
cancelable: false,
|
||||
steps: [
|
||||
{
|
||||
title: 'Create Persisted Admin',
|
||||
content: html`
|
||||
<div style="display: grid; gap: 16px; color: var(--dees-color-text-secondary); font-size: 14px; line-height: 1.5;">
|
||||
<p style="margin: 0;">
|
||||
This router is currently using the temporary bootstrap admin. Create the first persisted admin account to continue.
|
||||
</p>
|
||||
<dees-form>
|
||||
<dees-input-text .key=${'email'} .label=${'Admin email'} .required=${true}></dees-input-text>
|
||||
<dees-input-text .key=${'name'} .label=${'Display name'}></dees-input-text>
|
||||
<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>
|
||||
</div>
|
||||
`,
|
||||
menuOptions: [
|
||||
{
|
||||
name: 'Create admin',
|
||||
action: async (stepperArg: any) => {
|
||||
const form = stepperArg.shadowRoot?.querySelector('.selected dees-form') as any;
|
||||
if (!form) return;
|
||||
const formData = await form.collectFormData();
|
||||
const email = String(formData.email || '').trim();
|
||||
const name = String(formData.name || '').trim();
|
||||
const password = String(formData.password || '');
|
||||
const passwordConfirm = String(formData.passwordConfirm || '');
|
||||
|
||||
if (!email || !password) {
|
||||
form.setStatus?.('error', 'Email and password are required.');
|
||||
return;
|
||||
}
|
||||
if (password !== passwordConfirm) {
|
||||
form.setStatus?.('error', 'Passwords do not match.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
form.setStatus?.('pending', 'Creating persisted admin...');
|
||||
await appstate.createInitialAdminUser({
|
||||
email,
|
||||
name,
|
||||
password,
|
||||
enableIdpGlobalAuth: Boolean(formData.enableIdpGlobalAuth),
|
||||
});
|
||||
form.setStatus?.('success', 'Persisted admin created.');
|
||||
await stepperArg.destroy();
|
||||
this.bootstrapStepper = undefined;
|
||||
await appstate.usersStatePart.dispatchAction(appstate.fetchUsersAction, null);
|
||||
} catch (error) {
|
||||
form.setStatus?.('error', error instanceof Error ? error.message : 'Failed to create admin.');
|
||||
}
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
+5
-3
@@ -12,8 +12,8 @@ For reporting bugs, issues, or security vulnerabilities, please visit [community
|
||||
| --- | --- |
|
||||
| `index.ts` | Initializes the app router and renders `<ops-dashboard>` into `document.body`. |
|
||||
| `router.ts` | Defines top-level dashboard routes, subviews, redirects, and URL/state synchronization. |
|
||||
| `appstate.ts` | Holds reactive login, UI, config, stats, route, DNS, email, remote ingress, VPN, and log state. |
|
||||
| `elements/` | Contains the dashboard shell and feature-specific Dees web components. |
|
||||
| `appstate.ts` | Holds reactive login, bootstrap, UI, config, stats, route, DNS, email, remote ingress, VPN, and log state. |
|
||||
| `elements/` | Contains the dashboard shell, first-admin bootstrap stepper, and feature-specific Dees web components. |
|
||||
|
||||
## View Map
|
||||
|
||||
@@ -37,6 +37,8 @@ The dashboard talks to the dcrouter OpsServer through:
|
||||
- Dees web components and app-state subscriptions for UI updates
|
||||
- QR code rendering for VPN client UX
|
||||
|
||||
On a fresh DB-backed instance, the dashboard checks `getAdminBootstrapStatus` and shows a non-cancelable first-admin stepper before normal dashboard access.
|
||||
|
||||
## Usage
|
||||
|
||||
This package is primarily consumed by the main dcrouter build and served by OpsServer. Install it directly only when you intentionally need the dashboard module boundary.
|
||||
@@ -79,7 +81,7 @@ Use of these trademarks must comply with Task Venture Capital GmbH's Trademark G
|
||||
|
||||
### Company Information
|
||||
|
||||
Task Venture Capital GmbH
|
||||
Task Venture Capital GmbH
|
||||
Registered at District Court Bremen HRB 35230 HB, Germany
|
||||
|
||||
For any legal inquiries or further information, please contact us via email at hello@task.vc.
|
||||
|
||||
Reference in New Issue
Block a user