chore: initial commit
This commit is contained in:
@@ -0,0 +1,690 @@
|
||||
import {createHash, randomUUID} from 'node:crypto';
|
||||
import type {Socket} from 'socket.io';
|
||||
import type {BotEvent, BotEventName, ForwardAck, ForwardErrorCode, ForwardSessionStatus, InfoUpdate, JoinRoomReady,
|
||||
OpenSessionCommand, RelayClaims, RelayLimits, RelayMethod, RelayRequest, SessionScope} from '../types.ts';
|
||||
import {RelayError, fail, is_record, is_uuid, success, error_ack, validate_open, validate_grant,
|
||||
validate_request, validate_event, validate_info_update, validate_node_ack} from '../utils/relay_validation.ts';
|
||||
import {verify_token} from '../utils/token_utils.ts';
|
||||
|
||||
type Timer = ReturnType<typeof setTimeout>;
|
||||
type Result = ForwardAck<unknown>;
|
||||
interface DedupeEntry {fingerprint: string; expires_at: number; result?: Result; completed?: boolean; non_replayable: boolean; local?: boolean}
|
||||
interface Grant {
|
||||
id: string; user_id: number; permissions: Set<RelayMethod>; expires_at: number;
|
||||
timer?: Timer; web?: Binding; dedupe: Map<string, DedupeEntry>;
|
||||
}
|
||||
interface Session {
|
||||
scope: SessionScope; lease_expires_at: number; timer?: Timer; reclaim?: Timer;
|
||||
node?: Binding; generation: number; ready: boolean; had_web: boolean; first_ready: boolean;
|
||||
grants: Map<string, Grant>; revoked_grants: Map<string, number>; webs: Set<Binding>; pending: Set<Pending>;
|
||||
stream?: string; seq?: number; retired?: Set<string>;
|
||||
}
|
||||
interface Connection {
|
||||
socket: Socket; binding?: Binding; busy: boolean; auth_timer?: Timer;
|
||||
cancel_join?: (error: RelayError) => void; joining_session?: Session; joining_grant?: Grant;
|
||||
requests: number; request_window: number;
|
||||
logs?: number; log_window?: number;
|
||||
disconnect: () => void;
|
||||
}
|
||||
interface Binding {
|
||||
connection: Connection; session: Session; claims: RelayClaims; grant?: Grant;
|
||||
expires_at: number; timer?: Timer; generation: number;
|
||||
subscriptions: Set<BotEventName>; pending: Set<Pending>;
|
||||
}
|
||||
interface Pending {
|
||||
id: string; binding: Binding; node: Binding; generation: number;
|
||||
session: Session; timer?: Timer; entry: DedupeEntry; cancel_transport?: () => void;
|
||||
finish: (result: Result) => void;
|
||||
}
|
||||
export interface SessionStoreOptions {
|
||||
forward_id: number; key: string; limits: RelayLimits;
|
||||
on_status: (status: ForwardSessionStatus) => void;
|
||||
on_error?: (error: unknown) => void;
|
||||
}
|
||||
|
||||
function timer(callback: () => void, delay: number): Timer {
|
||||
const handle = setTimeout(callback, Math.max(1, Math.min(delay, 2_147_483_647)));
|
||||
handle.unref();
|
||||
return handle;
|
||||
}
|
||||
function same_scope(a: SessionScope, b: SessionScope): boolean {
|
||||
return a.forward_id === b.forward_id && a.forward_session_id === b.forward_session_id
|
||||
&& a.node_session_id === b.node_session_id && a.node_id === b.node_id && a.bot_id === b.bot_id;
|
||||
}
|
||||
function same_identity(a: RelayClaims, b: RelayClaims): boolean {
|
||||
return same_scope(a, b) && a.role === b.role && a.grant_id === b.grant_id && a.user_id === b.user_id;
|
||||
}
|
||||
function canonical(value: unknown): string {
|
||||
if (Array.isArray(value)) return `[${value.map(canonical).join(',')}]`;
|
||||
if (is_record(value)) return `{${Object.keys(value).sort().map(key => `${JSON.stringify(key)}:${canonical(value[key])}`).join(',')}}`;
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
export class RelaySessionStore {
|
||||
private readonly sessions = new Map<string, Session>();
|
||||
private readonly revoked_sessions = new Map<string, number>();
|
||||
private readonly connections = new Map<Socket, Connection>();
|
||||
private readonly pending = new Map<string, Pending>();
|
||||
private closed = false;
|
||||
private control = true;
|
||||
readonly limits: RelayLimits;
|
||||
private readonly options: SessionStoreOptions;
|
||||
|
||||
constructor(options: SessionStoreOptions) {this.options = options; this.limits = options.limits;}
|
||||
get control_available(): boolean {return this.control;}
|
||||
get size(): number {return this.sessions.size;}
|
||||
set_control_available(available: boolean): void {if (!this.closed) this.control = available;}
|
||||
|
||||
private report(session: Session, state: ForwardSessionStatus['state'], reason: string): void {
|
||||
try {
|
||||
this.options.on_status({...session.scope, node_ready: session.ready, state, reason, reported_at: Date.now()});
|
||||
} catch (error) {this.report_error(error);}
|
||||
}
|
||||
private report_error(error: unknown): void {
|
||||
try {this.options.on_error?.(error);} catch { /* Observer failures cannot retain relay resources. */ }
|
||||
}
|
||||
private active(session: Session): void {
|
||||
if (this.closed || this.sessions.get(session.scope.forward_session_id) !== session)
|
||||
fail('SESSION_STALE', 'Session is no longer active');
|
||||
if (session.lease_expires_at <= Date.now()) {
|
||||
this.close_session(session, 'lease_expired', 'TOKEN_EXPIRED');
|
||||
fail('TOKEN_EXPIRED', 'Session lease expired');
|
||||
}
|
||||
}
|
||||
private session(id: string): Session {
|
||||
const session = this.sessions.get(id);
|
||||
if (!session) fail('SESSION_STALE', 'Session is not registered');
|
||||
this.active(session);
|
||||
return session;
|
||||
}
|
||||
private control_required(): void {
|
||||
if (this.closed || !this.control) fail('RELAY_UNAVAILABLE', 'Control channel is unavailable', true);
|
||||
}
|
||||
private prune_revoked(entries: Map<string, number>): void {
|
||||
for (const [id, expires_at] of entries) if (expires_at <= Date.now()) entries.delete(id);
|
||||
}
|
||||
private tombstone_expiry(): number {return Date.now() + this.limits.max_token_age_ms + this.limits.clock_skew_ms;}
|
||||
open(command: unknown): OpenSessionCommand {
|
||||
this.control_required();
|
||||
validate_open(command, this.options.forward_id, this.limits);
|
||||
const value = command;
|
||||
this.prune_revoked(this.revoked_sessions);
|
||||
if (this.revoked_sessions.has(value.forward_session_id)) fail('SESSION_STALE', 'Session ID was revoked');
|
||||
const now = Date.now();
|
||||
if (value.lease_expires_at <= now || value.lease_expires_at > now + this.limits.max_lease_ms)
|
||||
fail('INVALID_REQUEST', 'Session lease is outside the allowed range');
|
||||
let session = this.sessions.get(value.forward_session_id);
|
||||
if (session && session.lease_expires_at <= now) {
|
||||
this.close_session(session, 'lease_expired', 'TOKEN_EXPIRED');
|
||||
fail('SESSION_STALE', 'Expired session ID cannot be reused');
|
||||
}
|
||||
if (session) {
|
||||
if (!same_scope(session.scope, value)) fail('SESSION_STALE', 'Session scope is immutable');
|
||||
session.lease_expires_at = Math.max(session.lease_expires_at, value.lease_expires_at);
|
||||
} else {
|
||||
for (const other of this.sessions.values()) {
|
||||
if (other.lease_expires_at <= now) this.close_session(other, 'lease_expired', 'TOKEN_EXPIRED');
|
||||
else if (other.scope.node_id === value.node_id && other.scope.bot_id === value.bot_id)
|
||||
fail('SESSION_STALE', 'Revoke the existing Node/Bot session before opening another');
|
||||
}
|
||||
if (this.sessions.size + this.revoked_sessions.size >= this.limits.max_sessions) fail('RATE_LIMITED', 'Session capacity reached', true);
|
||||
const {forward_id, forward_session_id, node_session_id, node_id, bot_id} = value;
|
||||
session = {scope: {forward_id, forward_session_id, node_session_id, node_id, bot_id},
|
||||
lease_expires_at: value.lease_expires_at, generation: 0, ready: false, had_web: false,
|
||||
first_ready: false, grants: new Map(), revoked_grants: new Map(), webs: new Set(), pending: new Set()};
|
||||
this.sessions.set(value.forward_session_id, session);
|
||||
this.report(session, 'open', 'session_opened');
|
||||
}
|
||||
clearTimeout(session.timer);
|
||||
const owned = session;
|
||||
session.timer = timer(() => {
|
||||
if (this.sessions.get(value.forward_session_id) === owned)
|
||||
this.close_session(owned, 'lease_expired', 'TOKEN_EXPIRED');
|
||||
}, session.lease_expires_at - Date.now());
|
||||
return {...session.scope, lease_expires_at: session.lease_expires_at};
|
||||
}
|
||||
authorize(command: unknown) {
|
||||
this.control_required();
|
||||
validate_grant(command, this.limits);
|
||||
const value = command;
|
||||
const session = this.session(value.forward_session_id);
|
||||
this.prune_revoked(session.revoked_grants);
|
||||
if (session.revoked_grants.has(value.grant_id)) fail('FORBIDDEN', 'Grant ID was revoked');
|
||||
const now = Date.now();
|
||||
if (value.expires_at <= now || value.expires_at > now + this.limits.max_lease_ms
|
||||
|| value.expires_at > session.lease_expires_at) fail('INVALID_REQUEST', 'Grant lease is outside the session lease');
|
||||
let grant = session.grants.get(value.grant_id);
|
||||
if (grant && grant.expires_at <= now) {
|
||||
this.close_grant(session, grant, 'grant_expired', 'TOKEN_EXPIRED');
|
||||
fail('FORBIDDEN', 'Expired grant ID cannot be reused');
|
||||
}
|
||||
if (grant) {
|
||||
if (grant.user_id !== value.user_id || grant.permissions.size !== value.permissions.length
|
||||
|| value.permissions.some(permission => !grant!.permissions.has(permission)))
|
||||
fail('FORBIDDEN', 'Grant identity and permissions are immutable');
|
||||
grant.expires_at = Math.max(grant.expires_at, value.expires_at);
|
||||
} else {
|
||||
if (session.grants.size + session.revoked_grants.size >= this.limits.max_grants_per_session) fail('RATE_LIMITED', 'Grant capacity reached', true);
|
||||
grant = {id: value.grant_id, user_id: value.user_id, permissions: new Set(value.permissions),
|
||||
expires_at: value.expires_at, dedupe: new Map()};
|
||||
session.grants.set(grant.id, grant);
|
||||
}
|
||||
clearTimeout(grant.timer);
|
||||
const owned = grant;
|
||||
grant.timer = timer(() => {
|
||||
if (session.grants.get(owned.id) === owned) this.close_grant(session, owned, 'grant_expired', 'TOKEN_EXPIRED');
|
||||
}, grant.expires_at - Date.now());
|
||||
return {...value, expires_at: grant.expires_at};
|
||||
}
|
||||
private revoke_command(command: unknown, grant: boolean): {forward_session_id: string; reason: string; grant_id?: string} {
|
||||
if (!is_record(command) || !is_uuid(command.forward_session_id) || typeof command.reason !== 'string'
|
||||
|| command.reason.trim().length < 1 || Buffer.byteLength(command.reason) > 256
|
||||
|| (grant && !is_uuid(command.grant_id))
|
||||
|| Object.keys(command).some(key => !['forward_session_id', 'reason', ...(grant ? ['grant_id'] : [])].includes(key)))
|
||||
fail('INVALID_REQUEST', 'Invalid revoke command');
|
||||
return command as {forward_session_id: string; reason: string; grant_id?: string};
|
||||
}
|
||||
revoke_session(command: unknown) {
|
||||
const value = this.revoke_command(command, false);
|
||||
const session = this.sessions.get(value.forward_session_id);
|
||||
if (session) this.close_session(session, value.reason, 'SESSION_STALE');
|
||||
else {
|
||||
this.prune_revoked(this.revoked_sessions);
|
||||
if (!this.revoked_sessions.has(value.forward_session_id) && this.sessions.size + this.revoked_sessions.size >= this.limits.max_sessions)
|
||||
fail('RATE_LIMITED', 'Revocation capacity reached', true);
|
||||
this.revoked_sessions.set(value.forward_session_id, this.tombstone_expiry());
|
||||
}
|
||||
return {forward_session_id: value.forward_session_id, revoked: true};
|
||||
}
|
||||
revoke_grant(command: unknown) {
|
||||
const value = this.revoke_command(command, true);
|
||||
const session = this.sessions.get(value.forward_session_id);
|
||||
const grant = session?.grants.get(value.grant_id!);
|
||||
if (grant) this.close_grant(session!, grant, value.reason, 'FORBIDDEN');
|
||||
else if (session) {
|
||||
this.prune_revoked(session.revoked_grants);
|
||||
if (!session.revoked_grants.has(value.grant_id!) && session.grants.size + session.revoked_grants.size >= this.limits.max_grants_per_session)
|
||||
fail('RATE_LIMITED', 'Grant revocation capacity reached', true);
|
||||
session.revoked_grants.set(value.grant_id!, this.tombstone_expiry());
|
||||
}
|
||||
return {forward_session_id: value.forward_session_id, grant_id: value.grant_id!, revoked: true};
|
||||
}
|
||||
|
||||
attach(socket: Socket): boolean {
|
||||
if (this.closed || this.connections.size >= this.limits.max_connections) {
|
||||
socket.emit('forward_session_closed', {reason: 'connection_capacity', code: 'RATE_LIMITED'});
|
||||
socket.disconnect(true);
|
||||
return false;
|
||||
}
|
||||
const connection: Connection = {socket, busy: false, requests: 0, request_window: Date.now(),
|
||||
disconnect: () => this.detach(socket)};
|
||||
this.connections.set(socket, connection);
|
||||
connection.auth_timer = timer(() => this.drop(connection, 'authentication_timeout', 'UNAUTHORIZED'), this.limits.auth_timeout_ms);
|
||||
socket.on('disconnect', connection.disconnect);
|
||||
return true;
|
||||
}
|
||||
detach(socket: Socket): void {
|
||||
const connection = this.connections.get(socket);
|
||||
if (!connection) return;
|
||||
this.connections.delete(socket);
|
||||
connection.cancel_join?.(new RelayError('CANCELLED', 'Connection closed while joining'));
|
||||
clearTimeout(connection.auth_timer);
|
||||
socket.off('disconnect', connection.disconnect);
|
||||
const binding = connection.binding;
|
||||
if (!binding) return;
|
||||
connection.binding = undefined;
|
||||
clearTimeout(binding.timer);
|
||||
for (const pending of [...binding.pending]) pending.finish(error_ack(new RelayError('CANCELLED', 'Web connection closed')));
|
||||
const session = binding.session;
|
||||
if (binding.claims.role === 'node') {
|
||||
if (session.node !== binding) return;
|
||||
session.node = undefined;
|
||||
session.ready = false;
|
||||
for (const pending of [...session.pending]) pending.finish(error_ack(new RelayError('NODE_OFFLINE', 'Node disconnected', true)));
|
||||
this.status(session, 'node_disconnected');
|
||||
} else {
|
||||
if (binding.grant?.web === binding) binding.grant.web = undefined;
|
||||
session.webs.delete(binding);
|
||||
if (this.sessions.get(session.scope.forward_session_id) === session && session.webs.size === 0)
|
||||
this.reclaim(session, this.limits.idle_timeout_ms, 'last_web_left');
|
||||
}
|
||||
}
|
||||
private drop(connection: Connection, reason: string, code: ForwardErrorCode): void {
|
||||
connection.cancel_join?.(new RelayError(code, reason));
|
||||
const binding = connection.binding;
|
||||
const scope = binding?.session.scope ?? connection.joining_session?.scope;
|
||||
if (connection.socket.connected) connection.socket.emit('forward_session_closed', {
|
||||
...(scope ? {forward_session_id: scope.forward_session_id} : {}), reason, code});
|
||||
this.detach(connection.socket);
|
||||
connection.socket.disconnect(true);
|
||||
}
|
||||
disconnect(socket: Socket, reason = 'relay_routes_closed'): void {
|
||||
const connection = this.connections.get(socket);
|
||||
if (connection) this.drop(connection, reason, 'CANCELLED');
|
||||
}
|
||||
can_reply(socket: Socket): boolean {
|
||||
const connection = this.connections.get(socket);
|
||||
return !!connection && this.writable(connection);
|
||||
}
|
||||
unknown_event(socket: Socket): void {
|
||||
const connection = this.connections.get(socket);
|
||||
if (!connection) fail('UNAUTHORIZED', 'Socket is disconnected');
|
||||
try {this.rate(connection);}
|
||||
catch (error) {this.drop(connection, 'unknown_event_rate_exceeded', 'RATE_LIMITED'); throw error;}
|
||||
}
|
||||
private writable(connection: Connection): boolean {
|
||||
const transport = connection.socket.conn as unknown as {writeBuffer: unknown[]};
|
||||
if (transport.writeBuffer.length >= this.limits.max_buffered_packets) {
|
||||
this.drop(connection, 'resync_required', 'RESYNC_REQUIRED');
|
||||
return false;
|
||||
}
|
||||
return connection.socket.connected;
|
||||
}
|
||||
private close_grant(session: Session, grant: Grant, reason: string, code: ForwardErrorCode): void {
|
||||
if (session.grants.get(grant.id) !== grant) return;
|
||||
session.grants.delete(grant.id);
|
||||
session.revoked_grants.set(grant.id, this.tombstone_expiry());
|
||||
clearTimeout(grant.timer);
|
||||
for (const connection of [...this.connections.values()])
|
||||
if (connection.joining_grant === grant) this.drop(connection, reason, code);
|
||||
if (grant.web) this.drop(grant.web.connection, reason, code);
|
||||
grant.dedupe.clear();
|
||||
}
|
||||
private close_session(session: Session, reason: string, code: ForwardErrorCode): void {
|
||||
if (this.sessions.get(session.scope.forward_session_id) !== session) return;
|
||||
this.sessions.delete(session.scope.forward_session_id);
|
||||
this.revoked_sessions.set(session.scope.forward_session_id, this.tombstone_expiry());
|
||||
clearTimeout(session.timer);
|
||||
clearTimeout(session.reclaim);
|
||||
for (const connection of [...this.connections.values()])
|
||||
if (connection.joining_session === session) this.drop(connection, reason, code);
|
||||
for (const pending of [...session.pending]) pending.finish(error_ack(new RelayError(code, reason)));
|
||||
for (const grant of session.grants.values()) clearTimeout(grant.timer);
|
||||
const sockets = [...session.webs, ...(session.node ? [session.node] : [])];
|
||||
session.ready = false;
|
||||
for (const binding of sockets) this.drop(binding.connection, reason, code);
|
||||
session.grants.clear();
|
||||
this.report(session, 'closed', reason);
|
||||
}
|
||||
private reclaim(session: Session, delay: number, reason: string): void {
|
||||
clearTimeout(session.reclaim);
|
||||
const owned = timer(() => {
|
||||
if (session.reclaim === owned && this.sessions.get(session.scope.forward_session_id) === session && !session.webs.size)
|
||||
this.close_session(session, reason, 'SESSION_STALE');
|
||||
}, delay);
|
||||
session.reclaim = owned;
|
||||
}
|
||||
private status(session: Session, reason: string): void {
|
||||
if (this.sessions.get(session.scope.forward_session_id) !== session) return;
|
||||
const data = {forward_session_id: session.scope.forward_session_id, node_ready: session.ready, reason};
|
||||
for (const web of [...session.webs]) if (this.valid(web)) this.send(web, 'relay_status', data);
|
||||
this.report(session, session.ready ? 'ready' : 'unavailable', reason);
|
||||
}
|
||||
private grant(session: Session, claims: RelayClaims): Grant | undefined {
|
||||
if (claims.role !== 'web') return undefined;
|
||||
const grant = session.grants.get(claims.grant_id!);
|
||||
if (!grant || grant.user_id !== claims.user_id) fail('FORBIDDEN', 'Web grant is not authorized');
|
||||
if (grant.expires_at <= Date.now()) {
|
||||
this.close_grant(session, grant, 'grant_expired', 'TOKEN_EXPIRED');
|
||||
fail('TOKEN_EXPIRED', 'Web grant expired');
|
||||
}
|
||||
if (claims.permissions.some(permission => !grant.permissions.has(permission))) fail('FORBIDDEN', 'Token exceeds grant permissions');
|
||||
return grant;
|
||||
}
|
||||
private valid(binding: Binding): boolean {
|
||||
const {connection, session, grant} = binding;
|
||||
if (this.connections.get(connection.socket) !== connection || connection.binding !== binding || !connection.socket.connected)
|
||||
return false;
|
||||
if (this.sessions.get(session.scope.forward_session_id) !== session) return false;
|
||||
if (binding.expires_at <= Date.now() || session.lease_expires_at <= Date.now()
|
||||
|| (grant && (session.grants.get(grant.id) !== grant || grant.expires_at <= Date.now()))) {
|
||||
this.drop(connection, 'authorization_expired', 'TOKEN_EXPIRED');
|
||||
return false;
|
||||
}
|
||||
return binding.claims.role === 'node' ? session.node === binding : grant?.web === binding;
|
||||
}
|
||||
private binding(socket: Socket, role?: RelayClaims['role']): Binding {
|
||||
const connection = this.connections.get(socket);
|
||||
if (!connection || connection.busy || !connection.binding || !this.valid(connection.binding))
|
||||
fail('UNAUTHORIZED', 'Socket is not bound to an active session');
|
||||
if (role && connection.binding.claims.role !== role) fail('FORBIDDEN', 'Socket role cannot perform this operation');
|
||||
return connection.binding;
|
||||
}
|
||||
private ready_data(binding: Binding): JoinRoomReady {
|
||||
return {...binding.session.scope, role: binding.claims.role, node_ready: binding.session.ready, expires_at: binding.expires_at};
|
||||
}
|
||||
private async join_room(connection: Connection, room: string): Promise<void> {
|
||||
let cancelled = false;
|
||||
let deadline: Timer | undefined;
|
||||
const cancellation = new Promise<never>((_resolve, reject) => {
|
||||
connection.cancel_join = error => {cancelled = true; reject(error);};
|
||||
deadline = timer(() => connection.cancel_join?.(new RelayError('TIMEOUT', 'Room join timed out', true)), this.limits.auth_timeout_ms);
|
||||
});
|
||||
const joining = Promise.resolve().then(() => connection.socket.join(room)).then(() => {
|
||||
// An async adapter can complete after disconnect/cancellation and add the
|
||||
// socket back to its room. Clean that late membership without retaining it.
|
||||
if (cancelled) void Promise.resolve(connection.socket.leave(room)).catch(error => this.report_error(error));
|
||||
});
|
||||
try {await Promise.race([joining, cancellation]);}
|
||||
finally {clearTimeout(deadline); connection.cancel_join = undefined;}
|
||||
}
|
||||
async join(socket: Socket, command: unknown, reauth = false): Promise<JoinRoomReady> {
|
||||
this.control_required();
|
||||
const connection = this.connections.get(socket);
|
||||
if (!connection || !socket.connected) fail('UNAUTHORIZED', 'Socket is disconnected');
|
||||
this.rate(connection);
|
||||
if (connection.busy) fail('INVALID_REQUEST', 'Another authentication operation is pending');
|
||||
if (!is_record(command) || Object.keys(command).length !== 1 || typeof command.token !== 'string')
|
||||
fail('INVALID_REQUEST', 'Expected a token');
|
||||
connection.busy = true;
|
||||
let joined = false;
|
||||
let room: string | undefined;
|
||||
try {
|
||||
const claims = verify_token(this.options.key, command.token, this.options.forward_id, this.limits);
|
||||
const session = this.session(claims.forward_session_id);
|
||||
if (!same_scope(session.scope, claims)) fail('SESSION_STALE', 'Token scope differs from the registered session');
|
||||
const grant = this.grant(session, claims);
|
||||
connection.joining_session = session;
|
||||
connection.joining_grant = grant;
|
||||
const previous = connection.binding;
|
||||
if (previous && (!same_identity(previous.claims, claims) || !this.valid(previous)))
|
||||
fail('FORBIDDEN', 'Socket identity cannot change');
|
||||
if (previous && reauth && (previous.claims.permissions.length !== claims.permissions.length
|
||||
|| claims.permissions.some(permission => !previous.claims.permissions.includes(permission))))
|
||||
fail('FORBIDDEN', 'Reauthorization cannot change permissions');
|
||||
if (reauth && !previous) fail('UNAUTHORIZED', 'Join before renewing authorization');
|
||||
if (previous && !reauth) return this.ready_data(previous);
|
||||
const expires_at = Math.min(claims.expires_at, session.lease_expires_at, grant?.expires_at ?? Infinity);
|
||||
if (expires_at <= Date.now()) fail('TOKEN_EXPIRED', 'Authorization expired');
|
||||
if (previous && expires_at < previous.expires_at) fail('INVALID_REQUEST', 'Reauthorization can only extend the lease');
|
||||
room = `relay:${session.scope.forward_id}:${session.scope.node_id}:${session.scope.bot_id}:${session.scope.node_session_id}:${session.scope.forward_session_id}:${claims.role}`;
|
||||
if (!previous) {await this.join_room(connection, room); joined = true;}
|
||||
this.control_required();
|
||||
this.active(session);
|
||||
if (!socket.connected || this.connections.get(socket) !== connection) fail('CANCELLED', 'Connection closed while joining');
|
||||
if (this.grant(session, claims) !== grant) fail('FORBIDDEN', 'Grant was replaced while joining');
|
||||
if (expires_at <= Date.now()) fail('TOKEN_EXPIRED', 'Authorization expired while joining');
|
||||
if (previous) {
|
||||
previous.claims = claims;
|
||||
previous.expires_at = expires_at;
|
||||
this.expiry(previous);
|
||||
return this.ready_data(previous);
|
||||
}
|
||||
const binding: Binding = {connection, session, claims, grant, expires_at, generation: session.generation,
|
||||
subscriptions: new Set(), pending: new Set()};
|
||||
connection.binding = binding;
|
||||
clearTimeout(connection.auth_timer);
|
||||
if (claims.role === 'node') {
|
||||
const old = session.node;
|
||||
binding.generation = ++session.generation;
|
||||
session.node = binding;
|
||||
session.ready = false;
|
||||
session.stream = undefined;
|
||||
session.seq = undefined;
|
||||
session.retired = undefined;
|
||||
for (const pending of [...session.pending]) pending.finish(error_ack(new RelayError('NODE_OFFLINE', 'Node connection replaced', true)));
|
||||
if (old) this.drop(old.connection, 'node_replaced', 'SESSION_STALE');
|
||||
this.status(session, 'node_joined');
|
||||
} else {
|
||||
const old = grant!.web;
|
||||
for (const [id, entry] of grant!.dedupe) if (entry.local) grant!.dedupe.delete(id);
|
||||
grant!.web = binding;
|
||||
session.webs.add(binding);
|
||||
session.had_web = true;
|
||||
clearTimeout(session.reclaim);
|
||||
session.reclaim = undefined;
|
||||
if (old) this.drop(old.connection, 'grant_connection_replaced', 'SESSION_STALE');
|
||||
}
|
||||
this.expiry(binding);
|
||||
return this.ready_data(binding);
|
||||
} catch (error) {
|
||||
if (joined && room && !connection.binding)
|
||||
void Promise.resolve(socket.leave(room)).catch(error => this.report_error(error));
|
||||
throw error;
|
||||
} finally {connection.busy = false; connection.joining_session = undefined; connection.joining_grant = undefined;}
|
||||
}
|
||||
private expiry(binding: Binding): void {
|
||||
clearTimeout(binding.timer);
|
||||
binding.timer = timer(() => {
|
||||
if (binding.connection.binding === binding) this.drop(binding.connection, 'authorization_expired', 'TOKEN_EXPIRED');
|
||||
}, binding.expires_at - Date.now());
|
||||
}
|
||||
node_ready(socket: Socket, command: unknown): {node_ready: true} {
|
||||
const binding = this.binding(socket, 'node');
|
||||
const session = binding.session;
|
||||
if (!is_record(command) || Object.keys(command).length !== 1 || command.forward_session_id !== session.scope.forward_session_id)
|
||||
fail('INVALID_REQUEST', 'Invalid Node readiness message');
|
||||
if (!session.ready) {
|
||||
session.ready = true;
|
||||
this.status(session, 'node_ready');
|
||||
if (!session.first_ready) {
|
||||
session.first_ready = true;
|
||||
if (!session.had_web && !session.webs.size) this.reclaim(session, this.limits.unused_timeout_ms, 'unused_session');
|
||||
}
|
||||
}
|
||||
return {node_ready: true};
|
||||
}
|
||||
private rate(connection: Connection): void {
|
||||
const now = Date.now();
|
||||
if (now - connection.request_window >= 1000) {connection.request_window = now; connection.requests = 0;}
|
||||
if (++connection.requests > this.limits.requests_per_second) fail('RATE_LIMITED', 'Request rate exceeded', true);
|
||||
}
|
||||
async request(socket: Socket, command: unknown): Promise<Result> {
|
||||
const binding = this.binding(socket, 'web');
|
||||
this.rate(binding.connection);
|
||||
validate_request(command, this.limits);
|
||||
const request = command;
|
||||
const permission = request.method === 'get_log_history' ? 'bot.logs'
|
||||
: request.method === 'tab_complete' ? 'bot.chat'
|
||||
: request.method === 'get_context_info' ? 'bot.snapshot'
|
||||
: request.method === 'get_data_file_list' || request.method === 'get_file_data' ? 'bot.files'
|
||||
: request.method === 'workflow.start' || request.method === 'workflow.stop' || request.method === 'workflow.control' ? 'workflow.control'
|
||||
: request.method === 'workflow.snapshot' || request.method === 'workflow.catalog'
|
||||
|| request.method === 'workflow.command' || request.method === 'workflow.runtime' ? 'bot.snapshot' : request.method;
|
||||
if (!binding.claims.permissions.includes(permission) || !binding.grant!.permissions.has(permission))
|
||||
fail('FORBIDDEN', 'Method is not authorized');
|
||||
const workflow_function = request.method === 'execute_function'
|
||||
&& (request.payload as {function: string}).function.split('/')[0] === 'workflow';
|
||||
if (workflow_function && (!binding.claims.permissions.includes('workflow.control') || !binding.grant!.permissions.has('workflow.control')))
|
||||
fail('FORBIDDEN', 'Workflow functions require workflow control permission');
|
||||
const non_replayable = request.method === 'bot.chat' || request.method === 'execute_function'
|
||||
|| request.method === 'workflow.start' || request.method === 'workflow.stop' || request.method === 'workflow.control';
|
||||
if (non_replayable || request.method === 'tab_complete') this.control_required();
|
||||
const now = Date.now();
|
||||
const dedupe = binding.grant!.dedupe;
|
||||
const file_request = request.method === 'get_data_file_list' || request.method === 'get_file_data';
|
||||
const fresh_read = file_request || request.method.startsWith('workflow.') && !non_replayable;
|
||||
for (const [id, entry] of dedupe) if (entry.expires_at <= now && (entry.completed || entry.result)) dedupe.delete(id);
|
||||
const fingerprint = createHash('sha256').update(canonical({method: request.method, payload: request.payload})).digest('hex');
|
||||
const existing = dedupe.get(request.request_id);
|
||||
if (existing) {
|
||||
if (existing.fingerprint !== fingerprint) fail('INVALID_REQUEST', 'Request ID was already used with a different payload');
|
||||
if (!existing.completed && !existing.result) fail('RATE_LIMITED', 'Request is already pending', true);
|
||||
if (existing.non_replayable) fail('CANCELLED', 'Mutating requests cannot be replayed');
|
||||
// Runtime reads must reflect the current Node and its permissions.
|
||||
if (request.method !== 'get_context_info' && !fresh_read) return existing.result!;
|
||||
}
|
||||
if (!existing && dedupe.size >= this.limits.max_dedupe_entries) {
|
||||
const oldest = [...dedupe].find(([, entry]) => entry.completed || entry.result);
|
||||
if (oldest) dedupe.delete(oldest[0]);
|
||||
else fail('RATE_LIMITED', 'Request deduplication capacity reached', true);
|
||||
}
|
||||
const entry: DedupeEntry = {fingerprint, expires_at: now + this.limits.dedupe_ttl_ms, non_replayable};
|
||||
if (request.method === 'bot.subscribe' || request.method === 'bot.unsubscribe') {
|
||||
entry.local = true;
|
||||
const events = (request.payload as {events: BotEventName[]}).events;
|
||||
if (request.method === 'bot.subscribe') binding.subscriptions = new Set(events);
|
||||
else for (const event of events) binding.subscriptions.delete(event);
|
||||
entry.result = success({events: [...binding.subscriptions]});
|
||||
dedupe.set(request.request_id, entry);
|
||||
return entry.result;
|
||||
}
|
||||
const session = binding.session;
|
||||
const node = session.node;
|
||||
if (!node || !session.ready || !this.valid(node)) fail('NODE_OFFLINE', 'Node relay is not ready', true);
|
||||
if (!node.claims.permissions.includes(permission)) fail('FORBIDDEN', 'Node token does not permit this method');
|
||||
if (workflow_function && !node.claims.permissions.includes('workflow.control')) fail('FORBIDDEN', 'Node token does not permit workflow functions');
|
||||
if (binding.pending.size >= this.limits.max_pending_per_web || this.pending.size >= this.limits.max_pending_total)
|
||||
fail('RATE_LIMITED', 'Pending request capacity reached', true);
|
||||
dedupe.set(request.request_id, entry);
|
||||
return new Promise<Result>(resolve => {
|
||||
const id = randomUUID();
|
||||
let done = false;
|
||||
const pending: Pending = {id, binding, node, session, generation: node.generation, entry, finish: result => {
|
||||
if (done) return;
|
||||
done = true;
|
||||
clearTimeout(pending.timer);
|
||||
this.pending.delete(id);
|
||||
session.pending.delete(pending);
|
||||
binding.pending.delete(pending);
|
||||
pending.cancel_transport?.();
|
||||
entry.completed = true;
|
||||
if (!fresh_read) entry.result = result;
|
||||
entry.expires_at = Date.now() + this.limits.dedupe_ttl_ms;
|
||||
resolve(result);
|
||||
}};
|
||||
this.pending.set(id, pending);
|
||||
session.pending.add(pending);
|
||||
binding.pending.add(pending);
|
||||
pending.timer = timer(() => pending.finish(error_ack(new RelayError('TIMEOUT', 'Node request timed out', true))), this.limits.rpc_timeout_ms);
|
||||
void this.dispatch(pending, request);
|
||||
});
|
||||
}
|
||||
private async dispatch(pending: Pending, request: RelayRequest): Promise<void> {
|
||||
const {session, node, binding} = pending;
|
||||
try {
|
||||
if ((node.connection.socket.conn as unknown as {writeBuffer: unknown[]}).writeBuffer.length >= this.limits.max_buffered_packets) {
|
||||
pending.finish(error_ack(new RelayError('RESYNC_REQUIRED', 'Node transport buffer exceeded its limit')));
|
||||
this.drop(node.connection, 'resync_required', 'RESYNC_REQUIRED');
|
||||
return;
|
||||
}
|
||||
// Socket.IO has no public ACK cancellation API. Release its ACK wrapper as
|
||||
// well as our own timer when a grant, Web, or Node is revoked/replaced.
|
||||
const acks = (node.connection.socket as unknown as {acks: Map<number, (...args: unknown[]) => void>}).acks;
|
||||
const before = new Set(acks.keys());
|
||||
const response_promise = node.connection.socket.timeout(this.limits.rpc_timeout_ms).emitWithAck('relay_request', {
|
||||
...request, correlation_id: pending.id, forward_session_id: session.scope.forward_session_id,
|
||||
deadline_at: Date.now() + this.limits.rpc_timeout_ms});
|
||||
const ack_id = [...acks.keys()].find(id => !before.has(id));
|
||||
pending.cancel_transport = () => {
|
||||
if (ack_id === undefined) return;
|
||||
const callback = acks.get(ack_id);
|
||||
acks.delete(ack_id);
|
||||
callback?.(error_ack(new RelayError('CANCELLED', 'Relay request was cancelled')));
|
||||
};
|
||||
const response = await response_promise;
|
||||
if (!this.pending.has(pending.id)) return;
|
||||
if (session.node !== node || session.generation !== pending.generation || !this.valid(node))
|
||||
fail('NODE_OFFLINE', 'Node connection changed', true);
|
||||
if (!this.valid(binding)) fail('CANCELLED', 'Web authorization ended');
|
||||
validate_node_ack(request.method, response, session.scope.bot_id, this.limits);
|
||||
if ((request.method === 'workflow.control' || request.method === 'workflow.command')
|
||||
&& response.status === 'success' && response.data !== null) {
|
||||
const input = request.payload as {command_id: string; action?: string; workflow_id?: string};
|
||||
const data = response.data as {command_id: string; action: string; workflow_id: string | null};
|
||||
if (data.command_id !== input.command_id || (request.method === 'workflow.control'
|
||||
&& (data.action !== input.action || (input.workflow_id !== undefined && data.workflow_id !== input.workflow_id))))
|
||||
fail('INVALID_REQUEST', 'Node workflow command does not match the request');
|
||||
}
|
||||
if (request.method === 'workflow.snapshot' && response.status === 'success') {
|
||||
const {run_id} = request.payload as {run_id?: string};
|
||||
if (run_id === undefined ? !Array.isArray(response.data)
|
||||
: response.data !== null && (Array.isArray(response.data) || (response.data as {run_id: string}).run_id !== run_id))
|
||||
fail('INVALID_REQUEST', 'Node workflow snapshot does not match the request');
|
||||
}
|
||||
if (request.method === 'get_file_data' && response.status === 'success') {
|
||||
const input = request.payload as {id: string; offset?: number; length?: number};
|
||||
const data = response.data as {id: string; offset: number; next_offset: number};
|
||||
if (data.id !== input.id || data.offset !== (input.offset ?? 0)
|
||||
|| data.next_offset - data.offset > (input.length ?? 262_144))
|
||||
fail('INVALID_REQUEST', 'Node file response does not match the request');
|
||||
}
|
||||
if (request.method === 'tab_complete' && response.status === 'success') {
|
||||
const {start, length} = response.data as {start: number; length: number};
|
||||
if (start + length > (request.payload as {text: string}).text.length)
|
||||
fail('INVALID_REQUEST', 'Node completion range exceeds the requested text');
|
||||
}
|
||||
if (request.method === 'bot.logs' && response.status === 'success'
|
||||
&& (response.data as {records: unknown[]}).records.length > (request.payload as {limit: number}).limit)
|
||||
fail('INVALID_REQUEST', 'Node logs response exceeds the requested limit');
|
||||
pending.finish(response);
|
||||
} catch (error) {
|
||||
if (!this.pending.has(pending.id)) return;
|
||||
pending.finish(error_ack(error instanceof RelayError ? error : new RelayError('TIMEOUT', 'Node request failed or timed out', true)));
|
||||
}
|
||||
}
|
||||
private send(binding: Binding, event: string, value: unknown): void {
|
||||
const socket = binding.connection.socket;
|
||||
if (!this.writable(binding.connection)) return;
|
||||
socket.emit(event, value);
|
||||
}
|
||||
private event_binding(socket: Socket): Binding {
|
||||
const binding = this.binding(socket, 'node');
|
||||
const connection = binding.connection;
|
||||
const now = Date.now();
|
||||
if (!connection.log_window || now - connection.log_window >= 1000) {
|
||||
connection.log_window = now;
|
||||
connection.logs = 0;
|
||||
}
|
||||
connection.logs = (connection.logs ?? 0) + 1;
|
||||
if (connection.logs > this.limits.events_per_second) {
|
||||
this.drop(connection, 'log_rate_exceeded', 'RATE_LIMITED');
|
||||
fail('RATE_LIMITED', 'Node log rate exceeded', true);
|
||||
}
|
||||
const session = binding.session;
|
||||
if (!session.ready) fail('NODE_OFFLINE', 'Node has not declared readiness', true);
|
||||
return binding;
|
||||
}
|
||||
log(socket: Socket, command: unknown): {accepted: true} {
|
||||
const binding = this.event_binding(socket);
|
||||
validate_event(command, this.limits);
|
||||
if (command.name !== 'log') fail('INVALID_REQUEST', 'Expected a log event');
|
||||
return this.fanout(binding, command, 'bot.logs', 'bot_log');
|
||||
}
|
||||
info(socket: Socket, command: unknown): {accepted: true} {
|
||||
const binding = this.event_binding(socket);
|
||||
validate_info_update(command, this.limits);
|
||||
if (!binding.claims.permissions.includes('bot.snapshot')) fail('FORBIDDEN', 'Node token does not permit info updates');
|
||||
return this.fanout(binding, command, 'bot.snapshot', 'info_update');
|
||||
}
|
||||
private fanout(binding: Binding, command: BotEvent | InfoUpdate, permission: RelayMethod,
|
||||
event: 'bot_log' | 'info_update'): {accepted: true} {
|
||||
const {session, connection} = binding;
|
||||
const label = event === 'bot_log' ? 'Log' : 'Info';
|
||||
const kind = event === 'bot_log' ? 'log' : 'info';
|
||||
if (command.forward_session_id !== session.scope.forward_session_id || command.bot_id !== session.scope.bot_id)
|
||||
fail('FORBIDDEN', `${label} scope does not match the Node connection`);
|
||||
if (session.stream !== command.stream_id) {
|
||||
session.retired ??= new Set();
|
||||
if (session.retired.has(command.stream_id)) fail('RESYNC_REQUIRED', `Retired ${kind} stream cannot resume`);
|
||||
if (session.stream) {
|
||||
if (session.retired.size >= 32) {
|
||||
this.drop(connection, 'resync_required', 'RESYNC_REQUIRED');
|
||||
fail('RESYNC_REQUIRED', `Too many ${kind} stream replacements`);
|
||||
}
|
||||
session.retired.add(session.stream);
|
||||
this.status(session, 'resync_required');
|
||||
}
|
||||
session.stream = command.stream_id;
|
||||
session.seq = -1;
|
||||
}
|
||||
if (command.seq <= (session.seq ?? -1)) fail('INVALID_REQUEST', `${label} sequence must increase`);
|
||||
session.seq = command.seq;
|
||||
for (const web of [...session.webs]) {
|
||||
if (this.valid(web) && web.claims.permissions.includes(permission) && web.grant!.permissions.has(permission))
|
||||
this.send(web, event, command);
|
||||
}
|
||||
return {accepted: true};
|
||||
}
|
||||
|
||||
close(): void {
|
||||
if (this.closed) return;
|
||||
this.closed = true;
|
||||
this.control = false;
|
||||
for (const session of [...this.sessions.values()]) this.close_session(session, 'relay_closed', 'RELAY_UNAVAILABLE');
|
||||
for (const connection of [...this.connections.values()]) this.drop(connection, 'relay_closed', 'RELAY_UNAVAILABLE');
|
||||
this.revoked_sessions.clear();
|
||||
}
|
||||
}
|
||||
|
||||
export function create_session_store(options: SessionStoreOptions): RelaySessionStore {return new RelaySessionStore(options);}
|
||||
Reference in New Issue
Block a user