Compare commits

...

10 Commits

Author SHA1 Message Date
345effee13 v10.1.0
Some checks failed
Docker (tags) / security (push) Failing after 1s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-02-27 10:24:20 +00:00
dee6897931 feat(api-tokens): add ability to roll (regenerate) API token secrets and UI to display the newly generated token once 2026-02-27 10:24:20 +00:00
56f41d70b3 v10.0.0
Some checks failed
Docker (tags) / security (push) Failing after 1s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-02-27 00:04:24 +00:00
8f570ae8a0 BREAKING CHANGE(remote-ingress): replace tlsConfigured boolean with tlsMode (custom | acme | self-signed) and compute TLS mode server-side 2026-02-27 00:04:24 +00:00
e58e24a92d v9.3.0
Some checks failed
Docker (tags) / security (push) Failing after 1s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-02-26 23:50:40 +00:00
12070bc7b5 feat(remoteingress): add TLS certificate resolution and passthrough for RemoteIngress tunnel 2026-02-26 23:50:40 +00:00
37d62c51f3 v9.2.0
Some checks failed
Docker (tags) / security (push) Failing after 0s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-02-26 23:15:00 +00:00
ea9427d46b feat(remoteingress): expose connected edge IPs and detected public IP; resolve proxy IPs from SmartProxy and improve ops UI 2026-02-26 23:15:00 +00:00
bc77321752 v9.1.10
Some checks failed
Docker (tags) / security (push) Failing after 1s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-02-26 21:34:01 +00:00
65aa546c1c fix(deps): bump @push.rocks/smartproxy to ^25.8.5 2026-02-26 21:34:01 +00:00
15 changed files with 304 additions and 26 deletions

View File

@@ -1,5 +1,48 @@
# Changelog # Changelog
## 2026-02-27 - 10.1.0 - feat(api-tokens)
add ability to roll (regenerate) API token secrets and UI to display the newly generated token once
- Server: added ApiTokenManager.rollToken(id) to regenerate a token secret, update its hash, persist it and log the action.
- Server: added opsserver handler 'rollApiToken' which requires admin identity and returns the new raw token value (shown once) or error messages.
- API: added typed request interface IReq_RollApiToken for the rollApiToken RPC.
- Web: added appstate.rollApiToken wrapper to call the new typed request.
- UI: ops-view-apitokens updated with a 'Roll' action and a modal flow to confirm rolling, call the API, refresh token list, and present the new token value to copy (token value is shown only once).
- Security: operation is admin-only and the raw token is returned only once after rolling.
## 2026-02-27 - 10.0.0 - BREAKING CHANGE(remote-ingress)
replace tlsConfigured boolean with tlsMode ('custom' | 'acme' | 'self-signed') and compute TLS mode server-side
- Server: compute remoteIngress.tlsMode = 'custom' when custom certPath/keyPath provided; else attempt to detect ACME by checking stored certs for hubDomain; default to 'self-signed' as fallback.
- API: replaced remoteIngress.tlsConfigured:boolean with tlsMode:'custom'|'acme'|'self-signed' — this is a breaking change for consumers of the config API.
- UI: ops view updated to display TLS Mode as a badge instead of a boolean "TLS Configured" field.
- Action required: update clients and integrations to read remoteIngress.tlsMode instead of tlsConfigured.
## 2026-02-26 - 9.3.0 - feat(remoteingress)
add TLS certificate resolution and passthrough for RemoteIngress tunnel
- Resolve TLS certs for the RemoteIngress tunnel with priority: explicit certPath/keyPath files → stored ACME cert for hubDomain → fallback to self-signed
- Expose tls option on ITunnelManagerConfig and forward certPem/keyPem into hub.start so the hub can use the provided TLS materials
- Add logging for cert selection and file read failures
- Bump dependency @serve.zone/remoteingress from ^4.2.0 to ^4.3.0
## 2026-02-26 - 9.2.0 - feat(remoteingress)
expose connected edge IPs and detected public IP; resolve proxy IPs from SmartProxy and improve ops UI
- Add detectedPublicIp to DC Router and populate it when a configured or auto-discovered public IP is chosen
- Use dcRouter.detectedPublicIp as a fallback for system.publicIp in the config handler
- Resolve proxy IPs from SmartProxy runtime settings when opts.proxyIps is not provided
- TunnelManager: capture peerAddr on edgeConnected and from Rust heartbeats, store per-edge publicIp, and add getConnectedEdgeIps()
- Expose connectedEdgeIps in the config API and return it in remoteIngress config
- Ops UI: show Connected Edge IPs, annotate 127.0.0.1 proxy IP as 'Remote Ingress' when applicable, and refresh remote ingress data during combined refresh when viewing remoteingress
- Bump dependency @serve.zone/remoteingress to ^4.2.0
## 2026-02-26 - 9.1.10 - fix(deps)
bump @push.rocks/smartproxy to ^25.8.5
- package.json: @push.rocks/smartproxy version updated from ^25.8.4 to ^25.8.5
- No other files changed
## 2026-02-26 - 9.1.9 - fix(deps(smartmta)) ## 2026-02-26 - 9.1.9 - fix(deps(smartmta))
bump @push.rocks/smartmta to ^5.3.0 bump @push.rocks/smartmta to ^5.3.0

View File

@@ -1,7 +1,7 @@
{ {
"name": "@serve.zone/dcrouter", "name": "@serve.zone/dcrouter",
"private": false, "private": false,
"version": "9.1.9", "version": "10.1.0",
"description": "A multifaceted routing service handling mail and SMS delivery functions.", "description": "A multifaceted routing service handling mail and SMS delivery functions.",
"type": "module", "type": "module",
"exports": { "exports": {
@@ -49,7 +49,7 @@
"@push.rocks/smartnetwork": "^4.4.0", "@push.rocks/smartnetwork": "^4.4.0",
"@push.rocks/smartpath": "^6.0.0", "@push.rocks/smartpath": "^6.0.0",
"@push.rocks/smartpromise": "^4.2.3", "@push.rocks/smartpromise": "^4.2.3",
"@push.rocks/smartproxy": "^25.8.4", "@push.rocks/smartproxy": "^25.8.5",
"@push.rocks/smartradius": "^1.1.1", "@push.rocks/smartradius": "^1.1.1",
"@push.rocks/smartrequest": "^5.0.1", "@push.rocks/smartrequest": "^5.0.1",
"@push.rocks/smartrx": "^3.0.10", "@push.rocks/smartrx": "^3.0.10",
@@ -57,7 +57,7 @@
"@push.rocks/smartunique": "^3.0.9", "@push.rocks/smartunique": "^3.0.9",
"@serve.zone/catalog": "^2.5.0", "@serve.zone/catalog": "^2.5.0",
"@serve.zone/interfaces": "^5.3.0", "@serve.zone/interfaces": "^5.3.0",
"@serve.zone/remoteingress": "^4.1.0", "@serve.zone/remoteingress": "^4.3.0",
"@tsclass/tsclass": "^9.3.0", "@tsclass/tsclass": "^9.3.0",
"lru-cache": "^11.2.6", "lru-cache": "^11.2.6",
"uuid": "^13.0.0" "uuid": "^13.0.0"

20
pnpm-lock.yaml generated
View File

@@ -75,8 +75,8 @@ importers:
specifier: ^4.2.3 specifier: ^4.2.3
version: 4.2.3 version: 4.2.3
'@push.rocks/smartproxy': '@push.rocks/smartproxy':
specifier: ^25.8.4 specifier: ^25.8.5
version: 25.8.4 version: 25.8.5
'@push.rocks/smartradius': '@push.rocks/smartradius':
specifier: ^1.1.1 specifier: ^1.1.1
version: 1.1.1 version: 1.1.1
@@ -99,8 +99,8 @@ importers:
specifier: ^5.3.0 specifier: ^5.3.0
version: 5.3.0 version: 5.3.0
'@serve.zone/remoteingress': '@serve.zone/remoteingress':
specifier: ^4.1.0 specifier: ^4.3.0
version: 4.1.0 version: 4.3.0
'@tsclass/tsclass': '@tsclass/tsclass':
specifier: ^9.3.0 specifier: ^9.3.0
version: 9.3.0 version: 9.3.0
@@ -1035,8 +1035,8 @@ packages:
'@push.rocks/smartpromise@4.2.3': '@push.rocks/smartpromise@4.2.3':
resolution: {integrity: sha512-Ycg/TJR+tMt+S3wSFurOpEoW6nXv12QBtKXgBcjMZ4RsdO28geN46U09osPn9N9WuwQy1PkmTV5J/V4F9U8qEw==} resolution: {integrity: sha512-Ycg/TJR+tMt+S3wSFurOpEoW6nXv12QBtKXgBcjMZ4RsdO28geN46U09osPn9N9WuwQy1PkmTV5J/V4F9U8qEw==}
'@push.rocks/smartproxy@25.8.4': '@push.rocks/smartproxy@25.8.5':
resolution: {integrity: sha512-j1qRbO4qFV1HJgBPzF56FzTVY0u4/8kEQBK52Qt+/FDnUITGVGVkEWZrbe2H7zodjH6t+EGNdN4QEywBBq3Ylw==} resolution: {integrity: sha512-oLmV+Bq7sSgQP9McTao/imb6Xb62QM7wlTFt5kNynrS5WK2wAe8cEjDKOcyu8N/WmzNCEClT5f/0xAtI6JxtkA==}
'@push.rocks/smartpuppeteer@2.0.5': '@push.rocks/smartpuppeteer@2.0.5':
resolution: {integrity: sha512-yK/qSeWVHIGWRp3c8S5tfdGP6WCKllZC4DR8d8CQlEjszOSBmHtlTdyyqOMBZ/BA4kd+eU5f3A1r4K2tGYty1g==} resolution: {integrity: sha512-yK/qSeWVHIGWRp3c8S5tfdGP6WCKllZC4DR8d8CQlEjszOSBmHtlTdyyqOMBZ/BA4kd+eU5f3A1r4K2tGYty1g==}
@@ -1344,8 +1344,8 @@ packages:
'@serve.zone/interfaces@5.3.0': '@serve.zone/interfaces@5.3.0':
resolution: {integrity: sha512-venO7wtDR9ixzD9NhdERBGjNKbFA5LL0yHw4eqGh0UpmvtXVc3SFG0uuHDilOKMZqZ8bttV88qVsFy1aSTJrtA==} resolution: {integrity: sha512-venO7wtDR9ixzD9NhdERBGjNKbFA5LL0yHw4eqGh0UpmvtXVc3SFG0uuHDilOKMZqZ8bttV88qVsFy1aSTJrtA==}
'@serve.zone/remoteingress@4.1.0': '@serve.zone/remoteingress@4.3.0':
resolution: {integrity: sha512-iGLEyqDsh1oK3tJxJUklc8vxJDNviGSsyA9EiUSwUsTW9LlqFkWzdujU7w4Ebj/DIBGlhnQe1opzv3+q/jOY5w==} resolution: {integrity: sha512-yk14uS6oWIP83Zpem4hGf8zi3W9pefnxijtSWp45WvZ+u9XTXIADQNaUZBSTCId8CYkfPkfRGaaaARunVdjFXg==}
'@sindresorhus/is@5.6.0': '@sindresorhus/is@5.6.0':
resolution: {integrity: sha512-TV7t8GKYaJWsn00tFDqBw8+Uqmr8A0fRU1tvTQhyZzGv0sJCGRQL3JGMI3ucuKo3XIZdUP+Lx7/gh2t3lewy7g==} resolution: {integrity: sha512-TV7t8GKYaJWsn00tFDqBw8+Uqmr8A0fRU1tvTQhyZzGv0sJCGRQL3JGMI3ucuKo3XIZdUP+Lx7/gh2t3lewy7g==}
@@ -6340,7 +6340,7 @@ snapshots:
'@push.rocks/smartpromise@4.2.3': {} '@push.rocks/smartpromise@4.2.3': {}
'@push.rocks/smartproxy@25.8.4': '@push.rocks/smartproxy@25.8.5':
dependencies: dependencies:
'@push.rocks/smartcrypto': 2.0.4 '@push.rocks/smartcrypto': 2.0.4
'@push.rocks/smartlog': 3.2.1 '@push.rocks/smartlog': 3.2.1
@@ -6827,7 +6827,7 @@ snapshots:
'@push.rocks/smartlog-interfaces': 3.0.2 '@push.rocks/smartlog-interfaces': 3.0.2
'@tsclass/tsclass': 9.3.0 '@tsclass/tsclass': 9.3.0
'@serve.zone/remoteingress@4.1.0': '@serve.zone/remoteingress@4.3.0':
dependencies: dependencies:
'@push.rocks/qenv': 6.1.3 '@push.rocks/qenv': 6.1.3
'@push.rocks/smartrust': 1.3.1 '@push.rocks/smartrust': 1.3.1

View File

@@ -3,6 +3,6 @@
*/ */
export const commitinfo = { export const commitinfo = {
name: '@serve.zone/dcrouter', name: '@serve.zone/dcrouter',
version: '9.1.9', version: '10.1.0',
description: 'A multifaceted routing service handling mail and SMS delivery functions.' description: 'A multifaceted routing service handling mail and SMS delivery functions.'
} }

View File

@@ -217,6 +217,9 @@ export class DcRouter {
public routeConfigManager?: RouteConfigManager; public routeConfigManager?: RouteConfigManager;
public apiTokenManager?: ApiTokenManager; public apiTokenManager?: ApiTokenManager;
// Auto-discovered public IP (populated by generateAuthoritativeRecords)
public detectedPublicIp: string | null = null;
// DNS query logging rate limiter state // DNS query logging rate limiter state
private dnsLogWindow: number[] = []; private dnsLogWindow: number[] = [];
private dnsBatchCount: number = 0; private dnsBatchCount: number = 0;
@@ -1574,6 +1577,7 @@ export class DcRouter {
} else if (this.options.publicIp) { } else if (this.options.publicIp) {
// Use explicitly configured public IP // Use explicitly configured public IP
publicIp = this.options.publicIp; publicIp = this.options.publicIp;
this.detectedPublicIp = publicIp;
logger.log('info', `Using configured public IP for nameserver A records: ${publicIp}`); logger.log('info', `Using configured public IP for nameserver A records: ${publicIp}`);
} else { } else {
// Auto-discover public IP using smartnetwork // Auto-discover public IP using smartnetwork
@@ -1584,6 +1588,7 @@ export class DcRouter {
if (publicIps.v4) { if (publicIps.v4) {
publicIp = publicIps.v4; publicIp = publicIps.v4;
this.detectedPublicIp = publicIp;
logger.log('info', `Auto-discovered public IPv4: ${publicIp}`); logger.log('info', `Auto-discovered public IPv4: ${publicIp}`);
} else { } else {
logger.log('warn', 'Could not auto-discover public IPv4 address'); logger.log('warn', 'Could not auto-discover public IPv4 address');
@@ -1709,10 +1714,42 @@ export class DcRouter {
const currentRoutes = this.options.smartProxyConfig?.routes || []; const currentRoutes = this.options.smartProxyConfig?.routes || [];
this.remoteIngressManager.setRoutes(currentRoutes as any[]); this.remoteIngressManager.setRoutes(currentRoutes as any[]);
// Resolve TLS certs for tunnel: explicit paths > ACME for hubDomain > self-signed (Rust default)
const riCfg = this.options.remoteIngressConfig;
let tlsConfig: { certPem: string; keyPem: string } | undefined;
// Priority 1: Explicit cert/key file paths
if (riCfg.tls?.certPath && riCfg.tls?.keyPath) {
try {
const certPem = plugins.fs.readFileSync(riCfg.tls.certPath, 'utf8');
const keyPem = plugins.fs.readFileSync(riCfg.tls.keyPath, 'utf8');
tlsConfig = { certPem, keyPem };
logger.log('info', 'Using explicit TLS cert/key for RemoteIngress tunnel');
} catch (err) {
logger.log('warn', `Failed to read RemoteIngress TLS cert/key files: ${err.message}`);
}
}
// Priority 2: Existing cert from SmartProxy cert store for hubDomain
if (!tlsConfig && riCfg.hubDomain) {
try {
const stored = await this.storageManager.getJSON(`/proxy-certs/${riCfg.hubDomain}`);
if (stored?.publicKey && stored?.privateKey) {
tlsConfig = { certPem: stored.publicKey, keyPem: stored.privateKey };
logger.log('info', `Using stored ACME cert for RemoteIngress tunnel TLS: ${riCfg.hubDomain}`);
}
} catch { /* no stored cert, fall through */ }
}
if (!tlsConfig) {
logger.log('info', 'No TLS cert configured for RemoteIngress tunnel — using auto-generated self-signed');
}
// Create and start the tunnel manager // Create and start the tunnel manager
this.tunnelManager = new TunnelManager(this.remoteIngressManager, { this.tunnelManager = new TunnelManager(this.remoteIngressManager, {
tunnelPort: this.options.remoteIngressConfig.tunnelPort ?? 8443, tunnelPort: riCfg.tunnelPort ?? 8443,
targetHost: '127.0.0.1', targetHost: '127.0.0.1',
tls: tlsConfig,
}); });
await this.tunnelManager.start(); await this.tunnelManager.start();

View File

@@ -122,6 +122,24 @@ export class ApiTokenManager {
return true; return true;
} }
/**
* Roll (regenerate) a token's secret while keeping its identity.
* Returns the new raw token value (shown once).
*/
public async rollToken(id: string): Promise<{ id: string; rawToken: string } | null> {
const stored = this.tokens.get(id);
if (!stored) return null;
const randomBytes = plugins.crypto.randomBytes(32);
const rawPayload = `${id}:${randomBytes.toString('base64url')}`;
const rawToken = `${TOKEN_PREFIX_STR}${rawPayload}`;
stored.tokenHash = plugins.crypto.createHash('sha256').update(rawToken).digest('hex');
await this.persistToken(stored);
logger.log('info', `API token '${stored.name}' rolled (id: ${id})`);
return { id, rawToken };
}
/** /**
* Enable or disable a token. * Enable or disable a token.
*/ */

View File

@@ -77,6 +77,25 @@ export class ApiTokenHandler {
), ),
); );
// Roll API token
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_RollApiToken>(
'rollApiToken',
async (dataArg) => {
await this.requireAdmin(dataArg.identity);
const manager = this.opsServerRef.dcRouterRef.apiTokenManager;
if (!manager) {
return { success: false, message: 'Token management not initialized' };
}
const result = await manager.rollToken(dataArg.id);
if (!result) {
return { success: false, message: 'Token not found' };
}
return { success: true, tokenValue: result.rawToken };
},
),
);
// Toggle API token // Toggle API token
this.typedrouter.addTypedHandler( this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ToggleApiToken>( new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ToggleApiToken>(

View File

@@ -40,11 +40,20 @@ export class ConfigHandler {
? 'filesystem' ? 'filesystem'
: 'memory'; : 'memory';
// Resolve proxy IPs: fall back to SmartProxy's runtime proxyIPs if not in opts
let proxyIps = opts.proxyIps || [];
if (proxyIps.length === 0 && dcRouter.smartProxy) {
const spSettings = (dcRouter.smartProxy as any).settings;
if (spSettings?.proxyIPs?.length > 0) {
proxyIps = spSettings.proxyIPs;
}
}
const system: interfaces.requests.IConfigData['system'] = { const system: interfaces.requests.IConfigData['system'] = {
baseDir: resolvedPaths.dcrouterHomeDir, baseDir: resolvedPaths.dcrouterHomeDir,
dataDir: resolvedPaths.dataDir, dataDir: resolvedPaths.dataDir,
publicIp: opts.publicIp || null, publicIp: opts.publicIp || dcRouter.detectedPublicIp || null,
proxyIps: opts.proxyIps || [], proxyIps,
uptime: Math.floor(process.uptime()), uptime: Math.floor(process.uptime()),
storageBackend, storageBackend,
storagePath: opts.storage?.fsPath || null, storagePath: opts.storage?.fsPath || null,
@@ -169,11 +178,27 @@ export class ConfigHandler {
// --- Remote Ingress --- // --- Remote Ingress ---
const riCfg = opts.remoteIngressConfig; const riCfg = opts.remoteIngressConfig;
const connectedEdgeIps = dcRouter.tunnelManager?.getConnectedEdgeIps() || [];
// Determine TLS mode: custom certs > ACME from cert store > self-signed fallback
let tlsMode: 'custom' | 'acme' | 'self-signed' = 'self-signed';
if (riCfg?.tls?.certPath && riCfg?.tls?.keyPath) {
tlsMode = 'custom';
} else if (riCfg?.hubDomain) {
try {
const stored = await dcRouter.storageManager.getJSON(`/proxy-certs/${riCfg.hubDomain}`);
if (stored?.publicKey && stored?.privateKey) {
tlsMode = 'acme';
}
} catch { /* no stored cert */ }
}
const remoteIngress: interfaces.requests.IConfigData['remoteIngress'] = { const remoteIngress: interfaces.requests.IConfigData['remoteIngress'] = {
enabled: !!dcRouter.remoteIngressManager, enabled: !!dcRouter.remoteIngressManager,
tunnelPort: riCfg?.tunnelPort || null, tunnelPort: riCfg?.tunnelPort || null,
hubDomain: riCfg?.hubDomain || null, hubDomain: riCfg?.hubDomain || null,
tlsConfigured: !!(riCfg?.tls?.certPath && riCfg?.tls?.keyPath), tlsMode,
connectedEdgeIps,
}; };
return { return {

View File

@@ -5,6 +5,10 @@ import type { RemoteIngressManager } from './classes.remoteingress-manager.js';
export interface ITunnelManagerConfig { export interface ITunnelManagerConfig {
tunnelPort?: number; tunnelPort?: number;
targetHost?: string; targetHost?: string;
tls?: {
certPem?: string;
keyPem?: string;
};
} }
/** /**
@@ -23,12 +27,11 @@ export class TunnelManager {
this.hub = new plugins.remoteingress.RemoteIngressHub(); this.hub = new plugins.remoteingress.RemoteIngressHub();
// Listen for edge connect/disconnect events // Listen for edge connect/disconnect events
this.hub.on('edgeConnected', (data: { edgeId: string }) => { this.hub.on('edgeConnected', (data: { edgeId: string; peerAddr: string }) => {
const existing = this.edgeStatuses.get(data.edgeId);
this.edgeStatuses.set(data.edgeId, { this.edgeStatuses.set(data.edgeId, {
edgeId: data.edgeId, edgeId: data.edgeId,
connected: true, connected: true,
publicIp: existing?.publicIp ?? null, publicIp: data.peerAddr || null,
activeTunnels: 0, activeTunnels: 0,
lastHeartbeat: Date.now(), lastHeartbeat: Date.now(),
connectedAt: Date.now(), connectedAt: Date.now(),
@@ -62,6 +65,7 @@ export class TunnelManager {
await this.hub.start({ await this.hub.start({
tunnelPort: this.config.tunnelPort ?? 8443, tunnelPort: this.config.tunnelPort ?? 8443,
targetHost: this.config.targetHost ?? '127.0.0.1', targetHost: this.config.targetHost ?? '127.0.0.1',
tls: this.config.tls,
}); });
// Send allowed edges to the hub // Send allowed edges to the hub
@@ -103,12 +107,16 @@ export class TunnelManager {
if (existing) { if (existing) {
existing.activeTunnels = rustEdge.activeStreams; existing.activeTunnels = rustEdge.activeStreams;
existing.lastHeartbeat = Date.now(); existing.lastHeartbeat = Date.now();
// Update peer address if available from Rust hub
if (rustEdge.peerAddr) {
existing.publicIp = rustEdge.peerAddr;
}
} else { } else {
// Missed edgeConnected event — add entry // Missed edgeConnected event — add entry
this.edgeStatuses.set(rustEdge.edgeId, { this.edgeStatuses.set(rustEdge.edgeId, {
edgeId: rustEdge.edgeId, edgeId: rustEdge.edgeId,
connected: true, connected: true,
publicIp: null, publicIp: rustEdge.peerAddr || null,
activeTunnels: rustEdge.activeStreams, activeTunnels: rustEdge.activeStreams,
lastHeartbeat: Date.now(), lastHeartbeat: Date.now(),
connectedAt: rustEdge.connectedAt * 1000, connectedAt: rustEdge.connectedAt * 1000,
@@ -158,6 +166,19 @@ export class TunnelManager {
return count; return count;
} }
/**
* Get the public IPs of all connected edges.
*/
public getConnectedEdgeIps(): string[] {
const ips: string[] = [];
for (const status of this.edgeStatuses.values()) {
if (status.connected && status.publicIp) {
ips.push(status.publicIp);
}
}
return ips;
}
/** /**
* Get the total number of active tunnels across all edges. * Get the total number of active tunnels across all edges.
*/ */

View File

@@ -63,6 +63,26 @@ export interface IReq_RevokeApiToken extends plugins.typedrequestInterfaces.impl
}; };
} }
/**
* Roll (regenerate) an API token's secret. Returns the new raw token value once.
* Admin JWT only.
*/
export interface IReq_RollApiToken extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_RollApiToken
> {
method: 'rollApiToken';
request: {
identity?: authInterfaces.IIdentity;
id: string;
};
response: {
success: boolean;
tokenValue?: string;
message?: string;
};
}
/** /**
* Enable or disable an API token. * Enable or disable an API token.
*/ */

View File

@@ -69,7 +69,8 @@ export interface IConfigData {
enabled: boolean; enabled: boolean;
tunnelPort: number | null; tunnelPort: number | null;
hubDomain: string | null; hubDomain: string | null;
tlsConfigured: boolean; tlsMode: 'custom' | 'acme' | 'self-signed';
connectedEdgeIps: string[];
}; };
} }

View File

@@ -3,6 +3,6 @@
*/ */
export const commitinfo = { export const commitinfo = {
name: '@serve.zone/dcrouter', name: '@serve.zone/dcrouter',
version: '9.1.9', version: '10.1.0',
description: 'A multifaceted routing service handling mail and SMS delivery functions.' description: 'A multifaceted routing service handling mail and SMS delivery functions.'
} }

View File

@@ -1115,6 +1115,18 @@ export async function createApiToken(name: string, scopes: interfaces.data.TApiT
}); });
} }
export async function rollApiToken(id: string) {
const context = getActionContext();
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_RollApiToken
>('/typedrequest', 'rollApiToken');
return request.fire({
identity: context.identity,
id,
});
}
export const revokeApiTokenAction = routeManagementStatePart.createAction<string>( export const revokeApiTokenAction = routeManagementStatePart.createAction<string>(
async (statePartArg, tokenId) => { async (statePartArg, tokenId) => {
const context = getActionContext(); const context = getActionContext();
@@ -1321,6 +1333,15 @@ async function dispatchCombinedRefreshAction() {
console.error('Certificate refresh failed:', error); console.error('Certificate refresh failed:', error);
} }
} }
// Refresh remote ingress data if on remoteingress view
if (currentView === 'remoteingress') {
try {
await remoteIngressStatePart.dispatchAction(fetchRemoteIngressAction, null);
} catch (error) {
console.error('Remote ingress refresh failed:', error);
}
}
} catch (error) { } catch (error) {
console.error('Combined refresh failed:', error); console.error('Combined refresh failed:', error);
} }

View File

@@ -152,6 +152,15 @@ export class OpsViewApiTokens extends DeesElement {
); );
}, },
}, },
{
name: 'Roll',
iconName: 'lucide:refresh-cw',
type: ['inRow', 'contextmenu'] as any,
actionFunc: async (actionData: any) => {
const token = actionData.item as interfaces.data.IApiTokenInfo;
await this.showRollTokenDialog(token);
},
},
{ {
name: 'Revoke', name: 'Revoke',
iconName: 'lucide:trash2', iconName: 'lucide:trash2',
@@ -279,6 +288,60 @@ export class OpsViewApiTokens extends DeesElement {
}); });
} }
private async showRollTokenDialog(token: interfaces.data.IApiTokenInfo) {
const { DeesModal } = await import('@design.estate/dees-catalog');
await DeesModal.createAndShow({
heading: 'Roll Token Secret',
content: html`
<div style="color: #ccc; padding: 8px 0;">
<p>This will regenerate the secret for <strong>${token.name}</strong>. The old token value will stop working immediately.</p>
</div>
`,
menuOptions: [
{
name: 'Cancel',
iconName: 'lucide:x',
action: async (modalArg: any) => await modalArg.destroy(),
},
{
name: 'Roll Token',
iconName: 'lucide:refresh-cw',
action: async (modalArg: any) => {
await modalArg.destroy();
try {
const response = await appstate.rollApiToken(token.id);
if (response.success && response.tokenValue) {
await appstate.routeManagementStatePart.dispatchAction(appstate.fetchApiTokensAction, null);
await DeesModal.createAndShow({
heading: 'Token Rolled',
content: html`
<div style="color: #ccc; padding: 8px 0;">
<p>Copy this token now. It will not be shown again.</p>
<div style="background: #111; padding: 12px; border-radius: 6px; margin-top: 8px;">
<code style="color: #0f8; word-break: break-all; font-size: 13px;">${response.tokenValue}</code>
</div>
</div>
`,
menuOptions: [
{
name: 'Done',
iconName: 'lucide:check',
action: async (m: any) => await m.destroy(),
},
],
});
}
} catch (error) {
console.error('Failed to roll token:', error);
}
},
},
],
});
}
async firstUpdated() { async firstUpdated() {
await appstate.routeManagementStatePart.dispatchAction(appstate.fetchApiTokensAction, null); await appstate.routeManagementStatePart.dispatchAction(appstate.fetchApiTokensAction, null);
} }

View File

@@ -103,11 +103,20 @@ export class OpsViewConfig extends DeesElement {
} }
private renderSystemSection(sys: appstate.IConfigState['config']['system']): TemplateResult { private renderSystemSection(sys: appstate.IConfigState['config']['system']): TemplateResult {
// Annotate proxy IPs with source hint when Remote Ingress is active
const ri = this.configState.config?.remoteIngress;
let proxyIpValues: string[] | null = sys.proxyIps.length > 0 ? [...sys.proxyIps] : null;
if (proxyIpValues && ri?.enabled && proxyIpValues.includes('127.0.0.1')) {
proxyIpValues = proxyIpValues.map(ip =>
ip === '127.0.0.1' ? '127.0.0.1 (Remote Ingress)' : ip
);
}
const fields: IConfigField[] = [ const fields: IConfigField[] = [
{ key: 'Base Directory', value: sys.baseDir }, { key: 'Base Directory', value: sys.baseDir },
{ key: 'Data Directory', value: sys.dataDir }, { key: 'Data Directory', value: sys.dataDir },
{ key: 'Public IP', value: sys.publicIp }, { key: 'Public IP', value: sys.publicIp },
{ key: 'Proxy IPs', value: sys.proxyIps.length > 0 ? sys.proxyIps : null, type: 'pills' }, { key: 'Proxy IPs', value: proxyIpValues, type: 'pills' },
{ key: 'Uptime', value: this.formatUptime(sys.uptime) }, { key: 'Uptime', value: this.formatUptime(sys.uptime) },
{ key: 'Storage Backend', value: sys.storageBackend, type: 'badge' }, { key: 'Storage Backend', value: sys.storageBackend, type: 'badge' },
{ key: 'Storage Path', value: sys.storagePath }, { key: 'Storage Path', value: sys.storagePath },
@@ -291,7 +300,8 @@ export class OpsViewConfig extends DeesElement {
const fields: IConfigField[] = [ const fields: IConfigField[] = [
{ key: 'Tunnel Port', value: ri.tunnelPort }, { key: 'Tunnel Port', value: ri.tunnelPort },
{ key: 'Hub Domain', value: ri.hubDomain }, { key: 'Hub Domain', value: ri.hubDomain },
{ key: 'TLS Configured', value: ri.tlsConfigured, type: 'boolean' }, { key: 'TLS Mode', value: ri.tlsMode, type: 'badge' },
{ key: 'Connected Edge IPs', value: ri.connectedEdgeIps?.length > 0 ? ri.connectedEdgeIps : null, type: 'pills' },
]; ];
const actions: IConfigSectionAction[] = [ const actions: IConfigSectionAction[] = [