@serve.zone/dcrouter 13.38.3 → 13.39.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 (31) hide show
  1. package/deno.json +2 -2
  2. package/dist_serve/bundle.js +350 -343
  3. package/dist_ts/00_commitinfo_data.js +1 -1
  4. package/dist_ts/db/documents/classes.remote-ingress-edge.doc.d.ts +2 -0
  5. package/dist_ts/db/documents/classes.remote-ingress-edge.doc.js +8 -2
  6. package/dist_ts/http3/http3-route-augmentation.d.ts +1 -1
  7. package/dist_ts/http3/http3-route-augmentation.js +3 -17
  8. package/dist_ts/opsserver/handlers/remoteingress.handler.js +3 -2
  9. package/dist_ts/radius/classes.radius.server.d.ts +4 -4
  10. package/dist_ts/radius/classes.radius.server.js +80 -66
  11. package/dist_ts/remoteingress/classes.remoteingress-manager.d.ts +4 -2
  12. package/dist_ts/remoteingress/classes.remoteingress-manager.js +9 -21
  13. package/dist_ts_interfaces/data/remoteingress.d.ts +10 -0
  14. package/dist_ts_interfaces/requests/remoteingress.d.ts +3 -1
  15. package/dist_ts_web/00_commitinfo_data.js +1 -1
  16. package/dist_ts_web/appstate.d.ts +2 -0
  17. package/dist_ts_web/appstate.js +3 -1
  18. package/dist_ts_web/elements/network/ops-view-remoteingress.d.ts +2 -0
  19. package/dist_ts_web/elements/network/ops-view-remoteingress.js +55 -2
  20. package/dist_ts_web/elements/overview/ops-view-config.js +4 -1
  21. package/package.json +4 -4
  22. package/ts/00_commitinfo_data.ts +1 -1
  23. package/ts/db/documents/classes.remote-ingress-edge.doc.ts +4 -0
  24. package/ts/http3/http3-route-augmentation.ts +2 -18
  25. package/ts/opsserver/handlers/remoteingress.handler.ts +2 -0
  26. package/ts/radius/classes.radius.server.ts +90 -66
  27. package/ts/remoteingress/classes.remoteingress-manager.ts +12 -21
  28. package/ts_web/00_commitinfo_data.ts +1 -1
  29. package/ts_web/appstate.ts +4 -0
  30. package/ts_web/elements/network/ops-view-remoteingress.ts +57 -1
  31. package/ts_web/elements/overview/ops-view-config.ts +10 -0
@@ -146,7 +146,7 @@ export class RadiusServer {
146
146
  authPort: this.config.authPort,
147
147
  acctPort: this.config.acctPort,
148
148
  bindAddress: this.config.bindAddress,
149
- defaultSecret: this.getDefaultSecret(),
149
+ secretResolver: this.resolveClientSecret.bind(this),
150
150
  authenticationHandler: this.handleAuthentication.bind(this),
151
151
  accountingHandler: this.handleAccounting.bind(this),
152
152
  });
@@ -189,19 +189,21 @@ export class RadiusServer {
189
189
  /**
190
190
  * Handle authentication request
191
191
  */
192
- private async handleAuthentication(request: any): Promise<any> {
192
+ private async handleAuthentication(
193
+ request: plugins.smartradius.IAuthenticationRequest,
194
+ ): Promise<plugins.smartradius.IAuthenticationResponse> {
193
195
  this.stats.authRequests++;
194
196
 
195
197
  const authData: IAuthRequestData = {
196
- username: request.attributes?.UserName || '',
197
- password: request.attributes?.UserPassword,
198
- nasIpAddress: request.attributes?.NasIpAddress || request.source?.address || '',
199
- nasPort: request.attributes?.NasPort,
200
- nasPortType: request.attributes?.NasPortType,
201
- nasIdentifier: request.attributes?.NasIdentifier,
202
- calledStationId: request.attributes?.CalledStationId,
203
- callingStationId: request.attributes?.CallingStationId,
204
- serviceType: request.attributes?.ServiceType,
198
+ username: request.username || '',
199
+ password: request.password,
200
+ nasIpAddress: request.nasIpAddress || request.clientAddress || '',
201
+ nasPort: request.nasPort,
202
+ nasPortType: request.nasPortType !== undefined ? String(request.nasPortType) : undefined,
203
+ nasIdentifier: request.nasIdentifier,
204
+ calledStationId: request.calledStationId,
205
+ callingStationId: request.callingStationId,
206
+ serviceType: request.serviceType !== undefined ? String(request.serviceType) : undefined,
205
207
  };
206
208
 
207
209
  logger.log('debug', `RADIUS Auth Request: user=${authData.username}, NAS=${authData.nasIpAddress}`);
@@ -215,15 +217,15 @@ export class RadiusServer {
215
217
  logger.log('info', `RADIUS Auth Accept: user=${authData.username}, VLAN=${result.vlanId}`);
216
218
 
217
219
  // Build response with VLAN attributes
218
- const response: any = {
220
+ const response: plugins.smartradius.IAuthenticationResponse = {
219
221
  code: plugins.smartradius.ERadiusCode.AccessAccept,
220
222
  replyMessage: result.replyMessage,
221
223
  };
222
224
 
223
225
  // Add VLAN attributes if assigned
224
226
  if (result.vlanId !== undefined) {
225
- response.tunnelType = 13; // VLAN
226
- response.tunnelMediumType = 6; // IEEE 802
227
+ response.tunnelType = plugins.smartradius.ETunnelType.Vlan;
228
+ response.tunnelMediumType = plugins.smartradius.ETunnelMediumType.Ieee802;
227
229
  response.tunnelPrivateGroupId = String(result.vlanId);
228
230
  }
229
231
 
@@ -257,34 +259,35 @@ export class RadiusServer {
257
259
  /**
258
260
  * Handle accounting request
259
261
  */
260
- private async handleAccounting(request: any): Promise<any> {
262
+ private async handleAccounting(
263
+ request: plugins.smartradius.IAccountingRequest,
264
+ ): Promise<plugins.smartradius.IAccountingResponse> {
261
265
  this.stats.accountingRequests++;
262
266
 
263
267
  if (!this.config.accounting?.enabled) {
264
268
  // Still respond even if not tracking
265
- return { code: plugins.smartradius.ERadiusCode.AccountingResponse };
269
+ return { success: true };
266
270
  }
267
271
 
268
- const statusType = request.attributes?.AcctStatusType;
269
- const sessionId = request.attributes?.AcctSessionId || '';
272
+ const statusType = request.statusType;
273
+ const sessionId = request.sessionId || '';
270
274
 
271
275
  const accountingData = {
272
276
  sessionId,
273
- username: request.attributes?.UserName || '',
274
- macAddress: request.attributes?.CallingStationId,
275
- nasIpAddress: request.attributes?.NasIpAddress || request.source?.address || '',
276
- nasPort: request.attributes?.NasPort,
277
- nasPortType: request.attributes?.NasPortType,
278
- nasIdentifier: request.attributes?.NasIdentifier,
279
- calledStationId: request.attributes?.CalledStationId,
280
- callingStationId: request.attributes?.CallingStationId,
281
- inputOctets: request.attributes?.AcctInputOctets,
282
- outputOctets: request.attributes?.AcctOutputOctets,
283
- inputPackets: request.attributes?.AcctInputPackets,
284
- outputPackets: request.attributes?.AcctOutputPackets,
285
- sessionTime: request.attributes?.AcctSessionTime,
286
- terminateCause: request.attributes?.AcctTerminateCause,
287
- serviceType: request.attributes?.ServiceType,
277
+ username: request.username || '',
278
+ macAddress: request.callingStationId,
279
+ nasIpAddress: request.nasIpAddress || request.clientAddress || '',
280
+ nasPort: request.nasPort,
281
+ nasPortType: request.nasPortType !== undefined ? String(request.nasPortType) : undefined,
282
+ nasIdentifier: request.nasIdentifier,
283
+ calledStationId: request.calledStationId,
284
+ callingStationId: request.callingStationId,
285
+ inputOctets: request.inputOctets,
286
+ outputOctets: request.outputOctets,
287
+ inputPackets: request.inputPackets,
288
+ outputPackets: request.outputPackets,
289
+ sessionTime: request.sessionTime,
290
+ terminateCause: request.terminateCause !== undefined ? String(request.terminateCause) : undefined,
288
291
  };
289
292
 
290
293
  try {
@@ -311,7 +314,7 @@ export class RadiusServer {
311
314
  logger.log('error', `RADIUS accounting error: ${(error as Error).message}`);
312
315
  }
313
316
 
314
- return { code: plugins.smartradius.ERadiusCode.AccountingResponse };
317
+ return { success: true };
315
318
  }
316
319
 
317
320
  /**
@@ -399,29 +402,58 @@ export class RadiusServer {
399
402
  continue;
400
403
  }
401
404
 
402
- // Handle CIDR ranges
403
- if (client.ipRange.includes('/')) {
404
- // For CIDR ranges, we'll use the network address as key
405
- // In practice, smartradius may handle this differently
406
- const [network] = client.ipRange.split('/');
407
- this.clientSecrets.set(network, client.secret);
408
- } else {
405
+ if (!client.ipRange.includes('/')) {
409
406
  this.clientSecrets.set(client.ipRange, client.secret);
410
407
  }
411
408
  }
412
409
  }
413
410
 
414
- /**
415
- * Get default secret for unknown clients
416
- */
417
- private getDefaultSecret(): string {
418
- // Use first enabled client's secret as default, or a random one
411
+ private resolveClientSecret(clientAddress: string): string | undefined {
412
+ const normalizedClientAddress = this.normalizeClientAddress(clientAddress);
413
+ if (!normalizedClientAddress) return undefined;
414
+
415
+ const exactSecret = this.clientSecrets.get(normalizedClientAddress);
416
+ if (exactSecret !== undefined) return exactSecret;
417
+
419
418
  for (const client of this.config.clients) {
420
- if (client.enabled) {
421
- return client.secret;
422
- }
419
+ if (!client.enabled || !client.ipRange.includes('/')) continue;
420
+ if (this.clientIpMatchesCidr(normalizedClientAddress, client.ipRange)) return client.secret;
423
421
  }
424
- return plugins.crypto.randomBytes(16).toString('hex');
422
+
423
+ return undefined;
424
+ }
425
+
426
+ private normalizeClientAddress(clientAddress: string): string | undefined {
427
+ const trimmedAddress = clientAddress.trim();
428
+ const normalizedAddress = trimmedAddress.startsWith('::ffff:')
429
+ ? trimmedAddress.slice('::ffff:'.length)
430
+ : trimmedAddress;
431
+ return plugins.net.isIP(normalizedAddress) ? normalizedAddress : undefined;
432
+ }
433
+
434
+ private clientIpMatchesCidr(clientAddress: string, cidr: string): boolean {
435
+ const [networkAddress, rawPrefix] = cidr.trim().split('/');
436
+ const normalizedNetworkAddress = this.normalizeClientAddress(networkAddress || '');
437
+ if (!normalizedNetworkAddress || rawPrefix === undefined) return false;
438
+
439
+ const clientIp = this.ipv4ToNumber(clientAddress);
440
+ const networkIp = this.ipv4ToNumber(normalizedNetworkAddress);
441
+ if (clientIp === undefined || networkIp === undefined) return false;
442
+
443
+ const prefix = Number(rawPrefix);
444
+ if (!Number.isInteger(prefix) || prefix < 0 || prefix > 32) return false;
445
+
446
+ const mask = prefix === 0 ? 0 : (0xffffffff << (32 - prefix)) >>> 0;
447
+ return ((clientIp & mask) >>> 0) === ((networkIp & mask) >>> 0);
448
+ }
449
+
450
+ private ipv4ToNumber(ipAddress: string): number | undefined {
451
+ if (plugins.net.isIP(ipAddress) !== 4) return undefined;
452
+ const parts = ipAddress.split('.').map((part) => Number(part));
453
+ if (parts.length !== 4 || parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) {
454
+ return undefined;
455
+ }
456
+ return (((parts[0] * 256 + parts[1]) * 256 + parts[2]) * 256 + parts[3]) >>> 0;
425
457
  }
426
458
 
427
459
  /**
@@ -436,16 +468,10 @@ export class RadiusServer {
436
468
  this.config.clients.push(client);
437
469
  }
438
470
 
439
- // Update client secrets if running
440
- if (this.running && this.radiusServer && client.enabled) {
441
- if (client.ipRange.includes('/')) {
442
- const [network] = client.ipRange.split('/');
443
- this.radiusServer.setClientSecret(network, client.secret);
444
- this.clientSecrets.set(network, client.secret);
445
- } else {
446
- this.radiusServer.setClientSecret(client.ipRange, client.secret);
447
- this.clientSecrets.set(client.ipRange, client.secret);
448
- }
471
+ this.buildClientSecretsMap();
472
+
473
+ if (this.running && this.radiusServer && client.enabled && !client.ipRange.includes('/')) {
474
+ this.radiusServer.setClientSecret(client.ipRange, client.secret);
449
475
  }
450
476
 
451
477
  logger.log('info', `RADIUS client ${client.enabled ? 'added' : 'disabled'}: ${client.name} (${client.ipRange})`);
@@ -460,12 +486,10 @@ export class RadiusServer {
460
486
  const client = this.config.clients[index];
461
487
  this.config.clients.splice(index, 1);
462
488
 
463
- // Remove from secrets map
464
- if (client.ipRange.includes('/')) {
465
- const [network] = client.ipRange.split('/');
466
- this.clientSecrets.delete(network);
467
- } else {
468
- this.clientSecrets.delete(client.ipRange);
489
+ this.buildClientSecretsMap();
490
+
491
+ if (this.radiusServer && !client.ipRange.includes('/')) {
492
+ this.radiusServer.removeClientSecret(client.ipRange);
469
493
  }
470
494
 
471
495
  logger.log('info', `RADIUS client removed: ${name}`);
@@ -1,29 +1,13 @@
1
1
  import * as plugins from '../plugins.js';
2
- import type { IRemoteIngress, IDcRouterRouteConfig } from '../../ts_interfaces/data/remoteingress.js';
2
+ import type { IRemoteIngress, IRemoteIngressPerformanceConfig, IDcRouterRouteConfig } from '../../ts_interfaces/data/remoteingress.js';
3
3
  import { RemoteIngressEdgeDoc } from '../db/index.js';
4
4
 
5
5
  interface IRemoteIngressFirewallConfig {
6
6
  blockedIps?: string[];
7
7
  }
8
8
 
9
- /**
10
- * Flatten a port range (number | number[] | Array<{from, to}>) to a sorted unique number array.
11
- */
12
- function extractPorts(portRange: number | Array<number | { from: number; to: number }>): number[] {
13
- const ports = new Set<number>();
14
- if (typeof portRange === 'number') {
15
- ports.add(portRange);
16
- } else if (Array.isArray(portRange)) {
17
- for (const entry of portRange) {
18
- if (typeof entry === 'number') {
19
- ports.add(entry);
20
- } else if (typeof entry === 'object' && 'from' in entry && 'to' in entry) {
21
- for (let p = entry.from; p <= entry.to; p++) {
22
- ports.add(p);
23
- }
24
- }
25
- }
26
- }
9
+ function extractPorts(portRange: plugins.smartproxy.IRouteConfig['match']['ports']): number[] {
10
+ const ports = new Set<number>(plugins.smartproxy.expandPortRange(portRange) as number[]);
27
11
  return [...ports].sort((a, b) => a - b);
28
12
  }
29
13
 
@@ -59,6 +43,7 @@ export class RemoteIngressManager {
59
43
  listenPortsUdp: doc.listenPortsUdp,
60
44
  enabled: doc.enabled,
61
45
  autoDerivePorts: doc.autoDerivePorts,
46
+ performance: doc.performance,
62
47
  tags: doc.tags,
63
48
  createdAt: doc.createdAt,
64
49
  updatedAt: doc.updatedAt,
@@ -189,6 +174,7 @@ export class RemoteIngressManager {
189
174
  listenPorts: number[] = [],
190
175
  tags?: string[],
191
176
  autoDerivePorts: boolean = true,
177
+ performance?: IRemoteIngressPerformanceConfig,
192
178
  ): Promise<IRemoteIngress> {
193
179
  const id = plugins.uuid.v4();
194
180
  const secret = plugins.crypto.randomBytes(32).toString('hex');
@@ -201,6 +187,7 @@ export class RemoteIngressManager {
201
187
  listenPorts,
202
188
  enabled: true,
203
189
  autoDerivePorts,
190
+ performance,
204
191
  tags: tags || [],
205
192
  createdAt: now,
206
193
  updatedAt: now,
@@ -237,6 +224,7 @@ export class RemoteIngressManager {
237
224
  listenPorts?: number[];
238
225
  autoDerivePorts?: boolean;
239
226
  enabled?: boolean;
227
+ performance?: IRemoteIngressPerformanceConfig;
240
228
  tags?: string[];
241
229
  },
242
230
  ): Promise<IRemoteIngress | null> {
@@ -249,6 +237,7 @@ export class RemoteIngressManager {
249
237
  if (updates.listenPorts !== undefined) edge.listenPorts = updates.listenPorts;
250
238
  if (updates.autoDerivePorts !== undefined) edge.autoDerivePorts = updates.autoDerivePorts;
251
239
  if (updates.enabled !== undefined) edge.enabled = updates.enabled;
240
+ if (updates.performance !== undefined) edge.performance = updates.performance;
252
241
  if (updates.tags !== undefined) edge.tags = updates.tags;
253
242
  edge.updatedAt = Date.now();
254
243
 
@@ -317,17 +306,19 @@ export class RemoteIngressManager {
317
306
  * Get the list of allowed edges (enabled only) for the Rust hub.
318
307
  * Includes listenPortsUdp when routes with transport 'udp' or 'all' are present.
319
308
  */
320
- public getAllowedEdges(): Array<{ id: string; secret: string; listenPorts: number[]; listenPortsUdp?: number[]; firewallConfig?: IRemoteIngressFirewallConfig }> {
321
- const result: Array<{ id: string; secret: string; listenPorts: number[]; listenPortsUdp?: number[]; firewallConfig?: IRemoteIngressFirewallConfig }> = [];
309
+ public getAllowedEdges(): Array<{ id: string; secret: string; listenPorts: number[]; listenPortsUdp?: number[]; firewallConfig?: IRemoteIngressFirewallConfig; performance?: IRemoteIngressPerformanceConfig }> {
310
+ const result: Array<{ id: string; secret: string; listenPorts: number[]; listenPortsUdp?: number[]; firewallConfig?: IRemoteIngressFirewallConfig; performance?: IRemoteIngressPerformanceConfig }> = [];
322
311
  for (const edge of this.edges.values()) {
323
312
  if (edge.enabled) {
324
313
  const listenPortsUdp = this.getEffectiveListenPortsUdp(edge);
314
+ const performance = edge.performance && Object.keys(edge.performance).length > 0 ? edge.performance : undefined;
325
315
  result.push({
326
316
  id: edge.id,
327
317
  secret: edge.secret,
328
318
  listenPorts: this.getEffectiveListenPorts(edge),
329
319
  ...(listenPortsUdp.length > 0 ? { listenPortsUdp } : {}),
330
320
  ...(this.firewallConfig ? { firewallConfig: this.firewallConfig } : {}),
321
+ ...(performance ? { performance } : {}),
331
322
  });
332
323
  }
333
324
  }
@@ -3,6 +3,6 @@
3
3
  */
4
4
  export const commitinfo = {
5
5
  name: '@serve.zone/dcrouter',
6
- version: '13.38.3',
6
+ version: '13.39.0',
7
7
  description: 'A multifaceted routing service handling mail and SMS delivery functions.'
8
8
  }
@@ -1120,6 +1120,7 @@ export const createRemoteIngressAction = remoteIngressStatePart.createAction<{
1120
1120
  name: string;
1121
1121
  listenPorts?: number[];
1122
1122
  autoDerivePorts?: boolean;
1123
+ performance?: interfaces.data.IRemoteIngressPerformanceConfig;
1123
1124
  tags?: string[];
1124
1125
  }>(async (statePartArg, dataArg, actionContext): Promise<IRemoteIngressState> => {
1125
1126
  const context = getActionContext();
@@ -1135,6 +1136,7 @@ export const createRemoteIngressAction = remoteIngressStatePart.createAction<{
1135
1136
  name: dataArg.name,
1136
1137
  listenPorts: dataArg.listenPorts,
1137
1138
  autoDerivePorts: dataArg.autoDerivePorts,
1139
+ performance: dataArg.performance,
1138
1140
  tags: dataArg.tags,
1139
1141
  });
1140
1142
 
@@ -1187,6 +1189,7 @@ export const updateRemoteIngressAction = remoteIngressStatePart.createAction<{
1187
1189
  name?: string;
1188
1190
  listenPorts?: number[];
1189
1191
  autoDerivePorts?: boolean;
1192
+ performance?: interfaces.data.IRemoteIngressPerformanceConfig;
1190
1193
  tags?: string[];
1191
1194
  }>(async (statePartArg, dataArg, actionContext): Promise<IRemoteIngressState> => {
1192
1195
  const context = getActionContext();
@@ -1203,6 +1206,7 @@ export const updateRemoteIngressAction = remoteIngressStatePart.createAction<{
1203
1206
  name: dataArg.name,
1204
1207
  listenPorts: dataArg.listenPorts,
1205
1208
  autoDerivePorts: dataArg.autoDerivePorts,
1209
+ performance: dataArg.performance,
1206
1210
  tags: dataArg.tags,
1207
1211
  });
1208
1212
 
@@ -242,6 +242,7 @@ export class OpsViewRemoteIngress extends DeesElement {
242
242
  publicIp: this.getEdgePublicIp(edge.id),
243
243
  ports: this.getPortsHtml(edge),
244
244
  tunnels: this.getEdgeTunnelCount(edge.id),
245
+ maxConnections: this.getMaxConnectionsHtml(edge),
245
246
  window: this.getWindowHtml(edge.id),
246
247
  queues: this.getQueuesHtml(edge.id),
247
248
  traffic: this.getTrafficHtml(edge.id),
@@ -261,6 +262,7 @@ export class OpsViewRemoteIngress extends DeesElement {
261
262
  <dees-input-text .key=${'name'} .label=${'Name'} .required=${true}></dees-input-text>
262
263
  <dees-input-text .key=${'listenPorts'} .label=${'Manual Ports'} .description=${'Comma-separated port numbers, optional'}></dees-input-text>
263
264
  <dees-input-checkbox .key=${'autoDerivePorts'} .label=${'Auto-derive ports from routes'} .value=${true}></dees-input-checkbox>
265
+ <dees-input-text .key=${'maxStreamsPerEdge'} .label=${'Max Connections'} .description=${'Optional maximum concurrent client connections for this edge. Leave empty to use the hub default.'}></dees-input-text>
264
266
  <dees-input-text .key=${'tags'} .label=${'Tags'} .description=${'Comma-separated, optional'}></dees-input-text>
265
267
  </dees-form>
266
268
  `,
@@ -284,12 +286,20 @@ export class OpsViewRemoteIngress extends DeesElement {
284
286
  ? portsStr.split(',').map((p: string) => parseInt(p.trim(), 10)).filter((p: number) => !isNaN(p))
285
287
  : undefined;
286
288
  const autoDerivePorts = formData.autoDerivePorts !== false;
289
+ let performance: interfaces.data.IRemoteIngressPerformanceConfig | undefined;
290
+ try {
291
+ performance = this.collectPerformanceOverride(formData);
292
+ } catch (err: unknown) {
293
+ const { DeesToast } = await import('@design.estate/dees-catalog');
294
+ DeesToast.show({ message: (err as Error).message, type: 'error', duration: 4000 });
295
+ return;
296
+ }
287
297
  const tags = formData.tags
288
298
  ? formData.tags.split(',').map((t: string) => t.trim()).filter(Boolean)
289
299
  : undefined;
290
300
  await appstate.remoteIngressStatePart.dispatchAction(
291
301
  appstate.createRemoteIngressAction,
292
- { name, listenPorts, autoDerivePorts, tags },
302
+ { name, listenPorts, autoDerivePorts, performance, tags },
293
303
  );
294
304
  await modalArg.destroy();
295
305
  },
@@ -338,6 +348,7 @@ export class OpsViewRemoteIngress extends DeesElement {
338
348
  <dees-input-text .key=${'name'} .label=${'Name'} .value=${edge.name}></dees-input-text>
339
349
  <dees-input-text .key=${'listenPorts'} .label=${'Manual Ports'} .description=${'Comma-separated port numbers'} .value=${(edge.listenPorts || []).join(', ')}></dees-input-text>
340
350
  <dees-input-checkbox .key=${'autoDerivePorts'} .label=${'Auto-derive ports from routes'} .value=${edge.autoDerivePorts !== false}></dees-input-checkbox>
351
+ <dees-input-text .key=${'maxStreamsPerEdge'} .label=${'Max Connections'} .description=${'Optional maximum concurrent client connections for this edge. Leave empty to use the hub default.'} .value=${edge.performance?.maxStreamsPerEdge?.toString() || ''}></dees-input-text>
341
352
  <dees-input-text .key=${'tags'} .label=${'Tags'} .description=${'Comma-separated'} .value=${(edge.tags || []).join(', ')}></dees-input-text>
342
353
  </dees-form>
343
354
  `,
@@ -359,6 +370,14 @@ export class OpsViewRemoteIngress extends DeesElement {
359
370
  ? portsStr.split(',').map((p: string) => parseInt(p.trim(), 10)).filter((p: number) => !isNaN(p))
360
371
  : [];
361
372
  const autoDerivePorts = formData.autoDerivePorts !== false;
373
+ let performance: interfaces.data.IRemoteIngressPerformanceConfig | undefined;
374
+ try {
375
+ performance = this.collectPerformanceOverride(formData, edge.performance);
376
+ } catch (err: unknown) {
377
+ const { DeesToast } = await import('@design.estate/dees-catalog');
378
+ DeesToast.show({ message: (err as Error).message, type: 'error', duration: 4000 });
379
+ return;
380
+ }
362
381
  const tags = formData.tags
363
382
  ? formData.tags.split(',').map((t: string) => t.trim()).filter(Boolean)
364
383
  : [];
@@ -369,6 +388,7 @@ export class OpsViewRemoteIngress extends DeesElement {
369
388
  name: formData.name || edge.name,
370
389
  listenPorts,
371
390
  autoDerivePorts,
391
+ performance,
372
392
  tags,
373
393
  },
374
394
  );
@@ -475,6 +495,19 @@ export class OpsViewRemoteIngress extends DeesElement {
475
495
  return status?.activeTunnels || 0;
476
496
  }
477
497
 
498
+ private getMaxConnectionsHtml(edge: interfaces.data.IRemoteIngress): TemplateResult | string {
499
+ const status = this.getEdgeStatus(edge.id);
500
+ const override = edge.performance?.maxStreamsPerEdge;
501
+ const effective = status?.performance?.maxStreamsPerEdge;
502
+ if (!override && !effective) return '-';
503
+ return html`
504
+ <div class="metricStack">
505
+ <span>${override || effective}</span>
506
+ <span class="metricMuted">${override ? 'edge override' : 'hub default'}</span>
507
+ </div>
508
+ `;
509
+ }
510
+
478
511
  private getTransportHtml(edgeId: string): TemplateResult | string {
479
512
  const status = this.getEdgeStatus(edgeId);
480
513
  if (!status?.connected) return '-';
@@ -535,4 +568,27 @@ export class OpsViewRemoteIngress extends DeesElement {
535
568
  }
536
569
  return `${value >= 10 || unitIndex === 0 ? value.toFixed(0) : value.toFixed(1)} ${units[unitIndex]}`;
537
570
  }
571
+
572
+ private collectPerformanceOverride(
573
+ formData: Record<string, any>,
574
+ base?: interfaces.data.IRemoteIngressPerformanceConfig,
575
+ ): interfaces.data.IRemoteIngressPerformanceConfig | undefined {
576
+ const next: interfaces.data.IRemoteIngressPerformanceConfig = { ...(base || {}) };
577
+ const maxStreamsText = `${formData.maxStreamsPerEdge || ''}`.trim();
578
+ if (maxStreamsText) {
579
+ const maxStreamsPerEdge = Number.parseInt(maxStreamsText, 10);
580
+ if (!Number.isInteger(maxStreamsPerEdge) || maxStreamsPerEdge < 1) {
581
+ throw new Error('Max Connections must be a positive integer');
582
+ }
583
+ next.maxStreamsPerEdge = maxStreamsPerEdge;
584
+ } else {
585
+ delete next.maxStreamsPerEdge;
586
+ }
587
+
588
+ if (Object.keys(next).length > 0) {
589
+ return next;
590
+ }
591
+
592
+ return base ? {} : undefined;
593
+ }
538
594
  }
@@ -304,6 +304,16 @@ export class OpsViewConfig extends DeesElement {
304
304
  { key: 'Connected Edge IPs', value: ri.connectedEdgeIps?.length > 0 ? ri.connectedEdgeIps : null, type: 'pills' },
305
305
  ];
306
306
 
307
+ if (ri.performance) {
308
+ fields.push(
309
+ { key: 'Performance Profile', value: ri.performance.profile || null, type: 'badge' },
310
+ { key: 'Max Connections / Edge', value: ri.performance.maxStreamsPerEdge ?? null },
311
+ { key: 'Client Write Timeout', value: ri.performance.clientWriteTimeoutMs ? `${ri.performance.clientWriteTimeoutMs} ms` : null },
312
+ { key: 'First Data Timeout', value: ri.performance.firstDataConnectTimeoutMs ? `${ri.performance.firstDataConnectTimeoutMs} ms` : null },
313
+ { key: 'Server-first Ports', value: ri.performance.serverFirstPorts?.length ? ri.performance.serverFirstPorts.map(String) : null, type: 'pills' },
314
+ );
315
+ }
316
+
307
317
  const actions: IConfigSectionAction[] = [
308
318
  { label: 'View Remote Ingress', icon: 'lucide:arrow-right', event: 'navigate', detail: { view: 'network', subview: 'remoteingress' } },
309
319
  ];