@lvxianchao/dsh-web-guard 2.0.1 → 2.1.1

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/CHANGELOG.md CHANGED
@@ -1,5 +1,29 @@
1
1
  # Changelog
2
2
 
3
+ ## 2.1.1 — 2026-11-05
4
+
5
+ ### Docs
6
+
7
+ - README and the admin page now describe the persisted session cache and the dead-session redirect; both still claimed sessions lived in memory and that a restart signed everyone out.
8
+ - Screenshots re-shot with the corrected wording.
9
+
10
+
11
+ ## 2.1.0 — 2026-11-05
12
+
13
+ ### Added
14
+
15
+ - **Sessions survive a restart.** The table is cached beside the password file (`dsh-web-guard-sessions-<port>.json`, mode 0600, atomic write), so upgrading or restarting DSH no longer signs every browser out — the open tab reconnects instead of hanging on "reconnecting". Rows that expired while the process was down are dropped on load.
16
+ - **Dead-session redirect.** The injected client probes the new `/__guard/session` endpoint (200 = live, 401 = gone) when the tab becomes visible, when the browser comes back online, after a WebSocket close, and every 30s. A 401 sends the browser to the login page with `?next=`, so a genuine revocation (password change, sign-out-all) looks like a login prompt rather than a dead spinner.
17
+
18
+ ### Changed
19
+
20
+ - Restarting the guard is no longer a way to sign everyone out. Use the admin page's "sign out all devices" — revocation is an explicit action now.
21
+
22
+ ### Security
23
+
24
+ - The session cache stores SHA-256 hashes, never raw tokens. Neither the file nor a heap dump can mint a working cookie.
25
+
26
+
3
27
  ## 2.0.1 — 2026-11-05
4
28
 
5
29
  ### Internal
package/README.md CHANGED
@@ -173,10 +173,21 @@ node -e "import('./lib/auth.js').then(m=>console.log(m.hashPassword(process.argv
173
173
  - Session tokens are stored server-side as SHA-256 digests; cookies are `HttpOnly`, `SameSite=Lax`, and `Secure` when TLS is on.
174
174
  - Login POSTs are checked for a same-hostname `Origin`. The session cookie's `SameSite` attribute is the primary CSRF defence.
175
175
  - Repeated failures from one IP trigger a temporary lock (in-memory, cleared on restart).
176
- - `/logout` ends the current browser session. To revoke everything, change the password and restart, or delete `$DSH_HOME/dsh-web-guard.json`.
176
+ - **The session table is cached on disk** (`<profile>/data/dsh-web-guard-sessions-<port>.json`, mode `0600`, atomic tmp+rename), so **restarting DSH no longer signs signed-in browsers out**. The file holds SHA-256 digests, never tokens — neither the cache nor a heap dump can mint a working cookie. The filename carries the listener port, so a shadow instance cannot overwrite the live one.
177
+ - `/logout` ends the current browser session. To revoke everything (a suspected leak, say), use the admin page's **sign out all devices** — **a restart is not a revocation any more**.
177
178
 
178
179
  **This is not multi-user.** One password, one privilege level. The harness agent can execute code, so anyone who gets in has code execution on that host — which is the product's design, not a bug in the gate.
179
180
 
181
+ ## When the session is gone
182
+
183
+ If the session disappears (password changed, sign-out-all, or no activity within `sessionDays`), an open tab does not sit on "reconnecting" forever:
184
+
185
+ - it probes `/__guard/session` when the tab becomes visible, when the network comes back, after a WebSocket close, and every 30 seconds
186
+ - only an explicit **401** triggers a redirect — a failed fetch (offline, server restarting) never does
187
+ - the redirect carries `?next=`, so you land back where you were after signing in
188
+
189
+ Restarts (upgrades, config changes) are invisible: the session table is restored from disk and the page reconnects on its own.
190
+
180
191
  ## Admin page
181
192
 
182
193
  A shield button appears at the bottom-right of the harness UI once you are signed in. It opens `/__guard/admin` (bookmark the URL if you prefer):
package/README.zh.md CHANGED
@@ -167,10 +167,21 @@ node -e "import('./lib/auth.js').then(m=>console.log(m.hashPassword(process.argv
167
167
  - 会话 token 服务端只存 SHA-256 摘要;cookie 是 `HttpOnly` + `SameSite=Lax`,启用 TLS 时加 `Secure`。
168
168
  - 登录 POST 校验 `Origin` 的 hostname 与请求 `Host` 一致(只比 hostname,因为反代常剥端口)。CSRF 的第一道防线仍是 `SameSite`。
169
169
  - 同一 IP 连续失败会临时锁定(内存计数,重启清零)。
170
- - `/logout` 只退当前浏览器;要全部吊销就改密码并重启,或删掉 `$DSH_HOME/dsh-web-guard.json`。
170
+ - **会话表现在会落盘**(`<profile>/data/dsh-web-guard-sessions-<port>.json`,`0600`,临时文件 + rename 原子写入),所以**重启 DSH 不会把已登录的浏览器踢下线**。文件里存的同样是 SHA-256 摘要,不是 token——拿到这个文件或内存 dump 都无法伪造 cookie。文件名带监听端口,影子实例不会覆盖正式实例。
171
+ - `/logout` 只退当前浏览器。要全部吊销(比如怀疑泄露),用管理页的**退出所有设备**:**重启不再等于吊销**。
171
172
 
172
173
  **这不是多用户系统**:一个密码,一个权限级别。DSH 的 agent 本身能执行代码,任何登录者都拥有该机器的执行权限——这是产品设计,不是闸门的缺陷。
173
174
 
175
+ ## 掉线之后
176
+
177
+ 会话没了(改过密码、点了退出所有设备、或超过 `sessionDays` 没活动)时,已经开着的标签页不会卡在"重新连接中":
178
+
179
+ - 切回标签页、网络恢复、WebSocket 断开、以及每 30 秒,页面都会向 `/__guard/session` 探一次
180
+ - 只有拿到明确的 **401** 才跳转(断网导致的请求失败不会误判)
181
+ - 跳转目标带 `?next=`,登录后回到原来那个地址
182
+
183
+ 需要重启(升级、改配置)则完全无感:会话表会从磁盘恢复,页面自己重连回来。
184
+
174
185
  ## 管理页
175
186
 
176
187
  登录后 DSH 界面右下角会出现一个盾牌按钮,点开就是 `/__guard/admin`(也可以直接把地址收藏起来):
package/lib/auth.js CHANGED
@@ -4,13 +4,17 @@
4
4
  * Design notes:
5
5
  * - Passwords are stored as scrypt `scrypt$N$r$p$salt$key` (same shape the
6
6
  * Hermes dashboard uses) so a hash can be pasted into config.
7
- * - Sessions live in memory: the plugin restarts with dsh, and a restart
8
- * should force a fresh login anyway. Tokens are stored hashed, so a heap
9
- * dump does not hand out live sessions.
7
+ * - Sessions are hashed before they reach memory *and* the on-disk cache, so
8
+ * neither a heap dump nor the state file can mint a working cookie. The
9
+ * cache exists so a restart (upgrade, config change) does not sign everyone
10
+ * out; revocation still goes through the admin page, and a session that
11
+ * expired while the process was down is dropped on load.
10
12
  * - Every session carries its own expiry and last-seen stamp; the cookie
11
13
  * lifetime is fixed at issue time, the server side slides on activity.
12
14
  */
13
15
  import { randomBytes, createHash, scryptSync, timingSafeEqual } from 'node:crypto'
16
+ import { readFileSync, writeFileSync, renameSync, mkdirSync } from 'node:fs'
17
+ import { dirname } from 'node:path'
14
18
 
15
19
  const SCRYPT_N = 16384
16
20
  const SCRYPT_R = 8
@@ -59,7 +63,7 @@ export class SessionStore {
59
63
  * @param {number} options.maxAttempts - failures before a temporary lock
60
64
  * @param {number} options.lockMinutes - lock duration in minutes
61
65
  */
62
- constructor({ sessionDays = 7, maxAttempts = 5, lockMinutes = 5 } = {}) {
66
+ constructor({ sessionDays = 7, maxAttempts = 5, lockMinutes = 5, file } = {}) {
63
67
  this.sessionMs = Math.max(0, sessionDays) * 24 * 60 * 60 * 1000
64
68
  this.maxAttempts = Math.max(1, maxAttempts)
65
69
  this.lockMs = Math.max(1, lockMinutes) * 60 * 1000
@@ -67,10 +71,66 @@ export class SessionStore {
67
71
  this.sessions = new Map()
68
72
  /** @type {Map<string, {fails:number,until:number}>} */
69
73
  this.failures = new Map()
74
+ this.file = typeof file === 'string' && file.length > 0 ? file : ''
75
+ this.persistTimer = null
70
76
  this.timer = setInterval(() => this.sweep(), 10 * 60 * 1000)
71
77
  this.timer.unref?.()
72
78
  }
73
79
 
80
+ /**
81
+ * Restore the table a previous process wrote. A missing or corrupt file is
82
+ * treated as "no sessions" — a damaged cache must never block sign-in.
83
+ */
84
+ load() {
85
+ if (!this.file) return
86
+ let data
87
+ try {
88
+ data = JSON.parse(readFileSync(this.file, 'utf8'))
89
+ } catch {
90
+ return
91
+ }
92
+ if (data === null || typeof data !== 'object' || data.version !== 1) return
93
+ if (data.sessions === null || typeof data.sessions !== 'object') return
94
+ const now = Date.now()
95
+ const limit = this.sessionMs === 0 ? 12 * 60 * 60 * 1000 : this.sessionMs
96
+ for (const [key, row] of Object.entries(data.sessions)) {
97
+ if (!/^[0-9a-f]{64}$/.test(key)) continue
98
+ if (typeof row?.createdAt !== 'number' || typeof row?.lastSeen !== 'number') continue
99
+ if (now - row.lastSeen > limit) continue
100
+ this.sessions.set(key, {
101
+ createdAt: row.createdAt,
102
+ lastSeen: row.lastSeen,
103
+ ip: typeof row.ip === 'string' ? row.ip : '',
104
+ ua: typeof row.ua === 'string' ? row.ua : '',
105
+ })
106
+ }
107
+ }
108
+
109
+ /** Write the table atomically (tmp + rename); disk problems never block auth. */
110
+ persist() {
111
+ if (!this.file) return
112
+ try {
113
+ const sessions = {}
114
+ for (const [key, row] of this.sessions) sessions[key] = row
115
+ mkdirSync(dirname(this.file), { recursive: true })
116
+ const tmp = `${this.file}.${process.pid}.tmp`
117
+ writeFileSync(tmp, JSON.stringify({ version: 1, savedAt: Date.now(), sessions }), { mode: 0o600 })
118
+ renameSync(tmp, this.file)
119
+ } catch {
120
+ // Read-only data dir or full disk: this process keeps working from memory.
121
+ }
122
+ }
123
+
124
+ /** Coalesce a burst of changes into one write. */
125
+ touch() {
126
+ if (!this.file || this.persistTimer !== null) return
127
+ this.persistTimer = setTimeout(() => {
128
+ this.persistTimer = null
129
+ this.persist()
130
+ }, 500)
131
+ this.persistTimer.unref?.()
132
+ }
133
+
74
134
  /** Whether this client is currently locked out. */
75
135
  isLocked(ip) {
76
136
  const row = this.failures.get(ip)
@@ -102,6 +162,7 @@ export class SessionStore {
102
162
  const token = randomBytes(32).toString('base64url')
103
163
  const now = Date.now()
104
164
  this.sessions.set(sha256(token), { createdAt: now, lastSeen: now, ip, ua })
165
+ this.touch()
105
166
  return token
106
167
  }
107
168
 
@@ -115,14 +176,17 @@ export class SessionStore {
115
176
  const limit = this.sessionMs === 0 ? 12 * 60 * 60 * 1000 : this.sessionMs
116
177
  if (now - row.lastSeen > limit) {
117
178
  this.sessions.delete(key)
179
+ this.touch()
118
180
  return undefined
119
181
  }
120
182
  row.lastSeen = now
183
+ this.touch()
121
184
  return row
122
185
  }
123
186
 
124
187
  revoke(token) {
125
- if (typeof token === 'string' && token.length > 0) this.sessions.delete(sha256(token))
188
+ if (typeof token !== 'string' || token.length === 0) return
189
+ if (this.sessions.delete(sha256(token))) this.touch()
126
190
  }
127
191
 
128
192
  /** List live sessions with a short opaque id (the hashed key prefix). */
@@ -143,6 +207,7 @@ export class SessionStore {
143
207
  for (const key of this.sessions.keys()) {
144
208
  if (key.slice(0, 16) === id) {
145
209
  this.sessions.delete(key)
210
+ this.touch()
146
211
  return true
147
212
  }
148
213
  }
@@ -157,21 +222,32 @@ export class SessionStore {
157
222
 
158
223
  revokeAll() {
159
224
  this.sessions.clear()
225
+ this.touch()
160
226
  }
161
227
 
162
228
  sweep() {
163
229
  const now = Date.now()
230
+ let dropped = false
164
231
  const limit = this.sessionMs === 0 ? 12 * 60 * 60 * 1000 : this.sessionMs
165
232
  for (const [key, row] of this.sessions) {
166
- if (now - row.lastSeen > limit) this.sessions.delete(key)
233
+ if (now - row.lastSeen > limit) {
234
+ this.sessions.delete(key)
235
+ dropped = true
236
+ }
167
237
  }
168
238
  for (const [ip, row] of this.failures) {
169
239
  if (row.until > 0 && row.until < now) this.failures.delete(ip)
170
240
  }
241
+ if (dropped) this.touch()
171
242
  }
172
243
 
173
244
  dispose() {
174
245
  clearInterval(this.timer)
246
+ if (this.persistTimer !== null) {
247
+ clearTimeout(this.persistTimer)
248
+ this.persistTimer = null
249
+ }
250
+ this.persist()
175
251
  this.sessions.clear()
176
252
  this.failures.clear()
177
253
  }
package/lib/i18n.js CHANGED
@@ -114,7 +114,7 @@ const ZH = {
114
114
  signOutAll: '退出所有设备',
115
115
  confirmSignOutAll: '将退出所有设备上的登录,包括当前这个。继续?',
116
116
  secSessions: '活跃会话',
117
- hintSessions: '每个浏览器一条记录。会话存在内存里,重启 DSH 会全部失效。',
117
+ hintSessions: '每个浏览器一条记录。会话落盘保存,重启 DSH 不会把人踢下线;要全员下线请点「退出所有设备」。',
118
118
  colSource: '来源',
119
119
  colBrowser: '浏览器',
120
120
  colLastSeen: '最后活动',
@@ -169,7 +169,7 @@ const EN = {
169
169
  signOutAll: 'Sign out everywhere',
170
170
  confirmSignOutAll: 'This signs out every device, including this one. Continue?',
171
171
  secSessions: 'Active sessions',
172
- hintSessions: 'One row per browser. Sessions live in memory and are cleared when DSH restarts.',
172
+ hintSessions: 'One row per browser. Sessions are persisted, so restarting DSH keeps everyone signed in — use "Sign out all devices" to revoke them.',
173
173
  colSource: 'Source',
174
174
  colBrowser: 'Browser',
175
175
  colLastSeen: 'Last seen',
package/lib/index.js CHANGED
@@ -60,6 +60,15 @@ function statePath(profileDir) {
60
60
  return join(profileDir, 'data', 'dsh-web-guard.json')
61
61
  }
62
62
 
63
+ /**
64
+ * Session cache lives beside the password but keyed by listener port: a shadow
65
+ * instance (dev.yml, port 3100) runs against the same profile and must not
66
+ * fight the live one over one file. Ports are exclusive, so this is a safe key.
67
+ */
68
+ function sessionsPath(profileDir, port) {
69
+ return join(profileDir, 'data', `dsh-web-guard-sessions-${port}.json`)
70
+ }
71
+
63
72
  function readState(profileDir) {
64
73
  for (const path of [statePath(profileDir), legacyStatePath()]) {
65
74
  try {
@@ -181,7 +190,11 @@ export function apply(ctx, config = {}) {
181
190
  sessionDays: Number(cfg.sessionDays),
182
191
  maxAttempts: Number(cfg.maxAttempts),
183
192
  lockMinutes: Number(cfg.lockMinutes),
193
+ file: sessionsPath(profileDir, cfg.listenPort),
184
194
  })
195
+ // Restore sessions from the previous process: a restart (upgrade, config
196
+ // change) must not sign every browser out. Expired rows are dropped on load.
197
+ sessions.load()
185
198
 
186
199
  // Locale follows the harness: its language picker writes `preference` into
187
200
  // the profile patch file. Uncached reads would hit the filesystem on every
@@ -396,6 +409,13 @@ export function apply(ctx, config = {}) {
396
409
 
397
410
  if (path === '/__guard/health') return json(res, 200, { ok: true, service: 'dsh-web-guard' })
398
411
 
412
+ // Authenticated probe: 200 when this browser still holds a live session,
413
+ // 401 when it does not. The injected client uses it to leave the
414
+ // "reconnecting" spinner for the login page instead of retrying forever.
415
+ if (path === '/__guard/session') {
416
+ return sessionOf(req) ? json(res, 200, { ok: true }) : json(res, 401, { ok: false })
417
+ }
418
+
399
419
  if (path === '/login' && req.method === 'GET') {
400
420
  if (sessionOf(req)) return redirect(res, '/')
401
421
  return html(res, 200, loginPage({ title: cfg.title, next: url.searchParams.get('next') ?? '/', repo: cfg.repo, locale }))
package/lib/inject.js CHANGED
@@ -58,6 +58,40 @@ border:1px solid #24282e;background:#0b0d10;box-shadow:0 24px 80px rgba(0,0,0,.6
58
58
  document.body.style.overflow = "hidden";
59
59
  }
60
60
  fab.addEventListener("click", open);
61
+
62
+ // ── session watchdog ────────────────────────────────────────────────
63
+ // DSH only ever sees "WebSocket closed"; it does not know this gate exists.
64
+ // When the session is really gone (password change, revoke-all, expiry) we
65
+ // send the browser to the login page instead of spinning on reconnect.
66
+ // A 401 is the only trigger: a failed fetch means the network is down.
67
+ var probing = false;
68
+ function probe() {
69
+ if (probing) return;
70
+ probing = true;
71
+ fetch("/__guard/session", { cache: "no-store", credentials: "same-origin" })
72
+ .then(function (r) {
73
+ if (r.status === 401) {
74
+ location.href = "/login?next=" + encodeURIComponent(location.pathname + location.search);
75
+ }
76
+ })
77
+ .catch(function () {})
78
+ .then(function () { probing = false; });
79
+ }
80
+ document.addEventListener("visibilitychange", function () { if (!document.hidden) probe(); });
81
+ window.addEventListener("online", probe);
82
+ setInterval(probe, 30000);
83
+ var NativeWS = window.WebSocket;
84
+ if (NativeWS) {
85
+ window.WebSocket = function (url, protocols) {
86
+ var ws = protocols === undefined ? new NativeWS(url) : new NativeWS(url, protocols);
87
+ ws.addEventListener("close", function () { setTimeout(probe, 2000); });
88
+ return ws;
89
+ };
90
+ window.WebSocket.prototype = NativeWS.prototype;
91
+ ["CONNECTING", "OPEN", "CLOSING", "CLOSED"].forEach(function (k) {
92
+ window.WebSocket[k] = NativeWS[k];
93
+ });
94
+ }
61
95
  window.addEventListener("message", function (ev) {
62
96
  if (ev.origin !== location.origin) return;
63
97
  if (ev.data === "dsh-web-guard:close") close();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lvxianchao/dsh-web-guard",
3
- "version": "2.0.1",
3
+ "version": "2.1.1",
4
4
  "description": "Password login for the DeepSeek Harness Web UI, so it can be exposed on the internet: sign in from anywhere instead of chasing one-off tokens, with reverse-proxy quirks handled.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",