Compare commits

...

14 Commits

Author SHA1 Message Date
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
54484518dc v9.1.9
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 17:53:45 +00:00
6fe1247d4d fix(deps(smartmta)): bump @push.rocks/smartmta to ^5.3.0 2026-02-26 17:53:45 +00:00
e59d80a3b3 v9.1.8
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 17:42:06 +00:00
6c4feba711 fix(deps): bump @serve.zone/remoteingress to ^4.1.0 2026-02-26 17:42:05 +00:00
006a9af20c v9.1.7
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 17:34:54 +00:00
dfb3b0ac37 fix(dcrouter): bump @push.rocks/smartproxy to ^25.8.4 and remove custom smartProxy timeout/connection lifetime settings from dcrouter 2026-02-26 17:34:54 +00:00
44c1a3a928 v9.1.6
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 17:14:52 +00:00
0c4e28455e fix(cleanup): prevent event listener and log stream leaks, tighten smartProxy connection timeouts, and improve graceful shutdown behavior 2026-02-26 17:14:51 +00:00
13 changed files with 298 additions and 90 deletions

View File

@@ -1,5 +1,58 @@
# Changelog
## 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))
bump @push.rocks/smartmta to ^5.3.0
- Updated @push.rocks/smartmta from ^5.2.6 to ^5.3.0 in package.json
- Patch release recommended (no source code changes)
## 2026-02-26 - 9.1.8 - fix(deps)
bump @serve.zone/remoteingress to ^4.1.0
- Updated dependency @serve.zone/remoteingress from ^4.0.1 to ^4.1.0 in package.json
- Non-breaking dependency update; recommend patch version bump
## 2026-02-26 - 9.1.7 - fix(dcrouter)
bump @push.rocks/smartproxy to ^25.8.4 and remove custom smartProxy timeout/connection lifetime settings from dcrouter
- Bumped dependency @push.rocks/smartproxy from ^25.8.3 to ^25.8.4 in package.json
- Removed explicit smartProxy options: socketTimeout, inactivityTimeout, keepAliveInactivityMultiplier, extendedKeepAliveLifetime, and maxConnectionLifetime from ts/classes.dcrouter.ts
## 2026-02-26 - 9.1.6 - fix(cleanup)
prevent event listener and log stream leaks, tighten smartProxy connection timeouts, and improve graceful shutdown behavior
- Tightened smartProxy connection timeouts and lifetimes (5m socketTimeout, 10m inactivityTimeout, keep-alive multiplier, 1h extendedKeepAliveLifetime, 4h maxConnectionLifetime).
- Remove event listeners before stopping services to avoid leaks (smartProxy, emailServer, dnsServer, remote ingress hub).
- OpsServer.stop now invokes logsHandler.cleanup to tear down active log streams and avoid duplicate push destinations.
- LogsHandler rewritten to use a module-level singleton push destination, track active stream stop callbacks, add cleanup(), guard against hung VirtualStream.sendData with a 10s timeout, and ensure intervals are cleared on stop.
- updateSmartProxyConfig removes listeners on the old instance before stopping it.
- Dependency bumps: @api.global/typedsocket ^4.1.2, @push.rocks/smartdata ^7.1.0, @push.rocks/smartmta ^5.2.6, @push.rocks/smartproxy ^25.8.3.
## 2026-02-26 - 9.1.5 - fix(remoteingress)
Reconcile tunnel manager edge statuses with authoritative Rust hub periodically; update active tunnel counts and heartbeats, add missed edges, remove stale entries, and clear reconcile interval on stop

View File

@@ -1,7 +1,7 @@
{
"name": "@serve.zone/dcrouter",
"private": false,
"version": "9.1.5",
"version": "9.3.0",
"description": "A multifaceted routing service handling mail and SMS delivery functions.",
"type": "module",
"exports": {
@@ -30,14 +30,14 @@
"@api.global/typedrequest": "^3.2.6",
"@api.global/typedrequest-interfaces": "^3.0.19",
"@api.global/typedserver": "^8.4.0",
"@api.global/typedsocket": "^4.1.0",
"@api.global/typedsocket": "^4.1.2",
"@apiclient.xyz/cloudflare": "^7.1.0",
"@design.estate/dees-catalog": "^3.43.3",
"@design.estate/dees-element": "^2.1.6",
"@push.rocks/projectinfo": "^5.0.2",
"@push.rocks/qenv": "^6.1.3",
"@push.rocks/smartacme": "^9.1.3",
"@push.rocks/smartdata": "^7.0.15",
"@push.rocks/smartdata": "^7.1.0",
"@push.rocks/smartdns": "^7.9.0",
"@push.rocks/smartfile": "^13.1.2",
"@push.rocks/smartguard": "^3.1.0",
@@ -45,11 +45,11 @@
"@push.rocks/smartlog": "^3.2.1",
"@push.rocks/smartmetrics": "^3.0.1",
"@push.rocks/smartmongo": "^5.1.0",
"@push.rocks/smartmta": "^5.2.2",
"@push.rocks/smartmta": "^5.3.0",
"@push.rocks/smartnetwork": "^4.4.0",
"@push.rocks/smartpath": "^6.0.0",
"@push.rocks/smartpromise": "^4.2.3",
"@push.rocks/smartproxy": "^25.8.1",
"@push.rocks/smartproxy": "^25.8.5",
"@push.rocks/smartradius": "^1.1.1",
"@push.rocks/smartrequest": "^5.0.1",
"@push.rocks/smartrx": "^3.0.10",
@@ -57,7 +57,7 @@
"@push.rocks/smartunique": "^3.0.9",
"@serve.zone/catalog": "^2.5.0",
"@serve.zone/interfaces": "^5.3.0",
"@serve.zone/remoteingress": "^4.0.1",
"@serve.zone/remoteingress": "^4.3.0",
"@tsclass/tsclass": "^9.3.0",
"lru-cache": "^11.2.6",
"uuid": "^13.0.0"

63
pnpm-lock.yaml generated
View File

@@ -18,8 +18,8 @@ importers:
specifier: ^8.4.0
version: 8.4.0(@tiptap/pm@2.27.2)
'@api.global/typedsocket':
specifier: ^4.1.0
version: 4.1.0(@push.rocks/smartserve@2.0.1)
specifier: ^4.1.2
version: 4.1.2(@push.rocks/smartserve@2.0.1)
'@apiclient.xyz/cloudflare':
specifier: ^7.1.0
version: 7.1.0
@@ -39,8 +39,8 @@ importers:
specifier: ^9.1.3
version: 9.1.3(socks@2.8.7)
'@push.rocks/smartdata':
specifier: ^7.0.15
version: 7.0.15(socks@2.8.7)
specifier: ^7.1.0
version: 7.1.0(socks@2.8.7)
'@push.rocks/smartdns':
specifier: ^7.9.0
version: 7.9.0
@@ -63,8 +63,8 @@ importers:
specifier: ^5.1.0
version: 5.1.0(socks@2.8.7)
'@push.rocks/smartmta':
specifier: ^5.2.2
version: 5.2.2
specifier: ^5.3.0
version: 5.3.0
'@push.rocks/smartnetwork':
specifier: ^4.4.0
version: 4.4.0
@@ -75,8 +75,8 @@ importers:
specifier: ^4.2.3
version: 4.2.3
'@push.rocks/smartproxy':
specifier: ^25.8.1
version: 25.8.1
specifier: ^25.8.5
version: 25.8.5
'@push.rocks/smartradius':
specifier: ^1.1.1
version: 1.1.1
@@ -99,8 +99,8 @@ importers:
specifier: ^5.3.0
version: 5.3.0
'@serve.zone/remoteingress':
specifier: ^4.0.1
version: 4.0.1
specifier: ^4.3.0
version: 4.3.0
'@tsclass/tsclass':
specifier: ^9.3.0
version: 9.3.0
@@ -155,8 +155,8 @@ packages:
'@push.rocks/smartserve':
optional: true
'@api.global/typedsocket@4.1.0':
resolution: {integrity: sha512-ttmoU5BNHmLAkAF/o+Ta8F5O4F7CUmkFo6LK7NKHQvuYJvodPMYWdhJ6yCINTF4pfCgljkMDUqoVKobm6ea4mQ==}
'@api.global/typedsocket@4.1.2':
resolution: {integrity: sha512-fZFuJY9ucFCICjF4wi6OvK8drsv6UcwVVsfamOT1HxFj7OBOYw6QHOceQ+cAQ8IrWbX817sf8gzlesl+jlG8JA==}
peerDependencies:
'@push.rocks/smartserve': '>=1.1.0'
@@ -894,8 +894,8 @@ packages:
'@push.rocks/smartdata@5.16.7':
resolution: {integrity: sha512-bu/YSIjQcwxWXkAsuhqE6zs7eT+bTIKV8+/H7TbbjpzeioLCyB3dZ/41cLZk37c/EYt4d4GHgZ0ww80OiKOUMg==}
'@push.rocks/smartdata@7.0.15':
resolution: {integrity: sha512-j09BUekmjiGZuvXmdGBiIpBTXFFnxrzG4rOBjZvPO/hG1BwNrvSkIVq20mIwdYomn8JGgya6oJ4Y7NL+FKTqEA==}
'@push.rocks/smartdata@7.1.0':
resolution: {integrity: sha512-ots0g7/96R2xs4ww4F2/2rIwAOPT5AmzP3ciD31YsF02o5WA4Gg6C5laLBUjV3hXCjazhzFsRVQTfwbjmPQe4w==}
'@push.rocks/smartdelay@3.0.5':
resolution: {integrity: sha512-mUuI7kj2f7ztjpic96FvRIlf2RsKBa5arw81AHNsndbxO6asRcxuWL8dTVxouEIK8YsBUlj0AsrCkHhMbLQdHw==}
@@ -996,12 +996,11 @@ packages:
'@push.rocks/smartmongo@5.1.0':
resolution: {integrity: sha512-2tpKf8K+SMdLHOEpafgKPIN+ypWTLwHc33hCUDNMQ1KaL7vokkavA44+fHxQydOGPMtDi22tSMFeVMCcUSzs4w==}
'@push.rocks/smartmta@5.2.2':
resolution: {integrity: sha512-0xKUi2BMM0HFYIPdNeNJZFitAiJ9CNbLlOJ8TenT+xInp7DKcSQ7ABER1rJKinPtvDjRDSiSqiF2iQR+O7299g==}
'@push.rocks/smartmta@5.3.0':
resolution: {integrity: sha512-uJI25fslzvrcenU36WCdt5gB8cCfkjUlY7PqlxEtFp474/l/kZxNnvirv1gnZLRNNa+ioe5aH18HKE+KcAjuxA==}
engines: {node: '>=14.0.0'}
cpu: [x64, arm64]
os: [darwin, linux, win32]
hasBin: true
'@push.rocks/smartmustache@3.0.2':
resolution: {integrity: sha512-G3LyRXoJhyM+iQhkvP/MR/2WYMvC9U7zc2J44JxUM5tPdkQ+o3++FbfRtnZj6rz5X/A7q03//vsxPitVQwoi2Q==}
@@ -1036,8 +1035,8 @@ packages:
'@push.rocks/smartpromise@4.2.3':
resolution: {integrity: sha512-Ycg/TJR+tMt+S3wSFurOpEoW6nXv12QBtKXgBcjMZ4RsdO28geN46U09osPn9N9WuwQy1PkmTV5J/V4F9U8qEw==}
'@push.rocks/smartproxy@25.8.1':
resolution: {integrity: sha512-f192aGYWXnF4pJNqBShy+pL6GPxFUECBWuymay5M5qD41uKS76GIieAegEu9/G9XhtFfricvu28s1JeXzU9fLA==}
'@push.rocks/smartproxy@25.8.5':
resolution: {integrity: sha512-oLmV+Bq7sSgQP9McTao/imb6Xb62QM7wlTFt5kNynrS5WK2wAe8cEjDKOcyu8N/WmzNCEClT5f/0xAtI6JxtkA==}
'@push.rocks/smartpuppeteer@2.0.5':
resolution: {integrity: sha512-yK/qSeWVHIGWRp3c8S5tfdGP6WCKllZC4DR8d8CQlEjszOSBmHtlTdyyqOMBZ/BA4kd+eU5f3A1r4K2tGYty1g==}
@@ -1345,8 +1344,8 @@ packages:
'@serve.zone/interfaces@5.3.0':
resolution: {integrity: sha512-venO7wtDR9ixzD9NhdERBGjNKbFA5LL0yHw4eqGh0UpmvtXVc3SFG0uuHDilOKMZqZ8bttV88qVsFy1aSTJrtA==}
'@serve.zone/remoteingress@4.0.1':
resolution: {integrity: sha512-vl3nSGETsIR/BE1T2lvVGD1s4AMqh1CBAP7SNUnshXzYFzyFD2Fs1VmOEXP5V7grglawIuewhu+Th7eomC6zIA==}
'@serve.zone/remoteingress@4.3.0':
resolution: {integrity: sha512-yk14uS6oWIP83Zpem4hGf8zi3W9pefnxijtSWp45WvZ+u9XTXIADQNaUZBSTCId8CYkfPkfRGaaaARunVdjFXg==}
'@sindresorhus/is@5.6.0':
resolution: {integrity: sha512-TV7t8GKYaJWsn00tFDqBw8+Uqmr8A0fRU1tvTQhyZzGv0sJCGRQL3JGMI3ucuKo3XIZdUP+Lx7/gh2t3lewy7g==}
@@ -4183,7 +4182,7 @@ packages:
hasBin: true
wordwrap@1.0.0:
resolution: {integrity: sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==}
resolution: {integrity: sha1-J1hIEIkUVqQXHI0CJkQa3pDLyus=}
wrap-ansi@6.2.0:
resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==}
@@ -4356,7 +4355,7 @@ snapshots:
dependencies:
'@api.global/typedrequest': 3.2.6
'@api.global/typedrequest-interfaces': 3.0.19
'@api.global/typedsocket': 4.1.0(@push.rocks/smartserve@2.0.1)
'@api.global/typedsocket': 4.1.2(@push.rocks/smartserve@2.0.1)
'@cloudflare/workers-types': 4.20260303.0
'@design.estate/dees-catalog': 3.43.3(@tiptap/pm@2.27.2)
'@design.estate/dees-comms': 1.0.30
@@ -4418,7 +4417,7 @@ snapshots:
- utf-8-validate
- vue
'@api.global/typedsocket@4.1.0(@push.rocks/smartserve@2.0.1)':
'@api.global/typedsocket@4.1.2(@push.rocks/smartserve@2.0.1)':
dependencies:
'@api.global/typedrequest': 3.2.6
'@api.global/typedrequest-interfaces': 3.0.19
@@ -5763,7 +5762,7 @@ snapshots:
'@apiclient.xyz/cloudflare': 7.1.0
'@peculiar/x509': 1.14.3
'@push.rocks/lik': 6.2.2
'@push.rocks/smartdata': 7.0.15(socks@2.8.7)
'@push.rocks/smartdata': 7.1.0(socks@2.8.7)
'@push.rocks/smartdelay': 3.0.5
'@push.rocks/smartdns': 7.9.0
'@push.rocks/smartlog': 3.2.1
@@ -5926,7 +5925,7 @@ snapshots:
- supports-color
- vue
'@push.rocks/smartdata@7.0.15(socks@2.8.7)':
'@push.rocks/smartdata@7.1.0(socks@2.8.7)':
dependencies:
'@push.rocks/lik': 6.2.2
'@push.rocks/smartdelay': 3.0.5
@@ -5935,7 +5934,7 @@ snapshots:
'@push.rocks/smartpromise': 4.2.3
'@push.rocks/smartrx': 3.0.10
'@push.rocks/smartstring': 4.1.0
'@push.rocks/smarttime': 4.1.1
'@push.rocks/smarttime': 4.2.3
'@push.rocks/smartunique': 3.0.9
'@push.rocks/taskbuffer': 3.5.0
'@tsclass/tsclass': 9.3.0
@@ -6234,14 +6233,14 @@ snapshots:
- supports-color
- vue
'@push.rocks/smartmta@5.2.2':
'@push.rocks/smartmta@5.3.0':
dependencies:
'@push.rocks/smartfile': 13.1.2
'@push.rocks/smartfs': 1.3.1
'@push.rocks/smartlog': 3.2.1
'@push.rocks/smartmail': 2.2.0
'@push.rocks/smartpath': 6.0.0
'@push.rocks/smartrust': 1.2.1
'@push.rocks/smartrust': 1.3.1
'@tsclass/tsclass': 9.3.0
lru-cache: 11.2.6
mailparser: 3.9.3
@@ -6341,11 +6340,11 @@ snapshots:
'@push.rocks/smartpromise@4.2.3': {}
'@push.rocks/smartproxy@25.8.1':
'@push.rocks/smartproxy@25.8.5':
dependencies:
'@push.rocks/smartcrypto': 2.0.4
'@push.rocks/smartlog': 3.2.1
'@push.rocks/smartrust': 1.2.1
'@push.rocks/smartrust': 1.3.1
'@tsclass/tsclass': 9.3.0
minimatch: 10.2.2
@@ -6828,7 +6827,7 @@ snapshots:
'@push.rocks/smartlog-interfaces': 3.0.2
'@tsclass/tsclass': 9.3.0
'@serve.zone/remoteingress@4.0.1':
'@serve.zone/remoteingress@4.3.0':
dependencies:
'@push.rocks/qenv': 6.1.3
'@push.rocks/smartrust': 1.3.1

View File

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

View File

@@ -217,6 +217,9 @@ export class DcRouter {
public routeConfigManager?: RouteConfigManager;
public apiTokenManager?: ApiTokenManager;
// Auto-discovered public IP (populated by generateAuthoritativeRecords)
public detectedPublicIp: string | null = null;
// DNS query logging rate limiter state
private dnsLogWindow: number[] = [];
private dnsBatchCount: number = 0;
@@ -903,6 +906,20 @@ export class DcRouter {
await this.opsServer.stop();
try {
// Remove event listeners before stopping services to prevent leaks
if (this.smartProxy) {
this.smartProxy.removeAllListeners();
}
if (this.emailServer) {
if ((this.emailServer as any).deliverySystem) {
(this.emailServer as any).deliverySystem.removeAllListeners();
}
this.emailServer.removeAllListeners();
}
if (this.dnsServer) {
this.dnsServer.removeAllListeners();
}
// Stop all services in parallel for faster shutdown
await Promise.all([
// Stop cache cleaner if running
@@ -976,10 +993,11 @@ export class DcRouter {
public async updateSmartProxyConfig(config: plugins.smartproxy.ISmartProxyOptions): Promise<void> {
// Stop existing SmartProxy if running
if (this.smartProxy) {
this.smartProxy.removeAllListeners();
await this.smartProxy.stop();
this.smartProxy = undefined;
}
// Update configuration
this.options.smartProxyConfig = config;
@@ -1103,6 +1121,11 @@ export class DcRouter {
try {
// Stop the unified email server which contains all components
if (this.emailServer) {
// Remove listeners before stopping to prevent leaks on config update cycles
if ((this.emailServer as any).deliverySystem) {
(this.emailServer as any).deliverySystem.removeAllListeners();
}
this.emailServer.removeAllListeners();
await this.emailServer.stop();
logger.log('info', 'Unified email server stopped');
this.emailServer = undefined;
@@ -1554,6 +1577,7 @@ export class DcRouter {
} else if (this.options.publicIp) {
// Use explicitly configured public IP
publicIp = this.options.publicIp;
this.detectedPublicIp = publicIp;
logger.log('info', `Using configured public IP for nameserver A records: ${publicIp}`);
} else {
// Auto-discover public IP using smartnetwork
@@ -1564,6 +1588,7 @@ export class DcRouter {
if (publicIps.v4) {
publicIp = publicIps.v4;
this.detectedPublicIp = publicIp;
logger.log('info', `Auto-discovered public IPv4: ${publicIp}`);
} else {
logger.log('warn', 'Could not auto-discover public IPv4 address');
@@ -1689,10 +1714,42 @@ export class DcRouter {
const currentRoutes = this.options.smartProxyConfig?.routes || [];
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
this.tunnelManager = new TunnelManager(this.remoteIngressManager, {
tunnelPort: this.options.remoteIngressConfig.tunnelPort ?? 8443,
tunnelPort: riCfg.tunnelPort ?? 8443,
targetHost: '127.0.0.1',
tls: tlsConfig,
});
await this.tunnelManager.start();

View File

@@ -70,6 +70,10 @@ export class OpsServer {
}
public async stop() {
// Clean up log handler streams and push destination before stopping the server
if (this.logsHandler) {
this.logsHandler.cleanup();
}
if (this.server) {
await this.server.stop();
}

View File

@@ -40,11 +40,20 @@ export class ConfigHandler {
? 'filesystem'
: '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'] = {
baseDir: resolvedPaths.dcrouterHomeDir,
dataDir: resolvedPaths.dataDir,
publicIp: opts.publicIp || null,
proxyIps: opts.proxyIps || [],
publicIp: opts.publicIp || dcRouter.detectedPublicIp || null,
proxyIps,
uptime: Math.floor(process.uptime()),
storageBackend,
storagePath: opts.storage?.fsPath || null,
@@ -169,11 +178,13 @@ export class ConfigHandler {
// --- Remote Ingress ---
const riCfg = opts.remoteIngressConfig;
const connectedEdgeIps = dcRouter.tunnelManager?.getConnectedEdgeIps() || [];
const remoteIngress: interfaces.requests.IConfigData['remoteIngress'] = {
enabled: !!dcRouter.remoteIngressManager,
tunnelPort: riCfg?.tunnelPort || null,
hubDomain: riCfg?.hubDomain || null,
tlsConfigured: !!(riCfg?.tls?.certPath && riCfg?.tls?.keyPath),
connectedEdgeIps,
};
return {

View File

@@ -3,8 +3,15 @@ import type { OpsServer } from '../classes.opsserver.js';
import * as interfaces from '../../../ts_interfaces/index.js';
import { logBuffer, baseLogger } from '../../logger.js';
// Module-level singleton: the log push destination is added once and reuses
// the current OpsServer reference so it survives OpsServer restarts without
// accumulating duplicate destinations.
let logPushDestinationInstalled = false;
let currentOpsServerRef: OpsServer | null = null;
export class LogsHandler {
public typedrouter = new plugins.typedrequest.TypedRouter();
private activeStreamStops: Set<() => void> = new Set();
constructor(private opsServerRef: OpsServer) {
// Add this handler's router to the parent
@@ -12,7 +19,21 @@ export class LogsHandler {
this.registerHandlers();
this.setupLogPushDestination();
}
/**
* Clean up all active log streams and deactivate the push destination.
* Called when OpsServer stops.
*/
public cleanup(): void {
// Stop all active follow-mode log streams
for (const stop of this.activeStreamStops) {
stop();
}
this.activeStreamStops.clear();
// Deactivate the push destination (it stays registered but becomes a no-op)
currentOpsServerRef = null;
}
private registerHandlers(): void {
// Get Recent Logs Handler
this.typedrouter.addTypedHandler(
@@ -27,16 +48,16 @@ export class LogsHandler {
dataArg.search,
dataArg.timeRange
);
return {
logs,
total: logs.length, // TODO: Implement proper total count
hasMore: false, // TODO: Implement proper pagination
total: logs.length,
hasMore: false,
};
}
)
);
// Get Log Stream Handler
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetLogStream>(
@@ -44,7 +65,7 @@ export class LogsHandler {
async (dataArg, toolsArg) => {
// Create a virtual stream for log streaming
const virtualStream = new plugins.typedrequest.VirtualStream<Uint8Array>();
// Set up log streaming
const streamLogs = this.setupLogStream(
virtualStream,
@@ -52,20 +73,21 @@ export class LogsHandler {
dataArg.filters?.category,
dataArg.follow
);
// Start streaming
streamLogs.start();
// VirtualStream handles cleanup automatically
// Track the stop function so we can clean up on shutdown
this.activeStreamStops.add(streamLogs.stop);
return {
logStream: virtualStream as any, // Cast to IVirtualStream interface
logStream: virtualStream as any,
};
}
)
);
}
private static mapLogLevel(smartlogLevel: string): 'debug' | 'info' | 'warn' | 'error' {
switch (smartlogLevel) {
case 'silly':
@@ -165,18 +187,30 @@ export class LogsHandler {
return mapped;
}
/**
* Add a log destination to the base logger that pushes entries
* to all connected ops_dashboard TypedSocket clients.
*
* Uses a module-level singleton so the destination is added only once,
* even across OpsServer restart cycles. The destination reads
* `currentOpsServerRef` dynamically so it always uses the active server.
*/
private setupLogPushDestination(): void {
const opsServerRef = this.opsServerRef;
// Update the module-level reference so the existing destination uses the new server
currentOpsServerRef = this.opsServerRef;
if (logPushDestinationInstalled) {
return; // destination already registered — just updated the ref
}
logPushDestinationInstalled = true;
baseLogger.addLogDestination({
async handleLog(logPackage: any) {
// Access the TypedSocket server instance from OpsServer
const typedsocket = opsServerRef.server?.typedserver?.typedsocket;
const opsServer = currentOpsServerRef;
if (!opsServer) return;
const typedsocket = opsServer.server?.typedserver?.typedsocket;
if (!typedsocket) return;
let connections: any[];
@@ -220,8 +254,18 @@ export class LogsHandler {
stop: () => void;
} {
let intervalId: NodeJS.Timeout | null = null;
let stopped = false;
let logIndex = 0;
const stop = () => {
stopped = true;
if (intervalId) {
clearInterval(intervalId);
intervalId = null;
}
this.activeStreamStops.delete(stop);
};
const start = () => {
if (!follow) {
// Send existing logs and close
@@ -236,13 +280,19 @@ export class LogsHandler {
const encoder = new TextEncoder();
virtualStream.sendData(encoder.encode(logData));
});
// VirtualStream doesn't have end() method - it closes automatically
});
return;
}
// For follow mode, simulate real-time log streaming
intervalId = setInterval(async () => {
if (stopped) {
// Guard: clear interval if stop() was called between ticks
clearInterval(intervalId!);
intervalId = null;
return;
}
const categories: Array<'smtp' | 'dns' | 'security' | 'system' | 'email'> = ['smtp', 'dns', 'security', 'system', 'email'];
const levels: Array<'debug' | 'info' | 'warn' | 'error'> = ['info', 'warn', 'error', 'debug'];
@@ -266,30 +316,21 @@ export class LogsHandler {
const logData = JSON.stringify(logEntry);
const encoder = new TextEncoder();
try {
await virtualStream.sendData(encoder.encode(logData));
// Use a timeout to detect hung streams (sendData can hang if the
// VirtualStream's keepAlive loop has ended)
await Promise.race([
virtualStream.sendData(encoder.encode(logData)),
new Promise<never>((_, reject) =>
setTimeout(() => reject(new Error('stream send timeout')), 10_000)
),
]);
} catch {
// Stream closed or errored — clean up to prevent interval leak
clearInterval(intervalId!);
intervalId = null;
// Stream closed, errored, or timed out — clean up
stop();
}
}, 2000); // Send a log every 2 seconds
// TODO: Hook into actual logger events
// logger.on('log', (logEntry) => {
// if (matchesCriteria(logEntry, level, service)) {
// virtualStream.sendData(formatLogEntry(logEntry));
// }
// });
}, 2000);
};
const stop = () => {
if (intervalId) {
clearInterval(intervalId);
intervalId = null;
}
// TODO: Unhook from logger events
};
return { start, stop };
}
}
}

View File

@@ -5,6 +5,10 @@ import type { RemoteIngressManager } from './classes.remoteingress-manager.js';
export interface ITunnelManagerConfig {
tunnelPort?: number;
targetHost?: string;
tls?: {
certPem?: string;
keyPem?: string;
};
}
/**
@@ -23,12 +27,11 @@ export class TunnelManager {
this.hub = new plugins.remoteingress.RemoteIngressHub();
// Listen for edge connect/disconnect events
this.hub.on('edgeConnected', (data: { edgeId: string }) => {
const existing = this.edgeStatuses.get(data.edgeId);
this.hub.on('edgeConnected', (data: { edgeId: string; peerAddr: string }) => {
this.edgeStatuses.set(data.edgeId, {
edgeId: data.edgeId,
connected: true,
publicIp: existing?.publicIp ?? null,
publicIp: data.peerAddr || null,
activeTunnels: 0,
lastHeartbeat: Date.now(),
connectedAt: Date.now(),
@@ -62,6 +65,7 @@ export class TunnelManager {
await this.hub.start({
tunnelPort: this.config.tunnelPort ?? 8443,
targetHost: this.config.targetHost ?? '127.0.0.1',
tls: this.config.tls,
});
// Send allowed edges to the hub
@@ -81,6 +85,8 @@ export class TunnelManager {
clearInterval(this.reconcileInterval);
this.reconcileInterval = null;
}
// Remove event listeners before stopping to prevent leaks
this.hub.removeAllListeners();
await this.hub.stop();
this.edgeStatuses.clear();
}
@@ -101,12 +107,16 @@ export class TunnelManager {
if (existing) {
existing.activeTunnels = rustEdge.activeStreams;
existing.lastHeartbeat = Date.now();
// Update peer address if available from Rust hub
if (rustEdge.peerAddr) {
existing.publicIp = rustEdge.peerAddr;
}
} else {
// Missed edgeConnected event — add entry
this.edgeStatuses.set(rustEdge.edgeId, {
edgeId: rustEdge.edgeId,
connected: true,
publicIp: null,
publicIp: rustEdge.peerAddr || null,
activeTunnels: rustEdge.activeStreams,
lastHeartbeat: Date.now(),
connectedAt: rustEdge.connectedAt * 1000,
@@ -156,6 +166,19 @@ export class TunnelManager {
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.
*/

View File

@@ -70,6 +70,7 @@ export interface IConfigData {
tunnelPort: number | null;
hubDomain: string | null;
tlsConfigured: boolean;
connectedEdgeIps: string[];
};
}

View File

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

View File

@@ -1321,6 +1321,15 @@ async function dispatchCombinedRefreshAction() {
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) {
console.error('Combined refresh failed:', error);
}

View File

@@ -103,11 +103,20 @@ export class OpsViewConfig extends DeesElement {
}
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[] = [
{ key: 'Base Directory', value: sys.baseDir },
{ key: 'Data Directory', value: sys.dataDir },
{ 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: 'Storage Backend', value: sys.storageBackend, type: 'badge' },
{ key: 'Storage Path', value: sys.storagePath },
@@ -292,6 +301,7 @@ export class OpsViewConfig extends DeesElement {
{ key: 'Tunnel Port', value: ri.tunnelPort },
{ key: 'Hub Domain', value: ri.hubDomain },
{ key: 'TLS Configured', value: ri.tlsConfigured, type: 'boolean' },
{ key: 'Connected Edge IPs', value: ri.connectedEdgeIps?.length > 0 ? ri.connectedEdgeIps : null, type: 'pills' },
];
const actions: IConfigSectionAction[] = [