瀏覽代碼

feat: LAN access token for API and file routes

- tokenGuard middleware: when config.accessToken (or ACCESS_TOKEN env,
  not persisted) is set, /api/* and /files require the token via cookie,
  X-Access-Token header, or ?token=; loopback, /healthz, /readyz and
  static pages stay open so the page can render its prompt
- frontend fetch wrapper prompts once on 401, stores the token in a
  cookie (covers <img> and CSV links automatically)
- settings page: accessToken field (empty = keep, '-' = clear)
- smoke suite: token phase over the LAN IP, +6 checks (52 total)
KenZ1117 2 周之前
父節點
當前提交
62c86e8de2
共有 7 個文件被更改,包括 167 次插入 和 4 次删除
  1. 27 0
      public/app.js
  2. 5 0
      public/index.html
  3. 80 3
      scripts/smoke.js
  4. 11 0
      server.js
  5. 2 0
      server/context.js
  6. 36 1
      server/middleware.js
  7. 6 0
      server/routes/settings.js

+ 27 - 0
public/app.js

@@ -1,5 +1,26 @@
 'use strict';
 'use strict';
 
 
+// 局域网访问口令:服务端开启 accessToken 时,API 返回 401 会引导输入一次口令,
+// 写入 cookie 后所有请求(fetch / 图片 / CSV 链接)自动携带,无需逐处改代码。
+(function () {
+  let tokenAsked = false;
+  const origFetch = window.fetch.bind(window);
+  window.fetch = async function (input, init) {
+    const res = await origFetch(input, init);
+    const url = typeof input === 'string' ? input : (input && input.url) || String(input || '');
+    if (res.status === 401 && /\/(api|files)\b/.test(url) && !tokenAsked) {
+      tokenAsked = true;
+      const token = window.prompt('此服务已开启访问口令(accessToken),请输入:');
+      if (token) {
+        document.cookie = 'accessToken=' + encodeURIComponent(token) + '; path=/; max-age=31536000; SameSite=Lax';
+        tokenAsked = false;
+        return origFetch(input, init);
+      }
+    }
+    return res;
+  };
+})();
+
 (function () {
 (function () {
   const views = {
   const views = {
     crawl: document.getElementById('viewCrawl'),
     crawl: document.getElementById('viewCrawl'),
@@ -58,6 +79,9 @@
     chatKeyInput.placeholder = settings.chatKeyFallback
     chatKeyInput.placeholder = settings.chatKeyFallback
       ? '留空复用上方 API Key'
       ? '留空复用上方 API Key'
       : (settings.chatApiKey ? '已配置,输入新 Key 可更换' : '留空复用上方 API Key;否则填对话模型专用 Key');
       : (settings.chatApiKey ? '已配置,输入新 Key 可更换' : '留空复用上方 API Key;否则填对话模型专用 Key');
+    const tokenInput = document.getElementById('settingsAccessToken');
+    tokenInput.value = '';
+    tokenInput.placeholder = settings.accessTokenSet ? '已设置,留空保持不变(输入 - 清除)' : '未设置';
     await loadModels(settings.model);
     await loadModels(settings.model);
     showSettingsStatus('已同步');
     showSettingsStatus('已同步');
   }
   }
@@ -83,6 +107,9 @@
           ...(document.getElementById('settingsCookie').value.trim()
           ...(document.getElementById('settingsCookie').value.trim()
             ? { cookie: document.getElementById('settingsCookie').value.trim() }
             ? { cookie: document.getElementById('settingsCookie').value.trim() }
             : {}),
             : {}),
+          ...(document.getElementById('settingsAccessToken').value.trim()
+            ? { accessToken: document.getElementById('settingsAccessToken').value.trim() }
+            : {}),
         }),
         }),
       });
       });
       const data = await response.json();
       const data = await response.json();

+ 5 - 0
public/index.html

@@ -537,6 +537,11 @@
                 <label>Cookie(淘宝闪购)</label>
                 <label>Cookie(淘宝闪购)</label>
 	                <textarea id="settingsCookie" rows="3" placeholder="登录 melody.shop.ele.me 后从 DevTools 复制"></textarea>
 	                <textarea id="settingsCookie" rows="3" placeholder="登录 melody.shop.ele.me 后从 DevTools 复制"></textarea>
 	                <div class="help">Cookie 保存后不再回显;会话过期时粘贴新值即可更新</div>
 	                <div class="help">Cookie 保存后不再回显;会话过期时粘贴新值即可更新</div>
+              </div>
+              <div class="field">
+                <label>局域网访问口令(accessToken)</label>
+                <input type="text" id="settingsAccessToken" placeholder="未设置" autocomplete="off">
+                <div class="help">设置后,同一局域网的其他电脑访问本服务需要输入此口令(本机 127.0.0.1 不受影响)。留空保持不变;输入 - 清除口令。临时开关也可用环境变量 ACCESS_TOKEN。</div>
               </div>
               </div>
 	              <div style="display:flex;align-items:center;gap:8px;">
 	              <div style="display:flex;align-items:center;gap:8px;">
 	                <button class="btn btn-primary btn-small" id="btnSaveSettings">保存设置</button>
 	                <button class="btn btn-primary btn-small" id="btnSaveSettings">保存设置</button>

+ 80 - 3
scripts/smoke.js

@@ -10,6 +10,7 @@
 const { spawn } = require('child_process');
 const { spawn } = require('child_process');
 const net = require('net');
 const net = require('net');
 const http = require('http');
 const http = require('http');
+const os = require('os');
 const path = require('path');
 const path = require('path');
 
 
 const ROOT = path.resolve(__dirname, '..');
 const ROOT = path.resolve(__dirname, '..');
@@ -26,11 +27,14 @@ function pickPort() {
   });
   });
 }
 }
 
 
-function request(port, method, urlPath, body) {
+function request(port, method, urlPath, body, host = '127.0.0.1', extraHeaders = {}) {
   return new Promise((resolve, reject) => {
   return new Promise((resolve, reject) => {
     const req = http.request({
     const req = http.request({
-      host: '127.0.0.1', port, method, path: urlPath, timeout: 15000,
-      headers: body !== undefined ? { 'Content-Type': 'application/json' } : {},
+      host, port, method, path: urlPath, timeout: 15000,
+      headers: {
+        ...(body !== undefined ? { 'Content-Type': 'application/json' } : {}),
+        ...extraHeaders,
+      },
     }, (res) => {
     }, (res) => {
       const chunks = [];
       const chunks = [];
       res.on('data', (c) => chunks.push(c));
       res.on('data', (c) => chunks.push(c));
@@ -48,6 +52,16 @@ function request(port, method, urlPath, body) {
   });
   });
 }
 }
 
 
+/** 取本机第一个非内部 IPv4 局域网地址(用于访问口令测试;回环地址在服务端直接放行) */
+function getLanIpv4() {
+  for (const list of Object.values(os.networkInterfaces())) {
+    for (const ni of list || []) {
+      if (ni.family === 'IPv4' && !ni.internal) return ni.address;
+    }
+  }
+  return '';
+}
+
 const results = [];
 const results = [];
 async function check(name, fn) {
 async function check(name, fn) {
   try {
   try {
@@ -74,6 +88,27 @@ async function waitForHealth(port, timeoutMs = 20000) {
   }
   }
 }
 }
 
 
+function waitForHealthLan(lanIp, port, timeoutMs = 20000) {
+  return new Promise((resolve, reject) => {
+    const start = Date.now();
+    const probe = () => {
+      const req = http.request({ host: lanIp, port, path: '/healthz', timeout: 3000 }, (res) => {
+        res.resume();
+        if (res.statusCode === 200) return resolve();
+        retry();
+      });
+      req.on('error', retry);
+      req.on('timeout', () => { req.destroy(); retry(); });
+      req.end();
+    };
+    const retry = () => {
+      if (Date.now() - start > timeoutMs) return reject(new Error('服务未在超时内启动(LAN)'));
+      setTimeout(probe, 300);
+    };
+    probe();
+  });
+}
+
 function enc(s) { return encodeURIComponent(s); }
 function enc(s) { return encodeURIComponent(s); }
 
 
 async function main() {
 async function main() {
@@ -276,6 +311,48 @@ async function main() {
       });
       });
       expect(exited, 'SIGTERM 后 8 秒内未退出');
       expect(exited, 'SIGTERM 后 8 秒内未退出');
     });
     });
+
+    // ---- 访问口令(需局域网 IP:本机回环地址在守卫中直接放行,测不到 401) ----
+    const lanIp = getLanIpv4();
+    if (!lanIp) {
+      results.push({ name: '访问口令(无局域网 IP,跳过)', ok: true });
+    } else {
+      const tport = await pickPort();
+      const tchild = spawn(process.execPath, [path.join(ROOT, 'server.js')], {
+        env: { ...process.env, PORT: String(tport), HOST: '0.0.0.0', ACCESS_TOKEN: 'smoke-token' },
+        stdio: ['ignore', 'ignore', 'ignore'],
+      });
+      try {
+        await waitForHealthLan(lanIp, tport);
+        await check('口令开启:无口令访问 API → 401', async () => {
+          const r = await request(tport, 'GET', '/api/models', undefined, lanIp);
+          expect(r.status === 401, `status=${r.status}`);
+        });
+        await check('口令开启:?token= 通过', async () => {
+          const r = await request(tport, 'GET', `/api/models?token=smoke-token`, undefined, lanIp);
+          expect(r.status === 200, `status=${r.status}`);
+        });
+        await check('口令开启:X-Access-Token 头通过', async () => {
+          const r = await request(tport, 'GET', '/api/models', undefined, lanIp, { 'X-Access-Token': 'smoke-token' });
+          expect(r.status === 200, `status=${r.status}`);
+        });
+        await check('口令开启:cookie 通过', async () => {
+          const r = await request(tport, 'GET', '/api/models', undefined, lanIp, { Cookie: 'accessToken=smoke-token' });
+          expect(r.status === 200, `status=${r.status}`);
+        });
+        await check('口令开启:/healthz 与静态页放行', async () => {
+          const h = await request(tport, 'GET', '/healthz', undefined, lanIp);
+          const s = await request(tport, 'GET', '/', undefined, lanIp);
+          expect(h.status === 200 && s.status === 200, `healthz=${h.status} static=${s.status}`);
+        });
+        await check('口令开启:错误口令 → 401', async () => {
+          const r = await request(tport, 'GET', '/api/models?token=wrong', undefined, lanIp);
+          expect(r.status === 401, `status=${r.status}`);
+        });
+      } finally {
+        try { tchild.kill('SIGKILL'); } catch { /* ignore */ }
+      }
+    }
   } catch (e) {
   } catch (e) {
     results.push({ name: '(测试执行中断)', ok: false, error: e.message });
     results.push({ name: '(测试执行中断)', ok: false, error: e.message });
   } finally {
   } finally {

+ 11 - 0
server.js

@@ -10,6 +10,8 @@ const { execFile } = require('child_process');
 
 
 const { createContext } = require('./server/context.js');
 const { createContext } = require('./server/context.js');
 const { createRouter } = require('./server/router.js');
 const { createRouter } = require('./server/router.js');
+const { makeTokenGuard } = require('./server/middleware.js');
+const { sendJson } = require('./server/util.js');
 const { createCrawlService } = require('./server/services/crawl.js');
 const { createCrawlService } = require('./server/services/crawl.js');
 const { createGenerationService } = require('./server/services/generation.js');
 const { createGenerationService } = require('./server/services/generation.js');
 const { createAutoSyncService } = require('./server/services/auto-sync.js');
 const { createAutoSyncService } = require('./server/services/auto-sync.js');
@@ -25,7 +27,16 @@ ctx.autoSync = createAutoSyncService(ctx);
 const app = createRouter();
 const app = createRouter();
 registerRoutes(app, ctx);
 registerRoutes(app, ctx);
 
 
+// 局域网访问口令:config.accessToken(或环境变量 ACCESS_TOKEN)非空时启用
+const tokenGuard = makeTokenGuard(ctx.config);
+
 const server = http.createServer((req, res) => {
 const server = http.createServer((req, res) => {
+  try {
+    const parsed = new URL(req.url, `http://${req.headers.host}`);
+    if (!tokenGuard(req, parsed.pathname, parsed.searchParams)) {
+      return sendJson(res, 401, { error: '需要访问口令(accessToken)' });
+    }
+  } catch { /* URL 非法时交给 dispatch 返回 400 */ }
   app.dispatch(req, res);
   app.dispatch(req, res);
 });
 });
 
 

+ 2 - 0
server/context.js

@@ -232,6 +232,8 @@ function createLogsService() {
 
 
 function createContext() {
 function createContext() {
   const config = loadConfig();
   const config = loadConfig();
+  // 局域网访问口令:环境变量可临时覆盖(不落盘),便于临时开启/测试
+  if (process.env.ACCESS_TOKEN) config.accessToken = process.env.ACCESS_TOKEN;
   const workspace = createWorkspaceService(config);
   const workspace = createWorkspaceService(config);
   try {
   try {
     fs.mkdirSync(workspace.getWorkspaceRoot(), { recursive: true });
     fs.mkdirSync(workspace.getWorkspaceRoot(), { recursive: true });

+ 36 - 1
server/middleware.js

@@ -39,4 +39,39 @@ function errorBoundary(handler) {
   };
   };
 }
 }
 
 
-module.exports = { ASK_CLIENT_CODES, errorStatus, sendError, errorBoundary };
+// ---------- 访问口令(局域网模式) ----------
+// config.accessToken 非空时启用:/api/* 与 /files 需要口令(cookie / header / query 三选一)。
+// 本机回环访问与健康检查放行;静态页面放行(无登录页,页面先加载,前端 401 后引导输入)。
+
+function parseCookie(header) {
+  const out = {};
+  String(header || '').split(';').forEach((part) => {
+    const i = part.indexOf('=');
+    if (i > 0) {
+      try { out[part.slice(0, i).trim()] = decodeURIComponent(part.slice(i + 1).trim()); }
+      catch { out[part.slice(0, i).trim()] = part.slice(i + 1).trim(); }
+    }
+  });
+  return out;
+}
+
+function isLoopbackRemote(req) {
+  const addr = String((req.socket && req.socket.remoteAddress) || '');
+  return addr === '127.0.0.1' || addr === '::1' || addr === '::ffff:127.0.0.1';
+}
+
+function makeTokenGuard(config) {
+  return function tokenGuard(req, pathname, searchParams) {
+    const expected = config.accessToken;
+    if (!expected) return true;
+    if (pathname === '/healthz' || pathname === '/readyz') return true;
+    if (!pathname.startsWith('/api/') && !pathname.startsWith('/files')) return true;
+    if (isLoopbackRemote(req)) return true;
+    if (searchParams.get('token') === expected) return true;
+    if (req.headers['x-access-token'] === expected) return true;
+    if (parseCookie(req.headers.cookie).accessToken === expected) return true;
+    return false;
+  };
+}
+
+module.exports = { ASK_CLIENT_CODES, errorStatus, sendError, errorBoundary, makeTokenGuard };

+ 6 - 0
server/routes/settings.js

@@ -30,6 +30,7 @@ function register(app, ctx) {
       chatKeyFallback: !ctx.config.chatApiKey && !!ctx.config.apiKey,
       chatKeyFallback: !ctx.config.chatApiKey && !!ctx.config.apiKey,
       autoSyncEnabled: !!(ctx.config.autoSync && ctx.config.autoSync.enabled),
       autoSyncEnabled: !!(ctx.config.autoSync && ctx.config.autoSync.enabled),
       autoSyncTime: (ctx.config.autoSync && ctx.config.autoSync.time) || '06:00',
       autoSyncTime: (ctx.config.autoSync && ctx.config.autoSync.time) || '06:00',
+      accessTokenSet: !!ctx.config.accessToken,
     });
     });
   });
   });
 
 
@@ -76,6 +77,11 @@ function register(app, ctx) {
         config.autoSync.time = t;
         config.autoSync.time = t;
       }
       }
     }
     }
+    if (body.accessToken !== undefined) {
+      const t = String(body.accessToken).trim();
+      if (t === '-') config.accessToken = ''; // 约定:输入 - 清除口令
+      else if (t) config.accessToken = t;
+    }
     ctx.saveConfig();
     ctx.saveConfig();
     if (body.cookie !== undefined) {
     if (body.cookie !== undefined) {
       ctx.session.cookie = body.cookie;
       ctx.session.cookie = body.cookie;