@serve.zone/dcrouter 11.12.4 → 11.13.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.
Files changed (50) hide show
  1. package/dist_serve/bundle.js +705 -548
  2. package/dist_ts/00_commitinfo_data.js +1 -1
  3. package/dist_ts/classes.dcrouter.d.ts +30 -0
  4. package/dist_ts/classes.dcrouter.js +92 -2
  5. package/dist_ts/config/classes.route-config-manager.d.ts +2 -1
  6. package/dist_ts/config/classes.route-config-manager.js +21 -5
  7. package/dist_ts/opsserver/classes.opsserver.d.ts +1 -0
  8. package/dist_ts/opsserver/classes.opsserver.js +3 -1
  9. package/dist_ts/opsserver/handlers/index.d.ts +1 -0
  10. package/dist_ts/opsserver/handlers/index.js +2 -1
  11. package/dist_ts/opsserver/handlers/vpn.handler.d.ts +6 -0
  12. package/dist_ts/opsserver/handlers/vpn.handler.js +199 -0
  13. package/dist_ts/plugins.d.ts +2 -1
  14. package/dist_ts/plugins.js +3 -2
  15. package/dist_ts/vpn/classes.vpn-manager.d.ts +113 -0
  16. package/dist_ts/vpn/classes.vpn-manager.js +297 -0
  17. package/dist_ts/vpn/index.d.ts +1 -0
  18. package/dist_ts/vpn/index.js +2 -0
  19. package/dist_ts_interfaces/data/index.d.ts +1 -0
  20. package/dist_ts_interfaces/data/index.js +2 -1
  21. package/dist_ts_interfaces/data/remoteingress.d.ts +10 -1
  22. package/dist_ts_interfaces/data/vpn.d.ts +43 -0
  23. package/dist_ts_interfaces/data/vpn.js +2 -0
  24. package/dist_ts_interfaces/requests/index.d.ts +1 -0
  25. package/dist_ts_interfaces/requests/index.js +2 -1
  26. package/dist_ts_interfaces/requests/vpn.d.ts +135 -0
  27. package/dist_ts_interfaces/requests/vpn.js +3 -0
  28. package/dist_ts_web/00_commitinfo_data.js +1 -1
  29. package/dist_ts_web/appstate.d.ts +22 -0
  30. package/dist_ts_web/appstate.js +111 -1
  31. package/dist_ts_web/elements/index.d.ts +1 -0
  32. package/dist_ts_web/elements/index.js +2 -1
  33. package/dist_ts_web/elements/ops-dashboard.js +7 -1
  34. package/dist_ts_web/elements/ops-view-vpn.d.ts +14 -0
  35. package/dist_ts_web/elements/ops-view-vpn.js +369 -0
  36. package/package.json +2 -1
  37. package/ts/00_commitinfo_data.ts +1 -1
  38. package/ts/classes.dcrouter.ts +126 -0
  39. package/ts/config/classes.route-config-manager.ts +20 -3
  40. package/ts/opsserver/classes.opsserver.ts +2 -0
  41. package/ts/opsserver/handlers/index.ts +2 -1
  42. package/ts/opsserver/handlers/vpn.handler.ts +257 -0
  43. package/ts/plugins.ts +2 -1
  44. package/ts/vpn/classes.vpn-manager.ts +378 -0
  45. package/ts/vpn/index.ts +1 -0
  46. package/ts_web/00_commitinfo_data.ts +1 -1
  47. package/ts_web/appstate.ts +164 -0
  48. package/ts_web/elements/index.ts +1 -0
  49. package/ts_web/elements/ops-dashboard.ts +6 -0
  50. package/ts_web/elements/ops-view-vpn.ts +330 -0
@@ -905,6 +905,161 @@ export const toggleRemoteIngressAction = remoteIngressStatePart.createAction<{
905
905
  }
906
906
  });
907
907
 
908
+ // ============================================================================
909
+ // VPN State
910
+ // ============================================================================
911
+
912
+ export interface IVpnState {
913
+ clients: interfaces.data.IVpnClient[];
914
+ status: interfaces.data.IVpnServerStatus | null;
915
+ isLoading: boolean;
916
+ error: string | null;
917
+ lastUpdated: number;
918
+ /** WireGuard config shown after create/rotate (only shown once) */
919
+ newClientConfig: string | null;
920
+ }
921
+
922
+ export const vpnStatePart = await appState.getStatePart<IVpnState>(
923
+ 'vpn',
924
+ {
925
+ clients: [],
926
+ status: null,
927
+ isLoading: false,
928
+ error: null,
929
+ lastUpdated: 0,
930
+ newClientConfig: null,
931
+ },
932
+ 'soft'
933
+ );
934
+
935
+ // ============================================================================
936
+ // VPN Actions
937
+ // ============================================================================
938
+
939
+ export const fetchVpnAction = vpnStatePart.createAction(async (statePartArg): Promise<IVpnState> => {
940
+ const context = getActionContext();
941
+ const currentState = statePartArg.getState()!;
942
+ if (!context.identity) return currentState;
943
+
944
+ try {
945
+ const clientsRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
946
+ interfaces.requests.IReq_GetVpnClients
947
+ >('/typedrequest', 'getVpnClients');
948
+
949
+ const statusRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
950
+ interfaces.requests.IReq_GetVpnStatus
951
+ >('/typedrequest', 'getVpnStatus');
952
+
953
+ const [clientsResponse, statusResponse] = await Promise.all([
954
+ clientsRequest.fire({ identity: context.identity }),
955
+ statusRequest.fire({ identity: context.identity }),
956
+ ]);
957
+
958
+ return {
959
+ ...currentState,
960
+ clients: clientsResponse.clients,
961
+ status: statusResponse.status,
962
+ isLoading: false,
963
+ error: null,
964
+ lastUpdated: Date.now(),
965
+ };
966
+ } catch (error) {
967
+ return {
968
+ ...currentState,
969
+ isLoading: false,
970
+ error: error instanceof Error ? error.message : 'Failed to fetch VPN data',
971
+ };
972
+ }
973
+ });
974
+
975
+ export const createVpnClientAction = vpnStatePart.createAction<{
976
+ clientId: string;
977
+ tags?: string[];
978
+ description?: string;
979
+ }>(async (statePartArg, dataArg, actionContext): Promise<IVpnState> => {
980
+ const context = getActionContext();
981
+ const currentState = statePartArg.getState()!;
982
+
983
+ try {
984
+ const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
985
+ interfaces.requests.IReq_CreateVpnClient
986
+ >('/typedrequest', 'createVpnClient');
987
+
988
+ const response = await request.fire({
989
+ identity: context.identity!,
990
+ clientId: dataArg.clientId,
991
+ tags: dataArg.tags,
992
+ description: dataArg.description,
993
+ });
994
+
995
+ if (!response.success) {
996
+ return { ...currentState, error: response.message || 'Failed to create client' };
997
+ }
998
+
999
+ const refreshed = await actionContext!.dispatch(fetchVpnAction, null);
1000
+ return {
1001
+ ...refreshed,
1002
+ newClientConfig: response.wireguardConfig || null,
1003
+ };
1004
+ } catch (error: unknown) {
1005
+ return {
1006
+ ...currentState,
1007
+ error: error instanceof Error ? error.message : 'Failed to create VPN client',
1008
+ };
1009
+ }
1010
+ });
1011
+
1012
+ export const deleteVpnClientAction = vpnStatePart.createAction<string>(
1013
+ async (statePartArg, clientId, actionContext): Promise<IVpnState> => {
1014
+ const context = getActionContext();
1015
+ const currentState = statePartArg.getState()!;
1016
+
1017
+ try {
1018
+ const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
1019
+ interfaces.requests.IReq_DeleteVpnClient
1020
+ >('/typedrequest', 'deleteVpnClient');
1021
+
1022
+ await request.fire({ identity: context.identity!, clientId });
1023
+ return await actionContext!.dispatch(fetchVpnAction, null);
1024
+ } catch (error: unknown) {
1025
+ return {
1026
+ ...currentState,
1027
+ error: error instanceof Error ? error.message : 'Failed to delete VPN client',
1028
+ };
1029
+ }
1030
+ },
1031
+ );
1032
+
1033
+ export const toggleVpnClientAction = vpnStatePart.createAction<{
1034
+ clientId: string;
1035
+ enabled: boolean;
1036
+ }>(async (statePartArg, dataArg, actionContext): Promise<IVpnState> => {
1037
+ const context = getActionContext();
1038
+ const currentState = statePartArg.getState()!;
1039
+
1040
+ try {
1041
+ const method = dataArg.enabled ? 'enableVpnClient' : 'disableVpnClient';
1042
+ type TReq = interfaces.requests.IReq_EnableVpnClient | interfaces.requests.IReq_DisableVpnClient;
1043
+ const request = new plugins.domtools.plugins.typedrequest.TypedRequest<TReq>(
1044
+ '/typedrequest', method,
1045
+ );
1046
+
1047
+ await request.fire({ identity: context.identity!, clientId: dataArg.clientId });
1048
+ return await actionContext!.dispatch(fetchVpnAction, null);
1049
+ } catch (error: unknown) {
1050
+ return {
1051
+ ...currentState,
1052
+ error: error instanceof Error ? error.message : 'Failed to toggle VPN client',
1053
+ };
1054
+ }
1055
+ });
1056
+
1057
+ export const clearNewClientConfigAction = vpnStatePart.createAction(
1058
+ async (statePartArg): Promise<IVpnState> => {
1059
+ return { ...statePartArg.getState()!, newClientConfig: null };
1060
+ },
1061
+ );
1062
+
908
1063
  // ============================================================================
909
1064
  // Route Management Actions
910
1065
  // ============================================================================
@@ -1372,6 +1527,15 @@ async function dispatchCombinedRefreshActionInner() {
1372
1527
  console.error('Remote ingress refresh failed:', error);
1373
1528
  }
1374
1529
  }
1530
+
1531
+ // Refresh VPN data if on vpn view
1532
+ if (currentView === 'vpn') {
1533
+ try {
1534
+ await vpnStatePart.dispatchAction(fetchVpnAction, null);
1535
+ } catch (error) {
1536
+ console.error('VPN refresh failed:', error);
1537
+ }
1538
+ }
1375
1539
  } catch (error) {
1376
1540
  console.error('Combined refresh failed:', error);
1377
1541
  // If the error looks like an auth failure (invalid JWT), force re-login
@@ -9,4 +9,5 @@ export * from './ops-view-apitokens.js';
9
9
  export * from './ops-view-security.js';
10
10
  export * from './ops-view-certificates.js';
11
11
  export * from './ops-view-remoteingress.js';
12
+ export * from './ops-view-vpn.js';
12
13
  export * from './shared/index.js';
@@ -24,6 +24,7 @@ import { OpsViewApiTokens } from './ops-view-apitokens.js';
24
24
  import { OpsViewSecurity } from './ops-view-security.js';
25
25
  import { OpsViewCertificates } from './ops-view-certificates.js';
26
26
  import { OpsViewRemoteIngress } from './ops-view-remoteingress.js';
27
+ import { OpsViewVpn } from './ops-view-vpn.js';
27
28
 
28
29
  @customElement('ops-dashboard')
29
30
  export class OpsDashboard extends DeesElement {
@@ -92,6 +93,11 @@ export class OpsDashboard extends DeesElement {
92
93
  iconName: 'lucide:globe',
93
94
  element: OpsViewRemoteIngress,
94
95
  },
96
+ {
97
+ name: 'VPN',
98
+ iconName: 'lucide:shield',
99
+ element: OpsViewVpn,
100
+ },
95
101
  ];
96
102
 
97
103
  /**
@@ -0,0 +1,330 @@
1
+ import {
2
+ DeesElement,
3
+ html,
4
+ customElement,
5
+ type TemplateResult,
6
+ css,
7
+ state,
8
+ cssManager,
9
+ } from '@design.estate/dees-element';
10
+ import * as appstate from '../appstate.js';
11
+ import * as interfaces from '../../dist_ts_interfaces/index.js';
12
+ import { viewHostCss } from './shared/css.js';
13
+ import { type IStatsTile } from '@design.estate/dees-catalog';
14
+
15
+ declare global {
16
+ interface HTMLElementTagNameMap {
17
+ 'ops-view-vpn': OpsViewVpn;
18
+ }
19
+ }
20
+
21
+ @customElement('ops-view-vpn')
22
+ export class OpsViewVpn extends DeesElement {
23
+ @state()
24
+ accessor vpnState: appstate.IVpnState = appstate.vpnStatePart.getState()!;
25
+
26
+ constructor() {
27
+ super();
28
+ const sub = appstate.vpnStatePart.select().subscribe((newState) => {
29
+ this.vpnState = newState;
30
+ });
31
+ this.rxSubscriptions.push(sub);
32
+ }
33
+
34
+ async connectedCallback() {
35
+ await super.connectedCallback();
36
+ await appstate.vpnStatePart.dispatchAction(appstate.fetchVpnAction, null);
37
+ }
38
+
39
+ public static styles = [
40
+ cssManager.defaultStyles,
41
+ viewHostCss,
42
+ css`
43
+ .vpnContainer {
44
+ display: flex;
45
+ flex-direction: column;
46
+ gap: 24px;
47
+ }
48
+
49
+ .statusBadge {
50
+ display: inline-flex;
51
+ align-items: center;
52
+ padding: 3px 10px;
53
+ border-radius: 12px;
54
+ font-size: 12px;
55
+ font-weight: 600;
56
+ letter-spacing: 0.02em;
57
+ text-transform: uppercase;
58
+ }
59
+
60
+ .statusBadge.enabled {
61
+ background: ${cssManager.bdTheme('#dcfce7', '#14532d')};
62
+ color: ${cssManager.bdTheme('#166534', '#4ade80')};
63
+ }
64
+
65
+ .statusBadge.disabled {
66
+ background: ${cssManager.bdTheme('#fef2f2', '#450a0a')};
67
+ color: ${cssManager.bdTheme('#991b1b', '#f87171')};
68
+ }
69
+
70
+ .configDialog {
71
+ padding: 16px;
72
+ background: ${cssManager.bdTheme('#fffbeb', '#1c1917')};
73
+ border: 1px solid ${cssManager.bdTheme('#fbbf24', '#92400e')};
74
+ border-radius: 8px;
75
+ margin-bottom: 16px;
76
+ }
77
+
78
+ .configDialog pre {
79
+ display: block;
80
+ padding: 12px;
81
+ background: ${cssManager.bdTheme('#1f2937', '#111827')};
82
+ color: #10b981;
83
+ border-radius: 4px;
84
+ font-family: monospace;
85
+ font-size: 12px;
86
+ white-space: pre-wrap;
87
+ word-break: break-all;
88
+ margin: 8px 0;
89
+ user-select: all;
90
+ max-height: 300px;
91
+ overflow-y: auto;
92
+ }
93
+
94
+ .configDialog .warning {
95
+ font-size: 12px;
96
+ color: ${cssManager.bdTheme('#92400e', '#fbbf24')};
97
+ margin-top: 8px;
98
+ }
99
+
100
+ .tagBadge {
101
+ display: inline-flex;
102
+ padding: 2px 8px;
103
+ border-radius: 4px;
104
+ font-size: 12px;
105
+ font-weight: 500;
106
+ background: ${cssManager.bdTheme('#eff6ff', '#172554')};
107
+ color: ${cssManager.bdTheme('#1e40af', '#60a5fa')};
108
+ margin-right: 4px;
109
+ }
110
+
111
+ .serverInfo {
112
+ display: grid;
113
+ grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
114
+ gap: 12px;
115
+ padding: 16px;
116
+ background: ${cssManager.bdTheme('#f9fafb', '#111827')};
117
+ border-radius: 8px;
118
+ border: 1px solid ${cssManager.bdTheme('#e5e7eb', '#1f2937')};
119
+ }
120
+
121
+ .serverInfo .infoItem {
122
+ display: flex;
123
+ flex-direction: column;
124
+ gap: 4px;
125
+ }
126
+
127
+ .serverInfo .infoLabel {
128
+ font-size: 11px;
129
+ font-weight: 600;
130
+ text-transform: uppercase;
131
+ letter-spacing: 0.05em;
132
+ color: ${cssManager.bdTheme('#6b7280', '#9ca3af')};
133
+ }
134
+
135
+ .serverInfo .infoValue {
136
+ font-size: 14px;
137
+ font-family: monospace;
138
+ color: ${cssManager.bdTheme('#111827', '#f9fafb')};
139
+ }
140
+ `,
141
+ ];
142
+
143
+ render(): TemplateResult {
144
+ const status = this.vpnState.status;
145
+ const clients = this.vpnState.clients;
146
+ const connectedCount = status?.connectedClients ?? 0;
147
+ const totalClients = clients.length;
148
+ const enabledClients = clients.filter(c => c.enabled).length;
149
+
150
+ const statsTiles: IStatsTile[] = [
151
+ {
152
+ id: 'totalClients',
153
+ title: 'Total Clients',
154
+ type: 'number',
155
+ value: totalClients,
156
+ icon: 'lucide:users',
157
+ description: 'Registered VPN clients',
158
+ color: '#3b82f6',
159
+ },
160
+ {
161
+ id: 'connectedClients',
162
+ title: 'Connected',
163
+ type: 'number',
164
+ value: connectedCount,
165
+ icon: 'lucide:link',
166
+ description: 'Currently connected',
167
+ color: '#10b981',
168
+ },
169
+ {
170
+ id: 'enabledClients',
171
+ title: 'Enabled',
172
+ type: 'number',
173
+ value: enabledClients,
174
+ icon: 'lucide:shieldCheck',
175
+ description: 'Active client registrations',
176
+ color: '#8b5cf6',
177
+ },
178
+ {
179
+ id: 'serverStatus',
180
+ title: 'Server',
181
+ type: 'text',
182
+ value: status?.running ? 'Running' : 'Stopped',
183
+ icon: 'lucide:server',
184
+ description: status?.running ? `${status.forwardingMode} mode` : 'VPN server not running',
185
+ color: status?.running ? '#10b981' : '#ef4444',
186
+ },
187
+ ];
188
+
189
+ return html`
190
+ <ops-sectionheading>VPN</ops-sectionheading>
191
+
192
+ ${this.vpnState.newClientConfig ? html`
193
+ <div class="configDialog">
194
+ <strong>Client created successfully!</strong>
195
+ <div class="warning">Copy the WireGuard config now. It contains private keys that won't be shown again.</div>
196
+ <pre>${this.vpnState.newClientConfig}</pre>
197
+ <dees-button
198
+ @click=${async () => {
199
+ if (navigator.clipboard && typeof navigator.clipboard.writeText === 'function') {
200
+ await navigator.clipboard.writeText(this.vpnState.newClientConfig!);
201
+ }
202
+ const { DeesToast } = await import('@design.estate/dees-catalog');
203
+ DeesToast.createAndShow({ message: 'Config copied to clipboard', type: 'success', duration: 3000 });
204
+ }}
205
+ >Copy to Clipboard</dees-button>
206
+ <dees-button
207
+ @click=${() => {
208
+ const blob = new Blob([this.vpnState.newClientConfig!], { type: 'text/plain' });
209
+ const url = URL.createObjectURL(blob);
210
+ const a = document.createElement('a');
211
+ a.href = url;
212
+ a.download = 'wireguard.conf';
213
+ a.click();
214
+ URL.revokeObjectURL(url);
215
+ }}
216
+ >Download .conf</dees-button>
217
+ <dees-button
218
+ @click=${() => appstate.vpnStatePart.dispatchAction(appstate.clearNewClientConfigAction, null)}
219
+ >Dismiss</dees-button>
220
+ </div>
221
+ ` : ''}
222
+
223
+ <dees-statsgrid .statsTiles=${statsTiles}></dees-statsgrid>
224
+
225
+ ${status ? html`
226
+ <div class="serverInfo">
227
+ <div class="infoItem">
228
+ <span class="infoLabel">Subnet</span>
229
+ <span class="infoValue">${status.subnet}</span>
230
+ </div>
231
+ <div class="infoItem">
232
+ <span class="infoLabel">WireGuard Port</span>
233
+ <span class="infoValue">${status.wgListenPort}</span>
234
+ </div>
235
+ <div class="infoItem">
236
+ <span class="infoLabel">Forwarding Mode</span>
237
+ <span class="infoValue">${status.forwardingMode}</span>
238
+ </div>
239
+ ${status.serverPublicKeys ? html`
240
+ <div class="infoItem">
241
+ <span class="infoLabel">WG Public Key</span>
242
+ <span class="infoValue" style="font-size: 11px; word-break: break-all;">${status.serverPublicKeys.wgPublicKey}</span>
243
+ </div>
244
+ ` : ''}
245
+ </div>
246
+ ` : ''}
247
+
248
+ <dees-table
249
+ .heading1=${'VPN Clients'}
250
+ .heading2=${'Manage WireGuard and SmartVPN client registrations'}
251
+ .data=${clients}
252
+ .displayFunction=${(client: interfaces.data.IVpnClient) => ({
253
+ 'Client ID': client.clientId,
254
+ 'Status': client.enabled
255
+ ? html`<span class="statusBadge enabled">enabled</span>`
256
+ : html`<span class="statusBadge disabled">disabled</span>`,
257
+ 'VPN IP': client.assignedIp || '-',
258
+ 'Tags': client.tags?.length
259
+ ? html`${client.tags.map(t => html`<span class="tagBadge">${t}</span>`)}`
260
+ : '-',
261
+ 'Description': client.description || '-',
262
+ 'Created': new Date(client.createdAt).toLocaleDateString(),
263
+ })}
264
+ .dataActions=${[
265
+ {
266
+ name: 'Toggle',
267
+ iconName: 'lucide:power',
268
+ action: async (client: interfaces.data.IVpnClient) => {
269
+ await appstate.vpnStatePart.dispatchAction(appstate.toggleVpnClientAction, {
270
+ clientId: client.clientId,
271
+ enabled: !client.enabled,
272
+ });
273
+ },
274
+ },
275
+ {
276
+ name: 'Delete',
277
+ iconName: 'lucide:trash2',
278
+ action: async (client: interfaces.data.IVpnClient) => {
279
+ const { DeesModal } = await import('@design.estate/dees-catalog');
280
+ DeesModal.createAndShow({
281
+ heading: 'Delete VPN Client',
282
+ content: html`<p>Are you sure you want to delete client "${client.clientId}"?</p>`,
283
+ menuOptions: [
284
+ { name: 'Cancel', action: async (modal: any) => modal.destroy() },
285
+ {
286
+ name: 'Delete',
287
+ action: async (modal: any) => {
288
+ await appstate.vpnStatePart.dispatchAction(appstate.deleteVpnClientAction, client.clientId);
289
+ modal.destroy();
290
+ },
291
+ },
292
+ ],
293
+ });
294
+ },
295
+ },
296
+ ]}
297
+ .createNewItem=${async () => {
298
+ const { DeesModal, DeesForm, DeesInputText } = await import('@design.estate/dees-catalog');
299
+ DeesModal.createAndShow({
300
+ heading: 'Create VPN Client',
301
+ content: html`
302
+ <dees-form>
303
+ <dees-input-text id="clientId" .label=${'Client ID'} .key=${'clientId'} required></dees-input-text>
304
+ <dees-input-text id="description" .label=${'Description'} .key=${'description'}></dees-input-text>
305
+ <dees-input-text id="tags" .label=${'Tags (comma-separated)'} .key=${'tags'}></dees-input-text>
306
+ </dees-form>
307
+ `,
308
+ menuOptions: [
309
+ { name: 'Cancel', action: async (modal: any) => modal.destroy() },
310
+ {
311
+ name: 'Create',
312
+ action: async (modal: any) => {
313
+ const form = modal.shadowRoot!.querySelector('dees-form') as any;
314
+ const data = await form.collectFormData();
315
+ const tags = data.tags ? data.tags.split(',').map((t: string) => t.trim()).filter(Boolean) : undefined;
316
+ await appstate.vpnStatePart.dispatchAction(appstate.createVpnClientAction, {
317
+ clientId: data.clientId,
318
+ description: data.description || undefined,
319
+ tags,
320
+ });
321
+ modal.destroy();
322
+ },
323
+ },
324
+ ],
325
+ });
326
+ }}
327
+ ></dees-table>
328
+ `;
329
+ }
330
+ }