@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.
- package/dist_serve/bundle.js +522 -493
- package/dist_ts/00_commitinfo_data.js +1 -1
- package/dist_ts/classes.dcrouter.d.ts +0 -5
- package/dist_ts/classes.dcrouter.js +9 -42
- package/dist_ts/config/classes.route-config-manager.js +22 -21
- package/dist_ts/opsserver/handlers/vpn.handler.js +36 -1
- package/dist_ts/vpn/classes.vpn-manager.d.ts +7 -0
- package/dist_ts/vpn/classes.vpn-manager.js +16 -1
- package/dist_ts_interfaces/data/vpn.d.ts +11 -0
- package/dist_ts_interfaces/requests/vpn.d.ts +29 -1
- package/dist_ts_web/00_commitinfo_data.js +1 -1
- package/dist_ts_web/appstate.d.ts +6 -0
- package/dist_ts_web/appstate.js +29 -2
- package/dist_ts_web/elements/ops-view-vpn.d.ts +2 -0
- package/dist_ts_web/elements/ops-view-vpn.js +150 -16
- package/package.json +2 -2
- package/readme.md +5 -4
- package/readme.storage.md +120 -0
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/classes.dcrouter.ts +9 -48
- package/ts/config/classes.route-config-manager.ts +21 -20
- package/ts/opsserver/handlers/vpn.handler.ts +48 -0
- package/ts/vpn/classes.vpn-manager.ts +16 -0
- package/ts_web/00_commitinfo_data.ts +1 -1
- package/ts_web/appstate.ts +42 -1
- package/ts_web/elements/ops-view-vpn.ts +149 -15
- package/ts_web/readme.md +2 -1
|
@@ -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
|
*/
|
package/ts_web/appstate.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
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: '
|
|
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:
|
|
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
|
|
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
|
|