Skip to content

Commit 6802994

Browse files
committed
feat: implement API proxy service and relay routing for DID-based remote access
1 parent 3011c74 commit 6802994

File tree

12 files changed

+756
-12
lines changed

12 files changed

+756
-12
lines changed
Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
1+
import type { IncomingMessage, ServerResponse } from 'node:http';
2+
import type { RuntimeContext } from '../types.js';
3+
import { getGlobalLogger } from '../../logger.js';
4+
5+
const logger = getGlobalLogger();
6+
const DID_PATTERN = /^did:claw:z[A-Za-z0-9]{32,}$/;
7+
const MAX_BODY_SIZE = 1_048_576; // 1 MB
8+
9+
// ── Simple in-memory rate limiter ────────────────────────
10+
11+
interface RateBucket {
12+
count: number;
13+
resetAt: number;
14+
}
15+
16+
const rateBuckets = new Map<string, RateBucket>();
17+
18+
function checkRateLimit(sourceIp: string, maxPerMinute: number): boolean {
19+
const now = Date.now();
20+
const bucket = rateBuckets.get(sourceIp);
21+
if (!bucket || now >= bucket.resetAt) {
22+
rateBuckets.set(sourceIp, { count: 1, resetAt: now + 60_000 });
23+
return true;
24+
}
25+
bucket.count++;
26+
return bucket.count <= maxPerMinute;
27+
}
28+
29+
// ── Body reading ─────────────────────────────────────────
30+
31+
function readBody(req: IncomingMessage, maxBytes: number): Promise<string> {
32+
return new Promise((resolve, reject) => {
33+
const chunks: Buffer[] = [];
34+
let totalSize = 0;
35+
req.on('data', (chunk: Buffer) => {
36+
totalSize += chunk.length;
37+
if (totalSize > maxBytes) {
38+
req.destroy();
39+
reject(new Error('Body too large'));
40+
return;
41+
}
42+
chunks.push(chunk);
43+
});
44+
req.on('end', () => resolve(Buffer.concat(chunks).toString('utf-8')));
45+
req.on('error', reject);
46+
});
47+
}
48+
49+
// ── JSON response helper ─────────────────────────────────
50+
51+
function writeJson(res: ServerResponse, status: number, data: unknown): void {
52+
res.setHeader('Content-Type', 'application/json');
53+
res.writeHead(status);
54+
res.end(JSON.stringify(data));
55+
}
56+
57+
/**
58+
* Handle /relay/* requests. Returns true if the request was handled.
59+
*
60+
* Routes:
61+
* GET /relay/info — gateway info
62+
* GET /relay/:targetDid/ping — DID reachability check
63+
* ALL /relay/:targetDid/api/v1/... — proxy API requests to target
64+
*/
65+
export async function handleRelayRequest(
66+
req: IncomingMessage,
67+
res: ServerResponse,
68+
pathname: string,
69+
ctx: RuntimeContext,
70+
): Promise<boolean> {
71+
if (!pathname.startsWith('/relay/') && pathname !== '/relay') return false;
72+
if (!ctx.apiProxyService) return false;
73+
74+
// Strip /relay prefix
75+
const subPath = pathname.slice('/relay'.length) || '/';
76+
77+
// GET /relay/info
78+
if (subPath === '/info' || subPath === '/info/') {
79+
if (req.method !== 'GET') {
80+
writeJson(res, 405, { error: 'Method not allowed' });
81+
return true;
82+
}
83+
const selfDid = ctx.identityService.getSelfDid();
84+
writeJson(res, 200, {
85+
data: {
86+
gatewayDid: selfDid,
87+
gatewayEnabled: true,
88+
},
89+
});
90+
return true;
91+
}
92+
93+
// Parse /:targetDid/...
94+
// subPath starts with '/', e.g. '/did:claw:zXXX/ping'
95+
const withoutLeadingSlash = subPath.slice(1);
96+
const firstSlash = withoutLeadingSlash.indexOf('/');
97+
const targetDid = firstSlash === -1
98+
? withoutLeadingSlash
99+
: withoutLeadingSlash.slice(0, firstSlash);
100+
const remaining = firstSlash === -1 ? '' : withoutLeadingSlash.slice(firstSlash);
101+
102+
if (!DID_PATTERN.test(decodeURIComponent(targetDid))) {
103+
writeJson(res, 400, { error: 'Invalid DID format. Expected did:claw:z...' });
104+
return true;
105+
}
106+
107+
const decodedDid = decodeURIComponent(targetDid);
108+
109+
// Rate limiting
110+
const sourceIp = (req.headers['x-forwarded-for'] as string)?.split(',')[0]?.trim()
111+
|| req.socket.remoteAddress
112+
|| 'unknown';
113+
if (!checkRateLimit(sourceIp, ctx.apiProxyService.config.rateLimitPerMinute)) {
114+
res.setHeader('Retry-After', '60');
115+
writeJson(res, 429, { error: 'Rate limit exceeded' });
116+
return true;
117+
}
118+
119+
// GET /:targetDid/ping
120+
if (remaining === '/ping' || remaining === '/ping/') {
121+
if (req.method !== 'GET') {
122+
writeJson(res, 405, { error: 'Method not allowed' });
123+
return true;
124+
}
125+
try {
126+
const result = await ctx.apiProxyService.ping(decodedDid);
127+
writeJson(res, 200, { data: result });
128+
} catch (err) {
129+
logger.error('[relay] Ping failed for %s: %s', decodedDid, (err as Error).message);
130+
writeJson(res, 502, { error: 'Ping failed' });
131+
}
132+
return true;
133+
}
134+
135+
// Proxy: /:targetDid/api/v1/...
136+
if (!remaining.startsWith('/api/')) {
137+
writeJson(res, 400, { error: 'Proxy path must start with /api/' });
138+
return true;
139+
}
140+
141+
// Read request body
142+
let body: string | undefined;
143+
if (req.method !== 'GET' && req.method !== 'HEAD' && req.method !== 'DELETE') {
144+
try {
145+
body = await readBody(req, MAX_BODY_SIZE);
146+
} catch {
147+
writeJson(res, 413, { error: 'Request body too large (max 1MB)' });
148+
return true;
149+
}
150+
}
151+
152+
// Collect headers to forward
153+
const forwardHeaders: Record<string, string> = {};
154+
if (req.headers.authorization) {
155+
forwardHeaders['authorization'] = req.headers.authorization as string;
156+
}
157+
if (req.headers['content-type']) {
158+
forwardHeaders['content-type'] = req.headers['content-type'] as string;
159+
}
160+
forwardHeaders['accept'] = (req.headers.accept as string) || 'application/json';
161+
162+
try {
163+
const proxyResponse = await ctx.apiProxyService.proxyRequest(
164+
decodedDid,
165+
req.method || 'GET',
166+
remaining,
167+
forwardHeaders,
168+
body,
169+
);
170+
171+
// Write proxy response back to client
172+
for (const [key, value] of Object.entries(proxyResponse.headers)) {
173+
if (key.toLowerCase() === 'transfer-encoding') continue;
174+
res.setHeader(key, String(value));
175+
}
176+
res.writeHead(proxyResponse.status);
177+
res.end(proxyResponse.body ?? '');
178+
} catch (err) {
179+
writeJson(res, 504, {
180+
error: 'Gateway timeout — target node did not respond',
181+
detail: (err as Error).message,
182+
});
183+
}
184+
185+
return true;
186+
}

packages/node/src/api/server.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import { clawnetRoutes } from './routes/clawnet.js';
1919
import { profileRoutes } from './routes/profile.js';
2020
import { sessionRoutes } from './routes/session.js';
2121
import { walletRoutes } from './routes/wallets.js';
22+
import { handleRelayRequest } from './routes/relay.js';
2223
import type { RuntimeContext } from './types.js';
2324

2425
function buildRouter(ctx: RuntimeContext): Router {
@@ -58,6 +59,8 @@ const AUTH_WHITELIST: Array<{ method?: string; path: string }> = [
5859
{ method: 'PUT', path: '/api/v1/attachments' },
5960
// GET /api/v1/profile/:did — cached peer profiles are public
6061
// (matched via startsWith in isAuthExempt since :did varies)
62+
// Relay routes — auth is enforced on the target node side
63+
{ path: '/relay' },
6164
];
6265

6366
function isAuthExempt(method: string, pathname: string): boolean {
@@ -130,6 +133,17 @@ export class ApiServer {
130133
return;
131134
}
132135

136+
// Handle /relay/* before standard router (relay needs wildcard path matching)
137+
try {
138+
const relayHandled = await handleRelayRequest(req, res, parsedUrl.pathname, this.ctx);
139+
if (relayHandled) return;
140+
} catch (error) {
141+
if (res.headersSent) return;
142+
const internal = error instanceof TelagentError ? error : new TelagentError(ErrorCodes.INTERNAL, error instanceof Error ? error.message : 'Unexpected error');
143+
problem(res, internal.toProblem(req.url));
144+
return;
145+
}
146+
133147
try {
134148
const matched = await this.router.handle(req, res);
135149
if (!matched && !res.headersSent) {

packages/node/src/api/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import type { NodeMonitoringService } from '../services/node-monitoring-service.
1212
import type { OwnerPermissionService } from '../services/owner-permission-service.js';
1313
import type { SelfProfileStore } from '../storage/profile-store.js';
1414
import type { PeerProfileRepository } from '../storage/peer-profile-repository.js';
15+
import type { ApiProxyService } from '../services/api-proxy-service.js';
1516

1617
export interface ApiServerConfig {
1718
host: string;
@@ -36,4 +37,5 @@ export interface RuntimeContext {
3637
selfProfileStore: SelfProfileStore;
3738
peerProfileRepository: PeerProfileRepository;
3839
configuredPassphrase?: string;
40+
apiProxyService?: ApiProxyService;
3941
}

packages/node/src/app.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { verifyPassphrase } from './clawnet/verify-passphrase.js';
1010
import { GroupIndexer } from './indexer/group-indexer.js';
1111
import { AttachmentService } from './services/attachment-service.js';
1212
import { ClawNetTransportService } from './services/clawnet-transport-service.js';
13+
import { ApiProxyService } from './services/api-proxy-service.js';
1314
import { ContractProvider } from './services/contract-provider.js';
1415
import { GroupService } from './services/group-service.js';
1516
import { IdentityAdapterService } from './services/identity-adapter-service.js';
@@ -49,6 +50,7 @@ export class TelagentNode {
4950
private messageService: MessageService | null = null;
5051
private attachmentService: AttachmentService | null = null;
5152
private clawnetTransportService: ClawNetTransportService | null = null;
53+
private apiProxyService: ApiProxyService | null = null;
5254
private monitoringService: NodeMonitoringService | null = null;
5355
private ownerPermissionService: OwnerPermissionService | null = null;
5456
private indexer: GroupIndexer | null = null;
@@ -241,6 +243,16 @@ export class TelagentNode {
241243
this.clawnetGateway,
242244
{ baseUrl: discovery.nodeUrl, apiKey: this.config.clawnet.apiKey },
243245
);
246+
247+
// API Proxy Service (DID-based remote access)
248+
if (this.config.apiProxy.enabled || this.config.apiProxy.gatewayEnabled) {
249+
this.apiProxyService = new ApiProxyService(
250+
this.config.apiProxy,
251+
this.clawnetTransportService,
252+
this.config.port,
253+
);
254+
}
255+
244256
this.monitoringService = new NodeMonitoringService({
245257
thresholds: {
246258
errorRateWarnRatio: this.config.monitoring.errorRateWarnRatio,
@@ -277,6 +289,7 @@ export class TelagentNode {
277289
selfProfileStore: this.selfProfileStore,
278290
peerProfileRepository: this.peerProfileRepository,
279291
configuredPassphrase: passphrase ?? undefined,
292+
apiProxyService: this.apiProxyService ?? undefined,
280293
};
281294

282295
this.apiServer = new ApiServer(runtime);
@@ -352,6 +365,19 @@ export class TelagentNode {
352365
logger.warn('[telagent] Failed to cache peer profile from %s: %s', sourceDid, (err as Error).message);
353366
}
354367
},
368+
// API Proxy callbacks (DID-based remote access)
369+
onApiProxyRequest: this.apiProxyService
370+
? (req, sourceDid) => this.apiProxyService!.handleProxyRequest(req, sourceDid)
371+
: undefined,
372+
onApiProxyResponse: this.apiProxyService
373+
? (res) => this.apiProxyService!.handleProxyResponse(res)
374+
: undefined,
375+
onApiProxyPing: this.apiProxyService
376+
? (pingId, sourceDid) => this.apiProxyService!.handlePing(sourceDid, pingId)
377+
: undefined,
378+
onApiProxyPong: this.apiProxyService
379+
? (pingId) => this.apiProxyService!.handlePong(pingId)
380+
: undefined,
355381
});
356382
logger.info('[telagent] P2P transport listener started');
357383
this.monitoringService.recordMailboxMaintenance(await this.messageService.runMaintenance());
@@ -371,6 +397,7 @@ export class TelagentNode {
371397
this.sessionManager?.lockAll();
372398

373399
this.stopMailboxCleaner();
400+
this.apiProxyService?.dispose();
374401
this.clawnetTransportService?.stopListening();
375402
await this.apiServer?.stop();
376403
await this.indexer?.stop();

packages/node/src/config.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,14 @@ export interface OwnerConfig {
5353
privateConversations: string[];
5454
}
5555

56+
export interface ApiProxyConfig {
57+
enabled: boolean;
58+
gatewayEnabled: boolean;
59+
timeoutMs: number;
60+
rateLimitPerMinute: number;
61+
maxBodyBytes: number;
62+
}
63+
5664
export interface AppConfig {
5765
host: string;
5866
port: number;
@@ -64,6 +72,7 @@ export interface AppConfig {
6472
clawnet: ClawNetConfig;
6573
owner: OwnerConfig;
6674
monitoring: MonitoringConfig;
75+
apiProxy: ApiProxyConfig;
6776
}
6877

6978
export function loadConfigFromEnv(): AppConfig {
@@ -157,6 +166,14 @@ export function loadConfigFromEnv(): AppConfig {
157166

158167
const publicUrl = process.env.TELAGENT_PUBLIC_URL?.trim() || undefined;
159168

169+
const apiProxy: ApiProxyConfig = {
170+
enabled: parseBoolean(process.env.TELAGENT_API_PROXY_ENABLED, true),
171+
gatewayEnabled: parseBoolean(process.env.TELAGENT_API_PROXY_GATEWAY_ENABLED, true),
172+
timeoutMs: Number(process.env.TELAGENT_API_PROXY_TIMEOUT_MS || 30_000),
173+
rateLimitPerMinute: Number(process.env.TELAGENT_API_PROXY_RATE_LIMIT || 60),
174+
maxBodyBytes: Number(process.env.TELAGENT_API_PROXY_MAX_BODY_BYTES || 1_048_576),
175+
};
176+
160177
return {
161178
host,
162179
port,
@@ -175,6 +192,7 @@ export function loadConfigFromEnv(): AppConfig {
175192
maintenanceStaleWarnSec: Number(process.env.TELAGENT_MONITOR_MAINT_STALE_WARN_SEC || 180),
176193
maintenanceStaleCriticalSec: Number(process.env.TELAGENT_MONITOR_MAINT_STALE_CRITICAL_SEC || 300),
177194
},
195+
apiProxy,
178196
};
179197
}
180198

0 commit comments

Comments
 (0)