cairn-ts 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +43 -0
- package/dist/index.cjs +1883 -0
- package/dist/index.d.cts +572 -0
- package/dist/index.d.ts +572 -0
- package/dist/index.js +1827 -0
- package/eslint.config.js +24 -0
- package/package.json +54 -0
- package/src/channel.ts +277 -0
- package/src/config.ts +161 -0
- package/src/crypto/aead.ts +80 -0
- package/src/crypto/double-ratchet.ts +355 -0
- package/src/crypto/exchange.ts +51 -0
- package/src/crypto/hkdf.ts +33 -0
- package/src/crypto/identity.ts +84 -0
- package/src/crypto/index.ts +20 -0
- package/src/crypto/noise.ts +415 -0
- package/src/crypto/sas.ts +36 -0
- package/src/crypto/spake2.ts +169 -0
- package/src/discovery/index.ts +38 -0
- package/src/discovery/manager.ts +138 -0
- package/src/discovery/rendezvous.ts +189 -0
- package/src/discovery/tracker.ts +251 -0
- package/src/errors.ts +166 -0
- package/src/index.ts +57 -0
- package/src/mesh/index.ts +48 -0
- package/src/mesh/relay.ts +100 -0
- package/src/mesh/routing-table.ts +196 -0
- package/src/node.ts +619 -0
- package/src/pairing/adapter.ts +51 -0
- package/src/pairing/index.ts +40 -0
- package/src/pairing/link.ts +127 -0
- package/src/pairing/payload.ts +98 -0
- package/src/pairing/pin.ts +115 -0
- package/src/pairing/psk.ts +49 -0
- package/src/pairing/qr.ts +52 -0
- package/src/pairing/rate-limit.ts +134 -0
- package/src/pairing/sas-flow.ts +45 -0
- package/src/pairing/state-machine.ts +438 -0
- package/src/pairing/unpairing.ts +50 -0
- package/src/protocol/custom-handler.ts +52 -0
- package/src/protocol/envelope.ts +138 -0
- package/src/protocol/index.ts +36 -0
- package/src/protocol/message-types.ts +74 -0
- package/src/protocol/version.ts +98 -0
- package/src/server/index.ts +67 -0
- package/src/server/management.ts +285 -0
- package/src/server/store-forward.ts +266 -0
- package/src/session/backoff.ts +58 -0
- package/src/session/heartbeat.ts +79 -0
- package/src/session/index.ts +26 -0
- package/src/session/message-queue.ts +133 -0
- package/src/session/network-monitor.ts +130 -0
- package/src/session/state-machine.ts +122 -0
- package/src/session.ts +223 -0
- package/src/transport/fallback.ts +475 -0
- package/src/transport/index.ts +46 -0
- package/src/transport/libp2p-node.ts +158 -0
- package/src/transport/nat.ts +348 -0
- package/tests/conformance/cbor-vectors.test.ts +250 -0
- package/tests/integration/pairing-session.test.ts +317 -0
- package/tests/unit/config-api.test.ts +310 -0
- package/tests/unit/crypto.test.ts +407 -0
- package/tests/unit/discovery.test.ts +618 -0
- package/tests/unit/double-ratchet.test.ts +185 -0
- package/tests/unit/mesh.test.ts +349 -0
- package/tests/unit/noise.test.ts +346 -0
- package/tests/unit/pairing-extras.test.ts +402 -0
- package/tests/unit/pairing.test.ts +572 -0
- package/tests/unit/protocol.test.ts +438 -0
- package/tests/unit/reconnection.test.ts +402 -0
- package/tests/unit/scaffolding.test.ts +142 -0
- package/tests/unit/server.test.ts +492 -0
- package/tests/unit/sessions.test.ts +595 -0
- package/tests/unit/transport.test.ts +604 -0
- package/tsconfig.json +20 -0
- package/vitest.config.ts +15 -0
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// Protocol module — CBOR envelope encode/decode, message types, version negotiation
|
|
2
|
+
|
|
3
|
+
export type { MessageEnvelope } from './envelope.js';
|
|
4
|
+
export { newMsgId, encodeEnvelope, encodeEnvelopeDeterministic, decodeEnvelope } from './envelope.js';
|
|
5
|
+
|
|
6
|
+
export {
|
|
7
|
+
// Pairing
|
|
8
|
+
PAIR_REQUEST, PAIR_CHALLENGE, PAIR_RESPONSE, PAIR_CONFIRM, PAIR_REJECT, PAIR_REVOKE,
|
|
9
|
+
// Session
|
|
10
|
+
SESSION_RESUME, SESSION_RESUME_ACK, SESSION_EXPIRED, SESSION_CLOSE,
|
|
11
|
+
// Data
|
|
12
|
+
DATA_MESSAGE, DATA_ACK, DATA_NACK,
|
|
13
|
+
// Control
|
|
14
|
+
HEARTBEAT, HEARTBEAT_ACK, TRANSPORT_MIGRATE, TRANSPORT_MIGRATE_ACK,
|
|
15
|
+
// Mesh
|
|
16
|
+
ROUTE_REQUEST, ROUTE_RESPONSE, RELAY_DATA, RELAY_ACK,
|
|
17
|
+
// Rendezvous
|
|
18
|
+
RENDEZVOUS_PUBLISH, RENDEZVOUS_QUERY, RENDEZVOUS_RESPONSE,
|
|
19
|
+
// Forward
|
|
20
|
+
FORWARD_REQUEST, FORWARD_ACK, FORWARD_DELIVER, FORWARD_PURGE,
|
|
21
|
+
// Version
|
|
22
|
+
VERSION_NEGOTIATE,
|
|
23
|
+
// Ranges
|
|
24
|
+
CAIRN_RESERVED_START, CAIRN_RESERVED_END, APP_EXTENSION_START, APP_EXTENSION_END,
|
|
25
|
+
// Helpers
|
|
26
|
+
messageCategory, isApplicationType,
|
|
27
|
+
} from './message-types.js';
|
|
28
|
+
|
|
29
|
+
export type { VersionNegotiatePayload } from './version.js';
|
|
30
|
+
export {
|
|
31
|
+
CURRENT_PROTOCOL_VERSION, SUPPORTED_VERSIONS,
|
|
32
|
+
selectVersion, createVersionNegotiate, parseVersionNegotiate, handleVersionNegotiate,
|
|
33
|
+
} from './version.js';
|
|
34
|
+
|
|
35
|
+
export type { CustomMessageCallback } from './custom-handler.js';
|
|
36
|
+
export { CustomMessageRegistry } from './custom-handler.js';
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// Pairing (0x01xx)
|
|
2
|
+
export const PAIR_REQUEST = 0x0100;
|
|
3
|
+
export const PAIR_CHALLENGE = 0x0101;
|
|
4
|
+
export const PAIR_RESPONSE = 0x0102;
|
|
5
|
+
export const PAIR_CONFIRM = 0x0103;
|
|
6
|
+
export const PAIR_REJECT = 0x0104;
|
|
7
|
+
export const PAIR_REVOKE = 0x0105;
|
|
8
|
+
|
|
9
|
+
// Session (0x02xx)
|
|
10
|
+
export const SESSION_RESUME = 0x0200;
|
|
11
|
+
export const SESSION_RESUME_ACK = 0x0201;
|
|
12
|
+
export const SESSION_EXPIRED = 0x0202;
|
|
13
|
+
export const SESSION_CLOSE = 0x0203;
|
|
14
|
+
|
|
15
|
+
// Data (0x03xx)
|
|
16
|
+
export const DATA_MESSAGE = 0x0300;
|
|
17
|
+
export const DATA_ACK = 0x0301;
|
|
18
|
+
export const DATA_NACK = 0x0302;
|
|
19
|
+
|
|
20
|
+
// Control (0x04xx)
|
|
21
|
+
export const HEARTBEAT = 0x0400;
|
|
22
|
+
export const HEARTBEAT_ACK = 0x0401;
|
|
23
|
+
export const TRANSPORT_MIGRATE = 0x0402;
|
|
24
|
+
export const TRANSPORT_MIGRATE_ACK = 0x0403;
|
|
25
|
+
|
|
26
|
+
// Mesh (0x05xx)
|
|
27
|
+
export const ROUTE_REQUEST = 0x0500;
|
|
28
|
+
export const ROUTE_RESPONSE = 0x0501;
|
|
29
|
+
export const RELAY_DATA = 0x0502;
|
|
30
|
+
export const RELAY_ACK = 0x0503;
|
|
31
|
+
|
|
32
|
+
// Rendezvous (0x06xx)
|
|
33
|
+
export const RENDEZVOUS_PUBLISH = 0x0600;
|
|
34
|
+
export const RENDEZVOUS_QUERY = 0x0601;
|
|
35
|
+
export const RENDEZVOUS_RESPONSE = 0x0602;
|
|
36
|
+
|
|
37
|
+
// Forward (0x07xx)
|
|
38
|
+
export const FORWARD_REQUEST = 0x0700;
|
|
39
|
+
export const FORWARD_ACK = 0x0701;
|
|
40
|
+
export const FORWARD_DELIVER = 0x0702;
|
|
41
|
+
export const FORWARD_PURGE = 0x0703;
|
|
42
|
+
|
|
43
|
+
// Version negotiation
|
|
44
|
+
export const VERSION_NEGOTIATE = 0x0001;
|
|
45
|
+
|
|
46
|
+
// Reserved ranges
|
|
47
|
+
export const CAIRN_RESERVED_START = 0x0100;
|
|
48
|
+
export const CAIRN_RESERVED_END = 0xefff;
|
|
49
|
+
export const APP_EXTENSION_START = 0xf000;
|
|
50
|
+
export const APP_EXTENSION_END = 0xffff;
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Returns the category name for a given message type code.
|
|
54
|
+
*/
|
|
55
|
+
export function messageCategory(msgType: number): string {
|
|
56
|
+
if (msgType === 0x0001) return 'version';
|
|
57
|
+
if (msgType >= 0x0100 && msgType <= 0x01ff) return 'pairing';
|
|
58
|
+
if (msgType >= 0x0200 && msgType <= 0x02ff) return 'session';
|
|
59
|
+
if (msgType >= 0x0300 && msgType <= 0x03ff) return 'data';
|
|
60
|
+
if (msgType >= 0x0400 && msgType <= 0x04ff) return 'control';
|
|
61
|
+
if (msgType >= 0x0500 && msgType <= 0x05ff) return 'mesh';
|
|
62
|
+
if (msgType >= 0x0600 && msgType <= 0x06ff) return 'rendezvous';
|
|
63
|
+
if (msgType >= 0x0700 && msgType <= 0x07ff) return 'forward';
|
|
64
|
+
if (msgType >= 0x0800 && msgType <= 0xefff) return 'reserved';
|
|
65
|
+
if (msgType >= APP_EXTENSION_START && msgType <= APP_EXTENSION_END) return 'application';
|
|
66
|
+
return 'reserved';
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Returns true if the given type code is in the application extension range.
|
|
71
|
+
*/
|
|
72
|
+
export function isApplicationType(msgType: number): boolean {
|
|
73
|
+
return msgType >= APP_EXTENSION_START && msgType <= APP_EXTENSION_END;
|
|
74
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { encode, decode } from 'cborg';
|
|
2
|
+
import { CairnError, VersionMismatchError } from '../errors.js';
|
|
3
|
+
import { MessageEnvelope, newMsgId } from './envelope.js';
|
|
4
|
+
import { VERSION_NEGOTIATE } from './message-types.js';
|
|
5
|
+
|
|
6
|
+
/** Current protocol version. */
|
|
7
|
+
export const CURRENT_PROTOCOL_VERSION = 1;
|
|
8
|
+
|
|
9
|
+
/** All protocol versions this implementation supports, highest first. */
|
|
10
|
+
export const SUPPORTED_VERSIONS: readonly number[] = [1];
|
|
11
|
+
|
|
12
|
+
/** Payload for VersionNegotiate messages. */
|
|
13
|
+
export interface VersionNegotiatePayload {
|
|
14
|
+
versions: number[];
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Select the highest mutually supported version.
|
|
19
|
+
*
|
|
20
|
+
* Returns the selected version, or throws VersionMismatchError.
|
|
21
|
+
*/
|
|
22
|
+
export function selectVersion(ourVersions: readonly number[], peerVersions: number[]): number {
|
|
23
|
+
for (const v of ourVersions) {
|
|
24
|
+
if (peerVersions.includes(v)) {
|
|
25
|
+
return v;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
throw new VersionMismatchError(
|
|
29
|
+
`no common protocol version: local supports [${ourVersions}], remote supports [${peerVersions}]`,
|
|
30
|
+
{
|
|
31
|
+
localVersions: [...ourVersions],
|
|
32
|
+
remoteVersions: [...peerVersions],
|
|
33
|
+
suggestion: 'update the peer with the older protocol version',
|
|
34
|
+
},
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** CBOR-encode a VersionNegotiatePayload. */
|
|
39
|
+
function encodePayload(payload: VersionNegotiatePayload): Uint8Array {
|
|
40
|
+
try {
|
|
41
|
+
return encode(payload);
|
|
42
|
+
} catch (e) {
|
|
43
|
+
throw new CairnError('PROTOCOL', `CBOR payload encode error: ${e}`);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** CBOR-decode a VersionNegotiatePayload. */
|
|
48
|
+
function decodePayload(data: Uint8Array): VersionNegotiatePayload {
|
|
49
|
+
try {
|
|
50
|
+
return decode(data) as VersionNegotiatePayload;
|
|
51
|
+
} catch (e) {
|
|
52
|
+
throw new CairnError('PROTOCOL', `CBOR payload decode error: ${e}`);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Create a VersionNegotiate message envelope advertising our supported versions. */
|
|
57
|
+
export function createVersionNegotiate(): MessageEnvelope {
|
|
58
|
+
const payload = encodePayload({ versions: [...SUPPORTED_VERSIONS] });
|
|
59
|
+
return {
|
|
60
|
+
version: CURRENT_PROTOCOL_VERSION,
|
|
61
|
+
type: VERSION_NEGOTIATE,
|
|
62
|
+
msgId: newMsgId(),
|
|
63
|
+
payload,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Parse a received VersionNegotiate envelope and extract the payload. */
|
|
68
|
+
export function parseVersionNegotiate(envelope: MessageEnvelope): VersionNegotiatePayload {
|
|
69
|
+
if (envelope.type !== VERSION_NEGOTIATE) {
|
|
70
|
+
throw new CairnError(
|
|
71
|
+
'PROTOCOL',
|
|
72
|
+
`expected VERSION_NEGOTIATE (0x${VERSION_NEGOTIATE.toString(16).padStart(4, '0')}), got 0x${envelope.type.toString(16).padStart(4, '0')}`,
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
return decodePayload(envelope.payload);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Process a received VersionNegotiate and produce a response.
|
|
80
|
+
*
|
|
81
|
+
* Returns [selectedVersion, responseEnvelope]. If incompatible, throws VersionMismatchError.
|
|
82
|
+
*/
|
|
83
|
+
export function handleVersionNegotiate(
|
|
84
|
+
received: MessageEnvelope,
|
|
85
|
+
): [number, MessageEnvelope] {
|
|
86
|
+
const peerPayload = parseVersionNegotiate(received);
|
|
87
|
+
const selected = selectVersion(SUPPORTED_VERSIONS, peerPayload.versions);
|
|
88
|
+
|
|
89
|
+
const responsePayload = encodePayload({ versions: [selected] });
|
|
90
|
+
const response: MessageEnvelope = {
|
|
91
|
+
version: CURRENT_PROTOCOL_VERSION,
|
|
92
|
+
type: VERSION_NEGOTIATE,
|
|
93
|
+
msgId: newMsgId(),
|
|
94
|
+
payload: responsePayload,
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
return [selected, response];
|
|
98
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// Server module — store-and-forward, management
|
|
2
|
+
|
|
3
|
+
export type {
|
|
4
|
+
ForwardRequest,
|
|
5
|
+
ForwardAck,
|
|
6
|
+
ForwardDeliver,
|
|
7
|
+
ForwardPurge,
|
|
8
|
+
StoredMessage,
|
|
9
|
+
RetentionPolicy,
|
|
10
|
+
} from './store-forward.js';
|
|
11
|
+
export {
|
|
12
|
+
FORWARD_CHANNEL,
|
|
13
|
+
MAX_SKIP_THRESHOLD,
|
|
14
|
+
defaultRetentionPolicy,
|
|
15
|
+
MessageStore,
|
|
16
|
+
DeduplicationTracker,
|
|
17
|
+
} from './store-forward.js';
|
|
18
|
+
|
|
19
|
+
export type {
|
|
20
|
+
ManagementConfig,
|
|
21
|
+
PeerInfo,
|
|
22
|
+
QueueInfo,
|
|
23
|
+
PeerRelayStats,
|
|
24
|
+
RelayStats,
|
|
25
|
+
PeersResponse,
|
|
26
|
+
QueuesResponse,
|
|
27
|
+
RelayStatsResponse,
|
|
28
|
+
HealthResponse,
|
|
29
|
+
} from './management.js';
|
|
30
|
+
export {
|
|
31
|
+
defaultManagementConfig,
|
|
32
|
+
ManagementState,
|
|
33
|
+
ManagementServer,
|
|
34
|
+
createManagementServer,
|
|
35
|
+
} from './management.js';
|
|
36
|
+
|
|
37
|
+
/** Server mode configuration posture (spec 10.2). */
|
|
38
|
+
export interface ServerConfig {
|
|
39
|
+
meshEnabled: boolean;
|
|
40
|
+
relayWilling: boolean;
|
|
41
|
+
relayCapacity: number;
|
|
42
|
+
storeForwardEnabled: boolean;
|
|
43
|
+
storeForwardMaxPerPeer: number;
|
|
44
|
+
storeForwardMaxAgeMs: number;
|
|
45
|
+
storeForwardMaxTotalSize: number;
|
|
46
|
+
sessionExpiryMs: number;
|
|
47
|
+
heartbeatIntervalMs: number;
|
|
48
|
+
reconnectMaxDurationMs: number | null;
|
|
49
|
+
headless: boolean;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Default server configuration. */
|
|
53
|
+
export function defaultServerConfig(): ServerConfig {
|
|
54
|
+
return {
|
|
55
|
+
meshEnabled: true,
|
|
56
|
+
relayWilling: true,
|
|
57
|
+
relayCapacity: 100,
|
|
58
|
+
storeForwardEnabled: true,
|
|
59
|
+
storeForwardMaxPerPeer: 1000,
|
|
60
|
+
storeForwardMaxAgeMs: 7 * 24 * 60 * 60 * 1000, // 7 days
|
|
61
|
+
storeForwardMaxTotalSize: 1_073_741_824, // 1 GB
|
|
62
|
+
sessionExpiryMs: 7 * 24 * 60 * 60 * 1000, // 7 days
|
|
63
|
+
heartbeatIntervalMs: 60_000, // 60s
|
|
64
|
+
reconnectMaxDurationMs: null, // indefinite
|
|
65
|
+
headless: true,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
// Management API for server-mode peers (spec 10.5, 10.7).
|
|
2
|
+
//
|
|
3
|
+
// Opt-in REST/JSON HTTP API bound to 127.0.0.1:9090 by default.
|
|
4
|
+
// Bearer token authentication with constant-time comparison.
|
|
5
|
+
|
|
6
|
+
import { createServer, IncomingMessage, ServerResponse, Server } from 'node:http';
|
|
7
|
+
import { timingSafeEqual } from 'node:crypto';
|
|
8
|
+
|
|
9
|
+
// ---------------------------------------------------------------------------
|
|
10
|
+
// Configuration
|
|
11
|
+
// ---------------------------------------------------------------------------
|
|
12
|
+
|
|
13
|
+
/** Management API configuration. */
|
|
14
|
+
export interface ManagementConfig {
|
|
15
|
+
/** Whether the management API is enabled. Default: false. */
|
|
16
|
+
enabled: boolean;
|
|
17
|
+
/** Bind address. Default: '127.0.0.1'. */
|
|
18
|
+
bindAddress: string;
|
|
19
|
+
/** Port. Default: 9090. */
|
|
20
|
+
port: number;
|
|
21
|
+
/** Bearer token for authentication. */
|
|
22
|
+
authToken: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Default management API configuration. */
|
|
26
|
+
export function defaultManagementConfig(): ManagementConfig {
|
|
27
|
+
return {
|
|
28
|
+
enabled: false,
|
|
29
|
+
bindAddress: '127.0.0.1',
|
|
30
|
+
port: 9090,
|
|
31
|
+
authToken: '',
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// ---------------------------------------------------------------------------
|
|
36
|
+
// Response types
|
|
37
|
+
// ---------------------------------------------------------------------------
|
|
38
|
+
|
|
39
|
+
/** Information about a paired peer. */
|
|
40
|
+
export interface PeerInfo {
|
|
41
|
+
peerId: string;
|
|
42
|
+
name: string;
|
|
43
|
+
connected: boolean;
|
|
44
|
+
lastSeen: string | null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Per-peer store-and-forward queue info. */
|
|
48
|
+
export interface QueueInfo {
|
|
49
|
+
peerId: string;
|
|
50
|
+
pendingMessages: number;
|
|
51
|
+
oldestMessageAgeSecs: number | null;
|
|
52
|
+
totalBytes: number;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Per-peer relay statistics. */
|
|
56
|
+
export interface PeerRelayStats {
|
|
57
|
+
peerId: string;
|
|
58
|
+
bytesRelayed: number;
|
|
59
|
+
activeStreams: number;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Relay statistics overview. */
|
|
63
|
+
export interface RelayStats {
|
|
64
|
+
activeConnections: number;
|
|
65
|
+
perPeer: PeerRelayStats[];
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Response for GET /peers. */
|
|
69
|
+
export interface PeersResponse {
|
|
70
|
+
peers: PeerInfo[];
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Response for GET /queues. */
|
|
74
|
+
export interface QueuesResponse {
|
|
75
|
+
queues: QueueInfo[];
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Response for GET /relay/stats. */
|
|
79
|
+
export interface RelayStatsResponse {
|
|
80
|
+
relay: RelayStats;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Response for GET /health. */
|
|
84
|
+
export interface HealthResponse {
|
|
85
|
+
status: 'healthy' | 'degraded';
|
|
86
|
+
uptimeSecs: number;
|
|
87
|
+
connectedPeers: number;
|
|
88
|
+
totalPeers: number;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// ---------------------------------------------------------------------------
|
|
92
|
+
// Shared state
|
|
93
|
+
// ---------------------------------------------------------------------------
|
|
94
|
+
|
|
95
|
+
/** Shared state accessible by all management API handlers. */
|
|
96
|
+
export class ManagementState {
|
|
97
|
+
readonly authTokenBytes: Buffer;
|
|
98
|
+
peers: PeerInfo[] = [];
|
|
99
|
+
queues: QueueInfo[] = [];
|
|
100
|
+
relayStats: RelayStats = { activeConnections: 0, perPeer: [] };
|
|
101
|
+
readonly startedAt: number;
|
|
102
|
+
|
|
103
|
+
constructor(authToken: string) {
|
|
104
|
+
this.authTokenBytes = Buffer.from(authToken, 'utf-8');
|
|
105
|
+
this.startedAt = Date.now();
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// ---------------------------------------------------------------------------
|
|
110
|
+
// Authentication
|
|
111
|
+
// ---------------------------------------------------------------------------
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Validate a bearer token using constant-time comparison.
|
|
115
|
+
* Returns true if the token matches.
|
|
116
|
+
*/
|
|
117
|
+
function validateToken(provided: string, expected: Buffer): boolean {
|
|
118
|
+
const providedBytes = Buffer.from(provided, 'utf-8');
|
|
119
|
+
if (providedBytes.length !== expected.length) {
|
|
120
|
+
// Still do a comparison against expected to avoid timing leak on length.
|
|
121
|
+
timingSafeEqual(expected, expected);
|
|
122
|
+
return false;
|
|
123
|
+
}
|
|
124
|
+
return timingSafeEqual(providedBytes, expected);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Extract bearer token from Authorization header.
|
|
129
|
+
* Returns the token string or null if not present/malformed.
|
|
130
|
+
*/
|
|
131
|
+
function extractBearerToken(req: IncomingMessage): string | null {
|
|
132
|
+
const header = req.headers['authorization'];
|
|
133
|
+
if (!header || !header.startsWith('Bearer ')) {
|
|
134
|
+
return null;
|
|
135
|
+
}
|
|
136
|
+
return header.slice(7);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// ---------------------------------------------------------------------------
|
|
140
|
+
// Route handling
|
|
141
|
+
// ---------------------------------------------------------------------------
|
|
142
|
+
|
|
143
|
+
function sendJson(res: ServerResponse, status: number, data: unknown): void {
|
|
144
|
+
res.writeHead(status, { 'Content-Type': 'application/json' });
|
|
145
|
+
res.end(JSON.stringify(data));
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function handlePeers(state: ManagementState, res: ServerResponse): void {
|
|
149
|
+
const response: PeersResponse = { peers: state.peers };
|
|
150
|
+
sendJson(res, 200, response);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function handleQueues(state: ManagementState, res: ServerResponse): void {
|
|
154
|
+
const response: QueuesResponse = { queues: state.queues };
|
|
155
|
+
sendJson(res, 200, response);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function handleRelayStats(state: ManagementState, res: ServerResponse): void {
|
|
159
|
+
const response: RelayStatsResponse = { relay: state.relayStats };
|
|
160
|
+
sendJson(res, 200, response);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function handleHealth(state: ManagementState, res: ServerResponse): void {
|
|
164
|
+
const totalPeers = state.peers.length;
|
|
165
|
+
const connectedPeers = state.peers.filter((p) => p.connected).length;
|
|
166
|
+
const uptimeSecs = Math.floor((Date.now() - state.startedAt) / 1000);
|
|
167
|
+
const status = connectedPeers > 0 ? 'healthy' : 'degraded';
|
|
168
|
+
|
|
169
|
+
const response: HealthResponse = {
|
|
170
|
+
status,
|
|
171
|
+
uptimeSecs,
|
|
172
|
+
connectedPeers,
|
|
173
|
+
totalPeers,
|
|
174
|
+
};
|
|
175
|
+
sendJson(res, 200, response);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function handlePairingQr(res: ServerResponse): void {
|
|
179
|
+
sendJson(res, 503, {
|
|
180
|
+
error: 'pairing QR generation not yet available (pending headless pairing integration)',
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// ---------------------------------------------------------------------------
|
|
185
|
+
// Server
|
|
186
|
+
// ---------------------------------------------------------------------------
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Create and start the management API HTTP server.
|
|
190
|
+
*
|
|
191
|
+
* Returns a handle with `close()` to stop the server.
|
|
192
|
+
*
|
|
193
|
+
* @throws Error if authToken is empty.
|
|
194
|
+
*/
|
|
195
|
+
export function createManagementServer(
|
|
196
|
+
config: ManagementConfig,
|
|
197
|
+
state: ManagementState,
|
|
198
|
+
): ManagementServer {
|
|
199
|
+
if (config.authToken === '') {
|
|
200
|
+
throw new Error('management API auth token is empty');
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Warn on non-loopback bind address.
|
|
204
|
+
if (config.bindAddress !== '127.0.0.1' && config.bindAddress !== '::1') {
|
|
205
|
+
console.warn(
|
|
206
|
+
`Management API exposed on non-loopback interface ${config.bindAddress} without TLS. This is insecure.`,
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const server = createServer((req, res) => {
|
|
211
|
+
// Authentication.
|
|
212
|
+
const token = extractBearerToken(req);
|
|
213
|
+
if (token === null || !validateToken(token, state.authTokenBytes)) {
|
|
214
|
+
sendJson(res, 401, { error: 'unauthorized' });
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// Routing.
|
|
219
|
+
const url = req.url ?? '/';
|
|
220
|
+
const method = req.method ?? 'GET';
|
|
221
|
+
|
|
222
|
+
if (method !== 'GET') {
|
|
223
|
+
sendJson(res, 405, { error: 'method not allowed' });
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
switch (url) {
|
|
228
|
+
case '/peers':
|
|
229
|
+
handlePeers(state, res);
|
|
230
|
+
break;
|
|
231
|
+
case '/queues':
|
|
232
|
+
handleQueues(state, res);
|
|
233
|
+
break;
|
|
234
|
+
case '/relay/stats':
|
|
235
|
+
handleRelayStats(state, res);
|
|
236
|
+
break;
|
|
237
|
+
case '/health':
|
|
238
|
+
handleHealth(state, res);
|
|
239
|
+
break;
|
|
240
|
+
case '/pairing/qr':
|
|
241
|
+
handlePairingQr(res);
|
|
242
|
+
break;
|
|
243
|
+
default:
|
|
244
|
+
sendJson(res, 404, { error: 'not found' });
|
|
245
|
+
break;
|
|
246
|
+
}
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
return new ManagementServer(server, config);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** Handle to a running management API server. */
|
|
253
|
+
export class ManagementServer {
|
|
254
|
+
private readonly _server: Server;
|
|
255
|
+
private readonly _config: ManagementConfig;
|
|
256
|
+
|
|
257
|
+
constructor(server: Server, config: ManagementConfig) {
|
|
258
|
+
this._server = server;
|
|
259
|
+
this._config = config;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/** Start listening. Returns a promise that resolves when the server is ready. */
|
|
263
|
+
async start(): Promise<void> {
|
|
264
|
+
return new Promise((resolve) => {
|
|
265
|
+
this._server.listen(this._config.port, this._config.bindAddress, () => {
|
|
266
|
+
resolve();
|
|
267
|
+
});
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** Stop the server. */
|
|
272
|
+
async close(): Promise<void> {
|
|
273
|
+
return new Promise((resolve, reject) => {
|
|
274
|
+
this._server.close((err) => {
|
|
275
|
+
if (err) reject(err);
|
|
276
|
+
else resolve();
|
|
277
|
+
});
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/** The underlying http.Server (for testing). */
|
|
282
|
+
get httpServer(): Server {
|
|
283
|
+
return this._server;
|
|
284
|
+
}
|
|
285
|
+
}
|