@fonlan/dsh-web-auth 0.1.4 → 0.1.7
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/README.md +26 -1
- package/lib/context-types.d.ts +12 -2
- package/lib/gate.d.ts +7 -2
- package/lib/gate.js +8 -2
- package/lib/handlers.d.ts +17 -0
- package/lib/handlers.js +88 -2
- package/lib/index.d.ts +18 -1
- package/lib/index.js +85 -3
- package/lib/remote-unlock.d.ts +58 -0
- package/lib/remote-unlock.js +65 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -11,6 +11,8 @@
|
|
|
11
11
|
- **防爆破**:登录与改密接口按客户端 IP 限速(连续 5 次失败锁 1 分钟起,指数退避至 30 分钟;15 分钟内累计)
|
|
12
12
|
- **反代友好**:Cookie `Secure` 自动跟随 `x-forwarded-proto`;限速信任回环对端的 `X-Forwarded-For`
|
|
13
13
|
- **监听地址切换**:设置卡片可一键把 dsh 的监听地址在 `127.0.0.1`(仅本机)与 `0.0.0.0`(所有网卡 / 局域网可访问)之间切换,热生效、无需重启进程(写入 `web-auth` settings 命名空间 → host 半区应用 patch → HMR 重绑 webserver)
|
|
14
|
+
- **远程设置解锁(可选)**:`unlockRemoteSettings` 组合配置在启动页注入 `__DSH_TRANSPORT__.ownsHost` 标志,让反代域名下的设置面(模型页、插件配置、常规设置等)完整可用,默认关闭(见下文安全权衡)
|
|
15
|
+
- **启动令牌收口**:未登录浏览器带旧 `?token=` 链接访问一律重定向回登录页,只有插件自身的服务端 mint 走回环放行通道——既不需要手工引导,也不会再撞上 DSH 的 `401` 死页面(`0.1.7` 起)
|
|
14
16
|
- 密码以 **scrypt 加盐哈希**存储(`$DSH_HOME/web-auth/password.hash`,0600),永不回显、不落明文配置
|
|
15
17
|
|
|
16
18
|
## 安装
|
|
@@ -42,7 +44,11 @@ dsh plugin --profile web update @fonlan/dsh-web-auth
|
|
|
42
44
|
|---|---|
|
|
43
45
|
| 未登录访问页面(GET/HEAD) | `302` → `/login?next=原路径`,登录后跳回 |
|
|
44
46
|
| 未登录访问 API(POST 等) | `401` JSON |
|
|
47
|
+
| DSH 启动令牌交换(`GET /?token=…`) | 仅**插件服务端的 mint 重入**(直连回环对端 + 内部标记头)或**已持有插件会话**的请求放行并改写为回环来源转发:DSH 自行校验令牌、签发自己的 `dsh-auth` cookie(`303` → 干净 `/`);令牌无效则收到 DSH 自带的 `401` |
|
|
48
|
+
| 浏览器直接打开打印的令牌 URL(未登录) | `302` → `/login?next=/`,**丢弃 `token` 参数**:令牌每次启动重新生成,旧书签/旧链接本就无效,放行只会把用户丢到 DSH 的 `401` 页面;登录后服务端 mint 会自动补发有效的 `dsh-auth` cookie |
|
|
49
|
+
| DSH 会话 cookie 自动签发 | 插件在放行响应上**服务端代理 DSH 的令牌交换**(走本机回环 `/?token=`),把 `dsh-auth` Set-Cookie 随响应转发给浏览器——新浏览器无需再手工打开打印的 token URL;每分钟最多一次(结果会持续续期 DSH 的 30 天 cookie) |
|
|
45
50
|
| 反代域名下已登录访问任意路径(含 `/api`、插件前缀) | 改写为回环来源放行,网关特权方法与插件围栏不再 403 |
|
|
51
|
+
| 反代域名下的设置页(模型页等) | 默认报 `settings are unavailable in this browser`——dsh 客户端按页面 hostname 判定"非本机浏览器",设置镜像保持进程本地。开启 `unlockRemoteSettings` 后启动页注入 `ownsHost`,设置面完整可用(读写仍过密码门 + 回环围栏) |
|
|
46
52
|
| 跨站请求(即使带 Cookie) | 仍被围栏 `403` 拒绝 |
|
|
47
53
|
| 未登录 WebSocket 升级 | 握手直接 `401` 拒绝 |
|
|
48
54
|
| 登录失败 | 跳回登录页显示「密码错误」(统一文案) |
|
|
@@ -52,6 +58,8 @@ dsh plugin --profile web update @fonlan/dsh-web-auth
|
|
|
52
58
|
| 切换监听地址(设置卡片) | 校验取值(仅 `127.0.0.1` / `0.0.0.0`)→ 写入 `web-auth` settings 命名空间(revision 设栅)→ host 半区应用 patch(profile 优先,home 兜底)→ HMR 热重载 webserver 重新绑定;**WebSocket 短暂断连后自动重连** |
|
|
53
59
|
| `/logout` | 清除 Cookie,跳转登录页 |
|
|
54
60
|
|
|
61
|
+
> **无需再处理 DSH 自己的启动令牌**:DSH 的浏览器会话认证(`dsh web` 启动时打印的 `http://127.0.0.1:3080/?token=…`)用 cookie 绑定 Host。插件会把所有已认证请求的 Host 改写为回环地址,而 DSH 的 cookie 必须与「DSH 看到的 Host」一致——`0.1.5` 起插件会在登录/会话期间**自动在服务端完成 DSH 令牌交换**并把 `dsh-auth` cookie 转交给浏览器(含持续续期),所以外部设备、新浏览器都不需要再手工访问带 token 的 URL。`0.1.7` 起这条路径进一步收紧:未登录浏览器带 `token` 访问会被重定向到 `/login` 并丢弃该参数(旧令牌在 dsh 重启后即失效,放行只会展示 DSH 自己的 `401` 页面),登录本身就已经完成 mint,不再需要任何手工引导步骤。
|
|
62
|
+
|
|
55
63
|
### 切换监听地址
|
|
56
64
|
|
|
57
65
|
dsh 默认只监听 `127.0.0.1`。在 **设置 → 插件 → 插件配置 → 访问认证** 卡片底部可以把它切换为 `0.0.0.0`(所有网卡,局域网可访问),或切回仅本机——无需重启 dsh 进程:
|
|
@@ -63,6 +71,22 @@ dsh 默认只监听 `127.0.0.1`。在 **设置 → 插件 → 插件配置 →
|
|
|
63
71
|
|
|
64
72
|
> 该能力依赖 webserver 行位于 profile/home patch 层(默认安装即如此)。若你的 webserver 配置来自自定义 `--patch` overlay,请直接编辑 overlay 文件。
|
|
65
73
|
|
|
74
|
+
### 远程设置解锁(`unlockRemoteSettings`,默认关闭)
|
|
75
|
+
|
|
76
|
+
**现象**:通过域名反代访问 dsh 时,设置 → 模型页报「加载提供方目录失败: settings are unavailable in this browser」,常规设置、插件配置等设置面同样不可用。这不是故障,是 dsh 的有意设计:设置文档(含 provider 凭据引用等敏感配置)只对"本机浏览器"开放——客户端在页面加载时按 `location.hostname` 判定是否回环(`localhost` / `127.x`),反代域名永远不算,设置镜像因此保持进程本地、根本不发起读取。注意这与服务端无关:本插件已把已认证请求改写为回环来源,`/api` 通道完全正常,只有客户端这道门在拦。
|
|
77
|
+
|
|
78
|
+
**开启**:在 profile 的 `cordis.patch.yml`(`~/.dsh/profiles/<profile>/cordis.patch.yml`)加一条 config 覆盖:
|
|
79
|
+
|
|
80
|
+
```yaml
|
|
81
|
+
- id: web-auth
|
|
82
|
+
config:
|
|
83
|
+
unlockRemoteSettings: true
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
HMR 会监听到 patch 变化并热重载 web-auth 行(无需重启进程)。生效后,域名下已登录页面的启动 HTML 会携带一段内联脚本,设置 `window.__DSH_TRANSPORT__.ownsHost = true`——dsh 客户端把它读作"该页面由宿主直接提供",设置镜像随之正常加载。只在成功渲染的 index 响应上注入(未登录请求在闸门处就被 302/401,根本到不了渲染);已存在的 `__DSH_TRANSPORT__` 载体只会合并、不会被覆盖。不需要时删掉该配置即回退(同样热生效)。
|
|
87
|
+
|
|
88
|
+
**安全权衡(请务必理解后再开启)**:`ownsHost` 是 dsh 画的"设置仅本机可改"边界;开启等于把这道边界改由本插件的密码门承担——**每个通过密码认证的浏览器**都能读写 host 设置文档(包括 provider API key 的引用、监听地址等)。若域名暴露在公网,请确保:强密码、反代全程 HTTPS、最好叠加反代层限流/IP 白名单。只在本机/内网使用或设置面不受影响时,保持默认关闭即可。
|
|
89
|
+
|
|
66
90
|
## 安全模型与风险(请务必阅读)
|
|
67
91
|
|
|
68
92
|
- **认证是访问控制,不是安全边界**。dsh 的 agent 拥有 bash 执行能力(远程代码执行级别),即使有密码保护,也不要把它当成不可攻破的堡垒:密码强度、反代层 WAF/限流、IP 白名单仍然值得做。
|
|
@@ -77,7 +101,7 @@ dsh 默认只监听 `127.0.0.1`。在 **设置 → 插件 → 插件配置 →
|
|
|
77
101
|
pnpm install
|
|
78
102
|
npm run build # host: tsc → lib/;client: esbuild → lib/client.js(ModuleLoader 格式)
|
|
79
103
|
npm run typecheck
|
|
80
|
-
npm run test #
|
|
104
|
+
npm run test # 单元 + 集成测试(node:test,真实 HTTP/WS 全流程)
|
|
81
105
|
```
|
|
82
106
|
|
|
83
107
|
仓库结构:
|
|
@@ -86,6 +110,7 @@ npm run test # 32 项单元 + 集成测试(node:test,真实 HTTP/WS
|
|
|
86
110
|
- `src/gate.ts` — 把认证闸门包到 node:http 服务器的 request/upgrade 监听器外层
|
|
87
111
|
- `src/handlers.ts` — 登录页/登录/登出/改密/状态/监听切换 路由处理
|
|
88
112
|
- `src/profile-patch.ts` — 监听地址切换:webserver 行的 patch 层文本改写(保留注释与 `!!js` 表达式)
|
|
113
|
+
- `src/remote-unlock.ts` — 远程设置解锁:`unlockRemoteSettings` 开启时经 `webServer.tapIndex` 注入 `ownsHost` 启动页脚本
|
|
89
114
|
- `src/settings.ts` — `web-auth` settings 命名空间注册 + listenHost 变更应用(卡片的分发键与数据源)
|
|
90
115
|
- `src/index.ts` — cordis 插件装配(host 半区)
|
|
91
116
|
- `src/client/index.tsx` — 设置卡片(浏览器半区,`settings.plugin.item` 插槽,keyed by `web-auth` 命名空间)
|
package/lib/context-types.d.ts
CHANGED
|
@@ -9,8 +9,12 @@ import type { IncomingMessage, Server, ServerResponse } from 'node:http';
|
|
|
9
9
|
export interface WebServerService {
|
|
10
10
|
/** The underlying node:http server (gated by wrapping its listeners). */
|
|
11
11
|
server: Server;
|
|
12
|
-
/**
|
|
13
|
-
|
|
12
|
+
/**
|
|
13
|
+
* The bound listening port (the OS-assigned value when config.port is 0).
|
|
14
|
+
* Undefined until the socket is bound, which happens concurrently with
|
|
15
|
+
* plugin loading — read it per request, never capture it at apply time.
|
|
16
|
+
*/
|
|
17
|
+
port?: number;
|
|
14
18
|
/** The configured bind host ('127.0.0.1' or '0.0.0.0'). */
|
|
15
19
|
host: string;
|
|
16
20
|
/** Register an exact-path route; returns the disposer. */
|
|
@@ -19,6 +23,12 @@ export interface WebServerService {
|
|
|
19
23
|
path: string;
|
|
20
24
|
handler: (req: IncomingMessage, res: ServerResponse) => void;
|
|
21
25
|
}): () => void;
|
|
26
|
+
/**
|
|
27
|
+
* Register a raw-HTML index transform over the boot page render (applied
|
|
28
|
+
* after the structured injection rows, in registration order); returns
|
|
29
|
+
* the disposer removing it.
|
|
30
|
+
*/
|
|
31
|
+
tapIndex(transform: (html: string) => string): () => void;
|
|
22
32
|
}
|
|
23
33
|
/** The plugin context members this plugin consumes. */
|
|
24
34
|
export interface PluginContext {
|
package/lib/gate.d.ts
CHANGED
|
@@ -18,8 +18,13 @@ import type { Duplex } from 'node:stream';
|
|
|
18
18
|
export interface GateHandlers {
|
|
19
19
|
/** Decide whether one request may proceed; false = gate answers it. */
|
|
20
20
|
allow(req: IncomingMessage, res: ServerResponse): boolean;
|
|
21
|
-
/**
|
|
22
|
-
|
|
21
|
+
/**
|
|
22
|
+
* Hook after an allowed request (cookie refresh / DSH cookie mint).
|
|
23
|
+
* May be async: the gate awaits it BEFORE forwarding, so response
|
|
24
|
+
* headers it appends reach the client; it must never throw, and a
|
|
25
|
+
* rejection must not prevent the downstream dispatch.
|
|
26
|
+
*/
|
|
27
|
+
passed?(req: IncomingMessage, res: ServerResponse): void | Promise<void>;
|
|
23
28
|
/** Decide whether one WebSocket upgrade may proceed. */
|
|
24
29
|
allowUpgrade(req: IncomingMessage): boolean;
|
|
25
30
|
}
|
package/lib/gate.js
CHANGED
|
@@ -22,10 +22,16 @@ export function installGate(server, handlers) {
|
|
|
22
22
|
const upgradeListeners = server.listeners('upgrade');
|
|
23
23
|
server.removeAllListeners('request');
|
|
24
24
|
server.removeAllListeners('upgrade');
|
|
25
|
-
server.on('request', (req, res) => {
|
|
25
|
+
server.on('request', async (req, res) => {
|
|
26
26
|
if (!handlers.allow(req, res))
|
|
27
27
|
return;
|
|
28
|
-
|
|
28
|
+
try {
|
|
29
|
+
await handlers.passed?.(req, res);
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
// A failing passed() hook must never block the downstream dispatch
|
|
33
|
+
// (response headers it could not attach are a soft failure).
|
|
34
|
+
}
|
|
29
35
|
for (const listener of requestListeners) {
|
|
30
36
|
;
|
|
31
37
|
listener(req, res);
|
package/lib/handlers.d.ts
CHANGED
|
@@ -21,6 +21,14 @@ export interface HandlerEnv {
|
|
|
21
21
|
state: AuthState;
|
|
22
22
|
limiter: RateLimiter;
|
|
23
23
|
now?: () => number;
|
|
24
|
+
/**
|
|
25
|
+
* Mint DSH's own browser-session cookie (the one dsh-client-connection
|
|
26
|
+
* validates against the process launch token) server-side, returning the
|
|
27
|
+
* raw Set-Cookie value to relay to the user's browser, or undefined when
|
|
28
|
+
* the exchange cannot run (no connection service, unreachable loopback,
|
|
29
|
+
* fetch failure). See buildDshCookieMinter in index.ts.
|
|
30
|
+
*/
|
|
31
|
+
mintDshCookie?(): Promise<string | undefined>;
|
|
24
32
|
/**
|
|
25
33
|
* Loopback authority ("127.0.0.1:<port>") used to rewrite the Host/Origin
|
|
26
34
|
* of AUTHENTICATED requests (any path prefix).
|
|
@@ -87,7 +95,16 @@ export declare function setSessionCookie(res: ServerResponse, secret: Buffer, no
|
|
|
87
95
|
export interface Gate {
|
|
88
96
|
allow(req: IncomingMessage, res: ServerResponse): boolean;
|
|
89
97
|
allowUpgrade(req: IncomingMessage): boolean;
|
|
98
|
+
passed?(req: IncomingMessage, res: ServerResponse): void | Promise<void>;
|
|
90
99
|
}
|
|
100
|
+
/** Number of `token` query parameters in the request URL. */
|
|
101
|
+
export declare function tokenQueryCount(req: IncomingMessage): number;
|
|
102
|
+
/**
|
|
103
|
+
* Marker header the server-side mint (buildDshCookieMinter) stamps on its
|
|
104
|
+
* loopback re-entry, letting the gate distinguish it from a browser that
|
|
105
|
+
* merely carries a (possibly stale) printed `?token=` URL.
|
|
106
|
+
*/
|
|
107
|
+
export declare const MINT_REENTRY_HEADER = "x-dsh-web-auth-mint";
|
|
91
108
|
/**
|
|
92
109
|
* Build the gate used to wrap the HTTP server listeners. Whitelisted paths
|
|
93
110
|
* (login page/form, logout) pass through; everything else requires a valid
|
package/lib/handlers.js
CHANGED
|
@@ -97,13 +97,44 @@ export function setSessionCookie(res, secret, now, secure) {
|
|
|
97
97
|
];
|
|
98
98
|
if (secure)
|
|
99
99
|
parts.push('Secure');
|
|
100
|
-
|
|
100
|
+
// appendHeader (not setHeader): the gate may have already attached the
|
|
101
|
+
// DSH cookie to this response — a setHeader would silently drop it.
|
|
102
|
+
res.appendHeader('set-cookie', parts.join('; '));
|
|
101
103
|
}
|
|
102
104
|
function clearCookie(res, secure) {
|
|
103
105
|
const parts = [COOKIE_NAME + '=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0'];
|
|
104
106
|
if (secure)
|
|
105
107
|
parts.push('Secure');
|
|
106
|
-
res.
|
|
108
|
+
res.appendHeader('set-cookie', parts.join('; '));
|
|
109
|
+
}
|
|
110
|
+
/** Minimum interval between server-side DSH cookie mints (debounce). */
|
|
111
|
+
const DSH_MINT_DEBOUNCE_MS = 1000;
|
|
112
|
+
/** Number of `token` query parameters in the request URL. */
|
|
113
|
+
export function tokenQueryCount(req) {
|
|
114
|
+
try {
|
|
115
|
+
return new URL(req.url ?? '/', 'http://x').searchParams.getAll('token').length;
|
|
116
|
+
}
|
|
117
|
+
catch {
|
|
118
|
+
return 0;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Marker header the server-side mint (buildDshCookieMinter) stamps on its
|
|
123
|
+
* loopback re-entry, letting the gate distinguish it from a browser that
|
|
124
|
+
* merely carries a (possibly stale) printed `?token=` URL.
|
|
125
|
+
*/
|
|
126
|
+
export const MINT_REENTRY_HEADER = 'x-dsh-web-auth-mint';
|
|
127
|
+
/**
|
|
128
|
+
* Whether this tokened request is the server-side mint's own loopback
|
|
129
|
+
* re-entry: the marker header AND a direct loopback TCP peer (not
|
|
130
|
+
* clientIp's XFF-aware view — a proxied remote client must not pass even
|
|
131
|
+
* if it forges the header, and the mint never traverses the proxy).
|
|
132
|
+
*/
|
|
133
|
+
function isMintReentry(req) {
|
|
134
|
+
if (req.headers[MINT_REENTRY_HEADER] !== '1')
|
|
135
|
+
return false;
|
|
136
|
+
const direct = req.socket.remoteAddress ?? '';
|
|
137
|
+
return direct === '127.0.0.1' || direct === '::1' || direct === '::ffff:127.0.0.1';
|
|
107
138
|
}
|
|
108
139
|
/**
|
|
109
140
|
* Build the gate used to wrap the HTTP server listeners. Whitelisted paths
|
|
@@ -114,6 +145,8 @@ function clearCookie(res, secure) {
|
|
|
114
145
|
*/
|
|
115
146
|
export function createGate(env) {
|
|
116
147
|
const now = env.now ?? Date.now;
|
|
148
|
+
/** Last server-side DSH cookie mint attempt (epoch ms). */
|
|
149
|
+
let lastMintAt = 0;
|
|
117
150
|
const whitelisted = (method, pathname) => {
|
|
118
151
|
if (pathname === '/login')
|
|
119
152
|
return method === 'GET' || method === 'HEAD' || method === 'POST';
|
|
@@ -122,6 +155,32 @@ export function createGate(env) {
|
|
|
122
155
|
return false;
|
|
123
156
|
};
|
|
124
157
|
const sessionOf = (req) => verifySession(cookieValue(req.headers.cookie, COOKIE_NAME), env.state.secret, now());
|
|
158
|
+
/**
|
|
159
|
+
* Mint DSH's browser-session cookie and attach it to the response when due
|
|
160
|
+
* (debounced globally, ~1/s — the mint is one cheap loopback exchange).
|
|
161
|
+
* Runs for ANY allowed request, authenticated or not: the minted DSH
|
|
162
|
+
* cookie alone never opens the gate (every other path still requires the
|
|
163
|
+
* plugin session), and minting early (login page, setup redirect) means a
|
|
164
|
+
* fresh browser carries both cookies by the time it first reaches DSH.
|
|
165
|
+
*/
|
|
166
|
+
const maybeMintDshCookie = async (res) => {
|
|
167
|
+
const mint = env.mintDshCookie;
|
|
168
|
+
if (mint === undefined)
|
|
169
|
+
return;
|
|
170
|
+
const t = now();
|
|
171
|
+
if (t - lastMintAt < DSH_MINT_DEBOUNCE_MS)
|
|
172
|
+
return;
|
|
173
|
+
lastMintAt = t;
|
|
174
|
+
try {
|
|
175
|
+
const cookie = await mint();
|
|
176
|
+
if (cookie !== undefined && !res.headersSent) {
|
|
177
|
+
res.appendHeader('set-cookie', cookie);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
catch (error) {
|
|
181
|
+
console.error('[dsh-web-auth] DSH cookie mint failed:', error);
|
|
182
|
+
}
|
|
183
|
+
};
|
|
125
184
|
/**
|
|
126
185
|
* Present AUTHENTICATED traffic as loopback to every downstream
|
|
127
186
|
* browser-trust fence, without tracking plugin path prefixes. The /api
|
|
@@ -155,6 +214,30 @@ export function createGate(env) {
|
|
|
155
214
|
const method = req.method ?? 'GET';
|
|
156
215
|
if (whitelisted(method, pathname))
|
|
157
216
|
return true;
|
|
217
|
+
// DSH launch-token exchange: GET / with a `token` query param is DSH's
|
|
218
|
+
// own bootstrap surface (the URL printed at startup). DSH validates the
|
|
219
|
+
// token itself and either mints its browser-session cookie (303 → clean
|
|
220
|
+
// /) or answers its own 401. Forward WITHOUT a plugin session only for
|
|
221
|
+
// the server-side mint's loopback re-entry (marker header + direct
|
|
222
|
+
// loopback peer, see buildDshCookieMinter) or when the browser already
|
|
223
|
+
// holds a plugin session — the minted cookie alone cannot open the gate
|
|
224
|
+
// (every other path still demands the session) — and always present the
|
|
225
|
+
// forwarded request as loopback, so the cookie DSH mints binds to the
|
|
226
|
+
// same authority every authenticated request uses after the rewrite.
|
|
227
|
+
// Any OTHER tokened request is a remote browser with a launch token that
|
|
228
|
+
// is stale the moment dsh restarts (the token is per-boot); letting it
|
|
229
|
+
// through renders DSH's raw 401 dead-end page. Redirect to /login
|
|
230
|
+
// instead: the server-side mint relays a fresh DSH cookie on the login
|
|
231
|
+
// response, so the post-login / request is already authenticated and
|
|
232
|
+
// the browser never needs the token itself.
|
|
233
|
+
if (method === 'GET' && pathname === '/' && tokenQueryCount(req) > 0) {
|
|
234
|
+
if (sessionOf(req) !== undefined || isMintReentry(req)) {
|
|
235
|
+
rewriteAsLoopback(req);
|
|
236
|
+
return true;
|
|
237
|
+
}
|
|
238
|
+
redirect(res, '/login?next=' + encodeURIComponent(sanitizeNext(pathname) ?? '/'));
|
|
239
|
+
return false;
|
|
240
|
+
}
|
|
158
241
|
const session = sessionOf(req);
|
|
159
242
|
if (session !== undefined) {
|
|
160
243
|
if (needsSessionRefresh(session, now())) {
|
|
@@ -177,6 +260,9 @@ export function createGate(env) {
|
|
|
177
260
|
return false;
|
|
178
261
|
rewriteAsLoopback(req);
|
|
179
262
|
return true;
|
|
263
|
+
},
|
|
264
|
+
async passed(req, res) {
|
|
265
|
+
await maybeMintDshCookie(res);
|
|
180
266
|
}
|
|
181
267
|
};
|
|
182
268
|
}
|
package/lib/index.d.ts
CHANGED
|
@@ -1,10 +1,27 @@
|
|
|
1
1
|
import type { PluginContext } from './context-types.ts';
|
|
2
|
+
import z from '@deepseek-ai/schemastery';
|
|
2
3
|
/** Stable Cordis plugin name. */
|
|
3
4
|
export declare const name = "web-auth";
|
|
4
5
|
/** Services required before the gate can be installed. */
|
|
5
6
|
export declare const inject: string[];
|
|
7
|
+
/** The plugin's composition config (patch-layer `config:` overrides). */
|
|
8
|
+
export interface WebAuthConfig {
|
|
9
|
+
/**
|
|
10
|
+
* Inject the `__DSH_TRANSPORT__.ownsHost` flag into the served boot page
|
|
11
|
+
* so pages reached through a reverse proxy/domain load the host settings
|
|
12
|
+
* document and the settings plane works remotely. Default off: the flag
|
|
13
|
+
* is a dsh security boundary, and turning it over lets every
|
|
14
|
+
* session-authenticated browser read and write the host settings document
|
|
15
|
+
* from wherever the domain is reachable. See remote-unlock.ts.
|
|
16
|
+
*/
|
|
17
|
+
unlockRemoteSettings: boolean;
|
|
18
|
+
}
|
|
19
|
+
/** Schema resolving the composition config (cordis Standard-Schema path). */
|
|
20
|
+
export declare const Config: z<WebAuthConfig>;
|
|
6
21
|
/**
|
|
7
22
|
* Mount the auth gate and its routes.
|
|
8
23
|
* @param ctx - plugin context carrying the webServer service (listening).
|
|
24
|
+
* @param config - validated composition config (Config schema defaults when
|
|
25
|
+
* the patch row spells none).
|
|
9
26
|
*/
|
|
10
|
-
export declare function apply(ctx: PluginContext): void;
|
|
27
|
+
export declare function apply(ctx: PluginContext, config: WebAuthConfig): void;
|
package/lib/index.js
CHANGED
|
@@ -10,25 +10,38 @@
|
|
|
10
10
|
* state.ts. Until a password is configured the gate is OPEN and a boot
|
|
11
11
|
* warning is logged; the login page then offers first-password setup.
|
|
12
12
|
*
|
|
13
|
+
* Opt-in composition config (`unlockRemoteSettings`, Config schema below)
|
|
14
|
+
* injects the `__DSH_TRANSPORT__.ownsHost` flag into the served boot page
|
|
15
|
+
* (webServer.tapIndex) so pages reached through a reverse proxy/domain can
|
|
16
|
+
* load the host settings document — see remote-unlock.ts.
|
|
17
|
+
*
|
|
13
18
|
* Install (bundle layer): dsh plugin --profile web add ./dsh-web-auth
|
|
14
19
|
*/
|
|
15
20
|
import { installGate } from "./gate.js";
|
|
16
21
|
import { RateLimiter } from "./auth-core.js";
|
|
17
22
|
import { readSecret, readPasswordHash, resolveDshHome, rotateSecret, writePasswordHash } from "./state.js";
|
|
18
|
-
import { createGate, handleChangePassword, handleListenChange, handleLoginPage, handleLoginPost, handleLogout, handleStatus } from "./handlers.js";
|
|
23
|
+
import { MINT_REENTRY_HEADER, createGate, handleChangePassword, handleListenChange, handleLoginPage, handleLoginPost, handleLogout, handleStatus } from "./handlers.js";
|
|
19
24
|
import { LISTEN_APPLY_DELAY_MS, prepareWebserverHostPatch, resolveListenPatchPath, writeWebserverHostPatch } from "./profile-patch.js";
|
|
20
25
|
import { installWebAuthSettings } from "./settings.js";
|
|
26
|
+
import { injectOwnsHost } from "./remote-unlock.js";
|
|
21
27
|
import { fileURLToPath } from 'node:url';
|
|
22
28
|
import { join } from 'node:path';
|
|
29
|
+
import z from '@deepseek-ai/schemastery';
|
|
23
30
|
/** Stable Cordis plugin name. */
|
|
24
31
|
export const name = 'web-auth';
|
|
25
32
|
/** Services required before the gate can be installed. */
|
|
26
33
|
export const inject = ['webServer'];
|
|
34
|
+
/** Schema resolving the composition config (cordis Standard-Schema path). */
|
|
35
|
+
export const Config = z.object({
|
|
36
|
+
unlockRemoteSettings: z.boolean().default(false)
|
|
37
|
+
});
|
|
27
38
|
/**
|
|
28
39
|
* Mount the auth gate and its routes.
|
|
29
40
|
* @param ctx - plugin context carrying the webServer service (listening).
|
|
41
|
+
* @param config - validated composition config (Config schema defaults when
|
|
42
|
+
* the patch row spells none).
|
|
30
43
|
*/
|
|
31
|
-
export function apply(ctx) {
|
|
44
|
+
export function apply(ctx, config) {
|
|
32
45
|
const dshHome = resolveDshHome();
|
|
33
46
|
const state = {
|
|
34
47
|
secret: readSecret(dshHome),
|
|
@@ -43,10 +56,26 @@ export function apply(ctx) {
|
|
|
43
56
|
const env = {
|
|
44
57
|
state,
|
|
45
58
|
limiter: new RateLimiter(),
|
|
59
|
+
// Server-side mint of DSH's own browser-session cookie: relayed to the
|
|
60
|
+
// browser on allowed responses so DSH's launch-token auth no longer
|
|
61
|
+
// requires opening the printed ?token= URL per browser. Bound lazily —
|
|
62
|
+
// a headless/CLI profile without the connection service simply never
|
|
63
|
+
// mints (each request then fails DSH auth silently, as before).
|
|
64
|
+
mintDshCookie: buildDshCookieMinter(ctx),
|
|
46
65
|
// Authenticated /api requests are presented to the gateway's trust fence
|
|
47
66
|
// as loopback, so the privileged-method pinning (settings.*, credentials.*,
|
|
48
67
|
// discoverModels, …) passes through the reverse proxy. See handlers.ts.
|
|
49
|
-
|
|
68
|
+
// Read per request, never captured: `webServer.port` is the *bound* port
|
|
69
|
+
// and plugins load concurrently with the webserver's bind, so it is still
|
|
70
|
+
// unassigned while this plugin applies (evaluating it here would freeze
|
|
71
|
+
// the authority into an unparseable "127.0.0.1:undefined" that every
|
|
72
|
+
// downstream fence then rejects). No port yet means no rewrite — the
|
|
73
|
+
// request's own Host is already a valid authority, which is also how a
|
|
74
|
+
// profile whose webserver is not listening at all stays consistent.
|
|
75
|
+
get loopbackAuthority() {
|
|
76
|
+
const port = ctx.webServer.port;
|
|
77
|
+
return port === undefined ? undefined : '127.0.0.1:' + String(port);
|
|
78
|
+
},
|
|
50
79
|
// Live listen-host switching: the webserver's bind host is composition
|
|
51
80
|
// config owned by the profile patch layer (or the home patch when it
|
|
52
81
|
// spells the webserver row), so the change is written there and the HMR
|
|
@@ -84,6 +113,12 @@ export function apply(ctx) {
|
|
|
84
113
|
ctx.effect(() => {
|
|
85
114
|
return () => disposeGate?.();
|
|
86
115
|
}, 'web-auth: auth gate');
|
|
116
|
+
// ── remote settings unlock (opt-in): inject ownsHost into the boot page ───
|
|
117
|
+
// A config-only patch change restarts this fiber (cordis update →
|
|
118
|
+
// dispose + re-apply), so the tap flips live with the composition.
|
|
119
|
+
if (config.unlockRemoteSettings) {
|
|
120
|
+
ctx.effect(() => ctx.webServer.tapIndex(injectOwnsHost), 'web-auth: remote settings unlock tap');
|
|
121
|
+
}
|
|
87
122
|
// ── routes (exact table; /api/web-auth/password wins over the /api prefix) ─
|
|
88
123
|
const routes = [
|
|
89
124
|
{
|
|
@@ -131,6 +166,53 @@ export function apply(ctx) {
|
|
|
131
166
|
ctx.effect(() => ctx.webServer.register({ kind: 'exact', path: route.path, handler: route.handler }), `web-auth: ${route.path} route`);
|
|
132
167
|
}
|
|
133
168
|
}
|
|
169
|
+
/**
|
|
170
|
+
* Server-side DSH browser-session cookie mint.
|
|
171
|
+
*
|
|
172
|
+
* dsh-client-connection exposes the process launch-token URL through its
|
|
173
|
+
* `connection` service (authenticatedUrl — the same call dsh-web-app uses to
|
|
174
|
+
* print the startup URL). This minter fetches that URL over the server's own
|
|
175
|
+
* loopback: the gate's token-exchange branch forwards it (still rewriting
|
|
176
|
+
* Host to loopback), DSH validates the token and answers a 303 with the
|
|
177
|
+
* signed `dsh-auth-<sha256(loopback)>` cookie — exactly the cookie every
|
|
178
|
+
* authenticated request needs after the Host rewrite. The Set-Cookie value is
|
|
179
|
+
* returned so the gate can relay it to the user's browser on ordinary
|
|
180
|
+
* responses, meaning new browsers/endpoints no longer need the printed token
|
|
181
|
+
* (and DSH's 30-day cookie is refreshed continuously).
|
|
182
|
+
*
|
|
183
|
+
* Returns undefined when the exchange cannot run (no connection service,
|
|
184
|
+
* non-loopback-only bind, fetch failure) — the caller degrades to the old
|
|
185
|
+
* behavior where the cookie must arrive via the printed token URL.
|
|
186
|
+
*/
|
|
187
|
+
function buildDshCookieMinter(ctx) {
|
|
188
|
+
let connection;
|
|
189
|
+
ctx.inject(['connection'], (cctx) => {
|
|
190
|
+
connection = cctx.connection;
|
|
191
|
+
});
|
|
192
|
+
return async () => {
|
|
193
|
+
if (connection === undefined)
|
|
194
|
+
return undefined;
|
|
195
|
+
const host = ctx.webServer.host === '0.0.0.0' || ctx.webServer.host === '::' ? '127.0.0.1' : ctx.webServer.host;
|
|
196
|
+
try {
|
|
197
|
+
const url = connection.authenticatedUrl(`http://${host}:${String(ctx.webServer.port)}`);
|
|
198
|
+
// The marker header is what the gate's token branch keys on to forward
|
|
199
|
+
// this re-entry without a plugin session; without it the loopback
|
|
200
|
+
// tokened GET would be redirected to /login like any stale bookmark.
|
|
201
|
+
const response = await fetch(url, { redirect: 'manual', headers: { [MINT_REENTRY_HEADER]: '1' } });
|
|
202
|
+
const cookies = response.headers.getSetCookie?.() ?? [];
|
|
203
|
+
const all = cookies.length > 0
|
|
204
|
+
? cookies
|
|
205
|
+
: (response.headers.get('set-cookie') ?? '').length > 0
|
|
206
|
+
? [response.headers.get('set-cookie') ?? '']
|
|
207
|
+
: [];
|
|
208
|
+
return all.find((cookie) => cookie.startsWith('dsh-auth-')) ?? all[0];
|
|
209
|
+
}
|
|
210
|
+
catch (error) {
|
|
211
|
+
console.error('[dsh-web-auth] could not mint DSH browser-session cookie:', error instanceof Error ? error.message : String(error));
|
|
212
|
+
return undefined;
|
|
213
|
+
}
|
|
214
|
+
};
|
|
215
|
+
}
|
|
134
216
|
/**
|
|
135
217
|
* Bind the live listen-host controller to this deployment's patch layer.
|
|
136
218
|
* Resolves which layer owns the webserver row (home outranks the profile),
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-web-auth — remote settings unlock (opt-in composition config).
|
|
3
|
+
*
|
|
4
|
+
* dsh decides "is this page the operator's local browser?" on the CLIENT,
|
|
5
|
+
* once per page load: dsh-client-connection computes `isLoopback` from the
|
|
6
|
+
* page's location hostname (or the transport's `ownsHost` flag), and the
|
|
7
|
+
* settings UI keeps its describe mirror process-local unless the page counts
|
|
8
|
+
* as loopback — every settings surface (models directory, plugin config, …)
|
|
9
|
+
* then answers "settings are unavailable in this browser". Pages reached
|
|
10
|
+
* through a reverse proxy/domain can never pass the hostname check, so the
|
|
11
|
+
* settings plane stays unreachable there EVEN THOUGH this plugin already
|
|
12
|
+
* presents every session-authenticated request to the server-side fences as
|
|
13
|
+
* loopback (the Host rewrite in handlers.ts): the two trust decisions are
|
|
14
|
+
* read on opposite sides of the wire.
|
|
15
|
+
*
|
|
16
|
+
* This module closes that gap with the one server-side signal the client
|
|
17
|
+
* honors: the webserver renders the boot page through a tap chain
|
|
18
|
+
* (`webServer.tapIndex`, the raw-HTML escape hatch DSH exposes for exactly
|
|
19
|
+
* this), and the client entry reads `globalThis.__DSH_TRANSPORT__.ownsHost`
|
|
20
|
+
* as the "this transport owns the host" flag. A tap that appends a tiny
|
|
21
|
+
* inline script setting the flag makes a proxied page present as a
|
|
22
|
+
* host-owned page: the settings mirror loads over the (already
|
|
23
|
+
* authenticated, already loopback-presented) /api plane and the settings
|
|
24
|
+
* UI works remotely.
|
|
25
|
+
*
|
|
26
|
+
* Deliberate scope:
|
|
27
|
+
* - Runs ONLY on rendered index responses. Unauthenticated requests are
|
|
28
|
+
* answered by the gate (302 /login) or DSH's own index auth before any
|
|
29
|
+
* render, so a proxied page receives the script only after passing both
|
|
30
|
+
* the plugin session and DSH's browser-session cookie.
|
|
31
|
+
* - Only ADDS the flag: an existing `__DSH_TRANSPORT__` carrier (fetch /
|
|
32
|
+
* openStream / loadBundle on an embedding platform) is merged, never
|
|
33
|
+
* replaced.
|
|
34
|
+
* - Off unless the composition opts in (`unlockRemoteSettings: true`):
|
|
35
|
+
* the flag IS the security boundary dsh drew — enabling it lets every
|
|
36
|
+
* authenticated browser read and write the host settings document from
|
|
37
|
+
* wherever the domain is reachable. The deployment owner makes that
|
|
38
|
+
* call, in the patch layer, next to the listen host it extends.
|
|
39
|
+
*/
|
|
40
|
+
/** Marker attribute on the injected script tag (also the idempotency key). */
|
|
41
|
+
export declare const OWNS_HOST_MARKER = "data-dsh-web-auth=\"owns-host\"";
|
|
42
|
+
/**
|
|
43
|
+
* The injected script: set `ownsHost` on the page transport, creating the
|
|
44
|
+
* transport object only when the page carries none. A classic (non-module)
|
|
45
|
+
* inline script — parsed before the deferred module entry executes, which is
|
|
46
|
+
* where dsh-client-connection reads the flag once.
|
|
47
|
+
*/
|
|
48
|
+
export declare const OWNS_HOST_SCRIPT = "<script data-dsh-web-auth=\"owns-host\">window.__DSH_TRANSPORT__ = window.__DSH_TRANSPORT__ || {}; window.__DSH_TRANSPORT__.ownsHost = true;</script>";
|
|
49
|
+
/**
|
|
50
|
+
* Append the ownsHost script to a rendered index.html body, before
|
|
51
|
+
* `</body>` (falling back to the end when the body tag is missing — the
|
|
52
|
+
* html-to-html contract of `webServer.tapIndex` expects a pure transform).
|
|
53
|
+
* Idempotent: a body that already carries the marker is returned as-is, so
|
|
54
|
+
* double registration across an HMR reload can never double-inject.
|
|
55
|
+
* @param html - the rendered index.html body.
|
|
56
|
+
* @returns the body with the script in place.
|
|
57
|
+
*/
|
|
58
|
+
export declare function injectOwnsHost(html: string): string;
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-web-auth — remote settings unlock (opt-in composition config).
|
|
3
|
+
*
|
|
4
|
+
* dsh decides "is this page the operator's local browser?" on the CLIENT,
|
|
5
|
+
* once per page load: dsh-client-connection computes `isLoopback` from the
|
|
6
|
+
* page's location hostname (or the transport's `ownsHost` flag), and the
|
|
7
|
+
* settings UI keeps its describe mirror process-local unless the page counts
|
|
8
|
+
* as loopback — every settings surface (models directory, plugin config, …)
|
|
9
|
+
* then answers "settings are unavailable in this browser". Pages reached
|
|
10
|
+
* through a reverse proxy/domain can never pass the hostname check, so the
|
|
11
|
+
* settings plane stays unreachable there EVEN THOUGH this plugin already
|
|
12
|
+
* presents every session-authenticated request to the server-side fences as
|
|
13
|
+
* loopback (the Host rewrite in handlers.ts): the two trust decisions are
|
|
14
|
+
* read on opposite sides of the wire.
|
|
15
|
+
*
|
|
16
|
+
* This module closes that gap with the one server-side signal the client
|
|
17
|
+
* honors: the webserver renders the boot page through a tap chain
|
|
18
|
+
* (`webServer.tapIndex`, the raw-HTML escape hatch DSH exposes for exactly
|
|
19
|
+
* this), and the client entry reads `globalThis.__DSH_TRANSPORT__.ownsHost`
|
|
20
|
+
* as the "this transport owns the host" flag. A tap that appends a tiny
|
|
21
|
+
* inline script setting the flag makes a proxied page present as a
|
|
22
|
+
* host-owned page: the settings mirror loads over the (already
|
|
23
|
+
* authenticated, already loopback-presented) /api plane and the settings
|
|
24
|
+
* UI works remotely.
|
|
25
|
+
*
|
|
26
|
+
* Deliberate scope:
|
|
27
|
+
* - Runs ONLY on rendered index responses. Unauthenticated requests are
|
|
28
|
+
* answered by the gate (302 /login) or DSH's own index auth before any
|
|
29
|
+
* render, so a proxied page receives the script only after passing both
|
|
30
|
+
* the plugin session and DSH's browser-session cookie.
|
|
31
|
+
* - Only ADDS the flag: an existing `__DSH_TRANSPORT__` carrier (fetch /
|
|
32
|
+
* openStream / loadBundle on an embedding platform) is merged, never
|
|
33
|
+
* replaced.
|
|
34
|
+
* - Off unless the composition opts in (`unlockRemoteSettings: true`):
|
|
35
|
+
* the flag IS the security boundary dsh drew — enabling it lets every
|
|
36
|
+
* authenticated browser read and write the host settings document from
|
|
37
|
+
* wherever the domain is reachable. The deployment owner makes that
|
|
38
|
+
* call, in the patch layer, next to the listen host it extends.
|
|
39
|
+
*/
|
|
40
|
+
/** Marker attribute on the injected script tag (also the idempotency key). */
|
|
41
|
+
export const OWNS_HOST_MARKER = 'data-dsh-web-auth="owns-host"';
|
|
42
|
+
/**
|
|
43
|
+
* The injected script: set `ownsHost` on the page transport, creating the
|
|
44
|
+
* transport object only when the page carries none. A classic (non-module)
|
|
45
|
+
* inline script — parsed before the deferred module entry executes, which is
|
|
46
|
+
* where dsh-client-connection reads the flag once.
|
|
47
|
+
*/
|
|
48
|
+
export const OWNS_HOST_SCRIPT = `<script ${OWNS_HOST_MARKER}>window.__DSH_TRANSPORT__ = window.__DSH_TRANSPORT__ || {}; window.__DSH_TRANSPORT__.ownsHost = true;</script>`;
|
|
49
|
+
/**
|
|
50
|
+
* Append the ownsHost script to a rendered index.html body, before
|
|
51
|
+
* `</body>` (falling back to the end when the body tag is missing — the
|
|
52
|
+
* html-to-html contract of `webServer.tapIndex` expects a pure transform).
|
|
53
|
+
* Idempotent: a body that already carries the marker is returned as-is, so
|
|
54
|
+
* double registration across an HMR reload can never double-inject.
|
|
55
|
+
* @param html - the rendered index.html body.
|
|
56
|
+
* @returns the body with the script in place.
|
|
57
|
+
*/
|
|
58
|
+
export function injectOwnsHost(html) {
|
|
59
|
+
if (html.includes(OWNS_HOST_MARKER))
|
|
60
|
+
return html;
|
|
61
|
+
const closeBody = /<\/body\b[^>]*>/i.exec(html);
|
|
62
|
+
if (closeBody === null)
|
|
63
|
+
return html + OWNS_HOST_SCRIPT;
|
|
64
|
+
return html.slice(0, closeBody.index) + OWNS_HOST_SCRIPT + html.slice(closeBody.index);
|
|
65
|
+
}
|
package/package.json
CHANGED