File size: 7,442 Bytes
06c3755
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
// v0.21 Web Shell — 同端口 HTTP+WS 服务器。
//
// 路径分派(deepseek host-webserver + host-frontend-static 同款模式):
//   GET  /healthz          — 免鉴权探活
//   POST /api/v1/cmd       — 前端指令 → gate.command()(鉴权:Bearer 头)
//   GET  /api/v1/snapshot  — 全量重拉兜底(鉴权)
//   GET  /api/v1/ws        — WS upgrade(鉴权:子协议 token)
//   其余 GET               — 静态 fallback(dist SPA;index 过鉴权,资产公开)
//
// server 只翻译:HTTP JSON ↔ gate.command / gate.snapshot;WS ↔ gate 信号。
// 不含业务。

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
  /** 静态 SPA dist 目录(v0.22 webapp 构建产物);缺省时不 serve 静态。 */
  distDir?: string
  host?: string
  port?: number
  /** 显式 token(缺省生成 / tokenFile 复用)。 */
  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

  // ---- 静态 fallback(dist SPA)----
  // 静态资产(含 index)免鉴权(v0.22 拍板):fragment token 不上送,index
  // 强制 Authorization 会让首次导航必然 401。index 只是空壳无数据——真正的
  // 数据面(cmd/snapshot/WS)全部强制 Bearer。前端加载后从 location.hash 取
  // token 调 API/WS;无 token 时显示引导页。
  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)))
    // 路径穿越防护(host-frontend-static 同款):越界 403。
    if (target !== distRoot && !target.startsWith(distRoot + sep)) {
      res.writeHead(403).end()
      return
    }
    let filePath = target
    try {
      if (!existsSync(filePath) || statSync(filePath).isDirectory()) {
        // SPA fallback:未命中的路径回 index.html。
        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) => {
    // strip query/fragment:req.url 含 ?a=b 时静态路径会 fallback 到 index.html
    // (文件名带 query 不存在),module 资产因此拿不到。
    const pathname = (req.url ?? '/').split('?')[0]!.split('#')[0]!
    // 1) healthz:免鉴权。
    if (pathname === '/healthz') {
      res.writeHead(200, { 'content-type': 'text/plain' })
      res.end('ok')
      return
    }
    // 2) /api/v1/cmd:POST JSON → gate.command。
    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
    }
    // 3) /api/v1/snapshot:全量重拉兜底。
    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
    }
    // 4) 其余 GET → 静态 fallback(含鉴权规则)。
    if (req.method === 'GET' || req.method === 'HEAD') {
      serveStatic(pathname, req, res)
      return
    }
    res.writeHead(405).end()
  })

  // 5) WS upgrade:仅 /api/v1/ws,子协议 token 鉴权;未命中路径 destroy。
  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())
      }),
  }
}

/** cmd 请求体 → gate.command(校验 kind 存在,未知 kind 干净报错)。 */
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)
}