| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| import { createServer, type Server, type IncomingMessage, type ServerResponse } from 'node:http' |
| import { existsSync, readFileSync, statSync } from 'node:fs' |
| import { extname, join, normalize, resolve, sep } from 'node:path' |
|
|
|
|
| import type { SignalGate, GateCommand } from '../signals/index.js' |
| import { createWebShellAuth, type WebShellAuth } from './auth.js' |
| import { createWebShellStream, type WebShellStream } from './stream.js' |
|
|
| export type WebShellServerOptions = { |
| gate: SignalGate |
| |
| distDir?: string |
| host?: string |
| port?: number |
| |
| token?: string |
| tokenFile?: string |
| } |
|
|
| export type WebShellServer = { |
| url: string |
| port: number |
| token: string |
| auth: WebShellAuth |
| stream: WebShellStream |
| close(): Promise<void> |
| } |
|
|
| const MIME: Record<string, string> = { |
| '.html': 'text/html; charset=utf-8', |
| '.js': 'text/javascript; charset=utf-8', |
| '.css': 'text/css; charset=utf-8', |
| '.svg': 'image/svg+xml', |
| '.json': 'application/json', |
| '.png': 'image/png', |
| '.ico': 'image/x-icon', |
| '.map': 'application/json', |
| } |
|
|
| const sendJson = (res: ServerResponse, status: number, body: unknown): void => { |
| res.writeHead(status, { 'content-type': 'application/json; charset=utf-8' }) |
| res.end(JSON.stringify(body)) |
| } |
|
|
| const readBody = (req: IncomingMessage): Promise<string> => |
| new Promise((resolveBody, rejectBody) => { |
| const chunks: Buffer[] = [] |
| req.on('data', (c: Buffer) => chunks.push(c)) |
| req.on('end', () => resolveBody(Buffer.concat(chunks).toString('utf8'))) |
| req.on('error', rejectBody) |
| }) |
|
|
| export const createWebShellServer = async (opts: WebShellServerOptions): Promise<WebShellServer> => { |
| const auth = createWebShellAuth({ |
| ...(opts.token !== undefined ? { token: opts.token } : {}), |
| ...(opts.tokenFile !== undefined ? { tokenFile: opts.tokenFile } : {}), |
| }) |
| const stream = createWebShellStream({ gate: opts.gate, authorize: (req) => auth.authorizeWs(req) }) |
| const distRoot = opts.distDir !== undefined ? resolve(opts.distDir) : undefined |
|
|
| |
| |
| |
| |
| |
| const serveStatic = (pathname: string, req: IncomingMessage, res: ServerResponse): void => { |
| if (distRoot === undefined) { |
| res.writeHead(404).end() |
| return |
| } |
| const target = resolve(normalize(join(distRoot, pathname === '/' ? 'index.html' : pathname))) |
| |
| if (target !== distRoot && !target.startsWith(distRoot + sep)) { |
| res.writeHead(403).end() |
| return |
| } |
| let filePath = target |
| try { |
| if (!existsSync(filePath) || statSync(filePath).isDirectory()) { |
| |
| filePath = join(distRoot, 'index.html') |
| } |
| } catch { |
| res.writeHead(500).end() |
| return |
| } |
| try { |
| const body = readFileSync(filePath) |
| const type = MIME[extname(filePath)] ?? 'application/octet-stream' |
| res.writeHead(200, { 'content-type': type }) |
| res.end(body) |
| } catch { |
| res.writeHead(404).end() |
| } |
| } |
|
|
| const server: Server = createServer((req, res) => { |
| |
| |
| const pathname = (req.url ?? '/').split('?')[0]!.split('#')[0]! |
| |
| if (pathname === '/healthz') { |
| res.writeHead(200, { 'content-type': 'text/plain' }) |
| res.end('ok') |
| return |
| } |
| |
| if (pathname === '/api/v1/cmd' && req.method === 'POST') { |
| if (!auth.authorizeRequest(req)) { |
| sendJson(res, 401, { error: 'unauthorized' }) |
| return |
| } |
| void readBody(req) |
| .then((body) => commandFromBody(opts.gate, body)) |
| .then((result) => sendJson(res, 200, { ok: true, result })) |
| .catch((e: unknown) => sendJson(res, 400, { ok: false, error: e instanceof Error ? e.message : String(e) })) |
| return |
| } |
| |
| if (pathname === '/api/v1/snapshot' && req.method === 'GET') { |
| if (!auth.authorizeRequest(req)) { |
| sendJson(res, 401, { error: 'unauthorized' }) |
| return |
| } |
| sendJson(res, 200, { ok: true, snapshot: opts.gate.snapshot() }) |
| return |
| } |
| |
| if (req.method === 'GET' || req.method === 'HEAD') { |
| serveStatic(pathname, req, res) |
| return |
| } |
| res.writeHead(405).end() |
| }) |
|
|
| |
| server.on('upgrade', (req, socket, head) => { |
| const pathname = (req.url ?? '/').split('?')[0]!.split('#')[0]! |
| if (pathname !== '/api/v1/ws') { |
| socket.destroy() |
| return |
| } |
| if (!auth.authorizeWs(req)) { |
| socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n') |
| socket.destroy() |
| return |
| } |
| stream.wss.handleUpgrade(req, socket, head, (ws) => { |
| stream.wss.emit('connection', ws, req) |
| }) |
| }) |
|
|
| const host = opts.host ?? '127.0.0.1' |
| const port = opts.port ?? 0 |
|
|
| await new Promise<void>((resolveListen, rejectListen) => { |
| server.once('error', rejectListen) |
| server.listen(port, host, () => { |
| server.off('error', rejectListen) |
| resolveListen() |
| }) |
| }) |
| const address = server.address() |
| const actualPort = address !== null && typeof address === 'object' ? address.port : port |
|
|
| return { |
| url: `http://${host}:${actualPort}/#token=${auth.token}`, |
| port: actualPort, |
| token: auth.token, |
| auth, |
| stream, |
| close: (): Promise<void> => |
| new Promise((resolveClose) => { |
| void stream.close() |
| server.close(() => resolveClose()) |
| }), |
| } |
| } |
|
|
| |
| const commandFromBody = async (gate: SignalGate, body: string): Promise<unknown> => { |
| let parsed: unknown |
| try { |
| parsed = JSON.parse(body) |
| } catch { |
| throw new Error('request body is not valid JSON') |
| } |
| if (typeof parsed !== 'object' || parsed === null || !('kind' in parsed)) { |
| throw new Error('command requires a "kind" field') |
| } |
| const cmd = parsed as GateCommand |
| return gate.command(cmd) |
| } |
|
|