Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions lib/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,15 @@ export function tokenForHost(host) {
if (isLanOverrideHost(host)) return getLanToken();
return classifyHost(host) === 'public' ? getAccessToken() : getLanToken();
}

/**
* 登录页凭据语义:长期有效的 PIN 允许密码管理器保存;Quick Tunnel 自动生成的
* 公网 PIN 每次隧道开启都会轮换,不应作为长期密码保存。
*/
export function credentialKindForHost(host) {
if (isLanOverrideHost(host) || classifyHost(host) !== 'public') return 'persistent';
return pinCustom('public') || tunnelMode() === 'named' ? 'persistent' : 'ephemeral';
}
/** 去掉 Host 的端口(与 classifyHost 一致),用于和手动设置的局域网地址比较。 */
function hostNameOnly(host) {
let name = String(host ?? '').trim().toLowerCase();
Expand Down Expand Up @@ -313,6 +322,7 @@ export function apply(ctx, config = {}, internals = {}) {
// dsh web 重启/更新后 sessionKey 变化 → 手机需重新输入
sessionKey: randomBytes(16).toString('hex'),
getToken: (host) => tokenForHost(host),
getCredentialKind: (host) => credentialKindForHost(host),
isProtected: (host) => {
if (isLanOverrideHost(host)) return lanAuthEnabled();
return classifyHost(host) === 'public' ? true : lanAuthEnabled();
Expand Down
72 changes: 59 additions & 13 deletions lib/proxy.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -195,10 +195,18 @@ function parseCookies(header) {
return out;
}

/** 登录页:按访问来源显示提示(局域网 / 公网);error: false|true|'locked'(locked 带剩余秒数)。 */
function loginPageHtml(error, isPublic, retryAfter = 0) {
/**
* 登录页:按访问来源显示提示(局域网 / 公网)。
* credentialKind: 'persistent'(可长期复用)|'ephemeral'(短期轮换,不建议保存)。
* error: false|true|'locked'(locked 带剩余秒数)。
*/
function loginPageHtml(error, isPublic, retryAfter = 0, credentialKind = 'ephemeral') {
const where = isPublic ? '此公网地址' : '此局域网地址';
const whereEn = isPublic ? 'This public address' : 'This LAN address';
const autocomplete = credentialKind === 'persistent' ? 'current-password' : 'off';
const usernameField = credentialKind === 'persistent'
? '<input class="implicit-username" id="pocket-username" name="username" type="text" autocomplete="username" value="dsh-pocket" tabindex="-1" aria-hidden="true" readonly>'
: '';
const errMsg = error === 'locked'
? `尝试次数过多,请 ${retryAfter} 秒后再试 | Too many attempts — try again in ${retryAfter}s`
: error ? '密码错误,请重试 | Wrong PIN, try again' : '';
Expand All @@ -210,18 +218,54 @@ body{margin:0;min-height:100vh;display:flex;align-items:center;justify-content:c
.card{background:#fff;border:1px solid #e5e7eb;border-radius:12px;padding:28px 24px;max-width:320px;width:calc(100% - 48px);text-align:center}
h1{font-size:16px;margin:0 0 4px;color:#111827}
p{font-size:13px;color:#6b7280;margin:0 0 16px}
input{width:100%;box-sizing:border-box;padding:10px 12px;font-size:18px;letter-spacing:6px;text-align:center;border:1px solid #d1d5db;border-radius:8px;outline:none;margin-bottom:12px}
input:focus{border-color:#4f6ef7}
button{width:100%;padding:10px;font-size:15px;background:#4f6ef7;color:#fff;border:none;border-radius:8px;cursor:pointer}
label.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}
.implicit-username{display:none}
.pin-field{position:relative;margin-bottom:12px}
input{width:100%;box-sizing:border-box;padding:10px 50px;font-size:18px;line-height:24px;letter-spacing:6px;text-align:center;caret-color:#4f6ef7;border:1px solid #d1d5db;border-radius:8px;outline:none}
input::-ms-reveal,input::-ms-clear{display:none}
input:focus{border-color:#4f6ef7;box-shadow:0 0 0 1px #4f6ef7}
.pin-toggle{position:absolute;right:2px;top:50%;transform:translateY(-50%);display:flex;align-items:center;justify-content:center;width:44px;height:44px;padding:0;background:transparent;color:#6b7280;border:0;border-radius:6px;cursor:pointer}
.pin-toggle:hover{color:#374151;background:#f3f4f6}
.pin-toggle:focus-visible{outline:2px solid #4f6ef7;outline-offset:1px}
.pin-toggle svg{width:20px;height:20px;fill:none;stroke:currentColor;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;pointer-events:none}
.pin-toggle .is-hidden{display:none}
.enter-button{width:100%;padding:10px;font-size:15px;background:#4f6ef7;color:#fff;border:none;border-radius:8px;cursor:pointer}
.err{color:#dc2626;font-size:12px;margin-bottom:10px;min-height:16px}
</style></head><body><div class="card">
<h1>🔐 DSH Pocket</h1>
<p>${where}受访问密码保护,请输入访问密码 | ${whereEn} is password-protected — enter the access PIN</p>
<div class="err">${errMsg}</div>
<form method="post" action="/pocket-login">
<input name="token" type="password" minlength="8" maxlength="64" autocomplete="one-time-code" autofocus required>
<button type="submit">进入 | Enter</button>
${usernameField}
<label class="sr-only" for="current-password">访问密码 | Access PIN</label>
<div class="pin-field">
<input id="current-password" name="token" type="password" minlength="8" maxlength="64" autocomplete="${autocomplete}" autocapitalize="none" spellcheck="false" autofocus required>
<button id="toggle-password" class="pin-toggle" type="button" aria-controls="current-password" aria-pressed="false" aria-label="显示访问密码 | Show access PIN" title="显示密码 | Show password">
<svg id="eye-icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7S2 12 2 12Z"/><circle cx="12" cy="12" r="3"/><path id="eye-slash" d="M3 3l18 18"/></svg>
</button>
</div>
<button class="enter-button" type="submit">进入 | Enter</button>
</form>
<script>
const pinInput=document.getElementById('current-password');
const pinToggle=document.getElementById('toggle-password');
const eyeSlash=document.getElementById('eye-slash');
// 指针点击眼睛时不抢走输入框焦点;键盘 Tab 到按钮时仍保留原生按钮行为。
pinToggle.addEventListener('pointerdown',(event)=>event.preventDefault());
pinToggle.addEventListener('click',()=>{
const show=pinInput.type==='password';
const hadFocus=document.activeElement===pinInput;
const start=pinInput.selectionStart;
const end=pinInput.selectionEnd;
pinInput.type=show?'text':'password';
// 图标表达当前状态:有斜线 = 密码隐藏;无斜线 = 密码可见。
eyeSlash.classList.toggle('is-hidden',show);
pinToggle.setAttribute('aria-pressed',String(show));
pinToggle.setAttribute('aria-label',show?'隐藏访问密码 | Hide access PIN':'显示访问密码 | Show access PIN');
pinToggle.title=show?'隐藏密码 | Hide password':'显示密码 | Show password';
if(hadFocus&&start!==null&&end!==null) pinInput.setSelectionRange(start,end);
});
</script>
</div></body></html>`;
}

Expand Down Expand Up @@ -700,9 +744,10 @@ function attachWebSocketHeartbeat(socket, { intervalMs = 30_000, missLimit = 2 }
* @param {string} [opts.host] 监听地址(默认 0.0.0.0:LAN 与隧道都能到)
* @param {{host:string,port:number}} [opts.upstream] 上游 dsh web(默认 127.0.0.1:3080)
* @param {string} [opts.injectHtml] 注入 HTML 的内容(默认 polyfill + 移动端适配;传 '' 关闭)
* @param {object} [opts.auth] 可选访问令牌认证(issue #13):{ getToken, getAltTokens?, isProtected, sessionKey }
* @param {object} [opts.auth] 可选访问令牌认证(issue #13):{ getToken, getAltTokens?, getCredentialKind?, isProtected, sessionKey }
* - getToken(host) → 主 PIN(公网/局域网各一个,按 host 分类)
* - getAltTokens?(host) → 替代令牌列表(可选),校验时与主 PIN 任一命中即放行
* - getCredentialKind?(host) → 'persistent'|'ephemeral',控制密码管理器提示(默认 ephemeral)
* @param {object|false} [opts.rateLimit] 登录速率限制参数覆盖(issue #40;测试用短窗口)
* @param {object|false} [opts.heartbeat] WebSocket 心跳注入(PR #41):{ intervalMs, missLimit };false 关闭(默认开:30s/容忍 2 个静默周期)
* @param {() => boolean} [opts.lanAccessEnabled] 局域网访问是否开启(默认开启)。关闭时拦截经局域网 Host 的请求(公网/loopback 不受影响)。
Expand Down Expand Up @@ -738,6 +783,7 @@ export function createPocketProxy({ port = 3081, host = '0.0.0.0', upstream = DE
const token = protectedHost ? (auth.getToken?.(host) ?? null) : null;
// 临时 PIN(issue #69):按 host 同源分发(公网临时 PIN 只在公网入口放行,局域网同理)
const altTokens = protectedHost && token && typeof auth.getAltTokens === 'function' ? (auth.getAltTokens(host) ?? []) : [];
const credentialKind = auth.getCredentialKind?.(host) === 'persistent' ? 'persistent' : 'ephemeral';
const sessionKey = auth.sessionKey ?? null;
const acceptedTokens = token ? [token, ...altTokens] : [];
if (protectedHost && token) {
Expand All @@ -751,7 +797,7 @@ export function createPocketProxy({ port = 3081, host = '0.0.0.0', upstream = DE
'cache-control': 'no-store',
'retry-after': String(rl.retryAfter),
});
res.end(loginPageHtml('locked', isPublic, rl.retryAfter));
res.end(loginPageHtml('locked', isPublic, rl.retryAfter, credentialKind));
return;
}
let body = '';
Expand All @@ -766,7 +812,7 @@ export function createPocketProxy({ port = 3081, host = '0.0.0.0', upstream = DE
'cache-control': 'no-store',
'retry-after': String(finalRl.retryAfter),
});
res.end(loginPageHtml('locked', isPublic, finalRl.retryAfter));
res.end(loginPageHtml('locked', isPublic, finalRl.retryAfter, credentialKind));
return;
}
const submitted = String(new URLSearchParams(body).get('token') ?? '');
Expand All @@ -788,7 +834,7 @@ export function createPocketProxy({ port = 3081, host = '0.0.0.0', upstream = DE
limiter?.record(ip);
log?.(`dsh-pocket: login failed from ${ip} | 登录失败 IP: ${ip}`);
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' });
res.end(loginPageHtml(true, isPublic, 0));
res.end(loginPageHtml(true, isPublic, 0, credentialKind));
}
});
return;
Expand All @@ -804,7 +850,7 @@ export function createPocketProxy({ port = 3081, host = '0.0.0.0', upstream = DE
if (rl.locked) {
if (isHtmlRequest(req)) {
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' });
res.end(loginPageHtml('locked', isPublic, rl.retryAfter));
res.end(loginPageHtml('locked', isPublic, rl.retryAfter, credentialKind));
} else {
res.writeHead(429, {
'content-type': 'application/json',
Expand All @@ -826,7 +872,7 @@ export function createPocketProxy({ port = 3081, host = '0.0.0.0', upstream = DE
// 锁定期间打开登录页也给提示(HTTP 200 + 锁定文案;429 语义留给 POST 拒绝)
const rl = limiter?.status(ip) ?? { locked: false, retryAfter: 0 };
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' });
res.end(loginPageHtml(rl.locked ? 'locked' : false, isPublic, rl.retryAfter));
res.end(loginPageHtml(rl.locked ? 'locked' : false, isPublic, rl.retryAfter, credentialKind));
} else {
res.writeHead(401, { 'content-type': 'application/json', 'cache-control': 'no-store' });
res.end('{"error":"unauthorized"}');
Expand Down
23 changes: 21 additions & 2 deletions test/auth-routing.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@ import { mkdtemp, rm } from 'node:fs/promises';
import { readFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { tokenForHost, isLanOverrideHost, rotateAccessToken } from '../lib/index.js';
import { setLanIpOverride, setLanAuthEnabled, lanIpOverride } from '../lib/settings.mjs';
import { tokenForHost, credentialKindForHost, isLanOverrideHost, rotateAccessToken } from '../lib/index.js';
import { setLanIpOverride, setLanAuthEnabled, lanIpOverride, setPinCustom, setTunnelMode } from '../lib/settings.mjs';

let tmpHome = null;
let savedHome = null;
Expand Down Expand Up @@ -79,3 +79,22 @@ test('issue #90:访问 PIN 必须用 CSPRNG 生成,不得回退到 Math.rand
// 200 次采样在 9×10⁷ 空间里几乎不可能撞车;若大量重复说明随机源退化了。
assert.ok(seen.size >= 199, `200 次采样应基本互不相同,实际 ${seen.size} 个不同值`);
}));

test('登录页凭据语义:LAN、Named 和自定义公网 PIN 可保存,Quick 自动 PIN 不保存', () => withTempHome(async () => {
setLanIpOverride('');
setTunnelMode('quick');
setPinCustom('public', false);

assert.equal(credentialKindForHost('192.168.1.5:3081'), 'persistent', 'LAN 自动 PIN 持久到手动刷新');
assert.equal(credentialKindForHost('abc.trycloudflare.com'), 'ephemeral', 'Quick 自动 PIN 每次开隧道轮换');

setPinCustom('public', true);
assert.equal(credentialKindForHost('abc.trycloudflare.com'), 'persistent', 'Quick 自定义 PIN 长期复用');

setPinCustom('public', false);
setTunnelMode('named');
assert.equal(credentialKindForHost('pocket.example.com'), 'persistent', 'Named Tunnel PIN 不自动轮换');

setLanIpOverride('203.0.113.5');
assert.equal(credentialKindForHost('203.0.113.5:3081'), 'persistent', '手动 LAN 地址覆盖仍按 LAN 持久 PIN 处理');
}));
18 changes: 17 additions & 1 deletion test/proxy.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -598,7 +598,11 @@ test('访问令牌认证(issue #13):公网需登录、cookie 放行、局
const proxy = await createPocketProxy({
port: 0, host: '127.0.0.1',
upstream: { host: '127.0.0.1', port: up.address().port },
auth: { getToken: () => TOKEN, isProtected: () => true },
auth: {
getToken: () => TOKEN,
getCredentialKind: (host) => String(host).includes('trycloudflare') ? 'ephemeral' : 'persistent',
isProtected: () => true,
},
});
const raw = (headers, method = 'GET', body, path = '/') => new Promise((resolve, reject) => {
const req = http.request({ host: '127.0.0.1', port: proxy.port, path, method, headers }, (res) => {
Expand All @@ -618,6 +622,16 @@ test('访问令牌认证(issue #13):公网需登录、cookie 放行、局
assert.equal(r1.status, 200);
assert.ok(r1.body.includes('访问密码'), '返回登录页');
assert.match(r1.body, /minlength="8" maxlength="64"/, '登录页允许输入 8–64 位自定义 PIN');
assert.match(r1.body, /id="current-password"[^>]+autocomplete="off"/, '短期轮换 PIN 不请求密码管理器保存');
assert.doesNotMatch(r1.body, /autocomplete="username"/, '短期轮换 PIN 不伪装成可保存的完整登录项');
assert.match(r1.body, /for="current-password"/, '登录页为密码输入框提供关联 label');
assert.match(r1.body, /type="button"[^>]+aria-controls="current-password"/, '显示密码按钮不会误提交表单且具备可访问语义');
assert.match(r1.body, /id="eye-icon"[^>]+aria-hidden="true"/, '显示密码按钮使用装饰性眼睛图标');
assert.match(r1.body, /id="eye-slash"/, '密码默认隐藏时眼睛带斜线');
assert.match(r1.body, /eyeSlash\.classList\.toggle\('is-hidden',show\)/, '切换时用 SVG 支持的 classList 改变斜线状态');
assert.match(r1.body, /padding:10px 50px;font-size:18px;line-height:24px;letter-spacing:6px;text-align:center;caret-color:/, '保留社区 PIN 字距与居中风格,同时为眼睛图标对称留位');
assert.match(r1.body, /setSelectionRange\(start,end\)/, '显示与隐藏切换时保留光标选区');
assert.match(r1.body, /pointerdown[^\n]+preventDefault/, '指针切换显示状态时不抢走输入框焦点');

// 2) 公网 API 无 cookie → 401(非 HTML 路径)
const r2 = await raw({ ...publicH, Accept: 'application/json' }, 'GET', undefined, '/api/hello');
Expand All @@ -643,6 +657,8 @@ test('访问令牌认证(issue #13):公网需登录、cookie 放行、局
const r6 = await raw(lanH);
assert.equal(r6.status, 200);
assert.ok(r6.body.includes('访问密码'), '局域网也需要密码(登录页)');
assert.match(r6.body, /id="current-password"[^>]+autocomplete="current-password"/, '长期有效的 LAN PIN 允许密码管理器填充');
assert.match(r6.body, /id="pocket-username"[^>]+autocomplete="username"[^>]+value="dsh-pocket"/, '固定 PIN 提供稳定的隐式账号供密码管理器关联');
// 局域网带 cookie → 放行
const r6b = await raw({ ...lanH, Cookie: 'dsh_pocket_token=' + TOKEN });
assert.equal(r6b.status, 200, '局域网带 cookie 放行');
Expand Down
Loading