From 93592bf9092399d11d99c71a2c046a28b13a7241 Mon Sep 17 00:00:00 2001 From: Juergen Kunz Date: Tue, 17 Feb 2026 19:36:40 +0000 Subject: [PATCH] feat(edge): support connection tokens when starting an edge and add token encode/decode utilities --- changelog.md | 9 +++++ test/test.ts | 49 ++++++++++++++++++++++++ ts/00_commitinfo_data.ts | 2 +- ts/classes.remoteingressedge.ts | 32 ++++++++++------ ts/classes.token.ts | 66 +++++++++++++++++++++++++++++++++ ts/index.ts | 1 + 6 files changed, 147 insertions(+), 12 deletions(-) create mode 100644 ts/classes.token.ts diff --git a/changelog.md b/changelog.md index 8b849e2..37f6432 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,14 @@ # Changelog +## 2026-02-17 - 3.1.0 - feat(edge) +support connection tokens when starting an edge and add token encode/decode utilities + +- Add classes.token.ts with encodeConnectionToken/decodeConnectionToken using a base64url compact JSON format +- Export token utilities from ts/index.ts +- RemoteIngressEdge.start now accepts a { token } option and decodes it to an IEdgeConfig before starting +- Add tests covering export availability, encode→decode roundtrip, malformed token, and missing fields +- Non-breaking change — recommend a minor version bump + ## 2026-02-17 - 3.0.4 - fix(build) bump dev dependencies, update build script, and refresh README docs diff --git a/test/test.ts b/test/test.ts index ed7a70f..04e5988 100644 --- a/test/test.ts +++ b/test/test.ts @@ -9,4 +9,53 @@ tap.test('should export RemoteIngressEdge', async () => { expect(remoteingress.RemoteIngressEdge).toBeTypeOf('function'); }); +tap.test('should export encodeConnectionToken and decodeConnectionToken', async () => { + expect(remoteingress.encodeConnectionToken).toBeTypeOf('function'); + expect(remoteingress.decodeConnectionToken).toBeTypeOf('function'); +}); + +tap.test('should roundtrip encode → decode a connection token', async () => { + const data: remoteingress.IConnectionTokenData = { + hubHost: 'hub.example.com', + hubPort: 8443, + edgeId: 'edge-001', + secret: 'super-secret-key', + }; + const token = remoteingress.encodeConnectionToken(data); + const decoded = remoteingress.decodeConnectionToken(token); + expect(decoded.hubHost).toEqual(data.hubHost); + expect(decoded.hubPort).toEqual(data.hubPort); + expect(decoded.edgeId).toEqual(data.edgeId); + expect(decoded.secret).toEqual(data.secret); +}); + +tap.test('should throw on malformed token', async () => { + let error: Error | undefined; + try { + remoteingress.decodeConnectionToken('not-valid-json!!!'); + } catch (e) { + error = e as Error; + } + expect(error).toBeInstanceOf(Error); + expect(error!.message).toInclude('Invalid connection token'); +}); + +tap.test('should throw on token with missing fields', async () => { + // Encode a partial object (missing 'p' and 's') + const partial = Buffer.from(JSON.stringify({ h: 'host', e: 'edge' }), 'utf-8') + .toString('base64') + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/, ''); + + let error: Error | undefined; + try { + remoteingress.decodeConnectionToken(partial); + } catch (e) { + error = e as Error; + } + expect(error).toBeInstanceOf(Error); + expect(error!.message).toInclude('missing required fields'); +}); + export default tap.start(); diff --git a/ts/00_commitinfo_data.ts b/ts/00_commitinfo_data.ts index 44a6f3b..c15a6aa 100644 --- a/ts/00_commitinfo_data.ts +++ b/ts/00_commitinfo_data.ts @@ -3,6 +3,6 @@ */ export const commitinfo = { name: '@serve.zone/remoteingress', - version: '3.0.4', + version: '3.1.0', description: 'Edge ingress tunnel for DcRouter - accepts incoming TCP connections at network edge and tunnels them to DcRouter SmartProxy preserving client IP via PROXY protocol v1.' } diff --git a/ts/classes.remoteingressedge.ts b/ts/classes.remoteingressedge.ts index 2a96da4..94498c3 100644 --- a/ts/classes.remoteingressedge.ts +++ b/ts/classes.remoteingressedge.ts @@ -1,5 +1,6 @@ import * as plugins from './plugins.js'; import { EventEmitter } from 'events'; +import { decodeConnectionToken } from './classes.token.js'; // Command map for the edge side of remoteingress-bin type TEdgeCommands = { @@ -13,8 +14,6 @@ type TEdgeCommands = { hubPort: number; edgeId: string; secret: string; - listenPorts: number[]; - stunIntervalSecs?: number; }; result: { started: boolean }; }; @@ -39,8 +38,6 @@ export interface IEdgeConfig { hubPort?: number; edgeId: string; secret: string; - listenPorts: number[]; - stunIntervalSecs?: number; } export class RemoteIngressEdge extends EventEmitter { @@ -86,20 +83,33 @@ export class RemoteIngressEdge extends EventEmitter { /** * Start the edge — spawns the Rust binary and connects to the hub. + * Accepts either a connection token or an explicit IEdgeConfig. */ - public async start(config: IEdgeConfig): Promise { + public async start(config: { token: string } | IEdgeConfig): Promise { + let edgeConfig: IEdgeConfig; + + if ('token' in config) { + const decoded = decodeConnectionToken(config.token); + edgeConfig = { + hubHost: decoded.hubHost, + hubPort: decoded.hubPort, + edgeId: decoded.edgeId, + secret: decoded.secret, + }; + } else { + edgeConfig = config; + } + const spawned = await this.bridge.spawn(); if (!spawned) { throw new Error('Failed to spawn remoteingress-bin'); } await this.bridge.sendCommand('startEdge', { - hubHost: config.hubHost, - hubPort: config.hubPort ?? 8443, - edgeId: config.edgeId, - secret: config.secret, - listenPorts: config.listenPorts, - stunIntervalSecs: config.stunIntervalSecs, + hubHost: edgeConfig.hubHost, + hubPort: edgeConfig.hubPort ?? 8443, + edgeId: edgeConfig.edgeId, + secret: edgeConfig.secret, }); this.started = true; diff --git a/ts/classes.token.ts b/ts/classes.token.ts new file mode 100644 index 0000000..1e88269 --- /dev/null +++ b/ts/classes.token.ts @@ -0,0 +1,66 @@ +/** + * Connection token utilities for RemoteIngress edge connections. + * A token is a base64url-encoded compact JSON object carrying hub connection details. + */ + +export interface IConnectionTokenData { + hubHost: string; + hubPort: number; + edgeId: string; + secret: string; +} + +/** + * Encode connection data into a single opaque token string (base64url). + */ +export function encodeConnectionToken(data: IConnectionTokenData): string { + const compact = JSON.stringify({ + h: data.hubHost, + p: data.hubPort, + e: data.edgeId, + s: data.secret, + }); + // base64url: standard base64 with + → -, / → _, trailing = stripped + return Buffer.from(compact, 'utf-8') + .toString('base64') + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/, ''); +} + +/** + * Decode a connection token back into its constituent fields. + * Throws on malformed or incomplete tokens. + */ +export function decodeConnectionToken(token: string): IConnectionTokenData { + let parsed: { h?: unknown; p?: unknown; e?: unknown; s?: unknown }; + try { + // Restore standard base64 from base64url + let base64 = token.replace(/-/g, '+').replace(/_/g, '/'); + // Re-add padding + const remainder = base64.length % 4; + if (remainder === 2) base64 += '=='; + else if (remainder === 3) base64 += '='; + + const json = Buffer.from(base64, 'base64').toString('utf-8'); + parsed = JSON.parse(json); + } catch { + throw new Error('Invalid connection token'); + } + + if ( + typeof parsed.h !== 'string' || + typeof parsed.p !== 'number' || + typeof parsed.e !== 'string' || + typeof parsed.s !== 'string' + ) { + throw new Error('Invalid connection token: missing required fields'); + } + + return { + hubHost: parsed.h, + hubPort: parsed.p, + edgeId: parsed.e, + secret: parsed.s, + }; +} diff --git a/ts/index.ts b/ts/index.ts index 152f63e..dab56af 100644 --- a/ts/index.ts +++ b/ts/index.ts @@ -1,2 +1,3 @@ export * from './classes.remoteingresshub.js'; export * from './classes.remoteingressedge.js'; +export * from './classes.token.js';