rdsh-gateway 0.1.0 → 0.2.1

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Liming Xie
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/dist/auth.d.ts ADDED
@@ -0,0 +1,24 @@
1
+ import { normalizeConfig } from "./config.ts";
2
+ import type { AuthUser } from "./config.ts";
3
+ /** 生成 scrypt 哈希(格式 `scrypt:$N:$r:$p:$salt:$hash`)。 */
4
+ export declare function hashPassword(password: string): Promise<string>;
5
+ /** 恒定时间校验密码。 */
6
+ export declare function verifyPassword(password: string, stored: string): Promise<boolean>;
7
+ /** 用户管理:读写 config.json 的 auth.users(低频管理操作,读改写即可)。 */
8
+ export declare class UserManager {
9
+ private readonly configPath;
10
+ constructor(configPath: string);
11
+ private read;
12
+ private write;
13
+ add(name: string, password: string): Promise<void>;
14
+ /** 改密:更新哈希并使 `auth.version + 1`(全部旧会话失效)。 */
15
+ passwd(name: string, password: string): Promise<boolean>;
16
+ list(): Promise<string[]>;
17
+ remove(name: string): Promise<boolean>;
18
+ /** 校验用户名/密码;成功返回用户,失败 null。 */
19
+ verify(name: string, password: string): Promise<AuthUser | null>;
20
+ /** 当前 auth.version(会话版本校验用)。 */
21
+ version(): Promise<number>;
22
+ }
23
+ /** 测试辅助:把内存对象规范化(供单测构造)。 */
24
+ export { normalizeConfig };
package/dist/auth.js ADDED
@@ -0,0 +1,117 @@
1
+ /**
2
+ * auth.ts — 用户/密码认证(scrypt 哈希)+ UserManager。
3
+ *
4
+ * 哈希格式:`scrypt:$N:$r:$p:$saltB64:$hashB64`(每用户随机盐,恒定时间校验)。
5
+ * 改密(passwd)会使 `auth.version + 1` —— 会话校验绑定版本,旧会话立即失效。
6
+ */
7
+ import { randomBytes, scrypt as scryptCb, timingSafeEqual } from "node:crypto";
8
+ import { writeFile, rename } from "node:fs/promises";
9
+ import { loadConfig, normalizeConfig } from "./config.js";
10
+ const SCRYPT_N = 16384;
11
+ const SCRYPT_R = 8;
12
+ const SCRYPT_P = 1;
13
+ const KEY_LEN = 64;
14
+ function scryptAsync(password, salt, keylen, options) {
15
+ return new Promise((resolve, reject) => {
16
+ scryptCb(password, salt, keylen, options, (err, derived) => {
17
+ if (err)
18
+ reject(err);
19
+ else
20
+ resolve(derived);
21
+ });
22
+ });
23
+ }
24
+ /** 生成 scrypt 哈希(格式 `scrypt:$N:$r:$p:$salt:$hash`)。 */
25
+ export async function hashPassword(password) {
26
+ const salt = randomBytes(16);
27
+ const derived = await scryptAsync(password, salt, KEY_LEN, { N: SCRYPT_N, r: SCRYPT_R, p: SCRYPT_P });
28
+ return `scrypt:${SCRYPT_N}:${SCRYPT_R}:${SCRYPT_P}:${salt.toString("base64url")}:${derived.toString("base64url")}`;
29
+ }
30
+ /** 恒定时间校验密码。 */
31
+ export async function verifyPassword(password, stored) {
32
+ const parts = stored.split(":");
33
+ if (parts.length !== 6 || parts[0] !== "scrypt")
34
+ return false;
35
+ const n = Number(parts[1]);
36
+ const r = Number(parts[2]);
37
+ const p = Number(parts[3]);
38
+ const saltB64 = parts[4];
39
+ const hashB64 = parts[5];
40
+ if (!Number.isInteger(n) || !Number.isInteger(r) || !Number.isInteger(p) || n < 1 || r < 1 || p < 1)
41
+ return false;
42
+ try {
43
+ const derived = await scryptAsync(password, Buffer.from(saltB64, "base64url"), KEY_LEN, { N: n, r, p });
44
+ return timingSafeEqual(derived, Buffer.from(hashB64, "base64url"));
45
+ }
46
+ catch {
47
+ return false;
48
+ }
49
+ }
50
+ /** 用户管理:读写 config.json 的 auth.users(低频管理操作,读改写即可)。 */
51
+ export class UserManager {
52
+ configPath;
53
+ constructor(configPath) {
54
+ this.configPath = configPath;
55
+ }
56
+ async read() {
57
+ return await loadConfig(this.configPath);
58
+ }
59
+ async write(config) {
60
+ const tmp = `${this.configPath}.tmp`;
61
+ await writeFile(tmp, `${JSON.stringify(config, null, 2)}\n`, { mode: 0o600 });
62
+ await rename(tmp, this.configPath);
63
+ }
64
+ async add(name, password) {
65
+ if (!/^[a-zA-Z0-9_.-]{1,64}$/.test(name)) {
66
+ throw new Error(`invalid username "${name}" (allowed: letters, digits, . _ -)`);
67
+ }
68
+ const config = await this.read();
69
+ if (config.auth.users.some((u) => u.name === name)) {
70
+ throw new Error(`user "${name}" already exists`);
71
+ }
72
+ config.auth.users.push({ name, passwordHash: await hashPassword(password) });
73
+ await this.write(config);
74
+ }
75
+ /** 改密:更新哈希并使 `auth.version + 1`(全部旧会话失效)。 */
76
+ async passwd(name, password) {
77
+ const config = await this.read();
78
+ const user = config.auth.users.find((u) => u.name === name);
79
+ if (user === undefined)
80
+ return false;
81
+ user.passwordHash = await hashPassword(password);
82
+ config.auth.version = (config.auth.version ?? 1) + 1;
83
+ await this.write(config);
84
+ return true;
85
+ }
86
+ async list() {
87
+ const config = await this.read();
88
+ return config.auth.users.map((u) => u.name);
89
+ }
90
+ async remove(name) {
91
+ const config = await this.read();
92
+ const before = config.auth.users.length;
93
+ config.auth.users = config.auth.users.filter((u) => u.name !== name);
94
+ if (config.auth.users.length === before)
95
+ return false;
96
+ await this.write(config);
97
+ return true;
98
+ }
99
+ /** 校验用户名/密码;成功返回用户,失败 null。 */
100
+ async verify(name, password) {
101
+ const config = await this.read();
102
+ const user = config.auth.users.find((u) => u.name === name);
103
+ if (user === undefined)
104
+ return null;
105
+ if (await verifyPassword(password, user.passwordHash))
106
+ return user;
107
+ return null;
108
+ }
109
+ /** 当前 auth.version(会话版本校验用)。 */
110
+ async version() {
111
+ const config = await this.read();
112
+ return config.auth.version ?? 1;
113
+ }
114
+ }
115
+ /** 测试辅助:把内存对象规范化(供单测构造)。 */
116
+ export { normalizeConfig };
117
+ //# sourceMappingURL=auth.js.map
package/dist/cidr.d.ts ADDED
@@ -0,0 +1,13 @@
1
+ /**
2
+ * cidr.ts — IPv4 CIDR 匹配(IP 白名单,零依赖)。
3
+ */
4
+ export interface Cidr {
5
+ base: number;
6
+ prefix: number;
7
+ }
8
+ /** 解析 `a.b.c.d/n`(prefix 缺省 = 32);非法返回 null。 */
9
+ export declare function parseCidr(cidr: string): Cidr | null;
10
+ /** IPv4 字符串 → uint32;非法返回 null(IPv6 等不支持,返回 null)。 */
11
+ export declare function ipToInt(ip: string): number | null;
12
+ /** ip 是否命中任一 CIDR。 */
13
+ export declare function ipInCidrs(ip: string, cidrs: string[]): boolean;
package/dist/cidr.js ADDED
@@ -0,0 +1,43 @@
1
+ /** 解析 `a.b.c.d/n`(prefix 缺省 = 32);非法返回 null。 */
2
+ export function parseCidr(cidr) {
3
+ const slash = cidr.indexOf("/");
4
+ const ip = slash === -1 ? cidr : cidr.slice(0, slash);
5
+ const prefixStr = slash === -1 ? "32" : cidr.slice(slash + 1);
6
+ const prefix = Number(prefixStr);
7
+ if (!Number.isInteger(prefix) || prefix < 0 || prefix > 32)
8
+ return null;
9
+ const octets = ip.split(".").map(Number);
10
+ if (octets.length !== 4 || octets.some((o) => !Number.isInteger(o) || o < 0 || o > 255))
11
+ return null;
12
+ const base = (((octets[0] << 24) | (octets[1] << 16) | (octets[2] << 8) | octets[3]) >>> 0);
13
+ return { base, prefix };
14
+ }
15
+ /** IPv4 字符串 → uint32;非法返回 null(IPv6 等不支持,返回 null)。 */
16
+ export function ipToInt(ip) {
17
+ // 剥离 IPv4-mapped IPv6 前缀(::ffff:a.b.c.d)
18
+ let candidate = ip;
19
+ if (candidate.toLowerCase().startsWith("::ffff:"))
20
+ candidate = candidate.slice(7);
21
+ if (candidate.includes(":"))
22
+ return null;
23
+ const octets = candidate.split(".").map(Number);
24
+ if (octets.length !== 4 || octets.some((o) => !Number.isInteger(o) || o < 0 || o > 255))
25
+ return null;
26
+ return (((octets[0] << 24) | (octets[1] << 16) | (octets[2] << 8) | octets[3]) >>> 0);
27
+ }
28
+ /** ip 是否命中任一 CIDR。 */
29
+ export function ipInCidrs(ip, cidrs) {
30
+ const ipInt = ipToInt(ip);
31
+ if (ipInt === null)
32
+ return false;
33
+ for (const cidr of cidrs) {
34
+ const parsed = parseCidr(cidr);
35
+ if (parsed === null)
36
+ continue;
37
+ const mask = parsed.prefix === 0 ? 0 : (0xffffffff << (32 - parsed.prefix)) >>> 0;
38
+ if ((ipInt & mask) === (parsed.base & mask))
39
+ return true;
40
+ }
41
+ return false;
42
+ }
43
+ //# sourceMappingURL=cidr.js.map
@@ -0,0 +1,33 @@
1
+ export type AuthMode = "pair" | "password" | "none";
2
+ export interface TlsConfig {
3
+ cert: string;
4
+ key: string;
5
+ }
6
+ export interface AuthUser {
7
+ name: string;
8
+ passwordHash: string;
9
+ }
10
+ export interface AuthConfig {
11
+ mode: AuthMode;
12
+ pairCode?: string;
13
+ /** 改密时 +1,用于使旧会话失效 */
14
+ version: number;
15
+ users: AuthUser[];
16
+ }
17
+ export interface RdshConfig {
18
+ host: string;
19
+ port: number;
20
+ sessionTtlSeconds: number;
21
+ tls?: TlsConfig;
22
+ behindProxy: boolean;
23
+ allowFrom: string[];
24
+ auth: AuthConfig;
25
+ dshPath?: string;
26
+ }
27
+ export declare const DEFAULT_CONFIG_PATH: string;
28
+ /** 解析配置文件路径(--config > $RDSH_CONFIG > 默认)。 */
29
+ export declare function resolveConfigPath(cliPath?: string, env?: NodeJS.ProcessEnv): string;
30
+ /** 加载并校验配置;文件不存在时返回默认值。 */
31
+ export declare function loadConfig(path: string): Promise<RdshConfig>;
32
+ /** 校验并规范化任意输入(测试/CLI 覆盖复用)。 */
33
+ export declare function normalizeConfig(raw: unknown, source?: string): RdshConfig;
package/dist/config.js ADDED
@@ -0,0 +1,127 @@
1
+ /**
2
+ * config.ts — 配置加载/默认/校验。持久配置唯一来源(~/.rdsh/config.json)。
3
+ *
4
+ * 优先级:CLI 参数 > config 文件 > 默认值。
5
+ * 路径:`--config <path>` > `$RDSH_CONFIG` > 默认 `~/.rdsh/config.json`。
6
+ */
7
+ import { readFile } from "node:fs/promises";
8
+ import { homedir } from "node:os";
9
+ import { join } from "node:path";
10
+ export const DEFAULT_CONFIG_PATH = join(homedir(), ".rdsh", "config.json");
11
+ const DEFAULT_AUTH = { mode: "pair", version: 1, users: [] };
12
+ const DEFAULTS = {
13
+ host: "0.0.0.0",
14
+ port: 8443,
15
+ sessionTtlSeconds: 12 * 3600,
16
+ behindProxy: false,
17
+ allowFrom: [],
18
+ auth: DEFAULT_AUTH,
19
+ };
20
+ /** 解析配置文件路径(--config > $RDSH_CONFIG > 默认)。 */
21
+ export function resolveConfigPath(cliPath, env = process.env) {
22
+ return cliPath ?? env.RDSH_CONFIG ?? DEFAULT_CONFIG_PATH;
23
+ }
24
+ /** 加载并校验配置;文件不存在时返回默认值。 */
25
+ export async function loadConfig(path) {
26
+ let raw = {};
27
+ try {
28
+ raw = JSON.parse(await readFile(path, "utf8"));
29
+ }
30
+ catch (err) {
31
+ const code = err.code;
32
+ if (code !== "ENOENT") {
33
+ throw new Error(`failed to read config ${path}: ${err.message}`);
34
+ }
35
+ // ENOENT → 默认配置
36
+ }
37
+ return normalizeConfig(raw, path);
38
+ }
39
+ /** 校验并规范化任意输入(测试/CLI 覆盖复用)。 */
40
+ export function normalizeConfig(raw, source = "config") {
41
+ if (typeof raw !== "object" || raw === null) {
42
+ throw new Error(`${source}: expected a JSON object`);
43
+ }
44
+ const cfg = raw;
45
+ const out = {
46
+ ...DEFAULTS,
47
+ auth: { ...DEFAULT_AUTH, users: [] },
48
+ };
49
+ if (cfg.host !== undefined) {
50
+ assertString(cfg.host, "host", source);
51
+ out.host = cfg.host;
52
+ }
53
+ if (cfg.port !== undefined) {
54
+ if (!Number.isInteger(cfg.port) || cfg.port < 0 || cfg.port > 65535) {
55
+ throw new Error(`${source}: invalid "port" ${JSON.stringify(cfg.port)}`);
56
+ }
57
+ out.port = cfg.port;
58
+ }
59
+ if (cfg.sessionTtlSeconds !== undefined) {
60
+ if (!Number.isInteger(cfg.sessionTtlSeconds) || cfg.sessionTtlSeconds <= 0) {
61
+ throw new Error(`${source}: invalid "sessionTtlSeconds" ${JSON.stringify(cfg.sessionTtlSeconds)}`);
62
+ }
63
+ out.sessionTtlSeconds = cfg.sessionTtlSeconds;
64
+ }
65
+ if (cfg.tls !== undefined) {
66
+ if (typeof cfg.tls !== "object" || cfg.tls === null)
67
+ throw new Error(`${source}: "tls" must be an object`);
68
+ const tls = cfg.tls;
69
+ assertString(tls.cert, "tls.cert", source);
70
+ assertString(tls.key, "tls.key", source);
71
+ out.tls = { cert: tls.cert, key: tls.key };
72
+ }
73
+ if (cfg.behindProxy !== undefined) {
74
+ if (typeof cfg.behindProxy !== "boolean")
75
+ throw new Error(`${source}: "behindProxy" must be boolean`);
76
+ out.behindProxy = cfg.behindProxy;
77
+ }
78
+ if (cfg.allowFrom !== undefined) {
79
+ if (!Array.isArray(cfg.allowFrom) || cfg.allowFrom.some((x) => typeof x !== "string")) {
80
+ throw new Error(`${source}: "allowFrom" must be an array of CIDR strings`);
81
+ }
82
+ out.allowFrom = cfg.allowFrom;
83
+ }
84
+ if (cfg.auth !== undefined) {
85
+ if (typeof cfg.auth !== "object" || cfg.auth === null)
86
+ throw new Error(`${source}: "auth" must be an object`);
87
+ const auth = cfg.auth;
88
+ if (auth.mode !== undefined) {
89
+ if (auth.mode !== "pair" && auth.mode !== "password" && auth.mode !== "none") {
90
+ throw new Error(`${source}: "auth.mode" must be pair|password|none`);
91
+ }
92
+ out.auth.mode = auth.mode;
93
+ }
94
+ if (auth.pairCode !== undefined) {
95
+ assertString(auth.pairCode, "auth.pairCode", source);
96
+ out.auth.pairCode = auth.pairCode;
97
+ }
98
+ if (auth.version !== undefined) {
99
+ if (!Number.isInteger(auth.version) || auth.version < 1) {
100
+ throw new Error(`${source}: "auth.version" must be a positive integer`);
101
+ }
102
+ out.auth.version = auth.version;
103
+ }
104
+ if (auth.users !== undefined) {
105
+ if (!Array.isArray(auth.users))
106
+ throw new Error(`${source}: "auth.users" must be an array`);
107
+ out.auth.users = auth.users.map((u, i) => {
108
+ if (typeof u !== "object" || u === null)
109
+ throw new Error(`${source}: auth.users[${i}] must be an object`);
110
+ const user = u;
111
+ assertString(user.name, `auth.users[${i}].name`, source);
112
+ assertString(user.passwordHash, `auth.users[${i}].passwordHash`, source);
113
+ return { name: user.name, passwordHash: user.passwordHash };
114
+ });
115
+ }
116
+ }
117
+ if (cfg.dshPath !== undefined) {
118
+ assertString(cfg.dshPath, "dshPath", source);
119
+ out.dshPath = cfg.dshPath;
120
+ }
121
+ return out;
122
+ }
123
+ function assertString(v, field, source) {
124
+ if (typeof v !== "string")
125
+ throw new Error(`${source}: "${field}" must be a string`);
126
+ }
127
+ //# sourceMappingURL=config.js.map
package/dist/index.d.ts CHANGED
@@ -12,6 +12,16 @@ export { startGateway } from "./server.ts";
12
12
  export type { GatewayOptions, RunningGateway } from "./server.ts";
13
13
  export { findDsh, spawnDsh } from "./spawn-dsh.ts";
14
14
  export type { SpawnedDsh } from "./spawn-dsh.ts";
15
- export { forwardHttp, createUpgradeProxy } from "./proxy.ts";
15
+ export { forwardHttp, createUpgradeProxy, rewriteHeadersForDsh } from "./proxy.ts";
16
16
  export type { ProxyTarget } from "./proxy.ts";
17
+ export { loadConfig, normalizeConfig, resolveConfigPath, DEFAULT_CONFIG_PATH } from "./config.ts";
18
+ export type { RdshConfig, AuthMode, AuthUser, AuthConfig, TlsConfig } from "./config.ts";
19
+ export { hashPassword, verifyPassword, UserManager } from "./auth.ts";
20
+ export { ipInCidrs, parseCidr, ipToInt } from "./cidr.ts";
21
+ export { loadTls } from "./tls.ts";
22
+ export type { TlsMaterial } from "./tls.ts";
23
+ export { loginPageHtml } from "./login-page.ts";
24
+ export { installService, uninstallService, serviceStatus, systemdUnit, launchdPlist } from "./service.ts";
17
25
  export declare const NAME = "rdsh-gateway";
26
+ export { join } from "./join.ts";
27
+ export type { JoinOptions } from "./join.ts";
package/dist/index.js CHANGED
@@ -8,6 +8,13 @@ export { SessionManager, SESSION_COOKIE, sessionTokenFromCookie } from "./sessio
8
8
  export { PairManager } from "./pair.js";
9
9
  export { startGateway } from "./server.js";
10
10
  export { findDsh, spawnDsh } from "./spawn-dsh.js";
11
- export { forwardHttp, createUpgradeProxy } from "./proxy.js";
11
+ export { forwardHttp, createUpgradeProxy, rewriteHeadersForDsh } from "./proxy.js";
12
+ export { loadConfig, normalizeConfig, resolveConfigPath, DEFAULT_CONFIG_PATH } from "./config.js";
13
+ export { hashPassword, verifyPassword, UserManager } from "./auth.js";
14
+ export { ipInCidrs, parseCidr, ipToInt } from "./cidr.js";
15
+ export { loadTls } from "./tls.js";
16
+ export { loginPageHtml } from "./login-page.js";
17
+ export { installService, uninstallService, serviceStatus, systemdUnit, launchdPlist } from "./service.js";
12
18
  export const NAME = "rdsh-gateway";
19
+ export { join } from "./join.js";
13
20
  //# sourceMappingURL=index.js.map
package/dist/join.d.ts ADDED
@@ -0,0 +1,9 @@
1
+ export interface JoinOptions {
2
+ hubUrl: string;
3
+ /** 直填 host token(跳过配对码绑定流程) */
4
+ token?: string;
5
+ dshPath?: string;
6
+ /** 跳过 TLS 证书校验(自签 hub 用;正式证书无需) */
7
+ insecure?: boolean;
8
+ }
9
+ export declare function join(opts: JoinOptions): Promise<void>;