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.
Files changed (227) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +385 -0
  3. package/dist/client/apple-splash-default-1170x2532.png +0 -0
  4. package/dist/client/apple-splash-landscape-1136x640.png +0 -0
  5. package/dist/client/apple-splash-landscape-1334x750.png +0 -0
  6. package/dist/client/apple-splash-landscape-1792x828.png +0 -0
  7. package/dist/client/apple-splash-landscape-2048x1536.png +0 -0
  8. package/dist/client/apple-splash-landscape-2160x1620.png +0 -0
  9. package/dist/client/apple-splash-landscape-2208x1242.png +0 -0
  10. package/dist/client/apple-splash-landscape-2224x1668.png +0 -0
  11. package/dist/client/apple-splash-landscape-2266x1488.png +0 -0
  12. package/dist/client/apple-splash-landscape-2360x1640.png +0 -0
  13. package/dist/client/apple-splash-landscape-2388x1668.png +0 -0
  14. package/dist/client/apple-splash-landscape-2436x1125.png +0 -0
  15. package/dist/client/apple-splash-landscape-2532x1170.png +0 -0
  16. package/dist/client/apple-splash-landscape-2556x1179.png +0 -0
  17. package/dist/client/apple-splash-landscape-2622x1206.png +0 -0
  18. package/dist/client/apple-splash-landscape-2688x1242.png +0 -0
  19. package/dist/client/apple-splash-landscape-2732x2048.png +0 -0
  20. package/dist/client/apple-splash-landscape-2778x1284.png +0 -0
  21. package/dist/client/apple-splash-landscape-2796x1290.png +0 -0
  22. package/dist/client/apple-splash-landscape-2868x1320.png +0 -0
  23. package/dist/client/apple-splash-portrait-1125x2436.png +0 -0
  24. package/dist/client/apple-splash-portrait-1170x2532.png +0 -0
  25. package/dist/client/apple-splash-portrait-1179x2556.png +0 -0
  26. package/dist/client/apple-splash-portrait-1206x2622.png +0 -0
  27. package/dist/client/apple-splash-portrait-1242x2208.png +0 -0
  28. package/dist/client/apple-splash-portrait-1242x2688.png +0 -0
  29. package/dist/client/apple-splash-portrait-1284x2778.png +0 -0
  30. package/dist/client/apple-splash-portrait-1290x2796.png +0 -0
  31. package/dist/client/apple-splash-portrait-1320x2868.png +0 -0
  32. package/dist/client/apple-splash-portrait-1488x2266.png +0 -0
  33. package/dist/client/apple-splash-portrait-1536x2048.png +0 -0
  34. package/dist/client/apple-splash-portrait-1620x2160.png +0 -0
  35. package/dist/client/apple-splash-portrait-1640x2360.png +0 -0
  36. package/dist/client/apple-splash-portrait-1668x2224.png +0 -0
  37. package/dist/client/apple-splash-portrait-1668x2388.png +0 -0
  38. package/dist/client/apple-splash-portrait-2048x2732.png +0 -0
  39. package/dist/client/apple-splash-portrait-640x1136.png +0 -0
  40. package/dist/client/apple-splash-portrait-750x1334.png +0 -0
  41. package/dist/client/apple-splash-portrait-828x1792.png +0 -0
  42. package/dist/client/apple-touch-icon-180x180.png +0 -0
  43. package/dist/client/assets/KaTeX_AMS-Regular-BQhdFMY1.woff2 +0 -0
  44. package/dist/client/assets/KaTeX_AMS-Regular-DMm9YOAa.woff +0 -0
  45. package/dist/client/assets/KaTeX_AMS-Regular-DRggAlZN.ttf +0 -0
  46. package/dist/client/assets/KaTeX_Caligraphic-Bold-ATXxdsX0.ttf +0 -0
  47. package/dist/client/assets/KaTeX_Caligraphic-Bold-BEiXGLvX.woff +0 -0
  48. package/dist/client/assets/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2 +0 -0
  49. package/dist/client/assets/KaTeX_Caligraphic-Regular-CTRA-rTL.woff +0 -0
  50. package/dist/client/assets/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2 +0 -0
  51. package/dist/client/assets/KaTeX_Caligraphic-Regular-wX97UBjC.ttf +0 -0
  52. package/dist/client/assets/KaTeX_Fraktur-Bold-BdnERNNW.ttf +0 -0
  53. package/dist/client/assets/KaTeX_Fraktur-Bold-BsDP51OF.woff +0 -0
  54. package/dist/client/assets/KaTeX_Fraktur-Bold-CL6g_b3V.woff2 +0 -0
  55. package/dist/client/assets/KaTeX_Fraktur-Regular-CB_wures.ttf +0 -0
  56. package/dist/client/assets/KaTeX_Fraktur-Regular-CTYiF6lA.woff2 +0 -0
  57. package/dist/client/assets/KaTeX_Fraktur-Regular-Dxdc4cR9.woff +0 -0
  58. package/dist/client/assets/KaTeX_Main-Bold-Cx986IdX.woff2 +0 -0
  59. package/dist/client/assets/KaTeX_Main-Bold-Jm3AIy58.woff +0 -0
  60. package/dist/client/assets/KaTeX_Main-Bold-waoOVXN0.ttf +0 -0
  61. package/dist/client/assets/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2 +0 -0
  62. package/dist/client/assets/KaTeX_Main-BoldItalic-DzxPMmG6.ttf +0 -0
  63. package/dist/client/assets/KaTeX_Main-BoldItalic-SpSLRI95.woff +0 -0
  64. package/dist/client/assets/KaTeX_Main-Italic-3WenGoN9.ttf +0 -0
  65. package/dist/client/assets/KaTeX_Main-Italic-BMLOBm91.woff +0 -0
  66. package/dist/client/assets/KaTeX_Main-Italic-NWA7e6Wa.woff2 +0 -0
  67. package/dist/client/assets/KaTeX_Main-Regular-B22Nviop.woff2 +0 -0
  68. package/dist/client/assets/KaTeX_Main-Regular-Dr94JaBh.woff +0 -0
  69. package/dist/client/assets/KaTeX_Main-Regular-ypZvNtVU.ttf +0 -0
  70. package/dist/client/assets/KaTeX_Math-BoldItalic-B3XSjfu4.ttf +0 -0
  71. package/dist/client/assets/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2 +0 -0
  72. package/dist/client/assets/KaTeX_Math-BoldItalic-iY-2wyZ7.woff +0 -0
  73. package/dist/client/assets/KaTeX_Math-Italic-DA0__PXp.woff +0 -0
  74. package/dist/client/assets/KaTeX_Math-Italic-flOr_0UB.ttf +0 -0
  75. package/dist/client/assets/KaTeX_Math-Italic-t53AETM-.woff2 +0 -0
  76. package/dist/client/assets/KaTeX_SansSerif-Bold-CFMepnvq.ttf +0 -0
  77. package/dist/client/assets/KaTeX_SansSerif-Bold-D1sUS0GD.woff2 +0 -0
  78. package/dist/client/assets/KaTeX_SansSerif-Bold-DbIhKOiC.woff +0 -0
  79. package/dist/client/assets/KaTeX_SansSerif-Italic-C3H0VqGB.woff2 +0 -0
  80. package/dist/client/assets/KaTeX_SansSerif-Italic-DN2j7dab.woff +0 -0
  81. package/dist/client/assets/KaTeX_SansSerif-Italic-YYjJ1zSn.ttf +0 -0
  82. package/dist/client/assets/KaTeX_SansSerif-Regular-BNo7hRIc.ttf +0 -0
  83. package/dist/client/assets/KaTeX_SansSerif-Regular-CS6fqUqJ.woff +0 -0
  84. package/dist/client/assets/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2 +0 -0
  85. package/dist/client/assets/KaTeX_Script-Regular-C5JkGWo-.ttf +0 -0
  86. package/dist/client/assets/KaTeX_Script-Regular-D3wIWfF6.woff2 +0 -0
  87. package/dist/client/assets/KaTeX_Script-Regular-D5yQViql.woff +0 -0
  88. package/dist/client/assets/KaTeX_Size1-Regular-C195tn64.woff +0 -0
  89. package/dist/client/assets/KaTeX_Size1-Regular-Dbsnue_I.ttf +0 -0
  90. package/dist/client/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 +0 -0
  91. package/dist/client/assets/KaTeX_Size2-Regular-B7gKUWhC.ttf +0 -0
  92. package/dist/client/assets/KaTeX_Size2-Regular-Dy4dx90m.woff2 +0 -0
  93. package/dist/client/assets/KaTeX_Size2-Regular-oD1tc_U0.woff +0 -0
  94. package/dist/client/assets/KaTeX_Size3-Regular-CTq5MqoE.woff +0 -0
  95. package/dist/client/assets/KaTeX_Size3-Regular-DgpXs0kz.ttf +0 -0
  96. package/dist/client/assets/KaTeX_Size4-Regular-BF-4gkZK.woff +0 -0
  97. package/dist/client/assets/KaTeX_Size4-Regular-DWFBv043.ttf +0 -0
  98. package/dist/client/assets/KaTeX_Size4-Regular-Dl5lxZxV.woff2 +0 -0
  99. package/dist/client/assets/KaTeX_Typewriter-Regular-C0xS9mPB.woff +0 -0
  100. package/dist/client/assets/KaTeX_Typewriter-Regular-CO6r4hn1.woff2 +0 -0
  101. package/dist/client/assets/KaTeX_Typewriter-Regular-D3Ib7_Hf.ttf +0 -0
  102. package/dist/client/assets/abnfDiagram-VRR7QNED-D0NxeSHA.js +1 -0
  103. package/dist/client/assets/arc-B7GoKc6u.js +1 -0
  104. package/dist/client/assets/architectureDiagram-ZJ3FMSHR-BQPW-wUx.js +36 -0
  105. package/dist/client/assets/blockDiagram-677ZJIJ3-CX_hbcgG.js +132 -0
  106. package/dist/client/assets/c4Diagram-LMCZKHZV-BwOMQA4x.js +10 -0
  107. package/dist/client/assets/channel-C2gUFcyU.js +1 -0
  108. package/dist/client/assets/chunk-2Q5K7J3B-BLJzEQXz.js +1 -0
  109. package/dist/client/assets/chunk-32BRIVSS-CjBDr_gz.js +1 -0
  110. package/dist/client/assets/chunk-5VM5RSS4-lddvv0x2.js +15 -0
  111. package/dist/client/assets/chunk-EX3LRPZG-Dtsq1Stg.js +231 -0
  112. package/dist/client/assets/chunk-JWPE2WC7-BG3PZx4j.js +1 -0
  113. package/dist/client/assets/chunk-MOJQB5TN-CMtlCuQg.js +88 -0
  114. package/dist/client/assets/chunk-RYQCIY6F-uplKqw64.js +1 -0
  115. package/dist/client/assets/chunk-V7JOEXUC-CpteLsY3.js +206 -0
  116. package/dist/client/assets/chunk-VR4S4FIN-1zYOJQXL.js +1 -0
  117. package/dist/client/assets/chunk-XXDRQBXY-C0wXH91I.js +1 -0
  118. package/dist/client/assets/classDiagram-OUVF2IWQ-BTOnA9HS.js +1 -0
  119. package/dist/client/assets/classDiagram-v2-EOCWNBFH-BTOnA9HS.js +1 -0
  120. package/dist/client/assets/cose-bilkent-JH36ORCC-BceT5ki2.js +1 -0
  121. package/dist/client/assets/cynefin-VYW2F7L2-BqCIkNeC.js +166 -0
  122. package/dist/client/assets/cynefinDiagram-TSTJHNR4-Dm7y-tsN.js +62 -0
  123. package/dist/client/assets/cytoscape.esm-D3_iZ_3b.js +321 -0
  124. package/dist/client/assets/dagre-VKFMJZFB-Bn8eh0lE.js +4 -0
  125. package/dist/client/assets/defaultLocale-CrowFXzY.js +1 -0
  126. package/dist/client/assets/diagram-FQU43EPY-DmZGMAsX.js +3 -0
  127. package/dist/client/assets/diagram-G47NLZAW-BLWSm_Rn.js +24 -0
  128. package/dist/client/assets/diagram-NH7WQ7WH-C2-HK8pv.js +24 -0
  129. package/dist/client/assets/diagram-OA4YK3LP-8Ex4XIU5.js +30 -0
  130. package/dist/client/assets/diagram-WEI45ONY-MRNIpBXG.js +41 -0
  131. package/dist/client/assets/diffWorker-B6msVyjo.js +18 -0
  132. package/dist/client/assets/dnd-BCFAPC0L.js +1 -0
  133. package/dist/client/assets/ebnfDiagram-CCIWWBDH-D7Z6wZi_.js +1 -0
  134. package/dist/client/assets/erDiagram-Q63AITRT-C96Q5x9y.js +85 -0
  135. package/dist/client/assets/flowDiagram-23GEKE2U-DzQp40QH.js +156 -0
  136. package/dist/client/assets/ganttDiagram-NO4QXBWP-ZzkchZJ8.js +292 -0
  137. package/dist/client/assets/gitGraphDiagram-IHSO6WYX-CFacEf54.js +106 -0
  138. package/dist/client/assets/graph-D2o_JWn5.js +1 -0
  139. package/dist/client/assets/index-Ca-rq7yX.js +6 -0
  140. package/dist/client/assets/index-Cv7q4nZR.css +1 -0
  141. package/dist/client/assets/index-XktPMm51.js +394 -0
  142. package/dist/client/assets/infoDiagram-FWYZ7A6U-CVRXs5rF.js +2 -0
  143. package/dist/client/assets/init-Gi6I4Gst.js +1 -0
  144. package/dist/client/assets/ishikawaDiagram-FXEZZL3T-B8FP_wzM.js +70 -0
  145. package/dist/client/assets/journeyDiagram-5HDEW3XC-BR__4Hbd.js +139 -0
  146. package/dist/client/assets/kanban-definition-HUTT4EX6-v39oZe6X.js +89 -0
  147. package/dist/client/assets/katex-55ZPZeOF.css +1 -0
  148. package/dist/client/assets/katex-DnlPpQZa.js +257 -0
  149. package/dist/client/assets/katex-HP8lGamR.js +257 -0
  150. package/dist/client/assets/layout-1eBIAoSl.js +1 -0
  151. package/dist/client/assets/linear-jlTiHqO6.js +1 -0
  152. package/dist/client/assets/map-BdqsVdb3.js +1 -0
  153. package/dist/client/assets/mermaid.core-DiiIN0EC.js +306 -0
  154. package/dist/client/assets/mindmap-definition-LN4V7U3C-AeUxAZeq.js +96 -0
  155. package/dist/client/assets/ordinal-Cboi1Yqb.js +1 -0
  156. package/dist/client/assets/pegDiagram-2B236MQR-Dh-bgAbv.js +1 -0
  157. package/dist/client/assets/pieDiagram-ENE6RG2P-De7Si7Tm.js +39 -0
  158. package/dist/client/assets/purify.es-CC4Brkr_.js +3 -0
  159. package/dist/client/assets/quadrantDiagram-ABIIQ3AL-CgWfHqy0.js +7 -0
  160. package/dist/client/assets/railroadDiagram-RFXS5EU6-BXnQW-pI.js +1 -0
  161. package/dist/client/assets/react-XDCjal2T.js +8 -0
  162. package/dist/client/assets/requirementDiagram-TGXJPOKE-CU1nnpUM.js +84 -0
  163. package/dist/client/assets/sankeyDiagram-HTMAVEWB-DFK-AXY6.js +40 -0
  164. package/dist/client/assets/sequenceDiagram-DBY2YBRQ-D_bVDWgq.js +162 -0
  165. package/dist/client/assets/sizeCapture-X5ZJPWSS-nh89ExHT.js +1 -0
  166. package/dist/client/assets/stateDiagram-2N3HPSRC-DUvT6Ruk.js +1 -0
  167. package/dist/client/assets/stateDiagram-v2-6OUMAXLB-Dz-3r-Yq.js +1 -0
  168. package/dist/client/assets/swimlanes-5IMT3BWC-ZYa52gjG.js +2 -0
  169. package/dist/client/assets/swimlanesDiagram-G3AALYLV-DXR_9lf9.js +8 -0
  170. package/dist/client/assets/terminal-C7X98Amf.js +10 -0
  171. package/dist/client/assets/timeline-definition-FHXFAJF6-Bp6A49KQ.js +120 -0
  172. package/dist/client/assets/vennDiagram-L72KCM5P-CDDUmLOI.js +34 -0
  173. package/dist/client/assets/wardleyDiagram-EHGQE667-CRc2ptu7.js +78 -0
  174. package/dist/client/assets/xychartDiagram-FW5EYKEG-ht_S0LUA.js +7 -0
  175. package/dist/client/favicon.ico +0 -0
  176. package/dist/client/fonts/JetBrainsMonoNL-Bold.woff2 +0 -0
  177. package/dist/client/fonts/JetBrainsMonoNL-BoldItalic.woff2 +0 -0
  178. package/dist/client/fonts/JetBrainsMonoNL-Italic.woff2 +0 -0
  179. package/dist/client/fonts/JetBrainsMonoNL-Regular.woff2 +0 -0
  180. package/dist/client/fonts/JetBrainsMonoNLNerdFontMono-Bold.ttf +0 -0
  181. package/dist/client/fonts/JetBrainsMonoNLNerdFontMono-BoldItalic.ttf +0 -0
  182. package/dist/client/fonts/JetBrainsMonoNLNerdFontMono-Italic.ttf +0 -0
  183. package/dist/client/fonts/JetBrainsMonoNLNerdFontMono-Regular.ttf +0 -0
  184. package/dist/client/fonts/NotoSansMonoCJKsc-Bold-Subset.woff2 +0 -0
  185. package/dist/client/fonts/NotoSansMonoCJKsc-Subset.woff2 +0 -0
  186. package/dist/client/fonts/NotoSansSymbols2-Subset.woff2 +0 -0
  187. package/dist/client/fonts/SymbolsNerdFontMono-Regular.woff2 +0 -0
  188. package/dist/client/fonts/TermdockStarSymbol-Subset.woff2 +0 -0
  189. package/dist/client/index.html +255 -0
  190. package/dist/client/manifest.webmanifest +1 -0
  191. package/dist/client/maskable-icon-512x512.png +0 -0
  192. package/dist/client/pwa-192x192.png +0 -0
  193. package/dist/client/pwa-192x192.svg +5 -0
  194. package/dist/client/pwa-512x512.png +0 -0
  195. package/dist/client/pwa-512x512.svg +5 -0
  196. package/dist/client/pwa-64x64.png +0 -0
  197. package/dist/client/registerSW.js +1 -0
  198. package/dist/client/robots.txt +4 -0
  199. package/dist/client/shell-integration/termdock.bash +107 -0
  200. package/dist/client/shell-integration/termdock.fish +60 -0
  201. package/dist/client/shell-integration/termdock.zsh +164 -0
  202. package/dist/client/sw-notifications.js +42 -0
  203. package/dist/client/sw.js +1 -0
  204. package/dist/client/workbox-e4022e15.js +1 -0
  205. package/dist/server/certificateWatcher.js +111 -0
  206. package/dist/server/cli.js +2534 -0
  207. package/dist/server/config.js +54 -0
  208. package/dist/server/entry.js +571 -0
  209. package/dist/server/onboardingServer.js +54 -0
  210. package/dist/server/routes/auth.js +48 -0
  211. package/dist/server/routes/filesystem.js +3749 -0
  212. package/dist/server/routes/local.js +32 -0
  213. package/dist/server/routes/onboarding.js +134 -0
  214. package/dist/server/routes/terminal.js +4879 -0
  215. package/dist/server/utils/authProtection.js +338 -0
  216. package/dist/server/utils/caffeinate.js +124 -0
  217. package/dist/server/utils/changeAuditStore.js +521 -0
  218. package/dist/server/utils/cookieSecurity.js +13 -0
  219. package/dist/server/utils/csrfProtection.js +112 -0
  220. package/dist/server/utils/localAccess.js +211 -0
  221. package/dist/server/utils/pathValidator.js +272 -0
  222. package/dist/server/utils/requestSecurity.js +103 -0
  223. package/dist/server/utils/serverLogger.js +57 -0
  224. package/dist/server/utils/settings.js +157 -0
  225. package/dist/server/utils/tmuxFocus.js +45 -0
  226. package/dist/server/utils/tmuxProgramDetection.js +148 -0
  227. package/package.json +125 -0
@@ -0,0 +1,338 @@
1
+ import crypto from 'crypto';
2
+ import fs from 'fs';
3
+ import os from 'os';
4
+ import path from 'path';
5
+ import { getCookieSecurityOptions } from './cookieSecurity.js';
6
+ // Auth state lives under ~/.termdock/. We keep two separate files:
7
+ // - auth.json: password hash (mode 0600). Presence enables auth.
8
+ // - auth-sessions.json: active session tokens (mode 0600). Persisted so
9
+ // clients survive a server restart without re-login.
10
+ const AUTH_DIR = path.join(os.homedir(), '.termdock');
11
+ const AUTH_FILE = path.join(AUTH_DIR, 'auth.json');
12
+ const SESSIONS_FILE = path.join(AUTH_DIR, 'auth-sessions.json');
13
+ // Cookie holding the opaque session token. httpOnly + sameSite=lax. The token
14
+ // itself is 32 random bytes hex; never derived from the password.
15
+ export const AUTH_COOKIE = 'termdock-auth';
16
+ const SESSION_TTL_MS = 30 * 24 * 60 * 60 * 1000; // 30 days
17
+ // scrypt parameters. N=2^15 keeps interactive login latency acceptable on
18
+ // laptops/phones while still being painful for offline brute force on a
19
+ // single secret.
20
+ const SCRYPT_N = 1 << 15;
21
+ const SCRYPT_R = 8;
22
+ const SCRYPT_P = 1;
23
+ const SCRYPT_KEY_LEN = 64;
24
+ const SCRYPT_SALT_LEN = 16;
25
+ // In-memory cache of active sessions, keyed by token. Mirrors SESSIONS_FILE.
26
+ const sessions = new Map();
27
+ let sessionsLoaded = false;
28
+ const loginAttempts = new Map();
29
+ function ensureAuthDir() {
30
+ if (!fs.existsSync(AUTH_DIR)) {
31
+ fs.mkdirSync(AUTH_DIR, { recursive: true, mode: 0o700 });
32
+ }
33
+ }
34
+ function writeFileSecure(filePath, content) {
35
+ ensureAuthDir();
36
+ fs.writeFileSync(filePath, content, { encoding: 'utf-8', mode: 0o600 });
37
+ // Best-effort chmod in case the file already existed with looser perms.
38
+ try {
39
+ fs.chmodSync(filePath, 0o600);
40
+ }
41
+ catch { /* ignore */ }
42
+ }
43
+ function scryptHashSync(password, salt) {
44
+ return crypto.scryptSync(password, salt, SCRYPT_KEY_LEN, {
45
+ N: SCRYPT_N,
46
+ r: SCRYPT_R,
47
+ p: SCRYPT_P,
48
+ // scrypt with large N can exceed default maxmem; raise it explicitly.
49
+ maxmem: 128 * SCRYPT_N * SCRYPT_R * 2,
50
+ });
51
+ }
52
+ export function hashPassword(password) {
53
+ if (typeof password !== 'string' || password.length === 0) {
54
+ throw new Error('Password must be a non-empty string');
55
+ }
56
+ const salt = crypto.randomBytes(SCRYPT_SALT_LEN);
57
+ const derived = scryptHashSync(password, salt);
58
+ return `scrypt$${salt.toString('hex')}$${derived.toString('hex')}`;
59
+ }
60
+ function verifyPasswordAgainstHash(password, stored) {
61
+ const parts = stored.split('$');
62
+ if (parts.length !== 3 || parts[0] !== 'scrypt')
63
+ return false;
64
+ let saltHex;
65
+ let hashHex;
66
+ try {
67
+ saltHex = parts[1];
68
+ hashHex = parts[2];
69
+ }
70
+ catch {
71
+ return false;
72
+ }
73
+ const salt = Buffer.from(saltHex, 'hex');
74
+ const expected = Buffer.from(hashHex, 'hex');
75
+ if (expected.length === 0)
76
+ return false;
77
+ let derived;
78
+ try {
79
+ derived = scryptHashSync(password, salt);
80
+ }
81
+ catch {
82
+ return false;
83
+ }
84
+ if (derived.length !== expected.length)
85
+ return false;
86
+ return crypto.timingSafeEqual(derived, expected);
87
+ }
88
+ // ── Env-provided password ──
89
+ // TERMDOCK_PASSWORD enables auth without touching ~/.termdock/auth.json and
90
+ // takes precedence over the stored hash. The plaintext is hashed once (lazily)
91
+ // so verification goes through the same scrypt + timingSafeEqual path as the
92
+ // file-based password.
93
+ const ENV_PASSWORD_VAR = 'TERMDOCK_PASSWORD';
94
+ function getEnvPassword() {
95
+ const value = process.env[ENV_PASSWORD_VAR];
96
+ return typeof value === 'string' && value.length > 0 ? value : null;
97
+ }
98
+ let cachedEnvHash = null;
99
+ function getEnvPasswordHash() {
100
+ const password = getEnvPassword();
101
+ if (!password)
102
+ return null;
103
+ if (!cachedEnvHash || cachedEnvHash.password !== password) {
104
+ cachedEnvHash = { password, hash: hashPassword(password) };
105
+ }
106
+ return cachedEnvHash.hash;
107
+ }
108
+ export function isEnvPasswordSet() {
109
+ return getEnvPassword() !== null;
110
+ }
111
+ export function isAuthEnabled() {
112
+ if (getEnvPassword())
113
+ return true;
114
+ try {
115
+ return fs.existsSync(AUTH_FILE);
116
+ }
117
+ catch {
118
+ return false;
119
+ }
120
+ }
121
+ export function readAuthFile() {
122
+ try {
123
+ if (!fs.existsSync(AUTH_FILE))
124
+ return null;
125
+ const raw = fs.readFileSync(AUTH_FILE, 'utf-8');
126
+ const parsed = JSON.parse(raw);
127
+ if (parsed.version !== 1 || typeof parsed.passwordHash !== 'string')
128
+ return null;
129
+ return {
130
+ version: 1,
131
+ passwordHash: parsed.passwordHash,
132
+ createdAt: typeof parsed.createdAt === 'number' ? parsed.createdAt : Date.now(),
133
+ };
134
+ }
135
+ catch {
136
+ return null;
137
+ }
138
+ }
139
+ export function writeAuthFile(passwordHash) {
140
+ const payload = { version: 1, passwordHash, createdAt: Date.now() };
141
+ writeFileSecure(AUTH_FILE, JSON.stringify(payload, null, 2));
142
+ }
143
+ export function clearAuthFile() {
144
+ try {
145
+ if (fs.existsSync(AUTH_FILE))
146
+ fs.rmSync(AUTH_FILE, { force: true });
147
+ }
148
+ catch { /* ignore */ }
149
+ // Clearing the password also invalidates all existing sessions.
150
+ sessions.clear();
151
+ persistSessions();
152
+ }
153
+ function loadSessionsIfNeeded() {
154
+ if (sessionsLoaded)
155
+ return;
156
+ sessionsLoaded = true;
157
+ try {
158
+ if (!fs.existsSync(SESSIONS_FILE))
159
+ return;
160
+ const raw = fs.readFileSync(SESSIONS_FILE, 'utf-8');
161
+ const parsed = JSON.parse(raw);
162
+ if (parsed.version !== 1 || !Array.isArray(parsed.sessions))
163
+ return;
164
+ const now = Date.now();
165
+ for (const record of parsed.sessions) {
166
+ if (record &&
167
+ typeof record.token === 'string' &&
168
+ typeof record.expiresAt === 'number' &&
169
+ typeof record.createdAt === 'number' &&
170
+ record.expiresAt > now) {
171
+ sessions.set(record.token, record);
172
+ }
173
+ }
174
+ }
175
+ catch { /* ignore */ }
176
+ }
177
+ let persistTimer = null;
178
+ function persistSessions() {
179
+ if (persistTimer)
180
+ clearTimeout(persistTimer);
181
+ persistTimer = setTimeout(() => {
182
+ try {
183
+ const payload = {
184
+ version: 1,
185
+ sessions: Array.from(sessions.values()),
186
+ };
187
+ writeFileSecure(SESSIONS_FILE, JSON.stringify(payload, null, 2));
188
+ }
189
+ catch { /* ignore */ }
190
+ }, 200);
191
+ }
192
+ function pruneExpiredSessions() {
193
+ loadSessionsIfNeeded();
194
+ const now = Date.now();
195
+ let changed = false;
196
+ for (const [token, record] of sessions.entries()) {
197
+ if (record.expiresAt <= now) {
198
+ sessions.delete(token);
199
+ changed = true;
200
+ }
201
+ }
202
+ if (changed)
203
+ persistSessions();
204
+ }
205
+ export function createSession() {
206
+ loadSessionsIfNeeded();
207
+ const token = crypto.randomBytes(32).toString('hex');
208
+ const now = Date.now();
209
+ const record = {
210
+ token,
211
+ createdAt: now,
212
+ expiresAt: now + SESSION_TTL_MS,
213
+ };
214
+ sessions.set(token, record);
215
+ persistSessions();
216
+ return record;
217
+ }
218
+ export function isSessionValid(token) {
219
+ if (!token || typeof token !== 'string')
220
+ return false;
221
+ loadSessionsIfNeeded();
222
+ const record = sessions.get(token);
223
+ if (!record)
224
+ return false;
225
+ if (record.expiresAt <= Date.now()) {
226
+ sessions.delete(token);
227
+ persistSessions();
228
+ return false;
229
+ }
230
+ return true;
231
+ }
232
+ export function destroySession(token) {
233
+ if (!token)
234
+ return;
235
+ loadSessionsIfNeeded();
236
+ if (sessions.delete(token))
237
+ persistSessions();
238
+ }
239
+ export function destroyAllSessions() {
240
+ sessions.clear();
241
+ persistSessions();
242
+ }
243
+ // ── Login rate limiting ──
244
+ // Exponential backoff per source IP: 0, 0, 0, 1s, 4s, 16s, 64s, 256s, capped.
245
+ // Resets to 0 on a successful login.
246
+ const BACKOFF_GRACE_FAILURES = 3;
247
+ const BACKOFF_BASE_MS = 1000;
248
+ const BACKOFF_CAP_MS = 15 * 60 * 1000; // 15 min hard cap per attempt window
249
+ export function getLoginBlockMs(ip) {
250
+ const record = loginAttempts.get(ip);
251
+ if (!record)
252
+ return 0;
253
+ const remaining = record.blockedUntil - Date.now();
254
+ return remaining > 0 ? remaining : 0;
255
+ }
256
+ export function recordLoginFailure(ip) {
257
+ const now = Date.now();
258
+ const record = loginAttempts.get(ip) ?? { failures: 0, blockedUntil: 0 };
259
+ record.failures += 1;
260
+ if (record.failures > BACKOFF_GRACE_FAILURES) {
261
+ const exponent = record.failures - BACKOFF_GRACE_FAILURES - 1;
262
+ const delay = Math.min(BACKOFF_CAP_MS, BACKOFF_BASE_MS * Math.pow(4, exponent));
263
+ record.blockedUntil = now + delay;
264
+ }
265
+ loginAttempts.set(ip, record);
266
+ }
267
+ export function recordLoginSuccess(ip) {
268
+ loginAttempts.delete(ip);
269
+ }
270
+ // ── Express middleware ──
271
+ // Returns true when the incoming request bears a valid session cookie.
272
+ export function isRequestAuthenticated(req) {
273
+ pruneExpiredSessions();
274
+ const token = req.cookies?.[AUTH_COOKIE];
275
+ return isSessionValid(typeof token === 'string' ? token : undefined);
276
+ }
277
+ // Middleware factory: blocks the request with 401 unless either auth is
278
+ // disabled (no auth.json) or the request carries a valid session cookie.
279
+ // Bypass paths are public (login/status/health/static).
280
+ export function requireAuth() {
281
+ return (req, res, next) => {
282
+ if (!isAuthEnabled())
283
+ return next();
284
+ if (isRequestAuthenticated(req))
285
+ return next();
286
+ res.status(401).json({ error: 'Authentication required', code: 'AUTH_REQUIRED' });
287
+ };
288
+ }
289
+ // Verify the cookie attached to a WebSocket upgrade request. Reads the raw
290
+ // Cookie header and matches AUTH_COOKIE without depending on Express middleware.
291
+ export function isUpgradeRequestAuthenticated(cookieHeader) {
292
+ if (!isAuthEnabled())
293
+ return true;
294
+ if (!cookieHeader)
295
+ return false;
296
+ const pairs = cookieHeader.split(/;\s*/);
297
+ for (const pair of pairs) {
298
+ const idx = pair.indexOf('=');
299
+ if (idx <= 0)
300
+ continue;
301
+ const name = pair.slice(0, idx).trim();
302
+ if (name !== AUTH_COOKIE)
303
+ continue;
304
+ const value = decodeURIComponent(pair.slice(idx + 1).trim());
305
+ if (isSessionValid(value))
306
+ return true;
307
+ }
308
+ return false;
309
+ }
310
+ export function getClientIp(req) {
311
+ // We do not trust X-Forwarded-* by default since this is a local tool. If
312
+ // the user puts it behind a reverse proxy with auth, the rate limiter only
313
+ // sees the proxy IP — that's an acceptable trade-off here.
314
+ return req.socket.remoteAddress ?? 'unknown';
315
+ }
316
+ export function setSessionCookie(res, token) {
317
+ res.cookie(AUTH_COOKIE, token, {
318
+ httpOnly: true,
319
+ ...getCookieSecurityOptions(),
320
+ maxAge: SESSION_TTL_MS,
321
+ path: '/',
322
+ });
323
+ }
324
+ export function clearSessionCookie(res) {
325
+ res.clearCookie(AUTH_COOKIE, { path: '/', ...getCookieSecurityOptions() });
326
+ }
327
+ export function verifyPassword(password) {
328
+ if (typeof password !== 'string' || password.length === 0)
329
+ return false;
330
+ // Env-provided password takes precedence over the stored hash.
331
+ const envHash = getEnvPasswordHash();
332
+ if (envHash)
333
+ return verifyPasswordAgainstHash(password, envHash);
334
+ const auth = readAuthFile();
335
+ if (!auth)
336
+ return false;
337
+ return verifyPasswordAgainstHash(password, auth.passwordHash);
338
+ }
@@ -0,0 +1,124 @@
1
+ import { spawn } from 'child_process';
2
+ import dns from 'dns';
3
+ import os from 'os';
4
+ import { getPreventSleepSetting, setPreventSleepSettingAsync } from './settings.js';
5
+ // ── Network detection ─────────────────────────────────────────────────
6
+ async function checkNetworkConnectivity() {
7
+ // Quick check: any non-loopback IPv4 interface?
8
+ const interfaces = os.networkInterfaces();
9
+ const hasNonLoopback = Object.values(interfaces).some((addrs) => addrs?.some((a) => !a.internal && a.family === 'IPv4'));
10
+ if (!hasNonLoopback)
11
+ return false;
12
+ // Deeper check: can we resolve DNS?
13
+ let timeout = null;
14
+ try {
15
+ await Promise.race([
16
+ dns.promises.resolve('cloudflare.com'),
17
+ new Promise((_, reject) => {
18
+ timeout = setTimeout(() => reject(new Error('DNS resolve timed out')), 5000);
19
+ }),
20
+ ]);
21
+ return true;
22
+ }
23
+ catch {
24
+ return false;
25
+ }
26
+ finally {
27
+ if (timeout)
28
+ clearTimeout(timeout);
29
+ }
30
+ }
31
+ // ── Caffeinate manager ────────────────────────────────────────────────
32
+ const isMacOS = process.platform === 'darwin';
33
+ let preventSleep = getPreventSleepSetting();
34
+ let caffeinateProcess = null;
35
+ let networkAvailable = false;
36
+ let monitorTimer = null;
37
+ let debounceTimer = null;
38
+ function shouldCaffeinate() {
39
+ return preventSleep && networkAvailable;
40
+ }
41
+ function spawnCaffeinate() {
42
+ if (!isMacOS || caffeinateProcess)
43
+ return;
44
+ try {
45
+ caffeinateProcess = spawn('caffeinate', ['-i', '-s'], { stdio: 'ignore' });
46
+ caffeinateProcess.on('exit', () => {
47
+ caffeinateProcess = null;
48
+ });
49
+ caffeinateProcess.unref();
50
+ }
51
+ catch { /* caffeinate not available */ }
52
+ }
53
+ function killCaffeinate() {
54
+ if (!caffeinateProcess)
55
+ return;
56
+ try {
57
+ caffeinateProcess.kill('SIGTERM');
58
+ }
59
+ catch { /* already dead */ }
60
+ caffeinateProcess = null;
61
+ }
62
+ function evaluate() {
63
+ if (shouldCaffeinate()) {
64
+ spawnCaffeinate();
65
+ }
66
+ else {
67
+ killCaffeinate();
68
+ }
69
+ }
70
+ function onNetworkChanged(available) {
71
+ if (available === networkAvailable)
72
+ return;
73
+ networkAvailable = available;
74
+ // Debounce: wait 5s before acting on network state change
75
+ if (debounceTimer)
76
+ clearTimeout(debounceTimer);
77
+ debounceTimer = setTimeout(() => {
78
+ debounceTimer = null;
79
+ evaluate();
80
+ }, 5000);
81
+ }
82
+ async function checkNetwork() {
83
+ const available = await checkNetworkConnectivity();
84
+ onNetworkChanged(available);
85
+ }
86
+ export const caffeinateManager = {
87
+ setPreventSleep(enabled) {
88
+ preventSleep = enabled;
89
+ void setPreventSleepSettingAsync(enabled).catch((error) => {
90
+ console.warn('[caffeinate] Failed to persist preventSleep setting:', error);
91
+ });
92
+ evaluate();
93
+ },
94
+ getPreventSleep() {
95
+ return preventSleep;
96
+ },
97
+ isActive() {
98
+ return caffeinateProcess !== null;
99
+ },
100
+ isNetworkAvailable() {
101
+ return networkAvailable;
102
+ },
103
+ startNetworkMonitor() {
104
+ // If setting is ON, start caffeinate immediately without waiting for
105
+ // the first network check. If there's no network, the async check
106
+ // will detect that and kill caffeinate shortly after.
107
+ if (preventSleep)
108
+ spawnCaffeinate();
109
+ // Start periodic network monitoring
110
+ void checkNetwork();
111
+ monitorTimer = setInterval(() => void checkNetwork(), 30_000);
112
+ },
113
+ shutdown() {
114
+ if (monitorTimer) {
115
+ clearInterval(monitorTimer);
116
+ monitorTimer = null;
117
+ }
118
+ if (debounceTimer) {
119
+ clearTimeout(debounceTimer);
120
+ debounceTimer = null;
121
+ }
122
+ killCaffeinate();
123
+ },
124
+ };