@serve.zone/dcrouter 11.21.4 → 11.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -72,6 +72,31 @@ export class VpnHandler {
72
72
  ),
73
73
  );
74
74
 
75
+ // Get currently connected VPN clients
76
+ viewRouter.addTypedHandler(
77
+ new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetVpnConnectedClients>(
78
+ 'getVpnConnectedClients',
79
+ async (dataArg, toolsArg) => {
80
+ const manager = this.opsServerRef.dcRouterRef.vpnManager;
81
+ if (!manager) {
82
+ return { connectedClients: [] };
83
+ }
84
+
85
+ const connected = await manager.getConnectedClients();
86
+ return {
87
+ connectedClients: connected.map((c) => ({
88
+ clientId: c.registeredClientId || c.clientId,
89
+ assignedIp: c.assignedIp,
90
+ connectedSince: c.connectedSince,
91
+ bytesSent: c.bytesSent,
92
+ bytesReceived: c.bytesReceived,
93
+ transport: c.transportType,
94
+ })),
95
+ };
96
+ },
97
+ ),
98
+ );
99
+
75
100
  // ---- Write endpoints (adminRouter — admin identity required via middleware) ----
76
101
 
77
102
  // Create a new VPN client
@@ -112,6 +137,29 @@ export class VpnHandler {
112
137
  ),
113
138
  );
114
139
 
140
+ // Update a VPN client's metadata
141
+ adminRouter.addTypedHandler(
142
+ new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_UpdateVpnClient>(
143
+ 'updateVpnClient',
144
+ async (dataArg, toolsArg) => {
145
+ const manager = this.opsServerRef.dcRouterRef.vpnManager;
146
+ if (!manager) {
147
+ return { success: false, message: 'VPN not configured' };
148
+ }
149
+
150
+ try {
151
+ await manager.updateClient(dataArg.clientId, {
152
+ description: dataArg.description,
153
+ serverDefinedClientTags: dataArg.serverDefinedClientTags,
154
+ });
155
+ return { success: true };
156
+ } catch (err: unknown) {
157
+ return { success: false, message: (err as Error).message };
158
+ }
159
+ },
160
+ ),
161
+ );
162
+
115
163
  // Delete a VPN client
116
164
  adminRouter.addTypedHandler(
117
165
  new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_DeleteVpnClient>(
@@ -275,6 +275,22 @@ export class VpnManager {
275
275
  this.config.onClientChanged?.();
276
276
  }
277
277
 
278
+ /**
279
+ * Update a client's metadata (description, tags) without rotating keys.
280
+ */
281
+ public async updateClient(clientId: string, update: {
282
+ description?: string;
283
+ serverDefinedClientTags?: string[];
284
+ }): Promise<void> {
285
+ const client = this.clients.get(clientId);
286
+ if (!client) throw new Error(`Client not found: ${clientId}`);
287
+ if (update.description !== undefined) client.description = update.description;
288
+ if (update.serverDefinedClientTags !== undefined) client.serverDefinedClientTags = update.serverDefinedClientTags;
289
+ client.updatedAt = Date.now();
290
+ await this.persistClient(client);
291
+ this.config.onClientChanged?.();
292
+ }
293
+
278
294
  /**
279
295
  * Rotate a client's keys. Returns the new config bundle.
280
296
  */
@@ -3,6 +3,6 @@
3
3
  */
4
4
  export const commitinfo = {
5
5
  name: '@serve.zone/dcrouter',
6
- version: '11.21.4',
6
+ version: '11.22.0',
7
7
  description: 'A multifaceted routing service handling mail and SMS delivery functions.'
8
8
  }
@@ -911,6 +911,7 @@ export const toggleRemoteIngressAction = remoteIngressStatePart.createAction<{
911
911
 
912
912
  export interface IVpnState {
913
913
  clients: interfaces.data.IVpnClient[];
914
+ connectedClients: interfaces.data.IVpnConnectedClient[];
914
915
  status: interfaces.data.IVpnServerStatus | null;
915
916
  isLoading: boolean;
916
917
  error: string | null;
@@ -923,6 +924,7 @@ export const vpnStatePart = await appState.getStatePart<IVpnState>(
923
924
  'vpn',
924
925
  {
925
926
  clients: [],
927
+ connectedClients: [],
926
928
  status: null,
927
929
  isLoading: false,
928
930
  error: null,
@@ -950,14 +952,20 @@ export const fetchVpnAction = vpnStatePart.createAction(async (statePartArg): Pr
950
952
  interfaces.requests.IReq_GetVpnStatus
951
953
  >('/typedrequest', 'getVpnStatus');
952
954
 
953
- const [clientsResponse, statusResponse] = await Promise.all([
955
+ const connectedRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
956
+ interfaces.requests.IReq_GetVpnConnectedClients
957
+ >('/typedrequest', 'getVpnConnectedClients');
958
+
959
+ const [clientsResponse, statusResponse, connectedResponse] = await Promise.all([
954
960
  clientsRequest.fire({ identity: context.identity }),
955
961
  statusRequest.fire({ identity: context.identity }),
962
+ connectedRequest.fire({ identity: context.identity }),
956
963
  ]);
957
964
 
958
965
  return {
959
966
  ...currentState,
960
967
  clients: clientsResponse.clients,
968
+ connectedClients: connectedResponse.connectedClients,
961
969
  status: statusResponse.status,
962
970
  isLoading: false,
963
971
  error: null,
@@ -1054,6 +1062,39 @@ export const toggleVpnClientAction = vpnStatePart.createAction<{
1054
1062
  }
1055
1063
  });
1056
1064
 
1065
+ export const updateVpnClientAction = vpnStatePart.createAction<{
1066
+ clientId: string;
1067
+ description?: string;
1068
+ serverDefinedClientTags?: string[];
1069
+ }>(async (statePartArg, dataArg, actionContext): Promise<IVpnState> => {
1070
+ const context = getActionContext();
1071
+ const currentState = statePartArg.getState()!;
1072
+
1073
+ try {
1074
+ const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
1075
+ interfaces.requests.IReq_UpdateVpnClient
1076
+ >('/typedrequest', 'updateVpnClient');
1077
+
1078
+ const response = await request.fire({
1079
+ identity: context.identity!,
1080
+ clientId: dataArg.clientId,
1081
+ description: dataArg.description,
1082
+ serverDefinedClientTags: dataArg.serverDefinedClientTags,
1083
+ });
1084
+
1085
+ if (!response.success) {
1086
+ return { ...currentState, error: response.message || 'Failed to update client' };
1087
+ }
1088
+
1089
+ return await actionContext!.dispatch(fetchVpnAction, null);
1090
+ } catch (error: unknown) {
1091
+ return {
1092
+ ...currentState,
1093
+ error: error instanceof Error ? error.message : 'Failed to update VPN client',
1094
+ };
1095
+ }
1096
+ });
1097
+
1057
1098
  export const clearNewClientConfigAction = vpnStatePart.createAction(
1058
1099
  async (statePartArg): Promise<IVpnState> => {
1059
1100
  return { ...statePartArg.getState()!, newClientConfig: null };
@@ -141,10 +141,16 @@ export class OpsViewVpn extends DeesElement {
141
141
  `,
142
142
  ];
143
143
 
144
+ /** Look up connected client info by clientId */
145
+ private getConnectedInfo(clientId: string): interfaces.data.IVpnConnectedClient | undefined {
146
+ return this.vpnState.connectedClients?.find(c => c.clientId === clientId);
147
+ }
148
+
144
149
  render(): TemplateResult {
145
150
  const status = this.vpnState.status;
146
151
  const clients = this.vpnState.clients;
147
- const connectedCount = status?.connectedClients ?? 0;
152
+ const connectedClients = this.vpnState.connectedClients || [];
153
+ const connectedCount = connectedClients.length;
148
154
  const totalClients = clients.length;
149
155
  const enabledClients = clients.filter(c => c.enabled).length;
150
156
 
@@ -270,18 +276,28 @@ export class OpsViewVpn extends DeesElement {
270
276
  .heading1=${'VPN Clients'}
271
277
  .heading2=${'Manage WireGuard and SmartVPN client registrations'}
272
278
  .data=${clients}
273
- .displayFunction=${(client: interfaces.data.IVpnClient) => ({
274
- 'Client ID': client.clientId,
275
- 'Status': client.enabled
276
- ? html`<span class="statusBadge enabled">enabled</span>`
277
- : html`<span class="statusBadge disabled">disabled</span>`,
278
- 'VPN IP': client.assignedIp || '-',
279
- 'Tags': client.serverDefinedClientTags?.length
280
- ? html`${client.serverDefinedClientTags.map(t => html`<span class="tagBadge">${t}</span>`)}`
281
- : '-',
282
- 'Description': client.description || '-',
283
- 'Created': new Date(client.createdAt).toLocaleDateString(),
284
- })}
279
+ .displayFunction=${(client: interfaces.data.IVpnClient) => {
280
+ const conn = this.getConnectedInfo(client.clientId);
281
+ let statusHtml;
282
+ if (!client.enabled) {
283
+ statusHtml = html`<span class="statusBadge disabled">disabled</span>`;
284
+ } else if (conn) {
285
+ const since = new Date(conn.connectedSince).toLocaleString();
286
+ statusHtml = html`<span class="statusBadge enabled" title="Since ${since}">connected</span>`;
287
+ } else {
288
+ statusHtml = html`<span class="statusBadge enabled" style="background: ${cssManager.bdTheme('#eff6ff', '#172554')}; color: ${cssManager.bdTheme('#1e40af', '#60a5fa')};">offline</span>`;
289
+ }
290
+ return {
291
+ 'Client ID': client.clientId,
292
+ 'Status': statusHtml,
293
+ 'VPN IP': client.assignedIp || '-',
294
+ 'Tags': client.serverDefinedClientTags?.length
295
+ ? html`${client.serverDefinedClientTags.map(t => html`<span class="tagBadge">${t}</span>`)}`
296
+ : '-',
297
+ 'Description': client.description || '-',
298
+ 'Created': new Date(client.createdAt).toLocaleDateString(),
299
+ };
300
+ }}
285
301
  .dataActions=${[
286
302
  {
287
303
  name: 'Create Client',
@@ -328,14 +344,91 @@ export class OpsViewVpn extends DeesElement {
328
344
  },
329
345
  },
330
346
  {
331
- name: 'Toggle',
347
+ name: 'Detail',
348
+ iconName: 'lucide:info',
349
+ type: ['doubleClick'],
350
+ actionFunc: async (actionData: any) => {
351
+ const client = actionData.item as interfaces.data.IVpnClient;
352
+ const conn = this.getConnectedInfo(client.clientId);
353
+ const { DeesModal } = await import('@design.estate/dees-catalog');
354
+
355
+ // Fetch telemetry on-demand
356
+ let telemetryHtml = html`<p style="color: #9ca3af;">Loading telemetry...</p>`;
357
+ try {
358
+ const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
359
+ interfaces.requests.IReq_GetVpnClientTelemetry
360
+ >('/typedrequest', 'getVpnClientTelemetry');
361
+ const response = await request.fire({
362
+ identity: appstate.loginStatePart.getState()!.identity!,
363
+ clientId: client.clientId,
364
+ });
365
+ const t = response.telemetry;
366
+ if (t) {
367
+ const formatBytes = (b: number) => b > 1048576 ? `${(b / 1048576).toFixed(1)} MB` : b > 1024 ? `${(b / 1024).toFixed(1)} KB` : `${b} B`;
368
+ telemetryHtml = html`
369
+ <div class="serverInfo" style="margin-top: 12px;">
370
+ <div class="infoItem"><span class="infoLabel">Bytes Sent</span><span class="infoValue">${formatBytes(t.bytesSent)}</span></div>
371
+ <div class="infoItem"><span class="infoLabel">Bytes Received</span><span class="infoValue">${formatBytes(t.bytesReceived)}</span></div>
372
+ <div class="infoItem"><span class="infoLabel">Keepalives</span><span class="infoValue">${t.keepalivesReceived}</span></div>
373
+ <div class="infoItem"><span class="infoLabel">Last Keepalive</span><span class="infoValue">${t.lastKeepaliveAt ? new Date(t.lastKeepaliveAt).toLocaleString() : '-'}</span></div>
374
+ <div class="infoItem"><span class="infoLabel">Packets Dropped</span><span class="infoValue">${t.packetsDropped}</span></div>
375
+ </div>
376
+ `;
377
+ } else {
378
+ telemetryHtml = html`<p style="color: #9ca3af;">No telemetry available (client not connected)</p>`;
379
+ }
380
+ } catch {
381
+ telemetryHtml = html`<p style="color: #9ca3af;">Telemetry unavailable</p>`;
382
+ }
383
+
384
+ DeesModal.createAndShow({
385
+ heading: `Client: ${client.clientId}`,
386
+ content: html`
387
+ <div class="serverInfo">
388
+ <div class="infoItem"><span class="infoLabel">Client ID</span><span class="infoValue">${client.clientId}</span></div>
389
+ <div class="infoItem"><span class="infoLabel">VPN IP</span><span class="infoValue">${client.assignedIp || '-'}</span></div>
390
+ <div class="infoItem"><span class="infoLabel">Status</span><span class="infoValue">${!client.enabled ? 'Disabled' : conn ? 'Connected' : 'Offline'}</span></div>
391
+ ${conn ? html`
392
+ <div class="infoItem"><span class="infoLabel">Connected Since</span><span class="infoValue">${new Date(conn.connectedSince).toLocaleString()}</span></div>
393
+ <div class="infoItem"><span class="infoLabel">Transport</span><span class="infoValue">${conn.transport}</span></div>
394
+ ` : ''}
395
+ <div class="infoItem"><span class="infoLabel">Description</span><span class="infoValue">${client.description || '-'}</span></div>
396
+ <div class="infoItem"><span class="infoLabel">Tags</span><span class="infoValue">${client.serverDefinedClientTags?.join(', ') || '-'}</span></div>
397
+ <div class="infoItem"><span class="infoLabel">Created</span><span class="infoValue">${new Date(client.createdAt).toLocaleString()}</span></div>
398
+ <div class="infoItem"><span class="infoLabel">Updated</span><span class="infoValue">${new Date(client.updatedAt).toLocaleString()}</span></div>
399
+ </div>
400
+ <h3 style="margin: 16px 0 4px; font-size: 14px;">Telemetry</h3>
401
+ ${telemetryHtml}
402
+ `,
403
+ menuOptions: [
404
+ { name: 'Close', iconName: 'lucide:x', action: async (m: any) => await m.destroy() },
405
+ ],
406
+ });
407
+ },
408
+ },
409
+ {
410
+ name: 'Enable',
411
+ iconName: 'lucide:power',
412
+ type: ['contextmenu', 'inRow'],
413
+ actionRelevancyCheckFunc: (actionData: any) => !actionData.item.enabled,
414
+ actionFunc: async (actionData: any) => {
415
+ const client = actionData.item as interfaces.data.IVpnClient;
416
+ await appstate.vpnStatePart.dispatchAction(appstate.toggleVpnClientAction, {
417
+ clientId: client.clientId,
418
+ enabled: true,
419
+ });
420
+ },
421
+ },
422
+ {
423
+ name: 'Disable',
332
424
  iconName: 'lucide:power',
333
425
  type: ['contextmenu', 'inRow'],
426
+ actionRelevancyCheckFunc: (actionData: any) => actionData.item.enabled,
334
427
  actionFunc: async (actionData: any) => {
335
428
  const client = actionData.item as interfaces.data.IVpnClient;
336
429
  await appstate.vpnStatePart.dispatchAction(appstate.toggleVpnClientAction, {
337
430
  clientId: client.clientId,
338
- enabled: !client.enabled,
431
+ enabled: false,
339
432
  });
340
433
  },
341
434
  },
@@ -449,6 +542,47 @@ export class OpsViewVpn extends DeesElement {
449
542
  });
450
543
  },
451
544
  },
545
+ {
546
+ name: 'Edit',
547
+ iconName: 'lucide:pencil',
548
+ type: ['contextmenu', 'inRow'],
549
+ actionFunc: async (actionData: any) => {
550
+ const client = actionData.item as interfaces.data.IVpnClient;
551
+ const { DeesModal } = await import('@design.estate/dees-catalog');
552
+ const currentDescription = client.description ?? '';
553
+ const currentTags = client.serverDefinedClientTags?.join(', ') ?? '';
554
+ DeesModal.createAndShow({
555
+ heading: `Edit: ${client.clientId}`,
556
+ content: html`
557
+ <dees-form>
558
+ <dees-input-text .key=${'description'} .label=${'Description'} .value=${currentDescription}></dees-input-text>
559
+ <dees-input-text .key=${'tags'} .label=${'Server-Defined Tags (comma-separated)'} .value=${currentTags}></dees-input-text>
560
+ </dees-form>
561
+ `,
562
+ menuOptions: [
563
+ { name: 'Cancel', iconName: 'lucide:x', action: async (modalArg: any) => await modalArg.destroy() },
564
+ {
565
+ name: 'Save',
566
+ iconName: 'lucide:check',
567
+ action: async (modalArg: any) => {
568
+ const form = modalArg.shadowRoot?.querySelector('.content')?.querySelector('dees-form');
569
+ if (!form) return;
570
+ const data = await form.collectFormData();
571
+ const serverDefinedClientTags = data.tags
572
+ ? data.tags.split(',').map((t: string) => t.trim()).filter(Boolean)
573
+ : [];
574
+ await appstate.vpnStatePart.dispatchAction(appstate.updateVpnClientAction, {
575
+ clientId: client.clientId,
576
+ description: data.description || undefined,
577
+ serverDefinedClientTags,
578
+ });
579
+ await modalArg.destroy();
580
+ },
581
+ },
582
+ ],
583
+ });
584
+ },
585
+ },
452
586
  {
453
587
  name: 'Rotate Keys',
454
588
  iconName: 'lucide:rotate-cw',
package/ts_web/readme.md CHANGED
@@ -53,7 +53,8 @@ For reporting bugs, issues, or security vulnerabilities, please visit [community
53
53
  ### 🔐 VPN Management
54
54
  - VPN server status with forwarding mode, subnet, and WireGuard port
55
55
  - Client registration table with create, enable/disable, and delete actions
56
- - WireGuard config download and clipboard copy on client creation
56
+ - WireGuard config download, clipboard copy, and **QR code display** on client creation
57
+ - QR code export for existing clients — scan with WireGuard mobile app (iOS/Android)
57
58
  - Per-client telemetry (bytes sent/received, keepalives)
58
59
  - Server public key display for manual client configuration
59
60