@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 +23 -0
- package/lib/auth.js +82 -6
- package/lib/index.js +20 -0
- package/lib/inject.js +34 -0
- package/package.json +1 -1
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
|
|
8
|
-
*
|
|
9
|
-
*
|
|
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
|
|
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)
|
|
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.
|
|
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",
|