Skip to content

Commit dc3484d

Browse files
committed
feat: implement TLS support for secure connections and HTTP to HTTPS redirection
1 parent 0daed57 commit dc3484d

File tree

6 files changed

+121
-19
lines changed

6 files changed

+121
-19
lines changed

.env.example

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,18 @@ TELAGENT_API_PORT=9529
88
# Required on cloud/public deployments so other nodes can reach you.
99
# TELAGENT_PUBLIC_URL=http://your-server-ip:9529
1010

11+
# --------------------------------------------------------------------
12+
# TLS / HTTPS (optional — set both CERT and KEY to enable)
13+
# When enabled, the node serves HTTPS on TLS_PORT (default 9443)
14+
# and the HTTP port (API_PORT) becomes a 301 redirect to HTTPS.
15+
# Without these, the node serves plain HTTP (use Caddy/nginx for TLS).
16+
# Generate self-signed cert for testing:
17+
# openssl req -x509 -newkey rsa:2048 -nodes -keyout /tmp/key.pem -out /tmp/cert.pem -days 365 -subj '/CN=localhost'
18+
# --------------------------------------------------------------------
19+
# TELAGENT_TLS_CERT=/path/to/cert.pem
20+
# TELAGENT_TLS_KEY=/path/to/key.pem
21+
# TELAGENT_TLS_PORT=9443
22+
1123
# --------------------------------------------------------------------
1224
# TelAgent storage
1325
# --------------------------------------------------------------------

packages/node/src/api/server.ts

Lines changed: 60 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1-
import { createServer, type IncomingMessage, type Server } from 'node:http';
1+
import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http';
2+
import { createServer as createHttpsServer, type Server as HttpsServer } from 'node:https';
3+
import { readFileSync } from 'node:fs';
24
import { performance } from 'node:perf_hooks';
35

46
import { ErrorCodes, TelagentError } from '@telagent/protocol';
@@ -96,18 +98,15 @@ function requireGlobalAuth(req: IncomingMessage, pathname: string, ctx: RuntimeC
9698

9799
export class ApiServer {
98100
private server: Server | null = null;
101+
private secureServer: HttpsServer | null = null;
99102
private readonly router: Router;
100103

101104
constructor(private readonly ctx: RuntimeContext) {
102105
this.router = buildRouter(ctx);
103106
}
104107

105-
async start(): Promise<void> {
106-
if (this.server) {
107-
return;
108-
}
109-
110-
this.server = createServer(async (req, res) => {
108+
private createRequestHandler() {
109+
return async (req: IncomingMessage, res: ServerResponse) => {
111110
const parsedUrl = new URL(req.url || '/', 'http://127.0.0.1');
112111
const startedAt = performance.now();
113112
res.once('finish', () => {
@@ -161,25 +160,68 @@ export class ApiServer {
161160
const internal = error instanceof TelagentError ? error : new TelagentError(ErrorCodes.INTERNAL, error instanceof Error ? error.message : 'Unexpected error');
162161
problem(res, internal.toProblem(req.url));
163162
}
164-
});
163+
};
164+
}
165165

166-
await new Promise<void>((resolve) => {
167-
this.server!.listen(this.ctx.config.port, this.ctx.config.host, resolve);
168-
});
166+
async start(): Promise<void> {
167+
if (this.server || this.secureServer) {
168+
return;
169+
}
170+
171+
const { tls } = this.ctx.config;
172+
173+
if (tls) {
174+
// ── HTTPS mode: main traffic on HTTPS, HTTP redirects to HTTPS ──
175+
const cert = readFileSync(tls.certPath);
176+
const key = readFileSync(tls.keyPath);
177+
178+
this.secureServer = createHttpsServer({ cert, key }, this.createRequestHandler());
179+
await new Promise<void>((resolve) => {
180+
this.secureServer!.listen(tls.httpsPort, this.ctx.config.host, resolve);
181+
});
182+
183+
// HTTP → HTTPS redirect server
184+
const httpsPort = tls.httpsPort;
185+
const redirectHost = this.ctx.config.host;
186+
this.server = createServer((req, res) => {
187+
const host = req.headers.host?.replace(/:\d+$/, '') || redirectHost;
188+
const location = `https://${host}${httpsPort === 443 ? '' : ':' + httpsPort}${req.url || '/'}`;
189+
res.writeHead(301, { Location: location });
190+
res.end();
191+
});
192+
await new Promise<void>((resolve) => {
193+
this.server!.listen(this.ctx.config.port, this.ctx.config.host, resolve);
194+
});
195+
} else {
196+
// ── Plain HTTP mode (default, unchanged) ──
197+
this.server = createServer(this.createRequestHandler());
198+
await new Promise<void>((resolve) => {
199+
this.server!.listen(this.ctx.config.port, this.ctx.config.host, resolve);
200+
});
201+
}
169202
}
170203

171204
async stop(): Promise<void> {
172-
if (!this.server) {
173-
return;
205+
const closeServer = (s: Server | HttpsServer) =>
206+
new Promise<void>((resolve) => { s.close(() => resolve()); });
207+
208+
const tasks: Promise<void>[] = [];
209+
if (this.secureServer) {
210+
tasks.push(closeServer(this.secureServer));
211+
this.secureServer = null;
212+
}
213+
if (this.server) {
214+
tasks.push(closeServer(this.server));
215+
this.server = null;
174216
}
175-
const current = this.server;
176-
this.server = null;
177-
await new Promise<void>((resolve) => {
178-
current.close(() => resolve());
179-
});
217+
await Promise.all(tasks);
180218
}
181219

182220
get httpServer(): Server | null {
183221
return this.server;
184222
}
223+
224+
get httpsServer(): HttpsServer | null {
225+
return this.secureServer;
226+
}
185227
}

packages/node/src/api/types.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,17 @@ import type { PeerProfileRepository } from '../storage/peer-profile-repository.j
1515
import type { ApiProxyService } from '../services/api-proxy-service.js';
1616
import type { EventPushService } from '../services/event-push-service.js';
1717

18+
export interface TlsServerConfig {
19+
certPath: string;
20+
keyPath: string;
21+
httpsPort: number;
22+
}
23+
1824
export interface ApiServerConfig {
1925
host: string;
2026
port: number;
2127
publicUrl?: string;
28+
tls?: TlsServerConfig;
2229
}
2330

2431
export interface RuntimeContext {

packages/node/src/app.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -281,6 +281,11 @@ export class TelagentNode {
281281
host: this.config.host,
282282
port: this.config.port,
283283
publicUrl: this.config.publicUrl,
284+
tls: this.config.tls ? {
285+
certPath: this.config.tls.certPath,
286+
keyPath: this.config.tls.keyPath,
287+
httpsPort: this.config.tls.httpsPort,
288+
} : undefined,
284289
},
285290
identityService: this.identityService,
286291
groupService: this.groupService,

packages/node/src/config.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import { accessSync, constants as fsConstants } from 'node:fs';
2+
13
import { ChainConfigSchema, type ChainConfig } from './services/chain-config.js';
24
import {
35
parseOwnerMode,
@@ -53,6 +55,12 @@ export interface OwnerConfig {
5355
privateConversations: string[];
5456
}
5557

58+
export interface TlsConfig {
59+
certPath: string;
60+
keyPath: string;
61+
httpsPort: number;
62+
}
63+
5664
export interface ApiProxyConfig {
5765
enabled: boolean;
5866
gatewayEnabled: boolean;
@@ -65,6 +73,7 @@ export interface AppConfig {
6573
host: string;
6674
port: number;
6775
publicUrl?: string;
76+
tls?: TlsConfig;
6877
paths: TelagentStoragePaths;
6978
mailboxCleanupIntervalSec: number;
7079
mailboxStore: MailboxStoreConfig;
@@ -107,6 +116,27 @@ export function loadConfigFromEnv(): AppConfig {
107116
const host = process.env.TELAGENT_API_HOST || '127.0.0.1';
108117
const port = Number(process.env.TELAGENT_API_PORT || 9529);
109118

119+
// ── TLS (optional) ──────────────────────────────────────
120+
let tls: TlsConfig | undefined;
121+
const tlsCert = process.env.TELAGENT_TLS_CERT?.trim();
122+
const tlsKey = process.env.TELAGENT_TLS_KEY?.trim();
123+
if (tlsCert && tlsKey) {
124+
for (const p of [tlsCert, tlsKey]) {
125+
try {
126+
accessSync(p, fsConstants.R_OK);
127+
} catch {
128+
throw new Error(`TLS file not readable: ${p}`);
129+
}
130+
}
131+
tls = {
132+
certPath: tlsCert,
133+
keyPath: tlsKey,
134+
httpsPort: Number(process.env.TELAGENT_TLS_PORT || 9443),
135+
};
136+
} else if (tlsCert || tlsKey) {
137+
throw new Error('Both TELAGENT_TLS_CERT and TELAGENT_TLS_KEY must be set to enable TLS');
138+
}
139+
110140
const chain = ChainConfigSchema.parse({
111141
rpcUrl: process.env.TELAGENT_CHAIN_RPC_URL,
112142
chainId: Number(process.env.TELAGENT_CHAIN_ID || 7625),
@@ -178,6 +208,7 @@ export function loadConfigFromEnv(): AppConfig {
178208
host,
179209
port,
180210
publicUrl,
211+
tls,
181212
paths,
182213
mailboxCleanupIntervalSec: Number(process.env.TELAGENT_MAILBOX_CLEANUP_INTERVAL_SEC || 60),
183214
mailboxStore,

packages/node/src/daemon.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,12 @@ async function main(): Promise<void> {
1919
const node = new TelagentNode(config);
2020

2121
await node.start();
22-
logger.info(`telagent node started at http://${config.host}:${config.port}`);
22+
if (config.tls) {
23+
logger.info(`telagent node started at https://${config.host}:${config.tls.httpsPort}`);
24+
logger.info(`http://${config.host}:${config.port} → redirect to HTTPS`);
25+
} else {
26+
logger.info(`telagent node started at http://${config.host}:${config.port}`);
27+
}
2328
logger.info(`chainId: ${config.chain.chainId}`);
2429

2530
const shutdown = async (signal: string): Promise<void> => {

0 commit comments

Comments
 (0)