@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.
- package/.claude/settings.local.json +72 -0
- package/README.md +126 -0
- package/bin/fws-cli.sh +4 -0
- package/bin/fws.ts +421 -0
- package/docs/cli-reference.md +211 -0
- package/docs/gws-support.md +276 -0
- package/package.json +28 -0
- package/src/config/rewrite-cache.ts +73 -0
- package/src/index.ts +3 -0
- package/src/proxy/mitm.ts +285 -0
- package/src/server/app.ts +26 -0
- package/src/server/middleware.ts +38 -0
- package/src/server/routes/calendar.ts +483 -0
- package/src/server/routes/control.ts +151 -0
- package/src/server/routes/drive.ts +342 -0
- package/src/server/routes/gmail.ts +758 -0
- package/src/server/routes/people.ts +239 -0
- package/src/server/routes/sheets.ts +242 -0
- package/src/server/routes/tasks.ts +191 -0
- package/src/store/index.ts +24 -0
- package/src/store/seed.ts +313 -0
- package/src/store/types.ts +225 -0
- package/src/util/id.ts +9 -0
- package/test/calendar.test.ts +227 -0
- package/test/drive.test.ts +153 -0
- package/test/gmail.test.ts +215 -0
- package/test/gws-validation.test.ts +883 -0
- package/test/helpers/harness.ts +109 -0
- package/test/snapshot.test.ts +80 -0
- package/tsconfig.json +17 -0
- package/vitest.config.ts +8 -0
|
@@ -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
|
+
}
|