termdock 1.0.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/LICENSE +21 -0
- package/README.md +385 -0
- package/dist/client/apple-splash-default-1170x2532.png +0 -0
- package/dist/client/apple-splash-landscape-1136x640.png +0 -0
- package/dist/client/apple-splash-landscape-1334x750.png +0 -0
- package/dist/client/apple-splash-landscape-1792x828.png +0 -0
- package/dist/client/apple-splash-landscape-2048x1536.png +0 -0
- package/dist/client/apple-splash-landscape-2160x1620.png +0 -0
- package/dist/client/apple-splash-landscape-2208x1242.png +0 -0
- package/dist/client/apple-splash-landscape-2224x1668.png +0 -0
- package/dist/client/apple-splash-landscape-2266x1488.png +0 -0
- package/dist/client/apple-splash-landscape-2360x1640.png +0 -0
- package/dist/client/apple-splash-landscape-2388x1668.png +0 -0
- package/dist/client/apple-splash-landscape-2436x1125.png +0 -0
- package/dist/client/apple-splash-landscape-2532x1170.png +0 -0
- package/dist/client/apple-splash-landscape-2556x1179.png +0 -0
- package/dist/client/apple-splash-landscape-2622x1206.png +0 -0
- package/dist/client/apple-splash-landscape-2688x1242.png +0 -0
- package/dist/client/apple-splash-landscape-2732x2048.png +0 -0
- package/dist/client/apple-splash-landscape-2778x1284.png +0 -0
- package/dist/client/apple-splash-landscape-2796x1290.png +0 -0
- package/dist/client/apple-splash-landscape-2868x1320.png +0 -0
- package/dist/client/apple-splash-portrait-1125x2436.png +0 -0
- package/dist/client/apple-splash-portrait-1170x2532.png +0 -0
- package/dist/client/apple-splash-portrait-1179x2556.png +0 -0
- package/dist/client/apple-splash-portrait-1206x2622.png +0 -0
- package/dist/client/apple-splash-portrait-1242x2208.png +0 -0
- package/dist/client/apple-splash-portrait-1242x2688.png +0 -0
- package/dist/client/apple-splash-portrait-1284x2778.png +0 -0
- package/dist/client/apple-splash-portrait-1290x2796.png +0 -0
- package/dist/client/apple-splash-portrait-1320x2868.png +0 -0
- package/dist/client/apple-splash-portrait-1488x2266.png +0 -0
- package/dist/client/apple-splash-portrait-1536x2048.png +0 -0
- package/dist/client/apple-splash-portrait-1620x2160.png +0 -0
- package/dist/client/apple-splash-portrait-1640x2360.png +0 -0
- package/dist/client/apple-splash-portrait-1668x2224.png +0 -0
- package/dist/client/apple-splash-portrait-1668x2388.png +0 -0
- package/dist/client/apple-splash-portrait-2048x2732.png +0 -0
- package/dist/client/apple-splash-portrait-640x1136.png +0 -0
- package/dist/client/apple-splash-portrait-750x1334.png +0 -0
- package/dist/client/apple-splash-portrait-828x1792.png +0 -0
- package/dist/client/apple-touch-icon-180x180.png +0 -0
- package/dist/client/assets/KaTeX_AMS-Regular-BQhdFMY1.woff2 +0 -0
- package/dist/client/assets/KaTeX_AMS-Regular-DMm9YOAa.woff +0 -0
- package/dist/client/assets/KaTeX_AMS-Regular-DRggAlZN.ttf +0 -0
- package/dist/client/assets/KaTeX_Caligraphic-Bold-ATXxdsX0.ttf +0 -0
- package/dist/client/assets/KaTeX_Caligraphic-Bold-BEiXGLvX.woff +0 -0
- package/dist/client/assets/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2 +0 -0
- package/dist/client/assets/KaTeX_Caligraphic-Regular-CTRA-rTL.woff +0 -0
- package/dist/client/assets/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2 +0 -0
- package/dist/client/assets/KaTeX_Caligraphic-Regular-wX97UBjC.ttf +0 -0
- package/dist/client/assets/KaTeX_Fraktur-Bold-BdnERNNW.ttf +0 -0
- package/dist/client/assets/KaTeX_Fraktur-Bold-BsDP51OF.woff +0 -0
- package/dist/client/assets/KaTeX_Fraktur-Bold-CL6g_b3V.woff2 +0 -0
- package/dist/client/assets/KaTeX_Fraktur-Regular-CB_wures.ttf +0 -0
- package/dist/client/assets/KaTeX_Fraktur-Regular-CTYiF6lA.woff2 +0 -0
- package/dist/client/assets/KaTeX_Fraktur-Regular-Dxdc4cR9.woff +0 -0
- package/dist/client/assets/KaTeX_Main-Bold-Cx986IdX.woff2 +0 -0
- package/dist/client/assets/KaTeX_Main-Bold-Jm3AIy58.woff +0 -0
- package/dist/client/assets/KaTeX_Main-Bold-waoOVXN0.ttf +0 -0
- package/dist/client/assets/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2 +0 -0
- package/dist/client/assets/KaTeX_Main-BoldItalic-DzxPMmG6.ttf +0 -0
- package/dist/client/assets/KaTeX_Main-BoldItalic-SpSLRI95.woff +0 -0
- package/dist/client/assets/KaTeX_Main-Italic-3WenGoN9.ttf +0 -0
- package/dist/client/assets/KaTeX_Main-Italic-BMLOBm91.woff +0 -0
- package/dist/client/assets/KaTeX_Main-Italic-NWA7e6Wa.woff2 +0 -0
- package/dist/client/assets/KaTeX_Main-Regular-B22Nviop.woff2 +0 -0
- package/dist/client/assets/KaTeX_Main-Regular-Dr94JaBh.woff +0 -0
- package/dist/client/assets/KaTeX_Main-Regular-ypZvNtVU.ttf +0 -0
- package/dist/client/assets/KaTeX_Math-BoldItalic-B3XSjfu4.ttf +0 -0
- package/dist/client/assets/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2 +0 -0
- package/dist/client/assets/KaTeX_Math-BoldItalic-iY-2wyZ7.woff +0 -0
- package/dist/client/assets/KaTeX_Math-Italic-DA0__PXp.woff +0 -0
- package/dist/client/assets/KaTeX_Math-Italic-flOr_0UB.ttf +0 -0
- package/dist/client/assets/KaTeX_Math-Italic-t53AETM-.woff2 +0 -0
- package/dist/client/assets/KaTeX_SansSerif-Bold-CFMepnvq.ttf +0 -0
- package/dist/client/assets/KaTeX_SansSerif-Bold-D1sUS0GD.woff2 +0 -0
- package/dist/client/assets/KaTeX_SansSerif-Bold-DbIhKOiC.woff +0 -0
- package/dist/client/assets/KaTeX_SansSerif-Italic-C3H0VqGB.woff2 +0 -0
- package/dist/client/assets/KaTeX_SansSerif-Italic-DN2j7dab.woff +0 -0
- package/dist/client/assets/KaTeX_SansSerif-Italic-YYjJ1zSn.ttf +0 -0
- package/dist/client/assets/KaTeX_SansSerif-Regular-BNo7hRIc.ttf +0 -0
- package/dist/client/assets/KaTeX_SansSerif-Regular-CS6fqUqJ.woff +0 -0
- package/dist/client/assets/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2 +0 -0
- package/dist/client/assets/KaTeX_Script-Regular-C5JkGWo-.ttf +0 -0
- package/dist/client/assets/KaTeX_Script-Regular-D3wIWfF6.woff2 +0 -0
- package/dist/client/assets/KaTeX_Script-Regular-D5yQViql.woff +0 -0
- package/dist/client/assets/KaTeX_Size1-Regular-C195tn64.woff +0 -0
- package/dist/client/assets/KaTeX_Size1-Regular-Dbsnue_I.ttf +0 -0
- package/dist/client/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 +0 -0
- package/dist/client/assets/KaTeX_Size2-Regular-B7gKUWhC.ttf +0 -0
- package/dist/client/assets/KaTeX_Size2-Regular-Dy4dx90m.woff2 +0 -0
- package/dist/client/assets/KaTeX_Size2-Regular-oD1tc_U0.woff +0 -0
- package/dist/client/assets/KaTeX_Size3-Regular-CTq5MqoE.woff +0 -0
- package/dist/client/assets/KaTeX_Size3-Regular-DgpXs0kz.ttf +0 -0
- package/dist/client/assets/KaTeX_Size4-Regular-BF-4gkZK.woff +0 -0
- package/dist/client/assets/KaTeX_Size4-Regular-DWFBv043.ttf +0 -0
- package/dist/client/assets/KaTeX_Size4-Regular-Dl5lxZxV.woff2 +0 -0
- package/dist/client/assets/KaTeX_Typewriter-Regular-C0xS9mPB.woff +0 -0
- package/dist/client/assets/KaTeX_Typewriter-Regular-CO6r4hn1.woff2 +0 -0
- package/dist/client/assets/KaTeX_Typewriter-Regular-D3Ib7_Hf.ttf +0 -0
- package/dist/client/assets/abnfDiagram-VRR7QNED-D0NxeSHA.js +1 -0
- package/dist/client/assets/arc-B7GoKc6u.js +1 -0
- package/dist/client/assets/architectureDiagram-ZJ3FMSHR-BQPW-wUx.js +36 -0
- package/dist/client/assets/blockDiagram-677ZJIJ3-CX_hbcgG.js +132 -0
- package/dist/client/assets/c4Diagram-LMCZKHZV-BwOMQA4x.js +10 -0
- package/dist/client/assets/channel-C2gUFcyU.js +1 -0
- package/dist/client/assets/chunk-2Q5K7J3B-BLJzEQXz.js +1 -0
- package/dist/client/assets/chunk-32BRIVSS-CjBDr_gz.js +1 -0
- package/dist/client/assets/chunk-5VM5RSS4-lddvv0x2.js +15 -0
- package/dist/client/assets/chunk-EX3LRPZG-Dtsq1Stg.js +231 -0
- package/dist/client/assets/chunk-JWPE2WC7-BG3PZx4j.js +1 -0
- package/dist/client/assets/chunk-MOJQB5TN-CMtlCuQg.js +88 -0
- package/dist/client/assets/chunk-RYQCIY6F-uplKqw64.js +1 -0
- package/dist/client/assets/chunk-V7JOEXUC-CpteLsY3.js +206 -0
- package/dist/client/assets/chunk-VR4S4FIN-1zYOJQXL.js +1 -0
- package/dist/client/assets/chunk-XXDRQBXY-C0wXH91I.js +1 -0
- package/dist/client/assets/classDiagram-OUVF2IWQ-BTOnA9HS.js +1 -0
- package/dist/client/assets/classDiagram-v2-EOCWNBFH-BTOnA9HS.js +1 -0
- package/dist/client/assets/cose-bilkent-JH36ORCC-BceT5ki2.js +1 -0
- package/dist/client/assets/cynefin-VYW2F7L2-BqCIkNeC.js +166 -0
- package/dist/client/assets/cynefinDiagram-TSTJHNR4-Dm7y-tsN.js +62 -0
- package/dist/client/assets/cytoscape.esm-D3_iZ_3b.js +321 -0
- package/dist/client/assets/dagre-VKFMJZFB-Bn8eh0lE.js +4 -0
- package/dist/client/assets/defaultLocale-CrowFXzY.js +1 -0
- package/dist/client/assets/diagram-FQU43EPY-DmZGMAsX.js +3 -0
- package/dist/client/assets/diagram-G47NLZAW-BLWSm_Rn.js +24 -0
- package/dist/client/assets/diagram-NH7WQ7WH-C2-HK8pv.js +24 -0
- package/dist/client/assets/diagram-OA4YK3LP-8Ex4XIU5.js +30 -0
- package/dist/client/assets/diagram-WEI45ONY-MRNIpBXG.js +41 -0
- package/dist/client/assets/diffWorker-B6msVyjo.js +18 -0
- package/dist/client/assets/dnd-BCFAPC0L.js +1 -0
- package/dist/client/assets/ebnfDiagram-CCIWWBDH-D7Z6wZi_.js +1 -0
- package/dist/client/assets/erDiagram-Q63AITRT-C96Q5x9y.js +85 -0
- package/dist/client/assets/flowDiagram-23GEKE2U-DzQp40QH.js +156 -0
- package/dist/client/assets/ganttDiagram-NO4QXBWP-ZzkchZJ8.js +292 -0
- package/dist/client/assets/gitGraphDiagram-IHSO6WYX-CFacEf54.js +106 -0
- package/dist/client/assets/graph-D2o_JWn5.js +1 -0
- package/dist/client/assets/index-Ca-rq7yX.js +6 -0
- package/dist/client/assets/index-Cv7q4nZR.css +1 -0
- package/dist/client/assets/index-XktPMm51.js +394 -0
- package/dist/client/assets/infoDiagram-FWYZ7A6U-CVRXs5rF.js +2 -0
- package/dist/client/assets/init-Gi6I4Gst.js +1 -0
- package/dist/client/assets/ishikawaDiagram-FXEZZL3T-B8FP_wzM.js +70 -0
- package/dist/client/assets/journeyDiagram-5HDEW3XC-BR__4Hbd.js +139 -0
- package/dist/client/assets/kanban-definition-HUTT4EX6-v39oZe6X.js +89 -0
- package/dist/client/assets/katex-55ZPZeOF.css +1 -0
- package/dist/client/assets/katex-DnlPpQZa.js +257 -0
- package/dist/client/assets/katex-HP8lGamR.js +257 -0
- package/dist/client/assets/layout-1eBIAoSl.js +1 -0
- package/dist/client/assets/linear-jlTiHqO6.js +1 -0
- package/dist/client/assets/map-BdqsVdb3.js +1 -0
- package/dist/client/assets/mermaid.core-DiiIN0EC.js +306 -0
- package/dist/client/assets/mindmap-definition-LN4V7U3C-AeUxAZeq.js +96 -0
- package/dist/client/assets/ordinal-Cboi1Yqb.js +1 -0
- package/dist/client/assets/pegDiagram-2B236MQR-Dh-bgAbv.js +1 -0
- package/dist/client/assets/pieDiagram-ENE6RG2P-De7Si7Tm.js +39 -0
- package/dist/client/assets/purify.es-CC4Brkr_.js +3 -0
- package/dist/client/assets/quadrantDiagram-ABIIQ3AL-CgWfHqy0.js +7 -0
- package/dist/client/assets/railroadDiagram-RFXS5EU6-BXnQW-pI.js +1 -0
- package/dist/client/assets/react-XDCjal2T.js +8 -0
- package/dist/client/assets/requirementDiagram-TGXJPOKE-CU1nnpUM.js +84 -0
- package/dist/client/assets/sankeyDiagram-HTMAVEWB-DFK-AXY6.js +40 -0
- package/dist/client/assets/sequenceDiagram-DBY2YBRQ-D_bVDWgq.js +162 -0
- package/dist/client/assets/sizeCapture-X5ZJPWSS-nh89ExHT.js +1 -0
- package/dist/client/assets/stateDiagram-2N3HPSRC-DUvT6Ruk.js +1 -0
- package/dist/client/assets/stateDiagram-v2-6OUMAXLB-Dz-3r-Yq.js +1 -0
- package/dist/client/assets/swimlanes-5IMT3BWC-ZYa52gjG.js +2 -0
- package/dist/client/assets/swimlanesDiagram-G3AALYLV-DXR_9lf9.js +8 -0
- package/dist/client/assets/terminal-C7X98Amf.js +10 -0
- package/dist/client/assets/timeline-definition-FHXFAJF6-Bp6A49KQ.js +120 -0
- package/dist/client/assets/vennDiagram-L72KCM5P-CDDUmLOI.js +34 -0
- package/dist/client/assets/wardleyDiagram-EHGQE667-CRc2ptu7.js +78 -0
- package/dist/client/assets/xychartDiagram-FW5EYKEG-ht_S0LUA.js +7 -0
- package/dist/client/favicon.ico +0 -0
- package/dist/client/fonts/JetBrainsMonoNL-Bold.woff2 +0 -0
- package/dist/client/fonts/JetBrainsMonoNL-BoldItalic.woff2 +0 -0
- package/dist/client/fonts/JetBrainsMonoNL-Italic.woff2 +0 -0
- package/dist/client/fonts/JetBrainsMonoNL-Regular.woff2 +0 -0
- package/dist/client/fonts/JetBrainsMonoNLNerdFontMono-Bold.ttf +0 -0
- package/dist/client/fonts/JetBrainsMonoNLNerdFontMono-BoldItalic.ttf +0 -0
- package/dist/client/fonts/JetBrainsMonoNLNerdFontMono-Italic.ttf +0 -0
- package/dist/client/fonts/JetBrainsMonoNLNerdFontMono-Regular.ttf +0 -0
- package/dist/client/fonts/NotoSansMonoCJKsc-Bold-Subset.woff2 +0 -0
- package/dist/client/fonts/NotoSansMonoCJKsc-Subset.woff2 +0 -0
- package/dist/client/fonts/NotoSansSymbols2-Subset.woff2 +0 -0
- package/dist/client/fonts/SymbolsNerdFontMono-Regular.woff2 +0 -0
- package/dist/client/fonts/TermdockStarSymbol-Subset.woff2 +0 -0
- package/dist/client/index.html +255 -0
- package/dist/client/manifest.webmanifest +1 -0
- package/dist/client/maskable-icon-512x512.png +0 -0
- package/dist/client/pwa-192x192.png +0 -0
- package/dist/client/pwa-192x192.svg +5 -0
- package/dist/client/pwa-512x512.png +0 -0
- package/dist/client/pwa-512x512.svg +5 -0
- package/dist/client/pwa-64x64.png +0 -0
- package/dist/client/registerSW.js +1 -0
- package/dist/client/robots.txt +4 -0
- package/dist/client/shell-integration/termdock.bash +107 -0
- package/dist/client/shell-integration/termdock.fish +60 -0
- package/dist/client/shell-integration/termdock.zsh +164 -0
- package/dist/client/sw-notifications.js +42 -0
- package/dist/client/sw.js +1 -0
- package/dist/client/workbox-e4022e15.js +1 -0
- package/dist/server/certificateWatcher.js +111 -0
- package/dist/server/cli.js +2534 -0
- package/dist/server/config.js +54 -0
- package/dist/server/entry.js +571 -0
- package/dist/server/onboardingServer.js +54 -0
- package/dist/server/routes/auth.js +48 -0
- package/dist/server/routes/filesystem.js +3749 -0
- package/dist/server/routes/local.js +32 -0
- package/dist/server/routes/onboarding.js +134 -0
- package/dist/server/routes/terminal.js +4879 -0
- package/dist/server/utils/authProtection.js +338 -0
- package/dist/server/utils/caffeinate.js +124 -0
- package/dist/server/utils/changeAuditStore.js +521 -0
- package/dist/server/utils/cookieSecurity.js +13 -0
- package/dist/server/utils/csrfProtection.js +112 -0
- package/dist/server/utils/localAccess.js +211 -0
- package/dist/server/utils/pathValidator.js +272 -0
- package/dist/server/utils/requestSecurity.js +103 -0
- package/dist/server/utils/serverLogger.js +57 -0
- package/dist/server/utils/settings.js +157 -0
- package/dist/server/utils/tmuxFocus.js +45 -0
- package/dist/server/utils/tmuxProgramDetection.js +148 -0
- package/package.json +125 -0
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
import os from 'os';
|
|
2
|
+
import multicastDns from 'multicast-dns';
|
|
3
|
+
import { LOCAL_ACCESS } from '../config.js';
|
|
4
|
+
import { createAutoLocalAccessName, getLocalAccessSetting, getLocalAccessSettingAsync, normalizeLocalAccessName, setLocalAccessSettingAsync, } from './settings.js';
|
|
5
|
+
import { isAuthEnabled } from './authProtection.js';
|
|
6
|
+
const LOCALHOST_NAMES = new Set(['localhost', '127.0.0.1', '::1']);
|
|
7
|
+
function hostnameForName(name) {
|
|
8
|
+
return `${name}.${LOCAL_ACCESS.domainSuffix}`;
|
|
9
|
+
}
|
|
10
|
+
function normalizeQuestionName(name) {
|
|
11
|
+
return name.endsWith('.') ? name.slice(0, -1).toLowerCase() : name.toLowerCase();
|
|
12
|
+
}
|
|
13
|
+
export function isLoopbackHost(host) {
|
|
14
|
+
return LOCALHOST_NAMES.has(host) || host.startsWith('127.');
|
|
15
|
+
}
|
|
16
|
+
function interfaceLabel(name) {
|
|
17
|
+
if (name === 'en0')
|
|
18
|
+
return 'Wi-Fi';
|
|
19
|
+
if (name.startsWith('en'))
|
|
20
|
+
return `Network adapter ${name}`;
|
|
21
|
+
if (name.startsWith('bridge'))
|
|
22
|
+
return `Bridge ${name}`;
|
|
23
|
+
return name;
|
|
24
|
+
}
|
|
25
|
+
export function getLanIPv4Interfaces() {
|
|
26
|
+
const interfaces = os.networkInterfaces();
|
|
27
|
+
const addresses = [];
|
|
28
|
+
for (const [name, entries] of Object.entries(interfaces)) {
|
|
29
|
+
for (const entry of entries ?? []) {
|
|
30
|
+
if (entry.family !== 'IPv4' || entry.internal)
|
|
31
|
+
continue;
|
|
32
|
+
if (entry.address.startsWith('169.254.'))
|
|
33
|
+
continue;
|
|
34
|
+
addresses.push({ name, address: entry.address, family: 'IPv4', label: interfaceLabel(name) });
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
const seen = new Set();
|
|
38
|
+
return addresses.filter((entry) => {
|
|
39
|
+
if (seen.has(entry.address))
|
|
40
|
+
return false;
|
|
41
|
+
seen.add(entry.address);
|
|
42
|
+
return true;
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
export function getLanIPv4Addresses() {
|
|
46
|
+
return getLanIPv4Interfaces().map((entry) => entry.address);
|
|
47
|
+
}
|
|
48
|
+
function buildState(setting, options, status, reason) {
|
|
49
|
+
const hostname = hostnameForName(setting.name);
|
|
50
|
+
const interfaces = getLanIPv4Interfaces();
|
|
51
|
+
const lanAddresses = interfaces.map((entry) => entry.address);
|
|
52
|
+
const onboardingHost = lanAddresses[0] ?? null;
|
|
53
|
+
const fallbackHostname = onboardingHost ?? hostname;
|
|
54
|
+
return {
|
|
55
|
+
name: setting.name,
|
|
56
|
+
source: setting.source,
|
|
57
|
+
hostname,
|
|
58
|
+
fallbackHostname,
|
|
59
|
+
url: `${options.scheme}://${hostname}:${options.port}`,
|
|
60
|
+
fallbackUrl: `${options.scheme}://${fallbackHostname}:${options.port}`,
|
|
61
|
+
onboardingUrl: onboardingHost ? `http://${onboardingHost}:${options.onboardingPort ?? options.port}/onboarding` : null,
|
|
62
|
+
status,
|
|
63
|
+
reason,
|
|
64
|
+
httpsEnabled: options.scheme === 'https',
|
|
65
|
+
caAvailable: Boolean(options.caCertPath),
|
|
66
|
+
lanAddresses,
|
|
67
|
+
interfaces,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
async function wait(ms) {
|
|
71
|
+
await new Promise((resolve) => setTimeout(resolve, ms));
|
|
72
|
+
}
|
|
73
|
+
export class LocalAccessManager {
|
|
74
|
+
mdns = null;
|
|
75
|
+
state = null;
|
|
76
|
+
options = null;
|
|
77
|
+
getState() {
|
|
78
|
+
const options = this.options ?? { host: '0.0.0.0', port: 9834, scheme: 'http' };
|
|
79
|
+
if (this.state)
|
|
80
|
+
return this.state;
|
|
81
|
+
return buildState(getLocalAccessSetting(), options, 'disabled', 'Local access has not started yet.');
|
|
82
|
+
}
|
|
83
|
+
async start(options) {
|
|
84
|
+
this.options = options;
|
|
85
|
+
await this.stop();
|
|
86
|
+
let setting = await getLocalAccessSettingAsync();
|
|
87
|
+
const lanAddresses = getLanIPv4Addresses();
|
|
88
|
+
if (isLoopbackHost(options.host)) {
|
|
89
|
+
this.state = buildState(setting, options, 'loopback-only', 'Server is bound to loopback only.');
|
|
90
|
+
return this.state;
|
|
91
|
+
}
|
|
92
|
+
if (lanAddresses.length === 0) {
|
|
93
|
+
this.state = buildState(setting, options, 'disabled', 'No LAN IPv4 address was found.');
|
|
94
|
+
return this.state;
|
|
95
|
+
}
|
|
96
|
+
if (!isAuthEnabled()) {
|
|
97
|
+
this.state = buildState(setting, options, 'needs-auth', 'Set an access password before advertising on the LAN.');
|
|
98
|
+
return this.state;
|
|
99
|
+
}
|
|
100
|
+
for (let attempt = 0; attempt < 5; attempt += 1) {
|
|
101
|
+
const hostnames = [hostnameForName(setting.name)];
|
|
102
|
+
const conflict = await this.hasConflict(hostnames, lanAddresses);
|
|
103
|
+
if (!conflict)
|
|
104
|
+
break;
|
|
105
|
+
if (setting.source === 'manual') {
|
|
106
|
+
this.state = buildState(setting, options, 'conflict', `${hostnames.join(' / ')} is already in use on this LAN.`);
|
|
107
|
+
return this.state;
|
|
108
|
+
}
|
|
109
|
+
setting = { name: createAutoLocalAccessName(), source: 'auto' };
|
|
110
|
+
await setLocalAccessSettingAsync(setting);
|
|
111
|
+
}
|
|
112
|
+
this.mdns = multicastDns({ loopback: true, reuseAddr: true });
|
|
113
|
+
this.mdns.on('query', (packet) => this.handleQuery(packet));
|
|
114
|
+
this.state = buildState(setting, options, 'active', null);
|
|
115
|
+
return this.state;
|
|
116
|
+
}
|
|
117
|
+
async updateName(input, source) {
|
|
118
|
+
const normalized = normalizeLocalAccessName(input);
|
|
119
|
+
if (!normalized) {
|
|
120
|
+
const current = this.getState();
|
|
121
|
+
this.state = { ...current, status: 'error', reason: 'Invalid local access name.' };
|
|
122
|
+
return this.state;
|
|
123
|
+
}
|
|
124
|
+
await setLocalAccessSettingAsync({ name: normalized, source });
|
|
125
|
+
if (!this.options) {
|
|
126
|
+
this.state = buildState({ name: normalized, source }, { host: '0.0.0.0', port: 9834, scheme: 'http' }, 'disabled', 'Local access has not started yet.');
|
|
127
|
+
return this.state;
|
|
128
|
+
}
|
|
129
|
+
return this.start(this.options);
|
|
130
|
+
}
|
|
131
|
+
async resetAutoName() {
|
|
132
|
+
await setLocalAccessSettingAsync({ name: createAutoLocalAccessName(), source: 'auto' });
|
|
133
|
+
if (!this.options) {
|
|
134
|
+
const setting = await getLocalAccessSettingAsync();
|
|
135
|
+
this.state = buildState(setting, { host: '0.0.0.0', port: 9834, scheme: 'http' }, 'disabled', 'Local access has not started yet.');
|
|
136
|
+
return this.state;
|
|
137
|
+
}
|
|
138
|
+
return this.start(this.options);
|
|
139
|
+
}
|
|
140
|
+
stop() {
|
|
141
|
+
return new Promise((resolve) => {
|
|
142
|
+
const mdns = this.mdns;
|
|
143
|
+
this.mdns = null;
|
|
144
|
+
if (!mdns) {
|
|
145
|
+
resolve();
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
try {
|
|
149
|
+
mdns.destroy(resolve);
|
|
150
|
+
}
|
|
151
|
+
catch {
|
|
152
|
+
resolve();
|
|
153
|
+
}
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
handleQuery(packet) {
|
|
157
|
+
const state = this.state;
|
|
158
|
+
if (!state || state.status !== 'active' || !this.mdns)
|
|
159
|
+
return;
|
|
160
|
+
const hostnames = new Set([state.hostname.toLowerCase()]);
|
|
161
|
+
const wantsAnswer = (packet.questions ?? []).some((question) => {
|
|
162
|
+
const qName = normalizeQuestionName(question.name);
|
|
163
|
+
return hostnames.has(qName) && (question.type === 'A' || question.type === 'ANY');
|
|
164
|
+
});
|
|
165
|
+
if (!wantsAnswer)
|
|
166
|
+
return;
|
|
167
|
+
const answers = Array.from(hostnames).flatMap((hostname) => state.lanAddresses.map((address) => ({
|
|
168
|
+
name: hostname,
|
|
169
|
+
type: 'A',
|
|
170
|
+
ttl: LOCAL_ACCESS.mdnsTtlSeconds,
|
|
171
|
+
data: address,
|
|
172
|
+
})));
|
|
173
|
+
try {
|
|
174
|
+
this.mdns.respond({ answers });
|
|
175
|
+
}
|
|
176
|
+
catch (error) {
|
|
177
|
+
console.warn('[local-access] failed to respond to mDNS query:', error);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
async hasConflict(hostnames, localAddresses) {
|
|
181
|
+
const mdns = multicastDns({ loopback: false, reuseAddr: true });
|
|
182
|
+
const localSet = new Set(localAddresses);
|
|
183
|
+
let conflict = false;
|
|
184
|
+
const targets = new Set(hostnames.map((hostname) => hostname.toLowerCase()));
|
|
185
|
+
mdns.on('response', (packet) => {
|
|
186
|
+
for (const answer of packet.answers ?? []) {
|
|
187
|
+
const name = normalizeQuestionName(answer.name);
|
|
188
|
+
if (!targets.has(name) || answer.type !== 'A')
|
|
189
|
+
continue;
|
|
190
|
+
if (typeof answer.data === 'string' && !localSet.has(answer.data)) {
|
|
191
|
+
conflict = true;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
});
|
|
195
|
+
try {
|
|
196
|
+
mdns.query(hostnames.map((name) => ({ name, type: 'A' })));
|
|
197
|
+
await wait(650);
|
|
198
|
+
}
|
|
199
|
+
catch {
|
|
200
|
+
return false;
|
|
201
|
+
}
|
|
202
|
+
finally {
|
|
203
|
+
try {
|
|
204
|
+
mdns.destroy();
|
|
205
|
+
}
|
|
206
|
+
catch { /* ignore */ }
|
|
207
|
+
}
|
|
208
|
+
return conflict;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
export const localAccessManager = new LocalAccessManager();
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
import path from 'path';
|
|
2
|
+
import fs from 'fs';
|
|
3
|
+
/**
|
|
4
|
+
* 安全路径验证器
|
|
5
|
+
* 防止路径遍历攻击,限制访问范围
|
|
6
|
+
*/
|
|
7
|
+
export class PathValidator {
|
|
8
|
+
allowedPaths;
|
|
9
|
+
defaultAllowedPaths;
|
|
10
|
+
constructor() {
|
|
11
|
+
// 默认允许的路径
|
|
12
|
+
this.defaultAllowedPaths = [
|
|
13
|
+
// 用户主目录
|
|
14
|
+
process.env.HOME || process.env.USERPROFILE || '/home',
|
|
15
|
+
// 临时目录
|
|
16
|
+
'/tmp',
|
|
17
|
+
// 当前工作目录
|
|
18
|
+
process.cwd(),
|
|
19
|
+
].filter(Boolean).map(p => this.normalizeAllowedPath(p));
|
|
20
|
+
this.allowedPaths = new Set(this.defaultAllowedPaths);
|
|
21
|
+
// 从环境变量读取额外的允许路径(平台分隔符:Windows 为 ";",POSIX 为 ":",
|
|
22
|
+
// 不能用 ":" 硬拆,否则 "C:\foo" 这类盘符路径会被拆碎)
|
|
23
|
+
const extraPaths = process.env.ALLOWED_PATHS?.split(path.delimiter).filter(Boolean) || [];
|
|
24
|
+
extraPaths.forEach(p => {
|
|
25
|
+
try {
|
|
26
|
+
this.allowedPaths.add(this.normalizeAllowedPath(p));
|
|
27
|
+
}
|
|
28
|
+
catch (error) {
|
|
29
|
+
console.warn(`Invalid allowed path: ${p}`, error);
|
|
30
|
+
}
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* 验证和规范化路径(仅允许目录)
|
|
35
|
+
* @param requestedPath 请求的路径
|
|
36
|
+
* @returns 安全、规范化的路径
|
|
37
|
+
* @throws Error 如果路径不安全
|
|
38
|
+
*/
|
|
39
|
+
validate(requestedPath) {
|
|
40
|
+
if (!requestedPath || typeof requestedPath !== 'string') {
|
|
41
|
+
throw new Error('Path must be a non-empty string');
|
|
42
|
+
}
|
|
43
|
+
// 解析路径,去除相对路径
|
|
44
|
+
let resolvedPath;
|
|
45
|
+
try {
|
|
46
|
+
resolvedPath = path.resolve(requestedPath);
|
|
47
|
+
}
|
|
48
|
+
catch (error) {
|
|
49
|
+
throw new Error(`Invalid path format: ${requestedPath}`);
|
|
50
|
+
}
|
|
51
|
+
// 检查路径是否存在
|
|
52
|
+
if (!fs.existsSync(resolvedPath)) {
|
|
53
|
+
throw new Error(`Directory does not exist: ${resolvedPath}`);
|
|
54
|
+
}
|
|
55
|
+
const realPath = fs.realpathSync(resolvedPath);
|
|
56
|
+
// 检查是否为目录
|
|
57
|
+
try {
|
|
58
|
+
const stat = fs.statSync(realPath);
|
|
59
|
+
if (!stat.isDirectory()) {
|
|
60
|
+
throw new Error(`Path is not a directory: ${realPath}`);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
catch (error) {
|
|
64
|
+
if (error instanceof Error && error.message.includes('not a directory')) {
|
|
65
|
+
throw error;
|
|
66
|
+
}
|
|
67
|
+
throw new Error(`Cannot access directory: ${realPath}`);
|
|
68
|
+
}
|
|
69
|
+
// 检查路径是否在允许的范围内
|
|
70
|
+
if (!this.isInAllowedPaths(realPath)) {
|
|
71
|
+
console.warn(`Access denied: ${realPath} is not in allowed paths`);
|
|
72
|
+
throw new Error('Access denied: directory not allowed');
|
|
73
|
+
}
|
|
74
|
+
// 额外的安全检查:避免系统关键目录
|
|
75
|
+
this.checkDangerousPatterns(realPath);
|
|
76
|
+
return realPath;
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* 异步验证和规范化路径(仅允许目录)。用于 HTTP 热路径,避免同步 fs I/O 阻塞 event loop。
|
|
80
|
+
*/
|
|
81
|
+
async validateAsync(requestedPath) {
|
|
82
|
+
if (!requestedPath || typeof requestedPath !== 'string') {
|
|
83
|
+
throw new Error('Path must be a non-empty string');
|
|
84
|
+
}
|
|
85
|
+
let resolvedPath;
|
|
86
|
+
try {
|
|
87
|
+
resolvedPath = path.resolve(requestedPath);
|
|
88
|
+
}
|
|
89
|
+
catch (error) {
|
|
90
|
+
throw new Error(`Invalid path format: ${requestedPath}`);
|
|
91
|
+
}
|
|
92
|
+
let realPath;
|
|
93
|
+
try {
|
|
94
|
+
realPath = await fs.promises.realpath(resolvedPath);
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
throw new Error(`Directory does not exist: ${resolvedPath}`);
|
|
98
|
+
}
|
|
99
|
+
try {
|
|
100
|
+
const stat = await fs.promises.stat(realPath);
|
|
101
|
+
if (!stat.isDirectory()) {
|
|
102
|
+
throw new Error(`Path is not a directory: ${realPath}`);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
catch (error) {
|
|
106
|
+
if (error instanceof Error && error.message.includes('not a directory')) {
|
|
107
|
+
throw error;
|
|
108
|
+
}
|
|
109
|
+
throw new Error(`Cannot access directory: ${realPath}`);
|
|
110
|
+
}
|
|
111
|
+
if (!this.isInAllowedPaths(realPath)) {
|
|
112
|
+
console.warn(`Access denied: ${realPath} is not in allowed paths`);
|
|
113
|
+
throw new Error('Access denied: directory not allowed');
|
|
114
|
+
}
|
|
115
|
+
this.checkDangerousPatterns(realPath);
|
|
116
|
+
return realPath;
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* 验证和规范化路径(允许文件和目录)
|
|
120
|
+
* @param requestedPath 请求的路径
|
|
121
|
+
* @returns 安全、规范化的路径
|
|
122
|
+
* @throws Error 如果路径不安全
|
|
123
|
+
*/
|
|
124
|
+
validatePath(requestedPath) {
|
|
125
|
+
if (!requestedPath || typeof requestedPath !== 'string') {
|
|
126
|
+
throw new Error('Path must be a non-empty string');
|
|
127
|
+
}
|
|
128
|
+
let resolvedPath;
|
|
129
|
+
try {
|
|
130
|
+
resolvedPath = path.resolve(requestedPath);
|
|
131
|
+
}
|
|
132
|
+
catch (error) {
|
|
133
|
+
throw new Error(`Invalid path format: ${requestedPath}`);
|
|
134
|
+
}
|
|
135
|
+
if (!fs.existsSync(resolvedPath)) {
|
|
136
|
+
throw new Error(`Path does not exist: ${resolvedPath}`);
|
|
137
|
+
}
|
|
138
|
+
const realPath = fs.realpathSync(resolvedPath);
|
|
139
|
+
// 对最终真实路径做白名单校验,避免允许目录内的 symlink 指向敏感位置。
|
|
140
|
+
try {
|
|
141
|
+
if (!this.isInAllowedPaths(realPath)) {
|
|
142
|
+
console.warn(`Access denied: ${realPath} is not in allowed paths`);
|
|
143
|
+
throw new Error('Access denied: path not allowed');
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
catch (error) {
|
|
147
|
+
if (error instanceof Error && error.message.includes('not allowed')) {
|
|
148
|
+
throw error;
|
|
149
|
+
}
|
|
150
|
+
throw new Error(`Cannot access path: ${realPath}`);
|
|
151
|
+
}
|
|
152
|
+
this.checkDangerousPatterns(realPath);
|
|
153
|
+
return realPath;
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* 异步验证和规范化路径(允许文件和目录)。用于 HTTP 热路径,避免同步 fs I/O 阻塞 event loop。
|
|
157
|
+
*/
|
|
158
|
+
async validatePathAsync(requestedPath) {
|
|
159
|
+
if (!requestedPath || typeof requestedPath !== 'string') {
|
|
160
|
+
throw new Error('Path must be a non-empty string');
|
|
161
|
+
}
|
|
162
|
+
let resolvedPath;
|
|
163
|
+
try {
|
|
164
|
+
resolvedPath = path.resolve(requestedPath);
|
|
165
|
+
}
|
|
166
|
+
catch (error) {
|
|
167
|
+
throw new Error(`Invalid path format: ${requestedPath}`);
|
|
168
|
+
}
|
|
169
|
+
let realPath;
|
|
170
|
+
try {
|
|
171
|
+
realPath = await fs.promises.realpath(resolvedPath);
|
|
172
|
+
}
|
|
173
|
+
catch {
|
|
174
|
+
throw new Error(`Path does not exist: ${resolvedPath}`);
|
|
175
|
+
}
|
|
176
|
+
try {
|
|
177
|
+
if (!this.isInAllowedPaths(realPath)) {
|
|
178
|
+
console.warn(`Access denied: ${realPath} is not in allowed paths`);
|
|
179
|
+
throw new Error('Access denied: path not allowed');
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
catch (error) {
|
|
183
|
+
if (error instanceof Error && error.message.includes('not allowed')) {
|
|
184
|
+
throw error;
|
|
185
|
+
}
|
|
186
|
+
throw new Error(`Cannot access path: ${realPath}`);
|
|
187
|
+
}
|
|
188
|
+
this.checkDangerousPatterns(realPath);
|
|
189
|
+
return realPath;
|
|
190
|
+
}
|
|
191
|
+
isInAllowedPaths(resolvedPath) {
|
|
192
|
+
for (const allowedPath of this.allowedPaths) {
|
|
193
|
+
if (this.isSameOrChildPath(allowedPath, resolvedPath)) {
|
|
194
|
+
return true;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
return false;
|
|
198
|
+
}
|
|
199
|
+
isSameOrChildPath(parentPath, childPath) {
|
|
200
|
+
const relativePath = path.relative(parentPath, childPath);
|
|
201
|
+
return relativePath === '' || (!relativePath.startsWith('..') &&
|
|
202
|
+
!path.isAbsolute(relativePath));
|
|
203
|
+
}
|
|
204
|
+
normalizeAllowedPath(inputPath) {
|
|
205
|
+
const resolved = path.resolve(inputPath);
|
|
206
|
+
try {
|
|
207
|
+
return fs.realpathSync(resolved);
|
|
208
|
+
}
|
|
209
|
+
catch {
|
|
210
|
+
return resolved;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
checkDangerousPatterns(resolvedPath) {
|
|
214
|
+
const dangerousPatterns = [
|
|
215
|
+
'/bin', '/sbin', '/usr/bin', '/usr/sbin',
|
|
216
|
+
'/etc', '/var', '/lib', '/proc', '/sys',
|
|
217
|
+
'/boot', '/root', '/dev'
|
|
218
|
+
];
|
|
219
|
+
for (const pattern of dangerousPatterns) {
|
|
220
|
+
if (this.isSameOrChildPath(pattern, resolvedPath)) {
|
|
221
|
+
console.warn(`Access denied: ${resolvedPath} is a system directory`);
|
|
222
|
+
throw new Error('Access denied: system directories are restricted');
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
/**
|
|
227
|
+
* 添加允许的路径
|
|
228
|
+
*/
|
|
229
|
+
addAllowedPath(pathToAdd) {
|
|
230
|
+
const resolved = this.normalizeAllowedPath(pathToAdd);
|
|
231
|
+
this.allowedPaths.add(resolved);
|
|
232
|
+
}
|
|
233
|
+
/**
|
|
234
|
+
* 将终端会话的 cwd 动态加入白名单(静默失败)。
|
|
235
|
+
* 终端里的 shell 本身不受路径限制,用户 cd 到哪,侧边栏/文件 API 就应能
|
|
236
|
+
* 浏览哪(默认白名单只有 HOME/cwd,Windows 上切到其它盘符会全部拒绝)。
|
|
237
|
+
* 仅在路径真实存在且是目录时放行。
|
|
238
|
+
*/
|
|
239
|
+
async allowSessionCwd(cwdPath) {
|
|
240
|
+
if (!cwdPath || typeof cwdPath !== 'string')
|
|
241
|
+
return;
|
|
242
|
+
try {
|
|
243
|
+
const stat = await fs.promises.stat(cwdPath);
|
|
244
|
+
if (stat.isDirectory()) {
|
|
245
|
+
this.addAllowedPath(cwdPath);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
catch {
|
|
249
|
+
// 路径不存在或不可读——不放行
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
/**
|
|
253
|
+
* 获取所有允许的路径
|
|
254
|
+
*/
|
|
255
|
+
getAllowedPaths() {
|
|
256
|
+
return Array.from(this.allowedPaths);
|
|
257
|
+
}
|
|
258
|
+
/**
|
|
259
|
+
* 检查路径是否在允许范围内(不抛出异常)
|
|
260
|
+
*/
|
|
261
|
+
isPathAllowed(requestedPath) {
|
|
262
|
+
try {
|
|
263
|
+
this.validate(requestedPath);
|
|
264
|
+
return true;
|
|
265
|
+
}
|
|
266
|
+
catch {
|
|
267
|
+
return false;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
// 单例实例
|
|
272
|
+
export const pathValidator = new PathValidator();
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { getLanIPv4Addresses, localAccessManager } from './localAccess.js';
|
|
2
|
+
const LOOPBACK_HOSTS = new Set(['localhost', '127.0.0.1', '::1']);
|
|
3
|
+
function stripPort(hostHeader) {
|
|
4
|
+
const raw = (hostHeader ?? '').trim().toLowerCase();
|
|
5
|
+
if (!raw)
|
|
6
|
+
return '';
|
|
7
|
+
if (raw.startsWith('[')) {
|
|
8
|
+
const end = raw.indexOf(']');
|
|
9
|
+
return end >= 0 ? raw.slice(1, end) : raw;
|
|
10
|
+
}
|
|
11
|
+
return raw.split(':')[0] ?? raw;
|
|
12
|
+
}
|
|
13
|
+
// 额外主机白名单:TERMDOCK_ALLOWED_HOSTS=逗号分隔的主机名/IP(带端口也行,
|
|
14
|
+
// 端口会被剥掉)。用于公网域名 / 反向代理 / 内网穿透等默认白名单覆盖不到的入口,
|
|
15
|
+
// 例如 TERMDOCK_ALLOWED_HOSTS=term.example.com,10.8.0.2
|
|
16
|
+
let cachedEnvHosts = null;
|
|
17
|
+
function getEnvExtraHosts() {
|
|
18
|
+
const raw = process.env.TERMDOCK_ALLOWED_HOSTS ?? '';
|
|
19
|
+
if (!cachedEnvHosts || cachedEnvHosts.raw !== raw) {
|
|
20
|
+
cachedEnvHosts = {
|
|
21
|
+
raw,
|
|
22
|
+
hosts: raw
|
|
23
|
+
.split(',')
|
|
24
|
+
.map((entry) => stripPort(entry.trim().toLowerCase()))
|
|
25
|
+
.filter((entry) => entry.length > 0),
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
return cachedEnvHosts.hosts;
|
|
29
|
+
}
|
|
30
|
+
export function getAllowedHosts() {
|
|
31
|
+
const hosts = new Set(LOOPBACK_HOSTS);
|
|
32
|
+
for (const address of getLanIPv4Addresses()) {
|
|
33
|
+
hosts.add(address.toLowerCase());
|
|
34
|
+
}
|
|
35
|
+
const localAccess = localAccessManager.getState();
|
|
36
|
+
if (localAccess.hostname) {
|
|
37
|
+
hosts.add(localAccess.hostname.toLowerCase());
|
|
38
|
+
}
|
|
39
|
+
for (const host of getEnvExtraHosts()) {
|
|
40
|
+
hosts.add(host);
|
|
41
|
+
}
|
|
42
|
+
return hosts;
|
|
43
|
+
}
|
|
44
|
+
export function isAllowedHost(hostHeader) {
|
|
45
|
+
const host = stripPort(hostHeader);
|
|
46
|
+
if (!host)
|
|
47
|
+
return false;
|
|
48
|
+
return getAllowedHosts().has(host);
|
|
49
|
+
}
|
|
50
|
+
export function validateHostMiddleware(req, res, next) {
|
|
51
|
+
if (!isAllowedHost(req.headers.host)) {
|
|
52
|
+
res.status(421).json({ error: 'Host is not allowed', code: 'HOST_NOT_ALLOWED' });
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
next();
|
|
56
|
+
}
|
|
57
|
+
export function isAllowedOrigin(originHeader, _hostHeader) {
|
|
58
|
+
// 与下方 isUpgradeOriginAllowed 同理:dev 模式下 Vite proxy (9833 → 9835) 用
|
|
59
|
+
// changeOrigin: true 把 Host 改写成 `localhost:9835`,而浏览器 Origin 仍是
|
|
60
|
+
// `http://192.168.x.x:9833`,强制 Origin === Host 会让 LAN 访问的所有 POST
|
|
61
|
+
// (包括登录)全部 403。Origin 头由浏览器控制、无法被网页攻击者伪造,所以只
|
|
62
|
+
// 要求 origin host 落在 allowedHosts 白名单内即可,防 CSRF 的强度不变。
|
|
63
|
+
// 注意:反向代理场景下 Host 头可能是内部地址,不用它来 gate Origin 检查,
|
|
64
|
+
// 否则 TERMDOCK_ALLOWED_HOSTS 只包含公网域名时会误杀正常登录请求。
|
|
65
|
+
if (!originHeader)
|
|
66
|
+
return true;
|
|
67
|
+
try {
|
|
68
|
+
const originHost = stripPort(new URL(originHeader).host);
|
|
69
|
+
if (!originHost)
|
|
70
|
+
return false;
|
|
71
|
+
return getAllowedHosts().has(originHost);
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
return false;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
export function validateOriginMiddleware(req, res, next) {
|
|
78
|
+
if (!isAllowedOrigin(req.headers.origin, req.headers.host)) {
|
|
79
|
+
res.status(403).json({ error: 'Origin is not allowed', code: 'ORIGIN_NOT_ALLOWED' });
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
next();
|
|
83
|
+
}
|
|
84
|
+
export function isUpgradeOriginAllowed(originHeader, _hostHeader) {
|
|
85
|
+
// WS 升级请求:Origin 头是浏览器实际发出的(不可伪造),而 Host 头可能被前置代理改写。
|
|
86
|
+
// 在 dev 模式下 Vite proxy (9833 → 9835) 用 changeOrigin: true 会把 Host 改写成
|
|
87
|
+
// `localhost:9835`,但 Origin 仍是 `http://192.168.x.x:9833`。这种 mismatch
|
|
88
|
+
// 会让正常 LAN 访问全部 403、WS 退化为持续 reconnecting。
|
|
89
|
+
// 这里只要求 origin host 落在 allowedHosts 里(loopback + LAN IPv4 + mDNS 域名),
|
|
90
|
+
// 不强制等于 request.headers.host。Origin 校验保持原强度(必须能解析出 host、
|
|
91
|
+
// 且 host 在白名单内),所以攻击者把 Origin 改成任意地址仍然过不了。
|
|
92
|
+
if (!originHeader)
|
|
93
|
+
return true;
|
|
94
|
+
try {
|
|
95
|
+
const originHost = stripPort(new URL(originHeader).host);
|
|
96
|
+
if (!originHost)
|
|
97
|
+
return false;
|
|
98
|
+
return getAllowedHosts().has(originHost);
|
|
99
|
+
}
|
|
100
|
+
catch {
|
|
101
|
+
return false;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import os from 'os';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
const TERMDOCK_DIR = path.join(os.homedir(), '.termdock');
|
|
5
|
+
const DEFAULT_MAX_BYTES = 10 * 1024 * 1024;
|
|
6
|
+
const writeChains = new Map();
|
|
7
|
+
const rotateInProgress = new Set();
|
|
8
|
+
async function rotateIfNeeded(filePath, maxBytes) {
|
|
9
|
+
if (rotateInProgress.has(filePath))
|
|
10
|
+
return;
|
|
11
|
+
rotateInProgress.add(filePath);
|
|
12
|
+
try {
|
|
13
|
+
const stat = await fs.promises.stat(filePath).catch(() => null);
|
|
14
|
+
if (!stat || stat.size < maxBytes)
|
|
15
|
+
return;
|
|
16
|
+
const rotatedPath = `${filePath}.1`;
|
|
17
|
+
await fs.promises.rm(rotatedPath, { force: true }).catch(() => undefined);
|
|
18
|
+
await fs.promises.rename(filePath, rotatedPath).catch(() => undefined);
|
|
19
|
+
}
|
|
20
|
+
finally {
|
|
21
|
+
rotateInProgress.delete(filePath);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
function enqueueWrite(filePath, line, maxBytes) {
|
|
25
|
+
const previous = writeChains.get(filePath) ?? Promise.resolve();
|
|
26
|
+
const next = previous
|
|
27
|
+
.catch(() => undefined)
|
|
28
|
+
.then(async () => {
|
|
29
|
+
await fs.promises.mkdir(TERMDOCK_DIR, { recursive: true });
|
|
30
|
+
await rotateIfNeeded(filePath, maxBytes);
|
|
31
|
+
await fs.promises.appendFile(filePath, line, 'utf8');
|
|
32
|
+
})
|
|
33
|
+
.catch(() => undefined);
|
|
34
|
+
writeChains.set(filePath, next);
|
|
35
|
+
}
|
|
36
|
+
export function getTermdockLogPath(name) {
|
|
37
|
+
return path.join(TERMDOCK_DIR, name);
|
|
38
|
+
}
|
|
39
|
+
export function writeJsonLog(name, entry, maxBytes = DEFAULT_MAX_BYTES) {
|
|
40
|
+
const filePath = getTermdockLogPath(name);
|
|
41
|
+
const line = JSON.stringify({
|
|
42
|
+
ts: new Date().toISOString(),
|
|
43
|
+
pid: process.pid,
|
|
44
|
+
...entry,
|
|
45
|
+
}) + '\n';
|
|
46
|
+
enqueueWrite(filePath, line, maxBytes);
|
|
47
|
+
}
|
|
48
|
+
export function writeTextLog(name, line, maxBytes = DEFAULT_MAX_BYTES) {
|
|
49
|
+
const filePath = getTermdockLogPath(name);
|
|
50
|
+
enqueueWrite(filePath, `${line.replace(/\n$/, '')}\n`, maxBytes);
|
|
51
|
+
}
|
|
52
|
+
export function writeErrorLog(entry) {
|
|
53
|
+
writeJsonLog('errors.log', entry);
|
|
54
|
+
}
|
|
55
|
+
export function writeDiffTraceLog(entry) {
|
|
56
|
+
writeJsonLog('diff-trace.log', entry);
|
|
57
|
+
}
|