feat(edge): support connection tokens when starting an edge and add token encode/decode utilities

This commit is contained in:
2026-02-17 19:36:40 +00:00
parent 73fc4ea28e
commit 93592bf909
6 changed files with 147 additions and 12 deletions

View File

@@ -1,5 +1,14 @@
# Changelog # 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) ## 2026-02-17 - 3.0.4 - fix(build)
bump dev dependencies, update build script, and refresh README docs bump dev dependencies, update build script, and refresh README docs

View File

@@ -9,4 +9,53 @@ tap.test('should export RemoteIngressEdge', async () => {
expect(remoteingress.RemoteIngressEdge).toBeTypeOf('function'); 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(); export default tap.start();

View File

@@ -3,6 +3,6 @@
*/ */
export const commitinfo = { export const commitinfo = {
name: '@serve.zone/remoteingress', 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.' 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.'
} }

View File

@@ -1,5 +1,6 @@
import * as plugins from './plugins.js'; import * as plugins from './plugins.js';
import { EventEmitter } from 'events'; import { EventEmitter } from 'events';
import { decodeConnectionToken } from './classes.token.js';
// Command map for the edge side of remoteingress-bin // Command map for the edge side of remoteingress-bin
type TEdgeCommands = { type TEdgeCommands = {
@@ -13,8 +14,6 @@ type TEdgeCommands = {
hubPort: number; hubPort: number;
edgeId: string; edgeId: string;
secret: string; secret: string;
listenPorts: number[];
stunIntervalSecs?: number;
}; };
result: { started: boolean }; result: { started: boolean };
}; };
@@ -39,8 +38,6 @@ export interface IEdgeConfig {
hubPort?: number; hubPort?: number;
edgeId: string; edgeId: string;
secret: string; secret: string;
listenPorts: number[];
stunIntervalSecs?: number;
} }
export class RemoteIngressEdge extends EventEmitter { 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. * 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<void> { public async start(config: { token: string } | IEdgeConfig): Promise<void> {
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(); const spawned = await this.bridge.spawn();
if (!spawned) { if (!spawned) {
throw new Error('Failed to spawn remoteingress-bin'); throw new Error('Failed to spawn remoteingress-bin');
} }
await this.bridge.sendCommand('startEdge', { await this.bridge.sendCommand('startEdge', {
hubHost: config.hubHost, hubHost: edgeConfig.hubHost,
hubPort: config.hubPort ?? 8443, hubPort: edgeConfig.hubPort ?? 8443,
edgeId: config.edgeId, edgeId: edgeConfig.edgeId,
secret: config.secret, secret: edgeConfig.secret,
listenPorts: config.listenPorts,
stunIntervalSecs: config.stunIntervalSecs,
}); });
this.started = true; this.started = true;

66
ts/classes.token.ts Normal file
View File

@@ -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,
};
}

View File

@@ -1,2 +1,3 @@
export * from './classes.remoteingresshub.js'; export * from './classes.remoteingresshub.js';
export * from './classes.remoteingressedge.js'; export * from './classes.remoteingressedge.js';
export * from './classes.token.js';