From 11ca64a1cd84b155ec3147516d572587bcf2077e Mon Sep 17 00:00:00 2001 From: Juergen Kunz Date: Tue, 31 Mar 2026 09:53:37 +0000 Subject: [PATCH] feat(vpn): add VPN client editing and connected client visibility in ops server --- changelog.md | 8 ++ package.json | 2 +- pnpm-lock.yaml | 10 +- readme.md | 9 +- readme.storage.md | 120 ++++++++++++++++++++ ts/00_commitinfo_data.ts | 2 +- ts/opsserver/handlers/vpn.handler.ts | 48 ++++++++ ts/vpn/classes.vpn-manager.ts | 16 +++ ts_interfaces/data/vpn.ts | 12 ++ ts_interfaces/requests/vpn.ts | 38 ++++++- ts_web/00_commitinfo_data.ts | 2 +- ts_web/appstate.ts | 43 ++++++- ts_web/elements/ops-view-vpn.ts | 164 ++++++++++++++++++++++++--- ts_web/readme.md | 3 +- 14 files changed, 447 insertions(+), 30 deletions(-) create mode 100644 readme.storage.md diff --git a/changelog.md b/changelog.md index 2ca4387..847ec05 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,13 @@ # Changelog +## 2026-03-31 - 11.22.0 - feat(vpn) +add VPN client editing and connected client visibility in ops server + +- Adds API support to list currently connected VPN clients and update client metadata without rotating keys +- Updates the web VPN view to show live connection status, client detail telemetry, and separate enable/disable actions +- Refreshes documentation for smart split tunnel behavior, QR code setup/export, and storage architecture +- Bumps @push.rocks/smartvpn from 1.16.4 to 1.16.5 + ## 2026-03-31 - 11.21.5 - fix(routing) apply VPN route allowlists dynamically after VPN clients load diff --git a/package.json b/package.json index 574ee95..991a542 100644 --- a/package.json +++ b/package.json @@ -59,7 +59,7 @@ "@push.rocks/smartrx": "^3.0.10", "@push.rocks/smartstate": "^2.3.0", "@push.rocks/smartunique": "^3.0.9", - "@push.rocks/smartvpn": "1.16.4", + "@push.rocks/smartvpn": "1.16.5", "@push.rocks/taskbuffer": "^8.0.2", "@serve.zone/catalog": "^2.9.0", "@serve.zone/interfaces": "^5.3.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index dbcac6b..b33aeb2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -96,8 +96,8 @@ importers: specifier: ^3.0.9 version: 3.0.9 '@push.rocks/smartvpn': - specifier: 1.16.4 - version: 1.16.4 + specifier: 1.16.5 + version: 1.16.5 '@push.rocks/taskbuffer': specifier: ^8.0.2 version: 8.0.2 @@ -1339,8 +1339,8 @@ packages: '@push.rocks/smartversion@3.0.5': resolution: {integrity: sha512-8MZSo1yqyaKxKq0Q5N188l4un++9GFWVbhCAX5mXJwewZHn97ujffTeL+eOQYpWFTEpUhaq1QhL4NhqObBCt1Q==} - '@push.rocks/smartvpn@1.16.4': - resolution: {integrity: sha512-ps7NcdBzaaGQFjHcXUN8JC623xZbLNyIYfICxDLJb2BxzzuZa667fW0KxQQCwLtZaB2txN5sMlaOKFi27tXTBA==} + '@push.rocks/smartvpn@1.16.5': + resolution: {integrity: sha512-wUau/Ad18p36AeIF5R33S45WEM78R7Y4SZSkWdxMdvKNIqSfn1mhf4Zd57iAtxvq+2iO246xfifBrATZWfjPeQ==} '@push.rocks/smartwatch@6.4.0': resolution: {integrity: sha512-KDswRgE/siBmZRCsRA07MtW5oF4c9uQEBkwTGPIWneHzksbCDsvs/7agKFEL7WnNifLNwo8w1K1qoiVWkX1fvw==} @@ -6622,7 +6622,7 @@ snapshots: '@types/semver': 7.7.1 semver: 7.7.4 - '@push.rocks/smartvpn@1.16.4': + '@push.rocks/smartvpn@1.16.5': dependencies: '@push.rocks/smartnftables': 1.1.0 '@push.rocks/smartpath': 6.0.0 diff --git a/readme.md b/readme.md index 0bdd501..07e4cbc 100644 --- a/readme.md +++ b/readme.md @@ -1030,8 +1030,8 @@ DcRouter integrates [`@push.rocks/smartvpn`](https://code.foss.global/push.rocks 1. **SmartVPN daemon** runs inside dcrouter with a Rust data plane (WireGuard via `boringtun`, custom protocol via Noise IK) 2. Clients connect and get assigned an IP from the VPN subnet (e.g. `10.8.0.0/24`) -3. **Split tunnel** by default — generated WireGuard configs only route VPN subnet traffic through the tunnel (`AllowedIPs = 10.8.0.0/24`), so regular internet traffic stays direct -4. Routes with `vpn: { required: true }` get `security.ipAllowList` automatically injected +3. **Smart split tunnel** — generated WireGuard configs auto-include the VPN subnet plus DNS-resolved IPs of VPN-gated domains. Domains from routes with `vpn.required` are resolved at config generation time, so clients route only the necessary traffic through the tunnel +4. Routes with `vpn: { required: true }` get `security.ipAllowList` dynamically injected (re-computed on every client change) 5. When `allowedServerDefinedClientTags` is set, only matching client IPs are injected (not the whole subnet) 6. SmartProxy enforces the allowlist — only authorized VPN clients can access protected routes 7. All VPN traffic is forced through SmartProxy via userspace NAT with PROXY protocol v2 — no root required @@ -1136,13 +1136,14 @@ Routes with `allowedServerDefinedClientTags` only permit VPN clients whose admin The OpsServer dashboard and API provide full VPN client lifecycle management: - **Create client** — generates WireGuard keypairs, assigns IP, returns a ready-to-use `.conf` file +- **QR code** — scan with the WireGuard mobile app (iOS/Android) for instant setup - **Enable / Disable** — toggle client access without deleting - **Rotate keys** — generate fresh keypairs (invalidates old ones) -- **Export config** — download in WireGuard (`.conf`) or SmartVPN (`.json`) format +- **Export config** — download in WireGuard (`.conf`), SmartVPN (`.json`), or scan as QR code - **Telemetry** — per-client bytes sent/received, keepalives, rate limiting - **Delete** — remove a client and revoke access -Standard WireGuard clients on any platform (iOS, Android, macOS, Windows, Linux) can connect using the generated `.conf` file — no custom VPN software needed. +Standard WireGuard clients on any platform (iOS, Android, macOS, Windows, Linux) can connect using the generated `.conf` file or by scanning the QR code — no custom VPN software needed. ## Certificate Management diff --git a/readme.storage.md b/readme.storage.md new file mode 100644 index 0000000..ad0ef6e --- /dev/null +++ b/readme.storage.md @@ -0,0 +1,120 @@ +# DCRouter Storage Overview + +DCRouter uses two complementary storage systems: **StorageManager** for configuration and state, and **CacheDb** for time-limited cached data. + +## StorageManager (Key-Value Store) + +A lightweight, pluggable key-value store for configuration, credentials, and runtime state. Data is persisted as flat JSON files on disk by default. + +### Default Path + +``` +~/.serve.zone/dcrouter/storage/ +``` + +Configurable via `options.storage.fsPath` or `options.baseDir`. + +### Backends + +```typescript +// Filesystem (default) +storage: { fsPath: '/var/lib/dcrouter/data' } + +// Custom (Redis, S3, etc.) +storage: { + readFunction: async (key) => await redis.get(key), + writeFunction: async (key, value) => await redis.set(key, value), +} + +// In-memory (omit storage config — data lost on restart) +``` + +### What's Stored + +| Prefix | Contents | Managed By | +|--------|----------|------------| +| `/vpn/server-keys` | VPN server Noise + WireGuard keypairs | `VpnManager` | +| `/vpn/clients/{clientId}` | VPN client registrations (keys, tags, description, assigned IP) | `VpnManager` | +| `/config-api/routes/{uuid}.json` | Programmatic routes (created via OpsServer API) | `RouteConfigManager` | +| `/config-api/tokens/{uuid}.json` | API tokens (hashed secrets, scopes, expiry) | `ApiTokenManager` | +| `/config-api/overrides/{routeName}.json` | Hardcoded route overrides (enable/disable) | `RouteConfigManager` | +| `/email/bounces/suppression-list.json` | Email bounce suppression list | `smartmta` | +| `/certs/*` | TLS certificates and ACME state | `SmartAcme` (via `StorageBackedCertManager`) | + +### API + +```typescript +// Read/write JSON +await storageManager.getJSON(key); +await storageManager.setJSON(key, value); + +// Raw string read/write +await storageManager.get(key); +await storageManager.set(key, value); + +// List keys by prefix +await storageManager.list('/vpn/clients/'); + +// Delete +await storageManager.delete(key); +``` + +## CacheDb (Embedded MongoDB) + +An embedded MongoDB-compatible database (via `@push.rocks/smartdb` + `@push.rocks/smartdata`) for cached data with automatic TTL-based cleanup. + +### Default Path + +``` +~/.serve.zone/dcrouter/tsmdb/ +``` + +Configurable via `options.cacheConfig.storagePath`. + +### What's Cached + +| Document Type | Default TTL | Purpose | +|--------------|-------------|---------| +| `CachedEmail` | 30 days | Email metadata cache for dashboard display | +| `CachedIPReputation` | 1 day | IP reputation lookup results (DNSBL checks) | + +### Configuration + +```typescript +cacheConfig: { + enabled: true, // default: true + storagePath: '~/.serve.zone/dcrouter/tsmdb', // default + dbName: 'dcrouter', // default + cleanupIntervalHours: 1, // how often to purge expired records + ttlConfig: { + emails: 30, // days + ipReputation: 1, // days + bounces: 30, // days (reserved) + dkimKeys: 90, // days (reserved) + suppression: 30, // days (reserved) + }, +} +``` + +### How It Works + +1. `CacheDb` starts a `LocalSmartDb` instance (embedded MongoDB process) +2. `smartdata` connects to it via Unix socket +3. Document classes (`CachedEmail`, `CachedIPReputation`) are decorated with `@Collection` and use `smartdata` ORM +4. `CacheCleaner` runs on a timer, purging records older than their configured TTL + +### Disabling + +For development or lightweight deployments, disable the cache to avoid starting a MongoDB process: + +```typescript +cacheConfig: { enabled: false } +``` + +## When to Use Which + +| Use Case | System | Why | +|----------|--------|-----| +| VPN keys, API tokens, routes, certs | **StorageManager** | Small JSON blobs, key-value access, no queries needed | +| Email metadata, IP reputation | **CacheDb** | Time-series data, TTL expiry, potential for queries/aggregation | +| Runtime state (connected clients, metrics) | **Neither** | In-memory only, rebuilt on startup | diff --git a/ts/00_commitinfo_data.ts b/ts/00_commitinfo_data.ts index f6f9f41..ce31aad 100644 --- a/ts/00_commitinfo_data.ts +++ b/ts/00_commitinfo_data.ts @@ -3,6 +3,6 @@ */ export const commitinfo = { name: '@serve.zone/dcrouter', - version: '11.21.5', + version: '11.22.0', description: 'A multifaceted routing service handling mail and SMS delivery functions.' } diff --git a/ts/opsserver/handlers/vpn.handler.ts b/ts/opsserver/handlers/vpn.handler.ts index c960941..fecabd8 100644 --- a/ts/opsserver/handlers/vpn.handler.ts +++ b/ts/opsserver/handlers/vpn.handler.ts @@ -72,6 +72,31 @@ export class VpnHandler { ), ); + // Get currently connected VPN clients + viewRouter.addTypedHandler( + new plugins.typedrequest.TypedHandler( + 'getVpnConnectedClients', + async (dataArg, toolsArg) => { + const manager = this.opsServerRef.dcRouterRef.vpnManager; + if (!manager) { + return { connectedClients: [] }; + } + + const connected = await manager.getConnectedClients(); + return { + connectedClients: connected.map((c) => ({ + clientId: c.registeredClientId || c.clientId, + assignedIp: c.assignedIp, + connectedSince: c.connectedSince, + bytesSent: c.bytesSent, + bytesReceived: c.bytesReceived, + transport: c.transportType, + })), + }; + }, + ), + ); + // ---- Write endpoints (adminRouter — admin identity required via middleware) ---- // Create a new VPN client @@ -112,6 +137,29 @@ export class VpnHandler { ), ); + // Update a VPN client's metadata + adminRouter.addTypedHandler( + new plugins.typedrequest.TypedHandler( + 'updateVpnClient', + async (dataArg, toolsArg) => { + const manager = this.opsServerRef.dcRouterRef.vpnManager; + if (!manager) { + return { success: false, message: 'VPN not configured' }; + } + + try { + await manager.updateClient(dataArg.clientId, { + description: dataArg.description, + serverDefinedClientTags: dataArg.serverDefinedClientTags, + }); + return { success: true }; + } catch (err: unknown) { + return { success: false, message: (err as Error).message }; + } + }, + ), + ); + // Delete a VPN client adminRouter.addTypedHandler( new plugins.typedrequest.TypedHandler( diff --git a/ts/vpn/classes.vpn-manager.ts b/ts/vpn/classes.vpn-manager.ts index f30bf66..7f0dc49 100644 --- a/ts/vpn/classes.vpn-manager.ts +++ b/ts/vpn/classes.vpn-manager.ts @@ -275,6 +275,22 @@ export class VpnManager { this.config.onClientChanged?.(); } + /** + * Update a client's metadata (description, tags) without rotating keys. + */ + public async updateClient(clientId: string, update: { + description?: string; + serverDefinedClientTags?: string[]; + }): Promise { + const client = this.clients.get(clientId); + if (!client) throw new Error(`Client not found: ${clientId}`); + if (update.description !== undefined) client.description = update.description; + if (update.serverDefinedClientTags !== undefined) client.serverDefinedClientTags = update.serverDefinedClientTags; + client.updatedAt = Date.now(); + await this.persistClient(client); + this.config.onClientChanged?.(); + } + /** * Rotate a client's keys. Returns the new config bundle. */ diff --git a/ts_interfaces/data/vpn.ts b/ts_interfaces/data/vpn.ts index df6a267..ee6dcb7 100644 --- a/ts_interfaces/data/vpn.ts +++ b/ts_interfaces/data/vpn.ts @@ -27,6 +27,18 @@ export interface IVpnServerStatus { connectedClients: number; } +/** + * A currently connected VPN client (runtime info from the daemon). + */ +export interface IVpnConnectedClient { + clientId: string; + assignedIp: string; + connectedSince: string; + bytesSent: number; + bytesReceived: number; + transport: string; +} + /** * VPN client telemetry data. */ diff --git a/ts_interfaces/requests/vpn.ts b/ts_interfaces/requests/vpn.ts index 4582ab8..c9cb38a 100644 --- a/ts_interfaces/requests/vpn.ts +++ b/ts_interfaces/requests/vpn.ts @@ -1,6 +1,6 @@ import * as plugins from '../plugins.js'; import * as authInterfaces from '../data/auth.js'; -import type { IVpnClient, IVpnServerStatus, IVpnClientTelemetry } from '../data/vpn.js'; +import type { IVpnClient, IVpnServerStatus, IVpnClientTelemetry, IVpnConnectedClient } from '../data/vpn.js'; // ============================================================================ // VPN Client Management @@ -61,6 +61,42 @@ export interface IReq_CreateVpnClient extends plugins.typedrequestInterfaces.imp }; } +/** + * Update a VPN client's metadata (description, tags) without rotating keys. + */ +export interface IReq_UpdateVpnClient extends plugins.typedrequestInterfaces.implementsTR< + plugins.typedrequestInterfaces.ITypedRequest, + IReq_UpdateVpnClient +> { + method: 'updateVpnClient'; + request: { + identity: authInterfaces.IIdentity; + clientId: string; + description?: string; + serverDefinedClientTags?: string[]; + }; + response: { + success: boolean; + message?: string; + }; +} + +/** + * Get currently connected VPN clients. + */ +export interface IReq_GetVpnConnectedClients extends plugins.typedrequestInterfaces.implementsTR< + plugins.typedrequestInterfaces.ITypedRequest, + IReq_GetVpnConnectedClients +> { + method: 'getVpnConnectedClients'; + request: { + identity: authInterfaces.IIdentity; + }; + response: { + connectedClients: IVpnConnectedClient[]; + }; +} + /** * Delete a VPN client. */ diff --git a/ts_web/00_commitinfo_data.ts b/ts_web/00_commitinfo_data.ts index f6f9f41..ce31aad 100644 --- a/ts_web/00_commitinfo_data.ts +++ b/ts_web/00_commitinfo_data.ts @@ -3,6 +3,6 @@ */ export const commitinfo = { name: '@serve.zone/dcrouter', - version: '11.21.5', + version: '11.22.0', description: 'A multifaceted routing service handling mail and SMS delivery functions.' } diff --git a/ts_web/appstate.ts b/ts_web/appstate.ts index e75cb24..e4d10d1 100644 --- a/ts_web/appstate.ts +++ b/ts_web/appstate.ts @@ -911,6 +911,7 @@ export const toggleRemoteIngressAction = remoteIngressStatePart.createAction<{ export interface IVpnState { clients: interfaces.data.IVpnClient[]; + connectedClients: interfaces.data.IVpnConnectedClient[]; status: interfaces.data.IVpnServerStatus | null; isLoading: boolean; error: string | null; @@ -923,6 +924,7 @@ export const vpnStatePart = await appState.getStatePart( 'vpn', { clients: [], + connectedClients: [], status: null, isLoading: false, error: null, @@ -950,14 +952,20 @@ export const fetchVpnAction = vpnStatePart.createAction(async (statePartArg): Pr interfaces.requests.IReq_GetVpnStatus >('/typedrequest', 'getVpnStatus'); - const [clientsResponse, statusResponse] = await Promise.all([ + const connectedRequest = new plugins.domtools.plugins.typedrequest.TypedRequest< + interfaces.requests.IReq_GetVpnConnectedClients + >('/typedrequest', 'getVpnConnectedClients'); + + const [clientsResponse, statusResponse, connectedResponse] = await Promise.all([ clientsRequest.fire({ identity: context.identity }), statusRequest.fire({ identity: context.identity }), + connectedRequest.fire({ identity: context.identity }), ]); return { ...currentState, clients: clientsResponse.clients, + connectedClients: connectedResponse.connectedClients, status: statusResponse.status, isLoading: false, error: null, @@ -1054,6 +1062,39 @@ export const toggleVpnClientAction = vpnStatePart.createAction<{ } }); +export const updateVpnClientAction = vpnStatePart.createAction<{ + clientId: string; + description?: string; + serverDefinedClientTags?: string[]; +}>(async (statePartArg, dataArg, actionContext): Promise => { + const context = getActionContext(); + const currentState = statePartArg.getState()!; + + try { + const request = new plugins.domtools.plugins.typedrequest.TypedRequest< + interfaces.requests.IReq_UpdateVpnClient + >('/typedrequest', 'updateVpnClient'); + + const response = await request.fire({ + identity: context.identity!, + clientId: dataArg.clientId, + description: dataArg.description, + serverDefinedClientTags: dataArg.serverDefinedClientTags, + }); + + if (!response.success) { + return { ...currentState, error: response.message || 'Failed to update client' }; + } + + return await actionContext!.dispatch(fetchVpnAction, null); + } catch (error: unknown) { + return { + ...currentState, + error: error instanceof Error ? error.message : 'Failed to update VPN client', + }; + } +}); + export const clearNewClientConfigAction = vpnStatePart.createAction( async (statePartArg): Promise => { return { ...statePartArg.getState()!, newClientConfig: null }; diff --git a/ts_web/elements/ops-view-vpn.ts b/ts_web/elements/ops-view-vpn.ts index a443a08..cb4bb73 100644 --- a/ts_web/elements/ops-view-vpn.ts +++ b/ts_web/elements/ops-view-vpn.ts @@ -141,10 +141,16 @@ export class OpsViewVpn extends DeesElement { `, ]; + /** Look up connected client info by clientId */ + private getConnectedInfo(clientId: string): interfaces.data.IVpnConnectedClient | undefined { + return this.vpnState.connectedClients?.find(c => c.clientId === clientId); + } + render(): TemplateResult { const status = this.vpnState.status; const clients = this.vpnState.clients; - const connectedCount = status?.connectedClients ?? 0; + const connectedClients = this.vpnState.connectedClients || []; + const connectedCount = connectedClients.length; const totalClients = clients.length; const enabledClients = clients.filter(c => c.enabled).length; @@ -270,18 +276,28 @@ export class OpsViewVpn extends DeesElement { .heading1=${'VPN Clients'} .heading2=${'Manage WireGuard and SmartVPN client registrations'} .data=${clients} - .displayFunction=${(client: interfaces.data.IVpnClient) => ({ - 'Client ID': client.clientId, - 'Status': client.enabled - ? html`enabled` - : html`disabled`, - 'VPN IP': client.assignedIp || '-', - 'Tags': client.serverDefinedClientTags?.length - ? html`${client.serverDefinedClientTags.map(t => html`${t}`)}` - : '-', - 'Description': client.description || '-', - 'Created': new Date(client.createdAt).toLocaleDateString(), - })} + .displayFunction=${(client: interfaces.data.IVpnClient) => { + const conn = this.getConnectedInfo(client.clientId); + let statusHtml; + if (!client.enabled) { + statusHtml = html`disabled`; + } else if (conn) { + const since = new Date(conn.connectedSince).toLocaleString(); + statusHtml = html`connected`; + } else { + statusHtml = html`offline`; + } + return { + 'Client ID': client.clientId, + 'Status': statusHtml, + 'VPN IP': client.assignedIp || '-', + 'Tags': client.serverDefinedClientTags?.length + ? html`${client.serverDefinedClientTags.map(t => html`${t}`)}` + : '-', + 'Description': client.description || '-', + 'Created': new Date(client.createdAt).toLocaleDateString(), + }; + }} .dataActions=${[ { name: 'Create Client', @@ -328,14 +344,91 @@ export class OpsViewVpn extends DeesElement { }, }, { - name: 'Toggle', + name: 'Detail', + iconName: 'lucide:info', + type: ['doubleClick'], + actionFunc: async (actionData: any) => { + const client = actionData.item as interfaces.data.IVpnClient; + const conn = this.getConnectedInfo(client.clientId); + const { DeesModal } = await import('@design.estate/dees-catalog'); + + // Fetch telemetry on-demand + let telemetryHtml = html`

Loading telemetry...

`; + try { + const request = new plugins.domtools.plugins.typedrequest.TypedRequest< + interfaces.requests.IReq_GetVpnClientTelemetry + >('/typedrequest', 'getVpnClientTelemetry'); + const response = await request.fire({ + identity: appstate.loginStatePart.getState()!.identity!, + clientId: client.clientId, + }); + const t = response.telemetry; + if (t) { + const formatBytes = (b: number) => b > 1048576 ? `${(b / 1048576).toFixed(1)} MB` : b > 1024 ? `${(b / 1024).toFixed(1)} KB` : `${b} B`; + telemetryHtml = html` +
+
Bytes Sent${formatBytes(t.bytesSent)}
+
Bytes Received${formatBytes(t.bytesReceived)}
+
Keepalives${t.keepalivesReceived}
+
Last Keepalive${t.lastKeepaliveAt ? new Date(t.lastKeepaliveAt).toLocaleString() : '-'}
+
Packets Dropped${t.packetsDropped}
+
+ `; + } else { + telemetryHtml = html`

No telemetry available (client not connected)

`; + } + } catch { + telemetryHtml = html`

Telemetry unavailable

`; + } + + DeesModal.createAndShow({ + heading: `Client: ${client.clientId}`, + content: html` +
+
Client ID${client.clientId}
+
VPN IP${client.assignedIp || '-'}
+
Status${!client.enabled ? 'Disabled' : conn ? 'Connected' : 'Offline'}
+ ${conn ? html` +
Connected Since${new Date(conn.connectedSince).toLocaleString()}
+
Transport${conn.transport}
+ ` : ''} +
Description${client.description || '-'}
+
Tags${client.serverDefinedClientTags?.join(', ') || '-'}
+
Created${new Date(client.createdAt).toLocaleString()}
+
Updated${new Date(client.updatedAt).toLocaleString()}
+
+

Telemetry

+ ${telemetryHtml} + `, + menuOptions: [ + { name: 'Close', iconName: 'lucide:x', action: async (m: any) => await m.destroy() }, + ], + }); + }, + }, + { + name: 'Enable', iconName: 'lucide:power', type: ['contextmenu', 'inRow'], + actionRelevancyCheckFunc: (actionData: any) => !actionData.item.enabled, actionFunc: async (actionData: any) => { const client = actionData.item as interfaces.data.IVpnClient; await appstate.vpnStatePart.dispatchAction(appstate.toggleVpnClientAction, { clientId: client.clientId, - enabled: !client.enabled, + enabled: true, + }); + }, + }, + { + name: 'Disable', + iconName: 'lucide:power', + type: ['contextmenu', 'inRow'], + actionRelevancyCheckFunc: (actionData: any) => actionData.item.enabled, + actionFunc: async (actionData: any) => { + const client = actionData.item as interfaces.data.IVpnClient; + await appstate.vpnStatePart.dispatchAction(appstate.toggleVpnClientAction, { + clientId: client.clientId, + enabled: false, }); }, }, @@ -449,6 +542,47 @@ export class OpsViewVpn extends DeesElement { }); }, }, + { + name: 'Edit', + iconName: 'lucide:pencil', + type: ['contextmenu', 'inRow'], + actionFunc: async (actionData: any) => { + const client = actionData.item as interfaces.data.IVpnClient; + const { DeesModal } = await import('@design.estate/dees-catalog'); + const currentDescription = client.description ?? ''; + const currentTags = client.serverDefinedClientTags?.join(', ') ?? ''; + DeesModal.createAndShow({ + heading: `Edit: ${client.clientId}`, + content: html` + + + + + `, + menuOptions: [ + { name: 'Cancel', iconName: 'lucide:x', action: async (modalArg: any) => await modalArg.destroy() }, + { + name: 'Save', + iconName: 'lucide:check', + action: async (modalArg: any) => { + const form = modalArg.shadowRoot?.querySelector('.content')?.querySelector('dees-form'); + if (!form) return; + const data = await form.collectFormData(); + const serverDefinedClientTags = data.tags + ? data.tags.split(',').map((t: string) => t.trim()).filter(Boolean) + : []; + await appstate.vpnStatePart.dispatchAction(appstate.updateVpnClientAction, { + clientId: client.clientId, + description: data.description || undefined, + serverDefinedClientTags, + }); + await modalArg.destroy(); + }, + }, + ], + }); + }, + }, { name: 'Rotate Keys', iconName: 'lucide:rotate-cw', diff --git a/ts_web/readme.md b/ts_web/readme.md index f121175..05dc50e 100644 --- a/ts_web/readme.md +++ b/ts_web/readme.md @@ -53,7 +53,8 @@ For reporting bugs, issues, or security vulnerabilities, please visit [community ### 🔐 VPN Management - VPN server status with forwarding mode, subnet, and WireGuard port - Client registration table with create, enable/disable, and delete actions -- WireGuard config download and clipboard copy on client creation +- WireGuard config download, clipboard copy, and **QR code display** on client creation +- QR code export for existing clients — scan with WireGuard mobile app (iOS/Android) - Per-client telemetry (bytes sent/received, keepalives) - Server public key display for manual client configuration