rdsh-gateway 0.10.0 → 0.11.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/dist/config.d.ts +3 -2
- package/dist/config.js +31 -10
- package/dist/direct-secret.d.ts +3 -0
- package/dist/direct-secret.js +35 -0
- package/dist/direct-ticket.d.ts +5 -0
- package/dist/direct-ticket.js +42 -0
- package/dist/direct.d.ts +47 -0
- package/dist/direct.js +79 -0
- package/dist/gate.d.ts +29 -0
- package/dist/gate.js +117 -0
- package/dist/index.d.ts +6 -2
- package/dist/index.js +4 -2
- package/dist/join.d.ts +24 -0
- package/dist/join.js +96 -6
- package/dist/rdsh-webview-api.d.ts +1 -1
- package/dist/rdsh-webview-api.js +20 -4
- package/dist/secure-context-polyfill.d.ts +9 -5
- package/dist/secure-context-polyfill.js +34 -5
- package/dist/serve.d.ts +0 -1
- package/dist/serve.js +8 -8
- package/dist/server.d.ts +18 -18
- package/dist/server.js +102 -52
- package/package.json +1 -1
package/dist/config.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export type AuthMode = "
|
|
1
|
+
export type AuthMode = "password" | "none";
|
|
2
2
|
export type HostMode = "lan" | "cloud" | "join";
|
|
3
3
|
export interface TlsConfig {
|
|
4
4
|
cert: string;
|
|
@@ -10,7 +10,6 @@ export interface AuthUser {
|
|
|
10
10
|
}
|
|
11
11
|
export interface AuthConfig {
|
|
12
12
|
mode: AuthMode;
|
|
13
|
-
pairCode?: string;
|
|
14
13
|
/** 改密时 +1,用于使旧会话失效 */
|
|
15
14
|
version: number;
|
|
16
15
|
users: AuthUser[];
|
|
@@ -56,6 +55,8 @@ export declare const DEFAULT_HOST_CONFIG_PATH: string;
|
|
|
56
55
|
export declare function resolveConfigPath(cliPath?: string, env?: NodeJS.ProcessEnv): string;
|
|
57
56
|
/** 原子写回配置(tmp + rename,0600)。 */
|
|
58
57
|
export declare function saveConfig(path: string, config: RdshConfig): Promise<void>;
|
|
58
|
+
/** 生成随机访问口令(base64url,8 字节 ≈ 11 字符,满足 feature 15 的 ≥4 位约束)。 */
|
|
59
|
+
export declare function generateAccessCode(): string;
|
|
59
60
|
/** 加载并校验配置;默认路径下文件不存在时尝试迁移旧 config.json,否则返回默认值。 */
|
|
60
61
|
export declare function loadConfig(path: string): Promise<RdshConfig>;
|
|
61
62
|
/** 校验并规范化任意输入(测试/CLI 覆盖复用)。 */
|
package/dist/config.js
CHANGED
|
@@ -9,14 +9,16 @@
|
|
|
9
9
|
import { readFile, writeFile, rename } from "node:fs/promises";
|
|
10
10
|
import { homedir } from "node:os";
|
|
11
11
|
import { join } from "node:path";
|
|
12
|
+
import { randomBytes } from "node:crypto";
|
|
12
13
|
export const DEFAULT_HOST_CONFIG_PATH = join(homedir(), ".rdsh", "host.json");
|
|
13
14
|
/** 旧版 serve 配置(迁移源,保留不删)。 */
|
|
14
15
|
const LEGACY_CONFIG_PATH = join(homedir(), ".rdsh", "config.json");
|
|
15
|
-
const DEFAULT_AUTH = { mode: "
|
|
16
|
+
const DEFAULT_AUTH = { mode: "none", version: 1, users: [] };
|
|
16
17
|
const DEFAULTS = {
|
|
17
18
|
mode: "lan",
|
|
18
19
|
host: "0.0.0.0",
|
|
19
|
-
|
|
20
|
+
// host 默认端口 = 8442(hub 默认 8443,同机跑 hub+host 不冲突;8443 是 Tomcat/HTTPS 备用口热门号)
|
|
21
|
+
port: 8442,
|
|
20
22
|
sessionTtlSeconds: 12 * 3600,
|
|
21
23
|
behindProxy: false,
|
|
22
24
|
allowFrom: [],
|
|
@@ -34,6 +36,15 @@ export async function saveConfig(path, config) {
|
|
|
34
36
|
await writeFile(tmp, `${JSON.stringify(config, null, 2)}\n`, { mode: 0o600 });
|
|
35
37
|
await rename(tmp, path);
|
|
36
38
|
}
|
|
39
|
+
/** 生成随机访问口令(base64url,8 字节 ≈ 11 字符,满足 feature 15 的 ≥4 位约束)。 */
|
|
40
|
+
export function generateAccessCode() {
|
|
41
|
+
return randomBytes(8).toString("base64url");
|
|
42
|
+
}
|
|
43
|
+
/** 旧配置是否使用了已废弃的动态配对码(`auth.mode: "pair"`)。 */
|
|
44
|
+
function isLegacyPairAuth(raw) {
|
|
45
|
+
const auth = raw?.auth;
|
|
46
|
+
return auth?.mode === "pair";
|
|
47
|
+
}
|
|
37
48
|
/** 加载并校验配置;默认路径下文件不存在时尝试迁移旧 config.json,否则返回默认值。 */
|
|
38
49
|
export async function loadConfig(path) {
|
|
39
50
|
let raw = {};
|
|
@@ -54,7 +65,15 @@ export async function loadConfig(path) {
|
|
|
54
65
|
}
|
|
55
66
|
}
|
|
56
67
|
}
|
|
57
|
-
|
|
68
|
+
const config = normalizeConfig(raw, path);
|
|
69
|
+
// 迁移:旧 `auth.mode: "pair"` → 生成访问口令并写回(幂等:写回后下次不再命中)
|
|
70
|
+
if (isLegacyPairAuth(raw) && (config.gateway?.accessCode ?? null) === null) {
|
|
71
|
+
const code = generateAccessCode();
|
|
72
|
+
config.gateway = { accessCode: code };
|
|
73
|
+
await saveConfig(path, config);
|
|
74
|
+
console.log(`rdsh: 检测到旧配对码配置,已迁移为访问口令(请记录,可用 \`rdsh host gate set\` 修改):${code}`);
|
|
75
|
+
}
|
|
76
|
+
return config;
|
|
58
77
|
}
|
|
59
78
|
/** 读取旧 ~/.rdsh/config.json(不存在 → null;坏 JSON → 抛错)。 */
|
|
60
79
|
async function readLegacyConfig() {
|
|
@@ -130,14 +149,16 @@ export function normalizeConfig(raw, source = "config") {
|
|
|
130
149
|
throw new Error(`${source}: "auth" must be an object`);
|
|
131
150
|
const auth = cfg.auth;
|
|
132
151
|
if (auth.mode !== undefined) {
|
|
133
|
-
|
|
134
|
-
|
|
152
|
+
// 兼容旧配置:`pair`(动态配对码)已废弃,规整为 `none`(无会话认证;直连口门禁由 gateway.accessCode 承担)
|
|
153
|
+
if (auth.mode === "pair") {
|
|
154
|
+
out.auth.mode = "none";
|
|
155
|
+
}
|
|
156
|
+
else if (auth.mode === "password" || auth.mode === "none") {
|
|
157
|
+
out.auth.mode = auth.mode;
|
|
158
|
+
}
|
|
159
|
+
else {
|
|
160
|
+
throw new Error(`${source}: "auth.mode" must be password|none`);
|
|
135
161
|
}
|
|
136
|
-
out.auth.mode = auth.mode;
|
|
137
|
-
}
|
|
138
|
-
if (auth.pairCode !== undefined) {
|
|
139
|
-
assertString(auth.pairCode, "auth.pairCode", source);
|
|
140
|
-
out.auth.pairCode = auth.pairCode;
|
|
141
162
|
}
|
|
142
163
|
if (auth.version !== undefined) {
|
|
143
164
|
if (!Number.isInteger(auth.version) || auth.version < 1) {
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* direct-secret.ts — 直连口专用密钥(per-host 随机密钥,自动生成)。
|
|
3
|
+
*
|
|
4
|
+
* 用途(24-direct-first,方案 B):直连口门禁的 ticket 与会话 cookie 用它签名,
|
|
5
|
+
* **独立于访问口令(accessCode)**——这样"不设口令"也能启用直连口(ticket 即门禁),
|
|
6
|
+
* 而口令降级为可选的额外一层。密钥自动生成并持久化到 ~/.rdsh/direct-secret(0600),
|
|
7
|
+
* 与 E2EE 静态密钥(e2ee-key.json)同一先例。
|
|
8
|
+
*/
|
|
9
|
+
import { randomBytes } from "node:crypto";
|
|
10
|
+
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
11
|
+
import { homedir } from "node:os";
|
|
12
|
+
import { join } from "node:path";
|
|
13
|
+
const RDSH_DIR = join(homedir(), ".rdsh");
|
|
14
|
+
const SECRET_FILE_NAME = "direct-secret";
|
|
15
|
+
export function directSecretPath(dir = RDSH_DIR) {
|
|
16
|
+
return join(dir, SECRET_FILE_NAME);
|
|
17
|
+
}
|
|
18
|
+
/** 读取或生成直连密钥(32 字节 base64url);坏文件/过短视为不存在并重新生成。 */
|
|
19
|
+
export function loadOrCreateDirectSecret(dir = RDSH_DIR) {
|
|
20
|
+
const path = directSecretPath(dir);
|
|
21
|
+
try {
|
|
22
|
+
const raw = readFileSync(path, "utf8").trim();
|
|
23
|
+
if (raw.length >= 32)
|
|
24
|
+
return raw;
|
|
25
|
+
}
|
|
26
|
+
catch (err) {
|
|
27
|
+
if (err.code !== "ENOENT")
|
|
28
|
+
throw err;
|
|
29
|
+
}
|
|
30
|
+
const secret = randomBytes(32).toString("base64url");
|
|
31
|
+
mkdirSync(dir, { recursive: true });
|
|
32
|
+
writeFileSync(path, `${secret}\n`, { mode: 0o600 });
|
|
33
|
+
return secret;
|
|
34
|
+
}
|
|
35
|
+
//# sourceMappingURL=direct-secret.js.map
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* direct-ticket.ts — 一次性直连票(R4):host 经 E2EE 通道随候选下发,客户端拼到直连 URL,
|
|
3
|
+
* 直连口校验后 303 换会话 cookie(免重输口令)。
|
|
4
|
+
*
|
|
5
|
+
* 结构复用访问 cookie 的 HMAC 形式(payload = `${exp}.${nonce}`,key = sha256(secret)),
|
|
6
|
+
* 额外做**单次消费**(used 集合)与短 TTL(默认 30s)。secret 用访问口令派生,改口令即吊销未用票。
|
|
7
|
+
*/
|
|
8
|
+
import { createHmac, createHash, randomBytes, timingSafeEqual } from "node:crypto";
|
|
9
|
+
export function createDirectTicketManager(secret, ttlMs = 30_000) {
|
|
10
|
+
const used = new Set();
|
|
11
|
+
const key = createHash("sha256").update(secret).digest();
|
|
12
|
+
const hmac = (payload) => createHmac("sha256", key).update(payload).digest("base64url");
|
|
13
|
+
return {
|
|
14
|
+
mint() {
|
|
15
|
+
const exp = Date.now() + ttlMs;
|
|
16
|
+
const nonce = randomBytes(16).toString("base64url");
|
|
17
|
+
const payload = `${exp}.${nonce}`;
|
|
18
|
+
return `${payload}.${hmac(payload)}`;
|
|
19
|
+
},
|
|
20
|
+
consume(ticket) {
|
|
21
|
+
const parts = ticket.split(".");
|
|
22
|
+
if (parts.length !== 3)
|
|
23
|
+
return false;
|
|
24
|
+
const [expStr, nonce, sig] = parts;
|
|
25
|
+
if (expStr === "" || nonce === "" || sig === "")
|
|
26
|
+
return false;
|
|
27
|
+
const exp = Number(expStr);
|
|
28
|
+
if (!Number.isFinite(exp) || exp <= Date.now())
|
|
29
|
+
return false;
|
|
30
|
+
const payload = `${expStr}.${nonce}`;
|
|
31
|
+
const expected = Buffer.from(hmac(payload));
|
|
32
|
+
const actual = Buffer.from(sig);
|
|
33
|
+
if (actual.length !== expected.length || !timingSafeEqual(actual, expected))
|
|
34
|
+
return false;
|
|
35
|
+
if (used.has(ticket))
|
|
36
|
+
return false;
|
|
37
|
+
used.add(ticket);
|
|
38
|
+
return true;
|
|
39
|
+
},
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
//# sourceMappingURL=direct-ticket.js.map
|
package/dist/direct.d.ts
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import type { RunningGateway } from "./server.ts";
|
|
2
|
+
import type { TlsMaterial } from "./tls.ts";
|
|
3
|
+
export interface DirectOptions {
|
|
4
|
+
/** 转发目标 dsh 端口(join 已 spawn 的同一个 dsh;插件形态 = ctx.webServer.port) */
|
|
5
|
+
dshPort: number;
|
|
6
|
+
/** 宿主代持的 dsh 会话 cookie(注入转发) */
|
|
7
|
+
dshAuthCookieHeader?: string | null;
|
|
8
|
+
/** 直连密钥(per-host 随机,自动生成);ticket 与会话 cookie 用它签名 */
|
|
9
|
+
secret: string;
|
|
10
|
+
/** 访问口令(可选增强层):非空时直连口额外要求口令(手动访问) */
|
|
11
|
+
accessCode?: string | null;
|
|
12
|
+
/** 一次性直连票校验(R4) */
|
|
13
|
+
consumeTicket?: (ticket: string) => boolean;
|
|
14
|
+
/** 监听地址,默认 0.0.0.0 */
|
|
15
|
+
host?: string;
|
|
16
|
+
/** 监听端口,默认 8442(复用 config.port) */
|
|
17
|
+
port?: number;
|
|
18
|
+
keyDir?: string;
|
|
19
|
+
dshUiCompat?: {
|
|
20
|
+
trustPairedAsLoopback?: boolean;
|
|
21
|
+
};
|
|
22
|
+
configPath?: string;
|
|
23
|
+
tlsMaterial?: TlsMaterial | null;
|
|
24
|
+
}
|
|
25
|
+
export interface DirectHandle {
|
|
26
|
+
gateway: RunningGateway;
|
|
27
|
+
actualPort: number;
|
|
28
|
+
/** 本机私网 IPv4 候选(IP + 实际端口) */
|
|
29
|
+
candidates(): {
|
|
30
|
+
host: string;
|
|
31
|
+
port: number;
|
|
32
|
+
}[];
|
|
33
|
+
stop(): Promise<void>;
|
|
34
|
+
}
|
|
35
|
+
export declare function startDirect(opts: DirectOptions): Promise<DirectHandle>;
|
|
36
|
+
/** 收集本机私网 IPv4 候选(含 link-local;不含公网与 loopback)。 */
|
|
37
|
+
export declare function lanCandidates(port: number): {
|
|
38
|
+
host: string;
|
|
39
|
+
port: number;
|
|
40
|
+
}[];
|
|
41
|
+
/** RFC1918 / link-local 判定(严格校验四段十进制)。 */
|
|
42
|
+
export declare function isPrivateIpv4(ip: string): boolean;
|
|
43
|
+
/**
|
|
44
|
+
* 直连 bootstrap 脚本(注入 DSH 首页):经 E2EE 取候选 + 直连票,挂到 `window.__rdshDirectInfo`(P2 客户端消费)。
|
|
45
|
+
* `pageToken` 为 null 时(未设口令)跳过 raw 授权,直接取候选。
|
|
46
|
+
*/
|
|
47
|
+
export declare function directBootstrapScript(pageToken: string | null): string;
|
package/dist/direct.js
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* direct.ts — 直连口(A 案):join 进程额外监听的内网端口,转发到**同一个 dsh**。
|
|
3
|
+
*
|
|
4
|
+
* 门禁 = **ticket(方案 B)**:直连口始终监听(join 模式),ticket/会话 cookie 用 per-host
|
|
5
|
+
* 随机密钥(direct-secret)签名,独立于访问口令;口令(accessCode)为可选增强层(手动访问)。
|
|
6
|
+
* P1 为 http 明文,P3 换 https 自签 + App 指纹固定。候选只下发私网 IPv4。
|
|
7
|
+
*/
|
|
8
|
+
import { networkInterfaces } from "node:os";
|
|
9
|
+
import { startGateway } from "./server.js";
|
|
10
|
+
export async function startDirect(opts) {
|
|
11
|
+
const gateway = await startGateway({
|
|
12
|
+
host: opts.host ?? "0.0.0.0",
|
|
13
|
+
port: opts.port ?? 8442,
|
|
14
|
+
sessionTtlSeconds: 12 * 3600,
|
|
15
|
+
dshPort: opts.dshPort,
|
|
16
|
+
dshAuthCookieHeader: opts.dshAuthCookieHeader ?? null,
|
|
17
|
+
directSecret: opts.secret,
|
|
18
|
+
accessCode: opts.accessCode ?? null,
|
|
19
|
+
consumeTicket: opts.consumeTicket,
|
|
20
|
+
authMode: "none",
|
|
21
|
+
keyDir: opts.keyDir,
|
|
22
|
+
dshUiCompat: opts.dshUiCompat,
|
|
23
|
+
configPath: opts.configPath,
|
|
24
|
+
tlsMaterial: opts.tlsMaterial ?? null,
|
|
25
|
+
});
|
|
26
|
+
const actualPort = gateway.actualPort;
|
|
27
|
+
return {
|
|
28
|
+
gateway,
|
|
29
|
+
actualPort,
|
|
30
|
+
candidates: () => lanCandidates(actualPort),
|
|
31
|
+
stop: async () => {
|
|
32
|
+
await new Promise((resolve) => gateway.server.close(() => resolve()));
|
|
33
|
+
gateway.dispose();
|
|
34
|
+
},
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
/** 收集本机私网 IPv4 候选(含 link-local;不含公网与 loopback)。 */
|
|
38
|
+
export function lanCandidates(port) {
|
|
39
|
+
const out = [];
|
|
40
|
+
for (const list of Object.values(networkInterfaces())) {
|
|
41
|
+
for (const ni of (list ?? [])) {
|
|
42
|
+
if (ni.family !== "IPv4" || ni.internal)
|
|
43
|
+
continue;
|
|
44
|
+
if (!isPrivateIpv4(ni.address))
|
|
45
|
+
continue;
|
|
46
|
+
out.push({ host: ni.address, port });
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return out;
|
|
50
|
+
}
|
|
51
|
+
/** RFC1918 / link-local 判定(严格校验四段十进制)。 */
|
|
52
|
+
export function isPrivateIpv4(ip) {
|
|
53
|
+
const parts = ip.split(".").map(Number);
|
|
54
|
+
if (parts.length !== 4 || parts.some((n) => !Number.isInteger(n) || n < 0 || n > 255))
|
|
55
|
+
return false;
|
|
56
|
+
const [a, b] = parts;
|
|
57
|
+
return a === 10 || (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168) || (a === 169 && b === 254);
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* 直连 bootstrap 脚本(注入 DSH 首页):经 E2EE 取候选 + 直连票,挂到 `window.__rdshDirectInfo`(P2 客户端消费)。
|
|
61
|
+
* `pageToken` 为 null 时(未设口令)跳过 raw 授权,直接取候选。
|
|
62
|
+
*/
|
|
63
|
+
export function directBootstrapScript(pageToken) {
|
|
64
|
+
const authorize = pageToken === null
|
|
65
|
+
? "Promise.resolve()"
|
|
66
|
+
: `fetch("/__rdsh/authorize?token=" + encodeURIComponent(${JSON.stringify(pageToken)}), { method: "POST" })`;
|
|
67
|
+
return `(function () {
|
|
68
|
+
if (window.__rdshDirectBootstrapped) return;
|
|
69
|
+
window.__rdshDirectBootstrapped = true;
|
|
70
|
+
try {
|
|
71
|
+
${authorize}
|
|
72
|
+
.then(function () { return fetch("/__rdsh/direct-candidates"); })
|
|
73
|
+
.then(function (r) { return r.json(); })
|
|
74
|
+
.then(function (info) { window.__rdshDirectInfo = info; })
|
|
75
|
+
.catch(function () {});
|
|
76
|
+
} catch (e) { /* ignore */ }
|
|
77
|
+
})();`;
|
|
78
|
+
}
|
|
79
|
+
//# sourceMappingURL=direct.js.map
|
package/dist/gate.d.ts
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* gate.ts — 访问口令(accessCode)门禁的公共组件:challenge 页 + cookie 校验。
|
|
3
|
+
*
|
|
4
|
+
* 供隧道口(join.ts 的明文分发器)与直连口(server.ts)共用,避免两份实现漂移。
|
|
5
|
+
* 对应 req 的「固定口令 / 门禁」(gateway.accessCode,feature 15)。
|
|
6
|
+
*/
|
|
7
|
+
import { GATE_COOKIE, GATE_COOKIE_TTL_MS, signGateCookie, verifyGateCookie, verifyGateCode } from "./access-gate.ts";
|
|
8
|
+
export { GATE_COOKIE, GATE_COOKIE_TTL_MS, signGateCookie, verifyGateCookie, verifyGateCode };
|
|
9
|
+
/** gate challenge 错误态(语言中立 key,gateChallengeHtml 内本地化)。 */
|
|
10
|
+
export type GateError = "wrong" | "locked" | null;
|
|
11
|
+
/** 从 Accept-Language 头取首个语言(数组取首、缺失 undefined)。 */
|
|
12
|
+
export declare function headerAcceptLanguage(headers: Record<string, string | string[] | undefined>): string | undefined;
|
|
13
|
+
/** 从 cookie 头提取 rdsh_gate 值(无则 null)。 */
|
|
14
|
+
export declare function gateCookieFromHeader(cookieHeader: string | string[] | undefined): string | null;
|
|
15
|
+
/** 访问口令 challenge 页(内联,零外部依赖;Accept-Language 含 zh → 中文,否则英文兜底)。 */
|
|
16
|
+
export declare function gateChallengeHtml(hostName: string, actionPath: string, error: GateError, acceptLanguage?: string): string;
|
|
17
|
+
/** 无口令直连的 403 页:ticket 只给 App 用,人肉访问唯一路径 = 设口令;给出两种启动方式各自的口令设置方法。 */
|
|
18
|
+
export declare function directForbiddenHtml(acceptLanguage?: string): string;
|
|
19
|
+
/** 全局失败封顶(直连口有真实客户端 IP,按 IP 维度限流更合理;与 join 的全局封顶保持同量级)。 */
|
|
20
|
+
export interface GateLimiter {
|
|
21
|
+
/** 是否处于锁定(true = 拒绝校验,直接回 locked challenge)。 */
|
|
22
|
+
blocked(ip: string): boolean;
|
|
23
|
+
/** 记录一次失败;达上限后进入锁定。 */
|
|
24
|
+
fail(ip: string): void;
|
|
25
|
+
/** 成功清除计数。 */
|
|
26
|
+
clear(ip: string): void;
|
|
27
|
+
}
|
|
28
|
+
/** 创建 gate 失败限流器:每 IP 连续错 N 次锁定 L 毫秒。 */
|
|
29
|
+
export declare function createGateLimiter(maxFails?: number, lockMs?: number): GateLimiter;
|
package/dist/gate.js
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* gate.ts — 访问口令(accessCode)门禁的公共组件:challenge 页 + cookie 校验。
|
|
3
|
+
*
|
|
4
|
+
* 供隧道口(join.ts 的明文分发器)与直连口(server.ts)共用,避免两份实现漂移。
|
|
5
|
+
* 对应 req 的「固定口令 / 门禁」(gateway.accessCode,feature 15)。
|
|
6
|
+
*/
|
|
7
|
+
import { GATE_COOKIE, GATE_COOKIE_TTL_MS, signGateCookie, verifyGateCookie, verifyGateCode } from "./access-gate.js";
|
|
8
|
+
export { GATE_COOKIE, GATE_COOKIE_TTL_MS, signGateCookie, verifyGateCookie, verifyGateCode };
|
|
9
|
+
/** 从 Accept-Language 头取首个语言(数组取首、缺失 undefined)。 */
|
|
10
|
+
export function headerAcceptLanguage(headers) {
|
|
11
|
+
const al = headers["accept-language"];
|
|
12
|
+
return Array.isArray(al) ? al.join(",") : typeof al === "string" ? al : undefined;
|
|
13
|
+
}
|
|
14
|
+
/** 从 cookie 头提取 rdsh_gate 值(无则 null)。 */
|
|
15
|
+
export function gateCookieFromHeader(cookieHeader) {
|
|
16
|
+
const s = Array.isArray(cookieHeader) ? cookieHeader.join(";") : typeof cookieHeader === "string" ? cookieHeader : "";
|
|
17
|
+
for (const part of s.split(";")) {
|
|
18
|
+
const idx = part.indexOf("=");
|
|
19
|
+
if (idx <= 0)
|
|
20
|
+
continue;
|
|
21
|
+
if (part.slice(0, idx).trim() === GATE_COOKIE)
|
|
22
|
+
return part.slice(idx + 1).trim();
|
|
23
|
+
}
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
/** HTML 转义(challenge 页内插 hostName/actionPath 防注入;actionPath 已 percent-encoded,此处为纵深防御)。 */
|
|
27
|
+
function escapeHtml(s) {
|
|
28
|
+
return s
|
|
29
|
+
.replaceAll("&", "&")
|
|
30
|
+
.replaceAll("<", "<")
|
|
31
|
+
.replaceAll(">", ">")
|
|
32
|
+
.replaceAll('"', """)
|
|
33
|
+
.replaceAll("'", "'");
|
|
34
|
+
}
|
|
35
|
+
/** 访问口令 challenge 页(内联,零外部依赖;Accept-Language 含 zh → 中文,否则英文兜底)。 */
|
|
36
|
+
export function gateChallengeHtml(hostName, actionPath, error, acceptLanguage) {
|
|
37
|
+
const safeHost = escapeHtml(hostName);
|
|
38
|
+
const safePath = escapeHtml(actionPath);
|
|
39
|
+
const zh = typeof acceptLanguage === "string" && /zh/i.test(acceptLanguage);
|
|
40
|
+
const t = zh
|
|
41
|
+
? { title: "访问密码", heading: `主机「${safeHost}」受访问密码保护`, note: "此密码由主机所有者设置,hub 无法绕过。", placeholder: "请输入访问密码", submit: "进入", wrong: "访问密码错误", locked: "尝试次数过多,请稍后再试" }
|
|
42
|
+
: { title: "Access code", heading: `Host "${safeHost}" is protected by an access code`, note: "This code is set by the host owner; the hub cannot bypass it.", placeholder: "Enter access code", submit: "Enter", wrong: "Incorrect access code", locked: "Too many attempts — please try again later" };
|
|
43
|
+
const errHtml = error === null ? "" : `<p style="color:#dc2626;font-size:13px;margin:10px 0 0">${error === "wrong" ? t.wrong : t.locked}</p>`;
|
|
44
|
+
return (`<!doctype html><html lang="${zh ? "zh-CN" : "en"}"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">` +
|
|
45
|
+
`<title>${t.title}</title>` +
|
|
46
|
+
`<body style="font-family:system-ui,sans-serif;background:#f6f7f9;margin:0">` +
|
|
47
|
+
`<div style="max-width:360px;margin:64px auto;background:#fff;border:1px solid #e5e7eb;border-radius:12px;padding:24px">` +
|
|
48
|
+
`<h1 style="font-size:18px;margin:0 0 8px">${t.heading}</h1>` +
|
|
49
|
+
`<p style="font-size:13px;color:#6b7280;margin:0 0 16px">${t.note}</p>` +
|
|
50
|
+
`<form method="POST" action="${safePath}">` +
|
|
51
|
+
`<input name="gate_code" type="password" autocomplete="off" autofocus placeholder="${t.placeholder}" style="width:100%;box-sizing:border-box;height:38px;border:1px solid #d1d5db;border-radius:8px;padding:0 12px;font-size:14px">` +
|
|
52
|
+
`<button type="submit" style="width:100%;margin-top:12px;height:38px;border:none;border-radius:8px;background:#2563eb;color:#fff;font-size:14px;cursor:pointer">${t.submit}</button>` +
|
|
53
|
+
`</form>${errHtml}</div></body></html>`);
|
|
54
|
+
}
|
|
55
|
+
/** 无口令直连的 403 页:ticket 只给 App 用,人肉访问唯一路径 = 设口令;给出两种启动方式各自的口令设置方法。 */
|
|
56
|
+
export function directForbiddenHtml(acceptLanguage) {
|
|
57
|
+
const zh = typeof acceptLanguage === "string" && /zh/i.test(acceptLanguage);
|
|
58
|
+
const t = zh
|
|
59
|
+
? {
|
|
60
|
+
title: "直接访问需要设置访问口令",
|
|
61
|
+
heading: "无法直接访问",
|
|
62
|
+
note: "为了保护这台主机,直接访问前需要先设置一个访问口令。",
|
|
63
|
+
how: "如何设置(取决于主机的启动方式):",
|
|
64
|
+
opt1: "命令行启动:在主机上执行 rdsh host gate set <口令>",
|
|
65
|
+
opt2: "插件启动(dsh-web-remote):在 DSH 的「远程访问」面板里设置访问密码",
|
|
66
|
+
}
|
|
67
|
+
: {
|
|
68
|
+
title: "Direct access requires an access code",
|
|
69
|
+
heading: "Direct access denied",
|
|
70
|
+
note: "To protect this host, direct access requires an access code.",
|
|
71
|
+
how: "How to set it (depends on how the host was started):",
|
|
72
|
+
opt1: "CLI: run rdsh host gate set <code> on the host",
|
|
73
|
+
opt2: "Plugin (dsh-web-remote): set the access code in the DSH \"Remote Access\" panel",
|
|
74
|
+
};
|
|
75
|
+
return (`<!doctype html><html lang="${zh ? "zh-CN" : "en"}"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">` +
|
|
76
|
+
`<title>${t.title}</title>` +
|
|
77
|
+
`<body style="font-family:system-ui,sans-serif;background:#f6f7f9;margin:0">` +
|
|
78
|
+
`<div style="max-width:440px;margin:64px auto;background:#fff;border:1px solid #e5e7eb;border-radius:12px;padding:24px">` +
|
|
79
|
+
`<h1 style="font-size:18px;margin:0 0 8px">${t.heading}</h1>` +
|
|
80
|
+
`<p style="font-size:13px;color:#6b7280;margin:0 0 12px">${t.note}</p>` +
|
|
81
|
+
`<p style="font-size:14px;color:#1f2937;margin:0 0 6px">${t.how}</p>` +
|
|
82
|
+
`<ol style="font-size:14px;color:#1f2937;line-height:1.8;margin:0;padding-left:20px">` +
|
|
83
|
+
`<li>${escapeHtml(t.opt1)}</li>` +
|
|
84
|
+
`<li>${escapeHtml(t.opt2)}</li>` +
|
|
85
|
+
`</ol></div></body></html>`);
|
|
86
|
+
}
|
|
87
|
+
/** 创建 gate 失败限流器:每 IP 连续错 N 次锁定 L 毫秒。 */
|
|
88
|
+
export function createGateLimiter(maxFails = 10, lockMs = 60_000) {
|
|
89
|
+
const states = new Map();
|
|
90
|
+
return {
|
|
91
|
+
blocked(ip) {
|
|
92
|
+
const s = states.get(ip);
|
|
93
|
+
if (s === undefined)
|
|
94
|
+
return false;
|
|
95
|
+
if (s.lockedUntil > Date.now())
|
|
96
|
+
return true;
|
|
97
|
+
if (s.lockedUntil !== 0) {
|
|
98
|
+
s.lockedUntil = 0;
|
|
99
|
+
s.fails = 0;
|
|
100
|
+
}
|
|
101
|
+
return false;
|
|
102
|
+
},
|
|
103
|
+
fail(ip) {
|
|
104
|
+
const s = states.get(ip) ?? { fails: 0, lockedUntil: 0 };
|
|
105
|
+
s.fails += 1;
|
|
106
|
+
if (s.fails >= maxFails) {
|
|
107
|
+
s.fails = 0;
|
|
108
|
+
s.lockedUntil = Date.now() + lockMs;
|
|
109
|
+
}
|
|
110
|
+
states.set(ip, s);
|
|
111
|
+
},
|
|
112
|
+
clear(ip) {
|
|
113
|
+
states.delete(ip);
|
|
114
|
+
},
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
//# sourceMappingURL=gate.js.map
|
package/dist/index.d.ts
CHANGED
|
@@ -7,14 +7,13 @@ export { serve } from "./serve.ts";
|
|
|
7
7
|
export type { ServeOptions } from "./serve.ts";
|
|
8
8
|
export { SessionManager, SESSION_COOKIE, sessionTokenFromCookie } from "./session.ts";
|
|
9
9
|
export type { SessionPayload } from "./session.ts";
|
|
10
|
-
export { PairManager } from "./pair.ts";
|
|
11
10
|
export { startGateway } from "./server.ts";
|
|
12
11
|
export type { GatewayOptions, RunningGateway } from "./server.ts";
|
|
13
12
|
export { findDsh, spawnDsh, dshSpawnEnv, RDSH_REMOTE_TTY, checkRemotePickerGraph, remotePickerWarning, exchangeDshSessionCookie, detectDshVersion, compareDshVersions, dshVersionWarning, DSH_COMPAT_MIN, DSH_COMPAT_MAX } from "./spawn-dsh.ts";
|
|
14
13
|
export type { SpawnedDsh } from "./spawn-dsh.ts";
|
|
15
14
|
export { forwardHttp, createUpgradeProxy, rewriteHeadersForDsh } from "./proxy.ts";
|
|
16
15
|
export type { ProxyTarget } from "./proxy.ts";
|
|
17
|
-
export { loadConfig, normalizeConfig, resolveConfigPath, saveConfig, DEFAULT_HOST_CONFIG_PATH } from "./config.ts";
|
|
16
|
+
export { loadConfig, normalizeConfig, resolveConfigPath, saveConfig, generateAccessCode, DEFAULT_HOST_CONFIG_PATH } from "./config.ts";
|
|
18
17
|
export type { RdshConfig, AuthMode, AuthUser, AuthConfig, TlsConfig, HostMode } from "./config.ts";
|
|
19
18
|
export { hashPassword, verifyPassword, UserManager } from "./auth.ts";
|
|
20
19
|
export { ipInCidrs, parseCidr, ipToInt } from "./cidr.ts";
|
|
@@ -26,6 +25,11 @@ export type { ServiceSpec } from "./service.ts";
|
|
|
26
25
|
export declare const NAME = "rdsh-gateway";
|
|
27
26
|
export { join, startJoin, registerJoin, detectInsecure, selfRevoke, patchLoopbackJs } from "./join.ts";
|
|
28
27
|
export type { JoinOptions, RegisterOutcome, JoinState, JoinHooks, StartJoinOptions, JoinHandle } from "./join.ts";
|
|
28
|
+
export { startDirect, lanCandidates, isPrivateIpv4 } from "./direct.ts";
|
|
29
|
+
export type { DirectOptions, DirectHandle } from "./direct.ts";
|
|
30
|
+
export { createDirectTicketManager } from "./direct-ticket.ts";
|
|
31
|
+
export type { DirectTicketManager } from "./direct-ticket.ts";
|
|
32
|
+
export { loadOrCreateDirectSecret, directSecretPath } from "./direct-secret.ts";
|
|
29
33
|
export { readPersistedToken, clearPersistedToken } from "./token-store.ts";
|
|
30
34
|
export { acquireJoinLock, releaseJoinLock, readJoinLock, JOIN_LOCK_PATH } from "./lock.ts";
|
|
31
35
|
export type { JoinLock, JoinLockRole } from "./lock.ts";
|
package/dist/index.js
CHANGED
|
@@ -5,11 +5,10 @@
|
|
|
5
5
|
*/
|
|
6
6
|
export { serve } from "./serve.js";
|
|
7
7
|
export { SessionManager, SESSION_COOKIE, sessionTokenFromCookie } from "./session.js";
|
|
8
|
-
export { PairManager } from "./pair.js";
|
|
9
8
|
export { startGateway } from "./server.js";
|
|
10
9
|
export { findDsh, spawnDsh, dshSpawnEnv, RDSH_REMOTE_TTY, checkRemotePickerGraph, remotePickerWarning, exchangeDshSessionCookie, detectDshVersion, compareDshVersions, dshVersionWarning, DSH_COMPAT_MIN, DSH_COMPAT_MAX } from "./spawn-dsh.js";
|
|
11
10
|
export { forwardHttp, createUpgradeProxy, rewriteHeadersForDsh } from "./proxy.js";
|
|
12
|
-
export { loadConfig, normalizeConfig, resolveConfigPath, saveConfig, DEFAULT_HOST_CONFIG_PATH } from "./config.js";
|
|
11
|
+
export { loadConfig, normalizeConfig, resolveConfigPath, saveConfig, generateAccessCode, DEFAULT_HOST_CONFIG_PATH } from "./config.js";
|
|
13
12
|
export { hashPassword, verifyPassword, UserManager } from "./auth.js";
|
|
14
13
|
export { ipInCidrs, parseCidr, ipToInt } from "./cidr.js";
|
|
15
14
|
export { loadTls } from "./tls.js";
|
|
@@ -17,6 +16,9 @@ export { loginPageHtml } from "./login-page.js";
|
|
|
17
16
|
export { installService, uninstallService, serviceStatus, startService, stopService, restartService, systemdUnit, launchdPlist, SERVICE_NAME, JOIN_SERVICE_NAME, HOST_SERVICE_NAME, HUB_SERVICE_NAME } from "./service.js";
|
|
18
17
|
export const NAME = "rdsh-gateway";
|
|
19
18
|
export { join, startJoin, registerJoin, detectInsecure, selfRevoke, patchLoopbackJs } from "./join.js";
|
|
19
|
+
export { startDirect, lanCandidates, isPrivateIpv4 } from "./direct.js";
|
|
20
|
+
export { createDirectTicketManager } from "./direct-ticket.js";
|
|
21
|
+
export { loadOrCreateDirectSecret, directSecretPath } from "./direct-secret.js";
|
|
20
22
|
export { readPersistedToken, clearPersistedToken } from "./token-store.js";
|
|
21
23
|
export { acquireJoinLock, releaseJoinLock, readJoinLock, JOIN_LOCK_PATH } from "./lock.js";
|
|
22
24
|
//# sourceMappingURL=index.js.map
|
package/dist/join.d.ts
CHANGED
|
@@ -1,6 +1,17 @@
|
|
|
1
1
|
import type { IncomingHttpHeaders } from "node:http";
|
|
2
2
|
import type { ProxyTarget } from "./proxy.ts";
|
|
3
3
|
import type { JoinLockRole } from "./lock.ts";
|
|
4
|
+
/**
|
|
5
|
+
* 页面授权脚本(plain 与 raw 两条 inner dispatcher 共用,禁止各写一份):
|
|
6
|
+
* 直连 bootstrap + (设了口令时)raw 门禁解锁 token。
|
|
7
|
+
*
|
|
8
|
+
* 为什么 raw 通道也**必须**注入:raw(E2EE)流按设计不向 host 转发任何 cookie,
|
|
9
|
+
* 页面只能靠这段脚本带 token 请求 `/__rdsh/authorize` 才能把 rawAuthorized 置真;
|
|
10
|
+
* 漏注入 ⇒ raw 门禁(accessCode 非空时为 fail-closed)永远无法满足 ⇒ 经 E2EE 的
|
|
11
|
+
* 客户端每个请求都被 CLOSE 403、表现为"一直 connecting"。
|
|
12
|
+
* (24-direct-first 首版只在 plain 注入,E2EE 客户端因此被锁死;本函数即该回归的单一来源。)
|
|
13
|
+
*/
|
|
14
|
+
export declare function pageAuthorizeScript(accessCode: string | null): string;
|
|
4
15
|
export interface JoinOptions {
|
|
5
16
|
hubUrl: string;
|
|
6
17
|
/** join token(用户级,register 换 host token) */
|
|
@@ -20,6 +31,11 @@ export interface JoinOptions {
|
|
|
20
31
|
gateway?: {
|
|
21
32
|
accessCode?: string | null;
|
|
22
33
|
};
|
|
34
|
+
/** 直连口(24-direct-first,A 案):host/port 来自 host.json;缺省不监听 */
|
|
35
|
+
direct?: {
|
|
36
|
+
host?: string;
|
|
37
|
+
port?: number;
|
|
38
|
+
};
|
|
23
39
|
}
|
|
24
40
|
/** 注册/接入结果:解析出的 host token + 是否需 insecure + 生效的主机名(缺省=机器 hostname)。 */
|
|
25
41
|
export interface RegisterOutcome {
|
|
@@ -58,6 +74,14 @@ export interface StartJoinOptions {
|
|
|
58
74
|
gateway?: {
|
|
59
75
|
accessCode?: string | null;
|
|
60
76
|
};
|
|
77
|
+
/** 直连相关回调(24-direct-first;提供时启用 raw 门禁 + 候选/直连票端点) */
|
|
78
|
+
direct?: {
|
|
79
|
+
candidates: () => {
|
|
80
|
+
host: string;
|
|
81
|
+
port: number;
|
|
82
|
+
}[];
|
|
83
|
+
mintTicket: () => string;
|
|
84
|
+
};
|
|
61
85
|
/** 主机名(challenge 页展示;缺省「本主机」) */
|
|
62
86
|
name?: string;
|
|
63
87
|
/** 宿主代持的 dsh 浏览器会话 cookie(`dsh-auth-*`,0.1.2+);有值时注入隧道→本地转发 */
|