@serve.zone/dcrouter 16.1.1 → 16.2.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 (42) hide show
  1. package/deno.json +1 -1
  2. package/dist_serve/bundle.js +11786 -7664
  3. package/dist_ts/00_commitinfo_data.js +1 -1
  4. package/dist_ts/config/classes.route-config-manager.d.ts +24 -4
  5. package/dist_ts/config/classes.route-config-manager.js +195 -34
  6. package/dist_ts/config/helpers.http-redirects.js +21 -12
  7. package/dist_ts/config/helpers.special-forwards.d.ts +14 -0
  8. package/dist_ts/config/helpers.special-forwards.js +85 -0
  9. package/dist_ts/opsserver/handlers/gatewayclient.handler.d.ts +1 -0
  10. package/dist_ts/opsserver/handlers/gatewayclient.handler.js +56 -9
  11. package/dist_ts/opsserver/handlers/route-management.handler.js +36 -1
  12. package/dist_ts_interfaces/data/route-management.d.ts +19 -1
  13. package/dist_ts_interfaces/data/route-management.js +5 -1
  14. package/dist_ts_interfaces/requests/route-management.d.ts +50 -1
  15. package/dist_ts_web/00_commitinfo_data.js +1 -1
  16. package/dist_ts_web/appstate/routes.d.ts +10 -0
  17. package/dist_ts_web/appstate/routes.js +67 -1
  18. package/dist_ts_web/elements/network/ops-view-redirects.d.ts +1 -0
  19. package/dist_ts_web/elements/network/ops-view-redirects.js +12 -4
  20. package/dist_ts_web/elements/network/ops-view-routes.d.ts +20 -1
  21. package/dist_ts_web/elements/network/ops-view-routes.js +182 -52
  22. package/dist_ts_web/elements/network/ops-view-special-forwards.d.ts +12 -0
  23. package/dist_ts_web/elements/network/ops-view-special-forwards.js +375 -0
  24. package/dist_ts_web/elements/ops-dashboard.js +1 -3
  25. package/dist_ts_web/router.js +10 -2
  26. package/package.json +5 -5
  27. package/readme.md +7 -3
  28. package/ts/00_commitinfo_data.ts +1 -1
  29. package/ts/config/classes.route-config-manager.ts +271 -32
  30. package/ts/config/helpers.http-redirects.ts +24 -16
  31. package/ts/config/helpers.special-forwards.ts +108 -0
  32. package/ts/opsserver/handlers/gatewayclient.handler.ts +55 -8
  33. package/ts/opsserver/handlers/route-management.handler.ts +59 -0
  34. package/ts_apiclient/readme.md +2 -2
  35. package/ts_web/00_commitinfo_data.ts +1 -1
  36. package/ts_web/appstate/routes.ts +84 -0
  37. package/ts_web/elements/network/ops-view-redirects.ts +5 -1
  38. package/ts_web/elements/network/ops-view-routes.ts +175 -31
  39. package/ts_web/elements/network/ops-view-special-forwards.ts +352 -0
  40. package/ts_web/elements/ops-dashboard.ts +0 -2
  41. package/ts_web/readme.md +9 -0
  42. package/ts_web/router.ts +10 -1
@@ -0,0 +1,352 @@
1
+ import {
2
+ DeesElement,
3
+ css,
4
+ cssManager,
5
+ customElement,
6
+ html,
7
+ state,
8
+ type TemplateResult,
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
+
14
+ const managedRouteKind = 'letsencrypt-http01-forward' as const;
15
+
16
+ function getDropdownKey(value: unknown): string {
17
+ return typeof value === 'string'
18
+ ? value
19
+ : value && typeof value === 'object' && 'key' in value
20
+ ? String((value as { key: unknown }).key)
21
+ : '';
22
+ }
23
+
24
+ function getDomains(route: interfaces.data.IMergedRoute): string[] {
25
+ const domains = route.route.match.domains;
26
+ if (!domains) return [];
27
+ return Array.isArray(domains) ? domains : [domains];
28
+ }
29
+
30
+ function getTarget(route: interfaces.data.IMergedRoute): { host: string; port: number } | undefined {
31
+ const target = route.route.action.targets?.[0];
32
+ if (!target || typeof target.port !== 'number') return undefined;
33
+ const host = typeof target.host === 'string'
34
+ ? target.host
35
+ : Array.isArray(target.host)
36
+ ? target.host[0]
37
+ : undefined;
38
+ return host ? { host, port: target.port } : undefined;
39
+ }
40
+
41
+ @customElement('ops-view-special-forwards')
42
+ export class OpsViewSpecialForwards extends DeesElement {
43
+ @state()
44
+ accessor routeState: appstate.IRouteManagementState = appstate.routeManagementStatePart.getState()!;
45
+
46
+ @state()
47
+ accessor profilesTargetsState: appstate.IProfilesTargetsState =
48
+ appstate.profilesTargetsStatePart.getState()!;
49
+
50
+ constructor() {
51
+ super();
52
+ this.rxSubscriptions.push(
53
+ appstate.routeManagementStatePart.select().subscribe((routeState) => {
54
+ this.routeState = routeState;
55
+ }),
56
+ appstate.profilesTargetsStatePart.select().subscribe((profilesTargetsState) => {
57
+ this.profilesTargetsState = profilesTargetsState;
58
+ }),
59
+ );
60
+ }
61
+
62
+ public static styles = [
63
+ cssManager.defaultStyles,
64
+ viewHostCss,
65
+ css`
66
+ :host {
67
+ display: block;
68
+ }
69
+
70
+ .specialForwardsContainer {
71
+ display: flex;
72
+ flex-direction: column;
73
+ gap: 20px;
74
+ }
75
+
76
+ .explanation {
77
+ padding: 14px 16px;
78
+ border: 1px solid var(--dees-color-border-default);
79
+ border-radius: 8px;
80
+ color: var(--dees-color-text-secondary);
81
+ font-size: 14px;
82
+ line-height: 1.5;
83
+ }
84
+
85
+ .explanation strong {
86
+ color: var(--dees-color-text-primary);
87
+ }
88
+
89
+ .error {
90
+ padding: 12px 16px;
91
+ border: 1px solid #ef4444;
92
+ border-radius: 8px;
93
+ color: #ef4444;
94
+ }
95
+ `,
96
+ ];
97
+
98
+ public render(): TemplateResult {
99
+ const forwards = this.routeState.mergedRoutes.filter((route) => {
100
+ return route.metadata?.managedRouteKind === managedRouteKind
101
+ && route.metadata.ownerType !== 'gatewayClient';
102
+ });
103
+
104
+ return html`
105
+ <div class="specialForwardsContainer">
106
+ <div class="explanation">
107
+ <strong>Let's Encrypt HTTP-01 Forward</strong> exposes only
108
+ <code>/.well-known/acme-challenge/*</code> on public port 80. DCRouter manages the
109
+ path, priority, protocol, and PUBLIC source profile; you choose domains and a target.
110
+ </div>
111
+
112
+ ${this.routeState.error
113
+ ? html`<div class="error">${this.routeState.error}</div>`
114
+ : ''}
115
+
116
+ <dees-table
117
+ .heading1=${'Special Forwards'}
118
+ .heading2=${'Server-managed routes for protocol-specific forwarding'}
119
+ .data=${forwards}
120
+ .showColumnFilters=${true}
121
+ .displayFunction=${(route: interfaces.data.IMergedRoute) => {
122
+ const target = getTarget(route);
123
+ return {
124
+ Status: route.enabled ? 'Enabled' : 'Disabled',
125
+ Type: 'Let\'s Encrypt HTTP-01',
126
+ Name: route.route.name || route.id,
127
+ Domains: getDomains(route).join(', '),
128
+ Path: route.route.match.path || '-',
129
+ Target: route.metadata?.networkTargetName
130
+ || (target ? `${target.host}:${target.port}` : '-'),
131
+ 'Remote Ingress': route.route.remoteIngress?.enabled ? 'Enabled' : 'Disabled',
132
+ };
133
+ }}
134
+ .dataActions=${[
135
+ {
136
+ name: 'Add HTTP-01 Forward',
137
+ iconName: 'lucide:plus',
138
+ type: ['header' as const],
139
+ actionFunc: async () => this.showForwardDialog(),
140
+ },
141
+ {
142
+ name: 'Refresh',
143
+ iconName: 'lucide:refreshCw',
144
+ type: ['header' as const],
145
+ actionFunc: async () => this.refreshData(),
146
+ },
147
+ {
148
+ name: 'Edit',
149
+ iconName: 'lucide:pencil',
150
+ type: ['inRow', 'contextmenu'] as any,
151
+ actionFunc: async (actionData: any) => {
152
+ await this.showForwardDialog(actionData.item as interfaces.data.IMergedRoute);
153
+ },
154
+ },
155
+ {
156
+ name: 'Enable / Disable',
157
+ iconName: 'lucide:power',
158
+ type: ['inRow', 'contextmenu'] as any,
159
+ actionFunc: async (actionData: any) => {
160
+ const route = actionData.item as interfaces.data.IMergedRoute;
161
+ await appstate.routeManagementStatePart.dispatchAction(
162
+ appstate.toggleRouteAction,
163
+ { id: route.id, enabled: !route.enabled },
164
+ );
165
+ },
166
+ },
167
+ {
168
+ name: 'Delete',
169
+ iconName: 'lucide:trash2',
170
+ type: ['inRow', 'contextmenu'] as any,
171
+ actionFunc: async (actionData: any) => {
172
+ await this.confirmDelete(actionData.item as interfaces.data.IMergedRoute);
173
+ },
174
+ },
175
+ ]}
176
+ ></dees-table>
177
+ </div>
178
+ `;
179
+ }
180
+
181
+ private async refreshData(): Promise<void> {
182
+ await Promise.all([
183
+ appstate.routeManagementStatePart.dispatchAction(appstate.fetchMergedRoutesAction, null),
184
+ appstate.profilesTargetsStatePart.dispatchAction(
185
+ appstate.fetchProfilesAndTargetsAction,
186
+ null,
187
+ ),
188
+ ]);
189
+ }
190
+
191
+ private async showForwardDialog(existing?: interfaces.data.IMergedRoute): Promise<void> {
192
+ const { DeesModal } = await import('@design.estate/dees-catalog');
193
+ const target = existing ? getTarget(existing) : undefined;
194
+ const targetOptions = [
195
+ { key: '', option: '(none — inline target)' },
196
+ ...this.profilesTargetsState.targets.map((networkTarget) => ({
197
+ key: networkTarget.id,
198
+ option: `${networkTarget.name} (${
199
+ Array.isArray(networkTarget.host) ? networkTarget.host.join(', ') : networkTarget.host
200
+ }:${networkTarget.port})`,
201
+ })),
202
+ ];
203
+ const selectedTarget = targetOptions.find((option) => {
204
+ return option.key === (existing?.metadata?.networkTargetRef || '');
205
+ }) || targetOptions[0];
206
+
207
+ await DeesModal.createAndShow({
208
+ heading: existing
209
+ ? `Edit HTTP-01 Forward: ${existing.route.name || existing.id}`
210
+ : 'Add Let\'s Encrypt HTTP-01 Forward',
211
+ content: html`
212
+ <dees-form>
213
+ <dees-input-text
214
+ .key=${'name'}
215
+ .label=${'Name'}
216
+ .value=${existing?.route.name || ''}
217
+ .required=${true}
218
+ ></dees-input-text>
219
+ <dees-input-list
220
+ .key=${'domains'}
221
+ .label=${'Domains'}
222
+ .description=${'Exact public hostnames; HTTP-01 does not support wildcards'}
223
+ .placeholder=${'Add domain...'}
224
+ .value=${existing ? getDomains(existing) : []}
225
+ ></dees-input-list>
226
+ <dees-input-dropdown
227
+ .key=${'networkTargetRef'}
228
+ .label=${'Network Target'}
229
+ .options=${targetOptions}
230
+ .selectedOption=${selectedTarget}
231
+ ></dees-input-dropdown>
232
+ <dees-input-text
233
+ .key=${'targetHost'}
234
+ .label=${'Inline Target Host'}
235
+ .description=${'Used only when no Network Target is selected'}
236
+ .value=${target?.host || ''}
237
+ ></dees-input-text>
238
+ <dees-input-text
239
+ .key=${'targetPort'}
240
+ .label=${'Inline Target Port'}
241
+ .description=${'Usually port 80 on the ACME responder'}
242
+ .value=${target ? String(target.port) : '80'}
243
+ ></dees-input-text>
244
+ <dees-input-checkbox
245
+ .key=${'remoteIngressEnabled'}
246
+ .label=${'Enable Remote Ingress'}
247
+ .value=${existing?.route.remoteIngress?.enabled === true}
248
+ ></dees-input-checkbox>
249
+ <dees-input-list
250
+ .key=${'remoteIngressEdgeFilter'}
251
+ .label=${'Remote Ingress Edge Filter'}
252
+ .description=${'Optional edge IDs or tags; leave empty for all edges'}
253
+ .placeholder=${'Add edge ID or tag...'}
254
+ .value=${existing?.route.remoteIngress?.edgeFilter || []}
255
+ ></dees-input-list>
256
+ </dees-form>
257
+ `,
258
+ menuOptions: [
259
+ {
260
+ name: 'Cancel',
261
+ iconName: 'lucide:x',
262
+ action: async (modalArg: any) => modalArg.destroy(),
263
+ },
264
+ {
265
+ name: existing ? 'Save' : 'Create',
266
+ iconName: existing ? 'lucide:check' : 'lucide:plus',
267
+ action: async (modalArg: any) => {
268
+ const form = modalArg.shadowRoot?.querySelector('.content')?.querySelector('dees-form');
269
+ if (!form) return;
270
+ const data = await form.collectFormData();
271
+ const name = String(data.name || '').trim();
272
+ const domains = Array.isArray(data.domains)
273
+ ? data.domains.map((domain: unknown) => String(domain).trim()).filter(Boolean)
274
+ : [];
275
+ const networkTargetRef = getDropdownKey(data.networkTargetRef).trim();
276
+ const targetHost = String(data.targetHost || '').trim();
277
+ const targetPort = Number.parseInt(String(data.targetPort || ''), 10);
278
+ if (!name || domains.length === 0) {
279
+ alert('Name and at least one domain are required.');
280
+ return;
281
+ }
282
+ if (
283
+ !networkTargetRef
284
+ && (!targetHost || !Number.isInteger(targetPort) || targetPort < 1 || targetPort > 65535)
285
+ ) {
286
+ alert('A valid inline target host and port are required.');
287
+ return;
288
+ }
289
+
290
+ const edgeFilter = Array.isArray(data.remoteIngressEdgeFilter)
291
+ ? data.remoteIngressEdgeFilter.map((entry: unknown) => String(entry).trim()).filter(Boolean)
292
+ : [];
293
+ const config: interfaces.data.ILetsEncryptHttp01ForwardConfig = {
294
+ name,
295
+ domains,
296
+ ...(networkTargetRef
297
+ ? { networkTargetRef }
298
+ : { target: { host: targetHost, port: targetPort } }),
299
+ ...(data.remoteIngressEnabled
300
+ ? {
301
+ remoteIngress: {
302
+ enabled: true,
303
+ ...(edgeFilter.length ? { edgeFilter } : {}),
304
+ },
305
+ }
306
+ : {}),
307
+ };
308
+
309
+ if (existing) {
310
+ await appstate.routeManagementStatePart.dispatchAction(
311
+ appstate.updateLetsEncryptHttp01ForwardAction,
312
+ { id: existing.id, config, enabled: existing.enabled },
313
+ );
314
+ } else {
315
+ await appstate.routeManagementStatePart.dispatchAction(
316
+ appstate.createLetsEncryptHttp01ForwardAction,
317
+ { config },
318
+ );
319
+ }
320
+ await modalArg.destroy();
321
+ },
322
+ },
323
+ ],
324
+ });
325
+ }
326
+
327
+ private async confirmDelete(route: interfaces.data.IMergedRoute): Promise<void> {
328
+ const { DeesModal } = await import('@design.estate/dees-catalog');
329
+ await DeesModal.createAndShow({
330
+ heading: `Delete Special Forward: ${route.route.name || route.id}`,
331
+ content: html`<p>This removes the managed HTTP-01 route for ${getDomains(route).join(', ')}.</p>`,
332
+ menuOptions: [
333
+ {
334
+ name: 'Cancel',
335
+ iconName: 'lucide:x',
336
+ action: async (modalArg: any) => modalArg.destroy(),
337
+ },
338
+ {
339
+ name: 'Delete',
340
+ iconName: 'lucide:trash2',
341
+ action: async (modalArg: any) => {
342
+ await appstate.routeManagementStatePart.dispatchAction(
343
+ appstate.deleteLetsEncryptHttp01ForwardAction,
344
+ route.id,
345
+ );
346
+ await modalArg.destroy();
347
+ },
348
+ },
349
+ ],
350
+ });
351
+ }
352
+ }
@@ -23,7 +23,6 @@ import { OpsViewConfig } from './overview/ops-view-config.js';
23
23
  // Network group
24
24
  import { OpsViewNetworkActivity } from './network/ops-view-network-activity.js';
25
25
  import { OpsViewRoutes } from './network/ops-view-routes.js';
26
- import { OpsViewRedirects } from './network/ops-view-redirects.js';
27
26
  import { OpsViewSourceProfiles } from './network/ops-view-sourceprofiles.js';
28
27
  import { OpsViewNetworkTargets } from './network/ops-view-networktargets.js';
29
28
  import { OpsViewTargetProfiles } from './network/ops-view-targetprofiles.js';
@@ -101,7 +100,6 @@ export class OpsDashboard extends DeesElement {
101
100
  subViews: [
102
101
  { slug: 'activity', name: 'Network Activity', iconName: 'lucide:activity', element: OpsViewNetworkActivity },
103
102
  { slug: 'routes', name: 'Routes', iconName: 'lucide:route', element: OpsViewRoutes },
104
- { slug: 'redirects', name: 'Redirects', iconName: 'lucide:CornerDownRight', element: OpsViewRedirects },
105
103
  { slug: 'sourceprofiles', name: 'Source Profiles', iconName: 'lucide:shieldCheck', element: OpsViewSourceProfiles },
106
104
  { slug: 'networktargets', name: 'Network Targets', iconName: 'lucide:server', element: OpsViewNetworkTargets },
107
105
  { slug: 'targetprofiles', name: 'Target Profiles', iconName: 'lucide:target', element: OpsViewTargetProfiles },
package/ts_web/readme.md CHANGED
@@ -27,6 +27,15 @@ For reporting bugs, issues, or security vulnerabilities, please visit [community
27
27
  | `security` | `overview`, `blocked`, `authentication` |
28
28
  | `domains` | `providers`, `domains`, `dns`, `certificates` |
29
29
 
30
+ ## Routes Navigation
31
+
32
+ The `/network/routes` view uses two levels of tabs:
33
+
34
+ - primary sections: **Standard Routes**, **Special Forwards**, and **Redirects**
35
+ - ownership filters within Standard Routes: **All**, **Operator**, **Gateway Client**, and **System**
36
+
37
+ Special Forwards create server-managed Let's Encrypt HTTP-01 routes for exact domains and a selected inline or Network Target backend. Redirects remain a derived runtime view. Tab state is stored in the `tab` and `owner` query parameters so deep links and browser history remain stable; the legacy `/network/redirects` URL is normalized to `/network/routes?tab=redirects`.
38
+
30
39
  ## Runtime Communication
31
40
 
32
41
  The dashboard talks to the dcrouter OpsServer through:
package/ts_web/router.ts CHANGED
@@ -9,7 +9,7 @@ const flatViews = ['logs'] as const;
9
9
  // Tabbed views and their valid subviews
10
10
  const subviewMap: Record<string, readonly string[]> = {
11
11
  overview: ['stats', 'configuration'] as const,
12
- network: ['activity', 'routes', 'redirects', 'sourceprofiles', 'networktargets', 'targetprofiles', 'remoteingress', 'vpn'] as const,
12
+ network: ['activity', 'routes', 'sourceprofiles', 'networktargets', 'targetprofiles', 'remoteingress', 'vpn'] as const,
13
13
  email: ['log', 'security', 'domains'] as const,
14
14
  access: ['gatewayclients', 'apitokens', 'users'] as const,
15
15
  security: ['overview', 'blocked', 'authentication'] as const,
@@ -102,6 +102,15 @@ class AppRouter {
102
102
  private handleInitialRoute(): void {
103
103
  const path = window.location.pathname;
104
104
 
105
+ if (path === '/network/redirects') {
106
+ const url = new URL(window.location.href);
107
+ url.pathname = '/network/routes';
108
+ url.searchParams.set('tab', 'redirects');
109
+ window.history.replaceState(window.history.state, '', url);
110
+ this.updateViewState('network', 'routes');
111
+ return;
112
+ }
113
+
105
114
  if (!path || path === '/') {
106
115
  this.router.pushUrl('/overview');
107
116
  return;