@juppytt/fws 0.1.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.
@@ -0,0 +1,285 @@
1
+ import net from 'node:net';
2
+ import tls from 'node:tls';
3
+ import http from 'node:http';
4
+ import crypto from 'node:crypto';
5
+ import fs from 'node:fs/promises';
6
+ import path from 'node:path';
7
+
8
+ const GOOGLEAPIS_HOSTS = [
9
+ 'gmail.googleapis.com',
10
+ 'www.googleapis.com',
11
+ 'tasks.googleapis.com',
12
+ 'workspaceevents.googleapis.com',
13
+ 'docs.googleapis.com',
14
+ 'slides.googleapis.com',
15
+ 'chat.googleapis.com',
16
+ 'classroom.googleapis.com',
17
+ 'forms.googleapis.com',
18
+ 'keep.googleapis.com',
19
+ 'meet.googleapis.com',
20
+ 'people.googleapis.com',
21
+ 'sheets.googleapis.com',
22
+ 'admin.googleapis.com',
23
+ ];
24
+
25
+ interface CertPair {
26
+ key: string;
27
+ cert: string;
28
+ }
29
+
30
+ let caCert: CertPair | null = null;
31
+ const hostCerts = new Map<string, CertPair>();
32
+
33
+ function generateCA(): CertPair {
34
+ // Generate CA key pair
35
+ const { privateKey, publicKey } = crypto.generateKeyPairSync('rsa', {
36
+ modulusLength: 2048,
37
+ });
38
+
39
+ // Self-signed CA certificate using Node's built-in X509Certificate isn't enough,
40
+ // we need to use the openssl-like approach with forge or raw ASN.1.
41
+ // Instead, use a simpler approach: generate with command-line openssl at startup time.
42
+ // For now, return placeholders - the actual generation happens in generateCACert().
43
+ return {
44
+ key: privateKey.export({ type: 'pkcs8', format: 'pem' }) as string,
45
+ cert: '', // filled by generateCACert
46
+ };
47
+ }
48
+
49
+ export async function generateCACert(dataDir: string): Promise<{ caPath: string; keyPath: string }> {
50
+ const certDir = path.join(dataDir, 'certs');
51
+ await fs.mkdir(certDir, { recursive: true });
52
+
53
+ const caPath = path.join(certDir, 'ca.crt');
54
+ const keyPath = path.join(certDir, 'ca.key');
55
+
56
+ // Check if CA already exists
57
+ try {
58
+ await fs.access(caPath);
59
+ await fs.access(keyPath);
60
+ caCert = {
61
+ cert: await fs.readFile(caPath, 'utf-8'),
62
+ key: await fs.readFile(keyPath, 'utf-8'),
63
+ };
64
+ return { caPath, keyPath };
65
+ } catch {}
66
+
67
+ // Generate new CA using openssl
68
+ const { execFileSync } = await import('node:child_process');
69
+ execFileSync('openssl', [
70
+ 'req', '-x509', '-newkey', 'rsa:2048',
71
+ '-keyout', keyPath, '-out', caPath,
72
+ '-days', '3650', '-nodes',
73
+ '-subj', '/CN=fws-mock-ca',
74
+ ], { stdio: 'pipe' });
75
+
76
+ caCert = {
77
+ cert: await fs.readFile(caPath, 'utf-8'),
78
+ key: await fs.readFile(keyPath, 'utf-8'),
79
+ };
80
+
81
+ return { caPath, keyPath };
82
+ }
83
+
84
+ async function getHostCert(hostname: string): Promise<CertPair> {
85
+ const cached = hostCerts.get(hostname);
86
+ if (cached) return cached;
87
+
88
+ if (!caCert) throw new Error('CA not initialized');
89
+
90
+ const { execFileSync } = await import('node:child_process');
91
+
92
+ // Generate host key
93
+ const hostKey = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 });
94
+ const keyPem = hostKey.privateKey.export({ type: 'pkcs8', format: 'pem' }) as string;
95
+
96
+ // Generate CSR and sign with CA
97
+ const tmpDir = `/tmp/fws-cert-${Date.now()}`;
98
+ await fs.mkdir(tmpDir, { recursive: true });
99
+
100
+ const keyFile = path.join(tmpDir, 'host.key');
101
+ const csrFile = path.join(tmpDir, 'host.csr');
102
+ const certFile = path.join(tmpDir, 'host.crt');
103
+ const extFile = path.join(tmpDir, 'ext.cnf');
104
+
105
+ await fs.writeFile(keyFile, keyPem);
106
+ await fs.writeFile(extFile, `subjectAltName=DNS:${hostname}\n`);
107
+
108
+ // Create CSR
109
+ execFileSync('openssl', [
110
+ 'req', '-new', '-key', keyFile, '-out', csrFile,
111
+ '-subj', `/CN=${hostname}`,
112
+ ], { stdio: 'pipe' });
113
+
114
+ // Find CA cert/key paths
115
+ const caCertPath = Object.keys(caCert).length ? undefined : undefined;
116
+
117
+ // Write CA cert/key to temp files for signing
118
+ const tmpCaCert = path.join(tmpDir, 'ca.crt');
119
+ const tmpCaKey = path.join(tmpDir, 'ca.key');
120
+ await fs.writeFile(tmpCaCert, caCert.cert);
121
+ await fs.writeFile(tmpCaKey, caCert.key);
122
+
123
+ // Sign with CA
124
+ execFileSync('openssl', [
125
+ 'x509', '-req', '-in', csrFile,
126
+ '-CA', tmpCaCert, '-CAkey', tmpCaKey,
127
+ '-CAcreateserial', '-out', certFile,
128
+ '-days', '365', '-extfile', extFile,
129
+ ], { stdio: 'pipe' });
130
+
131
+ const certPem = await fs.readFile(certFile, 'utf-8');
132
+
133
+ // Cleanup
134
+ await fs.rm(tmpDir, { recursive: true, force: true });
135
+
136
+ const pair = { key: keyPem, cert: certPem };
137
+ hostCerts.set(hostname, pair);
138
+ return pair;
139
+ }
140
+
141
+ export function startMitmProxy(mockPort: number, proxyPort: number): http.Server {
142
+ const proxy = http.createServer((_req, res) => {
143
+ res.writeHead(405);
144
+ res.end('MITM proxy only supports CONNECT');
145
+ });
146
+
147
+ proxy.on('connect', async (req, clientSocket, head) => {
148
+ const [hostname, portStr] = (req.url || '').split(':');
149
+ const port = parseInt(portStr) || 443;
150
+
151
+ // Only intercept googleapis.com hosts
152
+ if (!GOOGLEAPIS_HOSTS.some(h => hostname === h || hostname.endsWith('.' + h))) {
153
+ // Pass through to real server
154
+ const serverSocket = net.connect(port, hostname, () => {
155
+ clientSocket.write('HTTP/1.1 200 Connection Established\r\n\r\n');
156
+ serverSocket.write(head);
157
+ serverSocket.pipe(clientSocket);
158
+ clientSocket.pipe(serverSocket);
159
+ });
160
+ serverSocket.on('error', () => clientSocket.destroy());
161
+ clientSocket.on('error', () => serverSocket.destroy());
162
+ return;
163
+ }
164
+
165
+ // Intercept: terminate TLS and forward to mock server
166
+ try {
167
+ const hostCert = await getHostCert(hostname);
168
+
169
+ clientSocket.write('HTTP/1.1 200 Connection Established\r\n\r\n');
170
+
171
+ const tlsSocket = new tls.TLSSocket(clientSocket, {
172
+ isServer: true,
173
+ key: hostCert.key,
174
+ cert: hostCert.cert + caCert!.cert, // chain
175
+ });
176
+
177
+ if (head.length > 0) {
178
+ tlsSocket.unshift(head);
179
+ }
180
+
181
+ // Read the HTTP request from the TLS socket
182
+ handleInterceptedRequest(tlsSocket, hostname, mockPort);
183
+ } catch (err) {
184
+ clientSocket.write('HTTP/1.1 502 Bad Gateway\r\n\r\n');
185
+ clientSocket.destroy();
186
+ }
187
+ });
188
+
189
+ proxy.listen(proxyPort);
190
+ return proxy;
191
+ }
192
+
193
+ function handleInterceptedRequest(tlsSocket: tls.TLSSocket, hostname: string, mockPort: number): void {
194
+ let buffer = Buffer.alloc(0);
195
+
196
+ const onData = (chunk: Buffer) => {
197
+ buffer = Buffer.concat([buffer, chunk]);
198
+
199
+ // Check if we have a complete HTTP request header
200
+ const headerEnd = buffer.indexOf('\r\n\r\n');
201
+ if (headerEnd === -1) return; // wait for more data
202
+
203
+ tlsSocket.removeListener('data', onData);
204
+
205
+ const headerStr = buffer.subarray(0, headerEnd).toString('utf-8');
206
+ const bodyStart = buffer.subarray(headerEnd + 4);
207
+
208
+ const [requestLine, ...headerLines] = headerStr.split('\r\n');
209
+ const [method, urlPath] = requestLine.split(' ');
210
+
211
+ // Parse headers
212
+ const headers: Record<string, string> = {};
213
+ let contentLength = 0;
214
+ for (const line of headerLines) {
215
+ const colonIdx = line.indexOf(':');
216
+ if (colonIdx > 0) {
217
+ const key = line.slice(0, colonIdx).trim().toLowerCase();
218
+ const val = line.slice(colonIdx + 1).trim();
219
+ headers[key] = val;
220
+ if (key === 'content-length') contentLength = parseInt(val);
221
+ }
222
+ }
223
+
224
+ // Collect body if present
225
+ const collectBody = (bodyBuf: Buffer) => {
226
+ // Forward to mock server
227
+ const mockReq = http.request({
228
+ hostname: 'localhost',
229
+ port: mockPort,
230
+ path: urlPath,
231
+ method,
232
+ headers: {
233
+ ...headers,
234
+ host: `localhost:${mockPort}`,
235
+ },
236
+ }, (mockRes) => {
237
+ // Send response back through TLS socket
238
+ let respHeader = `HTTP/1.1 ${mockRes.statusCode} ${mockRes.statusMessage}\r\n`;
239
+ for (const [key, val] of Object.entries(mockRes.headers)) {
240
+ if (val) {
241
+ const vals = Array.isArray(val) ? val : [val];
242
+ for (const v of vals) {
243
+ respHeader += `${key}: ${v}\r\n`;
244
+ }
245
+ }
246
+ }
247
+ respHeader += '\r\n';
248
+
249
+ tlsSocket.write(respHeader);
250
+ mockRes.pipe(tlsSocket);
251
+ mockRes.on('end', () => {
252
+ tlsSocket.end();
253
+ });
254
+ });
255
+
256
+ mockReq.on('error', () => {
257
+ tlsSocket.write('HTTP/1.1 502 Bad Gateway\r\nContent-Length: 0\r\n\r\n');
258
+ tlsSocket.end();
259
+ });
260
+
261
+ if (bodyBuf.length > 0) {
262
+ mockReq.write(bodyBuf);
263
+ }
264
+ mockReq.end();
265
+ };
266
+
267
+ if (contentLength > 0 && bodyStart.length < contentLength) {
268
+ // Need more body data
269
+ const remaining: Buffer[] = [bodyStart];
270
+ let received = bodyStart.length;
271
+ tlsSocket.on('data', (chunk: Buffer) => {
272
+ remaining.push(chunk);
273
+ received += chunk.length;
274
+ if (received >= contentLength) {
275
+ collectBody(Buffer.concat(remaining));
276
+ }
277
+ });
278
+ } else {
279
+ collectBody(bodyStart);
280
+ }
281
+ };
282
+
283
+ tlsSocket.on('data', onData);
284
+ tlsSocket.on('error', () => {});
285
+ }
@@ -0,0 +1,26 @@
1
+ import express from 'express';
2
+ import { gmailRoutes } from './routes/gmail.js';
3
+ import { calendarRoutes } from './routes/calendar.js';
4
+ import { driveRoutes } from './routes/drive.js';
5
+ import { tasksRoutes } from './routes/tasks.js';
6
+ import { sheetsRoutes } from './routes/sheets.js';
7
+ import { peopleRoutes } from './routes/people.js';
8
+ import { controlRoutes } from './routes/control.js';
9
+ import { errorHandler } from './middleware.js';
10
+
11
+ export function createApp(): express.Express {
12
+ const app = express();
13
+ app.use(express.json({ limit: '10mb' }));
14
+
15
+ app.use(controlRoutes());
16
+ app.use(gmailRoutes());
17
+ app.use(calendarRoutes());
18
+ app.use(driveRoutes());
19
+ app.use(tasksRoutes());
20
+ app.use(sheetsRoutes());
21
+ app.use(peopleRoutes());
22
+
23
+ app.use(errorHandler);
24
+
25
+ return app;
26
+ }
@@ -0,0 +1,38 @@
1
+ import type { Request, Response, NextFunction } from 'express';
2
+
3
+ export function errorHandler(err: Error, _req: Request, res: Response, _next: NextFunction): void {
4
+ console.error('[fws] Error:', err.message);
5
+ res.status(500).json({
6
+ error: {
7
+ code: 500,
8
+ message: err.message,
9
+ status: 'INTERNAL',
10
+ },
11
+ });
12
+ }
13
+
14
+ export function notFoundError(message: string): { status: number; body: object } {
15
+ return {
16
+ status: 404,
17
+ body: {
18
+ error: {
19
+ code: 404,
20
+ message,
21
+ status: 'NOT_FOUND',
22
+ },
23
+ },
24
+ };
25
+ }
26
+
27
+ export function badRequestError(message: string): { status: number; body: object } {
28
+ return {
29
+ status: 400,
30
+ body: {
31
+ error: {
32
+ code: 400,
33
+ message,
34
+ status: 'INVALID_ARGUMENT',
35
+ },
36
+ },
37
+ };
38
+ }