rdsh-gateway 0.1.0 → 0.2.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/LICENSE +21 -0
- package/dist/auth.d.ts +24 -0
- package/dist/auth.js +117 -0
- package/dist/cidr.d.ts +13 -0
- package/dist/cidr.js +43 -0
- package/dist/config.d.ts +33 -0
- package/dist/config.js +127 -0
- package/dist/index.d.ts +11 -1
- package/dist/index.js +8 -1
- package/dist/join.d.ts +9 -0
- package/dist/join.js +352 -0
- package/dist/login-page.d.ts +4 -0
- package/dist/login-page.js +86 -0
- package/dist/proxy.d.ts +7 -0
- package/dist/proxy.js +16 -15
- package/dist/serve.d.ts +7 -6
- package/dist/serve.js +67 -30
- package/dist/server.d.ts +32 -7
- package/dist/server.js +198 -39
- package/dist/service.d.ts +11 -0
- package/dist/service.js +123 -0
- package/dist/session.d.ts +7 -5
- package/dist/session.js +10 -7
- package/dist/tls.d.ts +13 -0
- package/dist/tls.js +26 -0
- package/package.json +4 -3
package/dist/server.d.ts
CHANGED
|
@@ -1,14 +1,23 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* server.ts — HTTP 服务器 + 认证中间件 + 路由。
|
|
2
|
+
* server.ts — HTTP(S) 服务器 + 认证中间件 + 路由。
|
|
3
3
|
*
|
|
4
4
|
* 路由:
|
|
5
|
-
* GET /pair
|
|
6
|
-
* POST /pair
|
|
7
|
-
*
|
|
5
|
+
* GET /pair → 配对页(auth.mode=pair)
|
|
6
|
+
* POST /pair → 校验配对码(成功 Set-Cookie + 302 /;失败 401/429)
|
|
7
|
+
* GET /login → 登录页(auth.mode=password)
|
|
8
|
+
* POST /login → 校验用户名/密码(成功 Set-Cookie + 302 /;失败 401/429)
|
|
9
|
+
* 其余路径(含 upgrade)→ 有有效会话转发 dsh;无 → 307 /pair 或 /login
|
|
10
|
+
*
|
|
11
|
+
* M2:TLS(node:https)、auth.mode(pair/password/none)、IP 白名单(allowFrom)、
|
|
12
|
+
* 反代适配(behindProxy:X-Forwarded-For/Proto)、改密版本化会话(auth.version)。
|
|
8
13
|
*/
|
|
9
|
-
import { createServer } from "node:http";
|
|
14
|
+
import { createServer as createHttpServer } from "node:http";
|
|
15
|
+
import { createServer as createHttpsServer } from "node:https";
|
|
10
16
|
import { SessionManager } from "./session.ts";
|
|
11
17
|
import { PairManager } from "./pair.ts";
|
|
18
|
+
import { UserManager } from "./auth.ts";
|
|
19
|
+
import type { AuthMode } from "./config.ts";
|
|
20
|
+
import type { TlsMaterial } from "./tls.ts";
|
|
12
21
|
export interface GatewayOptions {
|
|
13
22
|
host: string;
|
|
14
23
|
port: number;
|
|
@@ -19,14 +28,30 @@ export interface GatewayOptions {
|
|
|
19
28
|
reset?: boolean;
|
|
20
29
|
/** 会话密钥目录(默认 ~/.rdsh;测试可注入临时目录) */
|
|
21
30
|
keyDir?: string;
|
|
22
|
-
/** true =
|
|
31
|
+
/** true = 跳过认证(--no-code / auth.mode=none) */
|
|
23
32
|
noCode?: boolean;
|
|
33
|
+
/** M2:认证模式(pair | password | none);默认 noCode? none : pair */
|
|
34
|
+
authMode?: AuthMode;
|
|
35
|
+
/** M2:改密版本号(会话校验绑定);默认 1 */
|
|
36
|
+
authVersion?: number;
|
|
37
|
+
/** M2:IP 白名单(CIDR);空 = 不限制 */
|
|
38
|
+
allowFrom?: string[];
|
|
39
|
+
/** M2:反代终止 TLS(信任 XFF,允许 password+http) */
|
|
40
|
+
behindProxy?: boolean;
|
|
41
|
+
/** M2:TLS 材料;提供则 https */
|
|
42
|
+
tlsMaterial?: TlsMaterial | null;
|
|
43
|
+
/** M2:password 模式验证用户 */
|
|
44
|
+
userManager?: UserManager;
|
|
45
|
+
/** M2:配置文件路径(fs.watch 热更新 auth.version/allowFrom) */
|
|
46
|
+
configPath?: string;
|
|
24
47
|
}
|
|
25
48
|
export interface RunningGateway {
|
|
26
|
-
server: ReturnType<typeof
|
|
49
|
+
server: ReturnType<typeof createHttpServer> | ReturnType<typeof createHttpsServer>;
|
|
27
50
|
sessions: SessionManager;
|
|
28
51
|
pair: PairManager;
|
|
29
52
|
/** 实际监听端口(port 0 → OS 分配) */
|
|
30
53
|
actualPort: number;
|
|
54
|
+
/** 关闭 fs.watch 等资源 */
|
|
55
|
+
dispose(): void;
|
|
31
56
|
}
|
|
32
57
|
export declare function startGateway(opts: GatewayOptions): Promise<RunningGateway>;
|
package/dist/server.js
CHANGED
|
@@ -1,17 +1,30 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* server.ts — HTTP 服务器 + 认证中间件 + 路由。
|
|
2
|
+
* server.ts — HTTP(S) 服务器 + 认证中间件 + 路由。
|
|
3
3
|
*
|
|
4
4
|
* 路由:
|
|
5
|
-
* GET /pair
|
|
6
|
-
* POST /pair
|
|
7
|
-
*
|
|
5
|
+
* GET /pair → 配对页(auth.mode=pair)
|
|
6
|
+
* POST /pair → 校验配对码(成功 Set-Cookie + 302 /;失败 401/429)
|
|
7
|
+
* GET /login → 登录页(auth.mode=password)
|
|
8
|
+
* POST /login → 校验用户名/密码(成功 Set-Cookie + 302 /;失败 401/429)
|
|
9
|
+
* 其余路径(含 upgrade)→ 有有效会话转发 dsh;无 → 307 /pair 或 /login
|
|
10
|
+
*
|
|
11
|
+
* M2:TLS(node:https)、auth.mode(pair/password/none)、IP 白名单(allowFrom)、
|
|
12
|
+
* 反代适配(behindProxy:X-Forwarded-For/Proto)、改密版本化会话(auth.version)。
|
|
8
13
|
*/
|
|
9
|
-
import { createServer } from "node:http";
|
|
14
|
+
import { createServer as createHttpServer } from "node:http";
|
|
15
|
+
import { createServer as createHttpsServer } from "node:https";
|
|
16
|
+
import { watch } from "node:fs";
|
|
17
|
+
import { basename, dirname } from "node:path";
|
|
10
18
|
import { SessionManager, sessionTokenFromCookie } from "./session.js";
|
|
11
19
|
import { PairManager } from "./pair.js";
|
|
12
20
|
import { createUpgradeProxy, forwardHttp } from "./proxy.js";
|
|
13
21
|
import { pairPageHtml } from "./pair-page.js";
|
|
22
|
+
import { loginPageHtml } from "./login-page.js";
|
|
14
23
|
import { SECURE_CONTEXT_POLYFILL } from "./secure-context-polyfill.js";
|
|
24
|
+
import { loadConfig } from "./config.js";
|
|
25
|
+
import { ipInCidrs } from "./cidr.js";
|
|
26
|
+
const MAX_LOGIN_FAILS = 5;
|
|
27
|
+
const LOGIN_LOCK_MS = 10 * 60 * 1000;
|
|
15
28
|
export async function startGateway(opts) {
|
|
16
29
|
const sessions = new SessionManager(opts.keyDir);
|
|
17
30
|
if (opts.reset) {
|
|
@@ -22,24 +35,58 @@ export async function startGateway(opts) {
|
|
|
22
35
|
}
|
|
23
36
|
const pair = new PairManager(opts.pairCode);
|
|
24
37
|
const target = { host: "127.0.0.1", port: opts.dshPort };
|
|
25
|
-
const
|
|
38
|
+
const authMode = opts.noCode ? "none" : (opts.authMode ?? "pair");
|
|
39
|
+
const behindProxy = opts.behindProxy === true;
|
|
40
|
+
// 安全硬约束:password 模式必须 TLS(反代除外)
|
|
41
|
+
if (authMode === "password" && !behindProxy && (opts.tlsMaterial === undefined || opts.tlsMaterial === null)) {
|
|
42
|
+
throw new Error('auth.mode=password requires TLS. Set tls.cert/key in config, or use behind_proxy with a reverse proxy.');
|
|
43
|
+
}
|
|
44
|
+
// 可变状态:config 热更新(改密版本 / IP 白名单)
|
|
45
|
+
let currentVersion = opts.authVersion ?? 1;
|
|
46
|
+
let currentAllowFrom = opts.allowFrom ?? [];
|
|
47
|
+
let configWatcher;
|
|
48
|
+
if (opts.configPath !== undefined) {
|
|
49
|
+
const configPath = opts.configPath;
|
|
50
|
+
const reload = () => {
|
|
51
|
+
void loadConfig(configPath).then((cfg) => {
|
|
52
|
+
currentVersion = cfg.auth.version;
|
|
53
|
+
currentAllowFrom = cfg.allowFrom;
|
|
54
|
+
}).catch(() => {
|
|
55
|
+
/* 配置暂不可读,保持旧值 */
|
|
56
|
+
});
|
|
57
|
+
};
|
|
58
|
+
// 监听目录而非文件:`rdsh user passwd` 用 tmp+rename 原子替换 config,
|
|
59
|
+
// 文件级 watch 会跟丢(macOS kqueue 盯的是旧 inode)。目录 vnode 稳定。
|
|
60
|
+
configWatcher = watch(dirname(configPath), (_event, filename) => {
|
|
61
|
+
if (filename === basename(configPath) || filename === null)
|
|
62
|
+
reload();
|
|
63
|
+
});
|
|
64
|
+
}
|
|
26
65
|
const ctx = {
|
|
27
66
|
sessions,
|
|
28
67
|
pair,
|
|
29
68
|
target,
|
|
30
69
|
sessionTtlSeconds: opts.sessionTtlSeconds,
|
|
31
|
-
|
|
70
|
+
authMode,
|
|
71
|
+
behindProxy,
|
|
72
|
+
getVersion: () => currentVersion,
|
|
73
|
+
isAllowed: (ip) => currentAllowFrom.length === 0 || ipInCidrs(ip, currentAllowFrom),
|
|
74
|
+
userManager: opts.userManager,
|
|
32
75
|
};
|
|
33
|
-
const
|
|
34
|
-
|
|
76
|
+
const loginLimiter = createLoginLimiter();
|
|
77
|
+
const requestHandler = (req, res) => {
|
|
78
|
+
void handleHttp(req, res, ctx, loginLimiter).catch(() => {
|
|
35
79
|
if (!res.headersSent)
|
|
36
80
|
res.writeHead(500);
|
|
37
81
|
res.end();
|
|
38
82
|
});
|
|
39
|
-
}
|
|
83
|
+
};
|
|
84
|
+
const server = opts.tlsMaterial === undefined || opts.tlsMaterial === null
|
|
85
|
+
? createHttpServer(requestHandler)
|
|
86
|
+
: createHttpsServer({ key: opts.tlsMaterial.key, cert: opts.tlsMaterial.cert }, requestHandler);
|
|
40
87
|
server.on("upgrade", (req, socket, head) => {
|
|
41
|
-
if (
|
|
42
|
-
socket.write(
|
|
88
|
+
if (ctx.authMode !== "none" && !hasValidSession(req, ctx)) {
|
|
89
|
+
socket.write(`HTTP/1.1 307 Temporary Redirect\r\nLocation: ${pairOrLogin(ctx)}\r\n\r\n`);
|
|
43
90
|
socket.destroy();
|
|
44
91
|
return;
|
|
45
92
|
}
|
|
@@ -51,25 +98,68 @@ export async function startGateway(opts) {
|
|
|
51
98
|
});
|
|
52
99
|
const address = server.address();
|
|
53
100
|
const actualPort = typeof address === "object" && address !== null ? address.port : opts.port;
|
|
54
|
-
|
|
101
|
+
const upgradeProxy = createUpgradeProxy(target);
|
|
102
|
+
return {
|
|
103
|
+
server,
|
|
104
|
+
sessions,
|
|
105
|
+
pair,
|
|
106
|
+
actualPort,
|
|
107
|
+
dispose: () => {
|
|
108
|
+
configWatcher?.close();
|
|
109
|
+
},
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
function pairOrLogin(ctx) {
|
|
113
|
+
return ctx.authMode === "password" ? "/login" : "/pair";
|
|
55
114
|
}
|
|
56
|
-
function hasValidSession(req,
|
|
115
|
+
function hasValidSession(req, ctx) {
|
|
57
116
|
const token = sessionTokenFromCookie(req.headers.cookie);
|
|
58
|
-
return token !== null && sessions.verify(token) !== null;
|
|
117
|
+
return token !== null && ctx.sessions.verify(token, ctx.getVersion()) !== null;
|
|
118
|
+
}
|
|
119
|
+
/** 客户端真实 IP(behindProxy 时取 XFF,仅当连接来自回环 —— 防伪造)。 */
|
|
120
|
+
function clientIp(req, ctx) {
|
|
121
|
+
const remote = req.socket.remoteAddress ?? "unknown";
|
|
122
|
+
if (ctx.behindProxy && (remote === "127.0.0.1" || remote === "::1" || remote === "::ffff:127.0.0.1")) {
|
|
123
|
+
const xff = req.headers["x-forwarded-for"];
|
|
124
|
+
if (typeof xff === "string" && xff.length > 0) {
|
|
125
|
+
return xff.split(",")[0].trim();
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
return remote;
|
|
59
129
|
}
|
|
60
|
-
async function handleHttp(req, res, ctx) {
|
|
130
|
+
async function handleHttp(req, res, ctx, loginLimiter) {
|
|
131
|
+
// IP 白名单(认证前)
|
|
132
|
+
if (!ctx.isAllowed(clientIp(req, ctx))) {
|
|
133
|
+
res.writeHead(403, { "content-type": "application/json" });
|
|
134
|
+
res.end(JSON.stringify({ error: { code: "FORBIDDEN", message: "source IP not allowed" } }));
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
61
137
|
const pathname = new URL(req.url ?? "/", "http://rdsh.local").pathname;
|
|
62
|
-
|
|
138
|
+
const isLoginMode = ctx.authMode === "password";
|
|
139
|
+
if (req.method === "GET" && pathname === "/pair" && !isLoginMode) {
|
|
63
140
|
res.writeHead(200, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" });
|
|
64
141
|
res.end(pairPageHtml());
|
|
65
142
|
return;
|
|
66
143
|
}
|
|
67
|
-
if (req.method === "POST" && pathname === "/pair") {
|
|
144
|
+
if (req.method === "POST" && pathname === "/pair" && !isLoginMode) {
|
|
68
145
|
await handlePairPost(req, res, ctx);
|
|
69
146
|
return;
|
|
70
147
|
}
|
|
71
|
-
if (
|
|
72
|
-
res.writeHead(
|
|
148
|
+
if (req.method === "GET" && pathname === "/login" && isLoginMode) {
|
|
149
|
+
res.writeHead(200, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" });
|
|
150
|
+
res.end(loginPageHtml());
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
if (req.method === "POST" && pathname === "/login" && isLoginMode) {
|
|
154
|
+
await handleLoginPost(req, res, ctx, loginLimiter);
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
if (ctx.authMode === "none") {
|
|
158
|
+
forwardHttp(req, res, ctx.target, { htmlInject: SECURE_CONTEXT_POLYFILL });
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
if (!hasValidSession(req, ctx)) {
|
|
162
|
+
res.writeHead(307, { location: pairOrLogin(ctx) });
|
|
73
163
|
res.end();
|
|
74
164
|
return;
|
|
75
165
|
}
|
|
@@ -77,24 +167,13 @@ async function handleHttp(req, res, ctx) {
|
|
|
77
167
|
}
|
|
78
168
|
async function handlePairPost(req, res, ctx) {
|
|
79
169
|
const ip = req.socket.remoteAddress ?? "unknown";
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
body += chunk;
|
|
83
|
-
if (body.length > 1024) {
|
|
84
|
-
res.writeHead(413, { "content-type": "application/json" });
|
|
85
|
-
res.end(JSON.stringify({ error: { code: "TOO_LARGE", message: "body too large" } }));
|
|
86
|
-
return;
|
|
87
|
-
}
|
|
88
|
-
}
|
|
89
|
-
let code;
|
|
90
|
-
try {
|
|
91
|
-
code = JSON.parse(body).code;
|
|
92
|
-
}
|
|
93
|
-
catch {
|
|
170
|
+
const body = await readJsonBody(req);
|
|
171
|
+
if (body === null) {
|
|
94
172
|
res.writeHead(400, { "content-type": "application/json" });
|
|
95
173
|
res.end(JSON.stringify({ error: { code: "BAD_REQUEST", message: "invalid body" } }));
|
|
96
174
|
return;
|
|
97
175
|
}
|
|
176
|
+
const code = body.code;
|
|
98
177
|
if (typeof code !== "string" || code.length > 16) {
|
|
99
178
|
res.writeHead(400, { "content-type": "application/json" });
|
|
100
179
|
res.end(JSON.stringify({ error: { code: "BAD_REQUEST", message: "invalid code" } }));
|
|
@@ -104,21 +183,101 @@ async function handlePairPost(req, res, ctx) {
|
|
|
104
183
|
if (result.ok) {
|
|
105
184
|
res.writeHead(302, {
|
|
106
185
|
location: "/",
|
|
107
|
-
"set-cookie": ctx.sessions.cookieHeader(ctx.sessionTtlSeconds),
|
|
186
|
+
"set-cookie": ctx.sessions.cookieHeader(ctx.sessionTtlSeconds, ctx.getVersion()),
|
|
108
187
|
});
|
|
109
188
|
res.end();
|
|
110
189
|
return;
|
|
111
190
|
}
|
|
112
191
|
if (result.locked) {
|
|
113
192
|
const retryAfter = Math.max(1, Math.ceil(result.retryAfterMs / 1000));
|
|
114
|
-
res.writeHead(429, {
|
|
115
|
-
"content-type": "application/json",
|
|
116
|
-
"retry-after": String(retryAfter),
|
|
117
|
-
});
|
|
193
|
+
res.writeHead(429, { "content-type": "application/json", "retry-after": String(retryAfter) });
|
|
118
194
|
res.end(JSON.stringify({ error: { code: "RATE_LIMITED", message: "too many attempts" } }));
|
|
119
195
|
return;
|
|
120
196
|
}
|
|
121
197
|
res.writeHead(401, { "content-type": "application/json" });
|
|
122
198
|
res.end(JSON.stringify({ error: { code: "BAD_CODE", message: "invalid pair code" } }));
|
|
123
199
|
}
|
|
200
|
+
async function handleLoginPost(req, res, ctx, loginLimiter) {
|
|
201
|
+
const ip = clientIp(req, ctx);
|
|
202
|
+
const lockedMs = loginLimiter.allow(ip);
|
|
203
|
+
if (lockedMs > 0) {
|
|
204
|
+
res.writeHead(429, { "content-type": "application/json", "retry-after": String(Math.ceil(lockedMs / 1000)) });
|
|
205
|
+
res.end(JSON.stringify({ error: { code: "RATE_LIMITED", message: "too many attempts" } }));
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
const body = await readJsonBody(req);
|
|
209
|
+
if (body === null || typeof body.name !== "string" || typeof body.password !== "string") {
|
|
210
|
+
res.writeHead(400, { "content-type": "application/json" });
|
|
211
|
+
res.end(JSON.stringify({ error: { code: "BAD_REQUEST", message: "invalid body" } }));
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
if (ctx.userManager === undefined) {
|
|
215
|
+
res.writeHead(500, { "content-type": "application/json" });
|
|
216
|
+
res.end(JSON.stringify({ error: { code: "NO_USERS", message: "no user manager configured" } }));
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
const user = await ctx.userManager.verify(body.name, body.password);
|
|
220
|
+
if (user === null) {
|
|
221
|
+
const locked = loginLimiter.fail(ip);
|
|
222
|
+
if (locked > 0) {
|
|
223
|
+
res.writeHead(429, { "content-type": "application/json", "retry-after": String(Math.ceil(locked / 1000)) });
|
|
224
|
+
res.end(JSON.stringify({ error: { code: "RATE_LIMITED", message: "too many attempts" } }));
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
res.writeHead(401, { "content-type": "application/json" });
|
|
228
|
+
res.end(JSON.stringify({ error: { code: "BAD_CREDENTIALS", message: "invalid username or password" } }));
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
loginLimiter.clear(ip);
|
|
232
|
+
res.writeHead(302, {
|
|
233
|
+
location: "/",
|
|
234
|
+
"set-cookie": ctx.sessions.cookieHeader(ctx.sessionTtlSeconds, ctx.getVersion()),
|
|
235
|
+
});
|
|
236
|
+
res.end();
|
|
237
|
+
}
|
|
238
|
+
async function readJsonBody(req) {
|
|
239
|
+
let body = "";
|
|
240
|
+
for await (const chunk of req) {
|
|
241
|
+
body += chunk;
|
|
242
|
+
if (body.length > 4096)
|
|
243
|
+
return null;
|
|
244
|
+
}
|
|
245
|
+
try {
|
|
246
|
+
const parsed = JSON.parse(body);
|
|
247
|
+
if (typeof parsed !== "object" || parsed === null)
|
|
248
|
+
return null;
|
|
249
|
+
return parsed;
|
|
250
|
+
}
|
|
251
|
+
catch {
|
|
252
|
+
return null;
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
/** 登录失败限流(按 IP,5 次/10 分钟)。 */
|
|
256
|
+
function createLoginLimiter() {
|
|
257
|
+
const locks = new Map();
|
|
258
|
+
return {
|
|
259
|
+
/** 剩余锁定毫秒(0 = 允许)。 */
|
|
260
|
+
allow(ip) {
|
|
261
|
+
const s = locks.get(ip);
|
|
262
|
+
if (s === undefined)
|
|
263
|
+
return 0;
|
|
264
|
+
const remain = s.lockedUntil - Date.now();
|
|
265
|
+
return remain > 0 ? remain : 0;
|
|
266
|
+
},
|
|
267
|
+
/** 记录失败;返回新锁定毫秒(>0 表示已锁定)。 */
|
|
268
|
+
fail(ip) {
|
|
269
|
+
const s = locks.get(ip) ?? { fails: 0, lockedUntil: 0 };
|
|
270
|
+
s.fails += 1;
|
|
271
|
+
if (s.fails >= MAX_LOGIN_FAILS) {
|
|
272
|
+
s.fails = 0;
|
|
273
|
+
s.lockedUntil = Date.now() + LOGIN_LOCK_MS;
|
|
274
|
+
}
|
|
275
|
+
locks.set(ip, s);
|
|
276
|
+
return s.lockedUntil > Date.now() ? s.lockedUntil - Date.now() : 0;
|
|
277
|
+
},
|
|
278
|
+
clear(ip) {
|
|
279
|
+
locks.delete(ip);
|
|
280
|
+
},
|
|
281
|
+
};
|
|
282
|
+
}
|
|
124
283
|
//# sourceMappingURL=server.js.map
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export declare const SERVICE_NAME = "rdsh";
|
|
2
|
+
/** systemd 用户级 unit 模板。subcommandArgs 默认 ["serve"](hub 用 ["hub","serve"])。 */
|
|
3
|
+
export declare function systemdUnit(execStart: string, configPath: string, subcommandArgs?: string[]): string;
|
|
4
|
+
/** launchd plist 模板。 */
|
|
5
|
+
export declare function launchdPlist(execStart: string, configPath: string, subcommandArgs?: string[]): string;
|
|
6
|
+
/** 安装并启动服务(用户级)。subcommandArgs 默认 ["serve"](hub 用 ["hub","serve"])。 */
|
|
7
|
+
export declare function installService(configPath: string, subcommandArgs?: string[]): Promise<string>;
|
|
8
|
+
/** 服务状态。 */
|
|
9
|
+
export declare function serviceStatus(): Promise<string>;
|
|
10
|
+
/** 停止并移除服务。 */
|
|
11
|
+
export declare function uninstallService(): Promise<string>;
|
package/dist/service.js
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* service.ts — 服务化:生成并安装 systemd unit(Linux)/ launchd plist(macOS)。
|
|
3
|
+
*
|
|
4
|
+
* 设计(roadmap M2):不自带 fork 后台 —— 交给系统进程管理器托管 rdsh(连带其
|
|
5
|
+
* spawn 的 dsh);用户级安装(~/.config/systemd/user / ~/Library/LaunchAgents),
|
|
6
|
+
* 无需 sudo;开机自启 + 崩溃重启(Restart=on-failure / KeepAlive)。
|
|
7
|
+
*/
|
|
8
|
+
import { mkdir, rm, writeFile } from "node:fs/promises";
|
|
9
|
+
import { homedir } from "node:os";
|
|
10
|
+
import { join } from "node:path";
|
|
11
|
+
import { execFile } from "node:child_process";
|
|
12
|
+
import { promisify } from "node:util";
|
|
13
|
+
const execFileP = promisify(execFile);
|
|
14
|
+
export const SERVICE_NAME = "rdsh";
|
|
15
|
+
const SYSTEMD_DIR = join(homedir(), ".config", "systemd", "user");
|
|
16
|
+
const SYSTEMD_UNIT = join(SYSTEMD_DIR, "rdsh.service");
|
|
17
|
+
const LAUNCHD_DIR = join(homedir(), "Library", "LaunchAgents");
|
|
18
|
+
const LAUNCHD_PLIST = join(LAUNCHD_DIR, "com.rdsh.plist");
|
|
19
|
+
function isLinux() {
|
|
20
|
+
return process.platform === "linux";
|
|
21
|
+
}
|
|
22
|
+
function serviceLogPath() {
|
|
23
|
+
return join(homedir(), ".rdsh", "service.log");
|
|
24
|
+
}
|
|
25
|
+
/** systemd 用户级 unit 模板。subcommandArgs 默认 ["serve"](hub 用 ["hub","serve"])。 */
|
|
26
|
+
export function systemdUnit(execStart, configPath, subcommandArgs = ["serve"]) {
|
|
27
|
+
const args = [...subcommandArgs, "--config", configPath].join(" ");
|
|
28
|
+
return `[Unit]
|
|
29
|
+
Description=rdsh — remote access for DeepSeek Harness
|
|
30
|
+
After=network.target
|
|
31
|
+
|
|
32
|
+
[Service]
|
|
33
|
+
Type=simple
|
|
34
|
+
ExecStart=${execStart} ${args}
|
|
35
|
+
Restart=on-failure
|
|
36
|
+
RestartSec=3
|
|
37
|
+
|
|
38
|
+
[Install]
|
|
39
|
+
WantedBy=default.target
|
|
40
|
+
`;
|
|
41
|
+
}
|
|
42
|
+
/** launchd plist 模板。 */
|
|
43
|
+
export function launchdPlist(execStart, configPath, subcommandArgs = ["serve"]) {
|
|
44
|
+
const args = [...subcommandArgs, "--config", configPath].map((a) => ` <string>${a}</string>`).join("\n");
|
|
45
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
46
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
47
|
+
<plist version="1.0">
|
|
48
|
+
<dict>
|
|
49
|
+
<key>Label</key>
|
|
50
|
+
<string>com.rdsh</string>
|
|
51
|
+
<key>ProgramArguments</key>
|
|
52
|
+
<array>
|
|
53
|
+
<string>${execStart}</string>
|
|
54
|
+
${args}
|
|
55
|
+
</array>
|
|
56
|
+
<key>RunAtLoad</key>
|
|
57
|
+
<true/>
|
|
58
|
+
<key>KeepAlive</key>
|
|
59
|
+
<true/>
|
|
60
|
+
<key>StandardOutPath</key>
|
|
61
|
+
<string>${serviceLogPath()}</string>
|
|
62
|
+
<key>StandardErrorPath</key>
|
|
63
|
+
<string>${serviceLogPath()}</string>
|
|
64
|
+
</dict>
|
|
65
|
+
</plist>
|
|
66
|
+
`;
|
|
67
|
+
}
|
|
68
|
+
async function run(cmd, args) {
|
|
69
|
+
try {
|
|
70
|
+
const { stdout } = await execFileP(cmd, args);
|
|
71
|
+
return String(stdout).trim();
|
|
72
|
+
}
|
|
73
|
+
catch (err) {
|
|
74
|
+
throw new Error(`${cmd} ${args.join(" ")} failed: ${err.message}`);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
/** 安装并启动服务(用户级)。subcommandArgs 默认 ["serve"](hub 用 ["hub","serve"])。 */
|
|
78
|
+
export async function installService(configPath, subcommandArgs = ["serve"]) {
|
|
79
|
+
const execStart = `${process.execPath} ${process.argv[1]}`;
|
|
80
|
+
if (isLinux()) {
|
|
81
|
+
await mkdir(SYSTEMD_DIR, { recursive: true });
|
|
82
|
+
await writeFile(SYSTEMD_UNIT, systemdUnit(execStart, configPath, subcommandArgs), { mode: 0o600 });
|
|
83
|
+
await run("systemctl", ["--user", "daemon-reload"]);
|
|
84
|
+
await run("systemctl", ["--user", "enable", "--now", SERVICE_NAME]);
|
|
85
|
+
return `installed systemd user unit: ${SYSTEMD_UNIT}`;
|
|
86
|
+
}
|
|
87
|
+
await mkdir(LAUNCHD_DIR, { recursive: true });
|
|
88
|
+
await writeFile(LAUNCHD_PLIST, launchdPlist(execStart, configPath, subcommandArgs), { mode: 0o600 });
|
|
89
|
+
await run("launchctl", ["load", LAUNCHD_PLIST]);
|
|
90
|
+
return `installed launchd plist: ${LAUNCHD_PLIST}`;
|
|
91
|
+
}
|
|
92
|
+
/** 服务状态。 */
|
|
93
|
+
export async function serviceStatus() {
|
|
94
|
+
if (isLinux()) {
|
|
95
|
+
try {
|
|
96
|
+
const active = await run("systemctl", ["--user", "is-active", SERVICE_NAME]);
|
|
97
|
+
return `active: ${active}`;
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
return "inactive";
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
try {
|
|
104
|
+
await run("launchctl", ["print", "com.rdsh"]);
|
|
105
|
+
return "active";
|
|
106
|
+
}
|
|
107
|
+
catch {
|
|
108
|
+
return "unloaded";
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
/** 停止并移除服务。 */
|
|
112
|
+
export async function uninstallService() {
|
|
113
|
+
if (isLinux()) {
|
|
114
|
+
await run("systemctl", ["--user", "disable", "--now", SERVICE_NAME]).catch(() => undefined);
|
|
115
|
+
await rm(SYSTEMD_UNIT, { force: true });
|
|
116
|
+
await run("systemctl", ["--user", "daemon-reload"]).catch(() => undefined);
|
|
117
|
+
return `removed ${SYSTEMD_UNIT}`;
|
|
118
|
+
}
|
|
119
|
+
await run("launchctl", ["unload", LAUNCHD_PLIST]).catch(() => undefined);
|
|
120
|
+
await rm(LAUNCHD_PLIST, { force: true });
|
|
121
|
+
return `removed ${LAUNCHD_PLIST}`;
|
|
122
|
+
}
|
|
123
|
+
//# sourceMappingURL=service.js.map
|
package/dist/session.d.ts
CHANGED
|
@@ -3,6 +3,8 @@ export interface SessionPayload {
|
|
|
3
3
|
sid: string;
|
|
4
4
|
/** 过期时刻(epoch 秒) */
|
|
5
5
|
exp: number;
|
|
6
|
+
/** auth.version(M2 改密版本;不匹配 → 会话无效) */
|
|
7
|
+
v: number;
|
|
6
8
|
}
|
|
7
9
|
export declare class SessionManager {
|
|
8
10
|
private key;
|
|
@@ -14,12 +16,12 @@ export declare class SessionManager {
|
|
|
14
16
|
init(): Promise<void>;
|
|
15
17
|
/** 删除密钥并重建 —— 全部已签发会话立即失效。 */
|
|
16
18
|
reset(): Promise<void>;
|
|
17
|
-
/** 签发一个有效期 ttlSeconds 的会话 token
|
|
18
|
-
sign(ttlSeconds: number): string;
|
|
19
|
-
/** 校验 token
|
|
20
|
-
verify(token: string): SessionPayload | null;
|
|
19
|
+
/** 签发一个有效期 ttlSeconds 的会话 token(携带 auth 版本号)。 */
|
|
20
|
+
sign(ttlSeconds: number, version?: number): string;
|
|
21
|
+
/** 校验 token;无效/过期/被篡改/版本不匹配 → null。 */
|
|
22
|
+
verify(token: string, version?: number): SessionPayload | null;
|
|
21
23
|
/** 生成 Set-Cookie 头(HttpOnly + SameSite=Lax)。 */
|
|
22
|
-
cookieHeader(ttlSeconds: number): string;
|
|
24
|
+
cookieHeader(ttlSeconds: number, version?: number): string;
|
|
23
25
|
private requireKey;
|
|
24
26
|
}
|
|
25
27
|
/** 从请求 Cookie 头解析出会话 token(无 → null)。 */
|
package/dist/session.js
CHANGED
|
@@ -33,19 +33,20 @@ export class SessionManager {
|
|
|
33
33
|
await rm(this.keyFile, { force: true });
|
|
34
34
|
this.key = await loadOrCreateKey(this.keyFile);
|
|
35
35
|
}
|
|
36
|
-
/** 签发一个有效期 ttlSeconds 的会话 token
|
|
37
|
-
sign(ttlSeconds) {
|
|
36
|
+
/** 签发一个有效期 ttlSeconds 的会话 token(携带 auth 版本号)。 */
|
|
37
|
+
sign(ttlSeconds, version = 1) {
|
|
38
38
|
const key = this.requireKey();
|
|
39
39
|
const payload = {
|
|
40
40
|
sid: randomUUID(),
|
|
41
41
|
exp: Math.floor(Date.now() / 1000) + ttlSeconds,
|
|
42
|
+
v: version,
|
|
42
43
|
};
|
|
43
44
|
const body = Buffer.from(JSON.stringify(payload));
|
|
44
45
|
const mac = createHmac("sha256", key).update(body).digest("base64url");
|
|
45
46
|
return `${mac}.${body.toString("base64url")}`;
|
|
46
47
|
}
|
|
47
|
-
/** 校验 token
|
|
48
|
-
verify(token) {
|
|
48
|
+
/** 校验 token;无效/过期/被篡改/版本不匹配 → null。 */
|
|
49
|
+
verify(token, version) {
|
|
49
50
|
const key = this.requireKey();
|
|
50
51
|
const dot = token.indexOf(".");
|
|
51
52
|
if (dot <= 0)
|
|
@@ -64,13 +65,15 @@ export class SessionManager {
|
|
|
64
65
|
return null;
|
|
65
66
|
try {
|
|
66
67
|
const payload = JSON.parse(bodyBytes.toString("utf8"));
|
|
67
|
-
if (typeof payload.sid !== "string" || typeof payload.exp !== "number")
|
|
68
|
+
if (typeof payload.sid !== "string" || typeof payload.exp !== "number" || typeof payload.v !== "number")
|
|
68
69
|
return null;
|
|
69
70
|
const now = Math.floor(Date.now() / 1000);
|
|
70
71
|
if (payload.exp <= now)
|
|
71
72
|
return null;
|
|
72
73
|
if (payload.exp > now + MAX_EXP_FUTURE_SECONDS)
|
|
73
74
|
return null;
|
|
75
|
+
if (version !== undefined && payload.v !== version)
|
|
76
|
+
return null;
|
|
74
77
|
return payload;
|
|
75
78
|
}
|
|
76
79
|
catch {
|
|
@@ -78,8 +81,8 @@ export class SessionManager {
|
|
|
78
81
|
}
|
|
79
82
|
}
|
|
80
83
|
/** 生成 Set-Cookie 头(HttpOnly + SameSite=Lax)。 */
|
|
81
|
-
cookieHeader(ttlSeconds) {
|
|
82
|
-
const token = this.sign(ttlSeconds);
|
|
84
|
+
cookieHeader(ttlSeconds, version = 1) {
|
|
85
|
+
const token = this.sign(ttlSeconds, version);
|
|
83
86
|
return `${SESSION_COOKIE}=${token}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${ttlSeconds}`;
|
|
84
87
|
}
|
|
85
88
|
requireKey() {
|
package/dist/tls.d.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export interface TlsMaterial {
|
|
2
|
+
key: string;
|
|
3
|
+
cert: string;
|
|
4
|
+
}
|
|
5
|
+
/**
|
|
6
|
+
* 解析 TLS 材料。
|
|
7
|
+
* @param tls config.tls(cert/key 路径)
|
|
8
|
+
* @param behindProxy 反代终止 TLS 时返回 null
|
|
9
|
+
*/
|
|
10
|
+
export declare function loadTls(tls?: {
|
|
11
|
+
cert: string;
|
|
12
|
+
key: string;
|
|
13
|
+
}, behindProxy?: boolean): Promise<TlsMaterial | null>;
|
package/dist/tls.js
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* tls.ts — TLS 证书材料加载。
|
|
3
|
+
*
|
|
4
|
+
* - 有 `tls.cert/key`(任意 PEM:acme.sh / Let's Encrypt / 云厂商)→ 读取返回;
|
|
5
|
+
* - 无 → 返回 null(网关跑 http;`auth.mode=password` 且非反代时由
|
|
6
|
+
* server.ts 的安全约束拒绝启动);
|
|
7
|
+
* - `behindProxy`(反代终止 TLS)→ 返回 null。
|
|
8
|
+
*
|
|
9
|
+
* 注:不内置自签证书生成 —— 无证书即 http;需要 https 请自行提供证书
|
|
10
|
+
* (acme.sh / Let's Encrypt / 云厂商 / openssl 手动生成)。
|
|
11
|
+
*/
|
|
12
|
+
import { readFile } from "node:fs/promises";
|
|
13
|
+
/**
|
|
14
|
+
* 解析 TLS 材料。
|
|
15
|
+
* @param tls config.tls(cert/key 路径)
|
|
16
|
+
* @param behindProxy 反代终止 TLS 时返回 null
|
|
17
|
+
*/
|
|
18
|
+
export async function loadTls(tls, behindProxy = false) {
|
|
19
|
+
if (behindProxy)
|
|
20
|
+
return null;
|
|
21
|
+
if (!tls)
|
|
22
|
+
return null;
|
|
23
|
+
const [cert, key] = await Promise.all([readFile(tls.cert, "utf8"), readFile(tls.key, "utf8")]);
|
|
24
|
+
return { cert, key };
|
|
25
|
+
}
|
|
26
|
+
//# sourceMappingURL=tls.js.map
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "rdsh-gateway",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "rdsh host-side component: LAN auth gateway + outbound tunnel endpoint (spawns dsh web)",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -20,7 +20,8 @@
|
|
|
20
20
|
"node": ">=22"
|
|
21
21
|
},
|
|
22
22
|
"dependencies": {
|
|
23
|
-
"ws": "^8.18.0"
|
|
23
|
+
"ws": "^8.18.0",
|
|
24
|
+
"rdsh-tunnel": "0.1.0"
|
|
24
25
|
},
|
|
25
26
|
"devDependencies": {
|
|
26
27
|
"@types/ws": "^8.5.0"
|
|
@@ -29,4 +30,4 @@
|
|
|
29
30
|
"build": "tsc -p tsconfig.json",
|
|
30
31
|
"test": "node --test test/"
|
|
31
32
|
}
|
|
32
|
-
}
|
|
33
|
+
}
|