@lvxianchao/dsh-web-guard 2.0.0 → 2.1.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/CHANGELOG.md CHANGED
@@ -1,5 +1,28 @@
1
1
  # Changelog
2
2
 
3
+ ## 2.1.0 — 2026-11-05
4
+
5
+ ### Added
6
+
7
+ - **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.
8
+ - **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.
9
+
10
+ ### Changed
11
+
12
+ - 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.
13
+
14
+ ### Security
15
+
16
+ - The session cache stores SHA-256 hashes, never raw tokens. Neither the file nor a heap dump can mint a working cookie.
17
+
18
+
19
+ ## 2.0.1 — 2026-11-05
20
+
21
+ ### Internal
22
+
23
+ - Publishing is automatic: pushing a `v*` tag publishes to npm from GitHub Actions through an OIDC trusted publisher, with provenance. A mismatched tag/version fails the run instead of shipping the wrong thing.
24
+
25
+
3
26
  ## 2.0.0 — 2026-11-05
4
27
 
5
28
  ### Breaking
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/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.0",
3
+ "version": "2.1.0",
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",