@mrrisega/dsh-remote 0.3.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 +81 -0
- package/README.md +103 -0
- package/clients/dsh-remote/dsh-bridge.mjs +658 -0
- package/clients/dsh-remote/package.json +11 -0
- package/clients/dsh-remote/src/lifecycle.mjs +3 -0
- package/clients/dsh-remote/test/bridge-gzip.test.mjs +215 -0
- package/clients/dsh-remote/test/lifecycle.test.mjs +7 -0
- package/clients/dsh-remote/test/tunnel-auth.test.mjs +111 -0
- package/docs/self-hosting.md +109 -0
- package/dsh-setup.mjs +616 -0
- package/package.json +37 -0
- package/packages/dsh-remote-ui/README.md +81 -0
- package/packages/dsh-remote-ui/lib/client.js +1173 -0
- package/packages/dsh-remote-ui/lib/index.js +733 -0
- package/packages/dsh-remote-ui/package.json +26 -0
- package/packages/dsh-remote-ui/test/account-info.test.mjs +92 -0
- package/packages/dsh-remote-ui/test/account-switch.test.mjs +80 -0
- package/packages/dsh-remote-ui/test/feedback-proxy.test.mjs +154 -0
- package/packages/dsh-remote-ui/test/register-proxy.test.mjs +52 -0
- package/packages/dsh-remote-ui/test/settings-entry.test.mjs +282 -0
- package/packages/dsh-remote-ui/test/sms-captcha-ui.test.mjs +123 -0
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* bridge 响应 gzip 压缩契约测试(防回归):
|
|
4
|
+
* maybeCompressResponse 各分支单测 + 经 handleHttpFrame/doHttp 的本地上游全链路集成。
|
|
5
|
+
*
|
|
6
|
+
* 注意:dsh-bridge.mjs 顶层会读 DSH_BRIDGE_* 环境变量并写 .dsh-config.json,
|
|
7
|
+
* 因此必须先设好 env(设备 id + 临时配置 + 本地上游地址)再动态 import。
|
|
8
|
+
*
|
|
9
|
+
* 用法: node --test test/bridge-gzip.test.mjs
|
|
10
|
+
*/
|
|
11
|
+
import assert from "node:assert/strict";
|
|
12
|
+
import { randomBytes } from "node:crypto";
|
|
13
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
14
|
+
import http from "node:http";
|
|
15
|
+
import os from "node:os";
|
|
16
|
+
import path from "node:path";
|
|
17
|
+
import test, { after } from "node:test";
|
|
18
|
+
import { gunzipSync } from "node:zlib";
|
|
19
|
+
|
|
20
|
+
// ---------- 本地假上游(doHttp 全链路用;端口先占再 import,UPSTREAM 在模块加载时固定) ----------
|
|
21
|
+
|
|
22
|
+
const BIG_JSON = JSON.stringify({
|
|
23
|
+
ok: true,
|
|
24
|
+
items: Array.from({ length: 5000 }, (_, i) => ({ id: i, name: `item-${i}`, desc: "x".repeat(20) }))
|
|
25
|
+
});
|
|
26
|
+
const TINY_TEXT = "tiny";
|
|
27
|
+
|
|
28
|
+
const upstream = http.createServer((req, res) => {
|
|
29
|
+
if (req.url === "/big-json") {
|
|
30
|
+
res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
|
|
31
|
+
res.end(BIG_JSON);
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
if (req.url === "/tiny") {
|
|
35
|
+
res.writeHead(200, { "content-type": "text/plain; charset=utf-8" });
|
|
36
|
+
res.end(TINY_TEXT);
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
if (req.url === "/no-content") {
|
|
40
|
+
res.writeHead(204, { "content-type": "text/plain" });
|
|
41
|
+
res.end();
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
res.writeHead(200, { "content-type": "text/plain; charset=utf-8" });
|
|
45
|
+
res.end("hello-upstream");
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
const tmpDir = mkdtempSync(path.join(os.tmpdir(), "dsh-bridge-gzip-"));
|
|
49
|
+
process.env.DSH_BRIDGE_DEVICE_ID = "dev-gziptest0001";
|
|
50
|
+
process.env.DSH_BRIDGE_CONFIG = path.join(tmpDir, "config.json");
|
|
51
|
+
|
|
52
|
+
const { maybeCompressResponse, handleHttpFrame } = await new Promise((resolve, reject) => {
|
|
53
|
+
upstream.listen(0, "127.0.0.1", () => {
|
|
54
|
+
process.env.DSH_BRIDGE_UPSTREAM = `http://127.0.0.1:${upstream.address().port}`;
|
|
55
|
+
import("../dsh-bridge.mjs").then(resolve, reject);
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
after(() => {
|
|
60
|
+
try { upstream.close(); } catch {}
|
|
61
|
+
try { rmSync(tmpDir, { recursive: true, force: true }); } catch {}
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
// ---------- maybeCompressResponse 单测 ----------
|
|
65
|
+
|
|
66
|
+
const BIG = Buffer.from(BIG_JSON, "utf8"); // ≈250KB,可压缩
|
|
67
|
+
const GZIP_ACCEPT = "gzip, deflate, br, zstd";
|
|
68
|
+
|
|
69
|
+
test("压缩 js/json/css/svg/xml/text:返回 gzip buf + content-encoding 头,解压后与原内容一致", async () => {
|
|
70
|
+
for (const contentType of [
|
|
71
|
+
"application/javascript",
|
|
72
|
+
"application/json; charset=utf-8",
|
|
73
|
+
"text/css",
|
|
74
|
+
"image/svg+xml",
|
|
75
|
+
"application/xml",
|
|
76
|
+
"text/html; charset=utf-8"
|
|
77
|
+
]) {
|
|
78
|
+
const r = await maybeCompressResponse({
|
|
79
|
+
buf: BIG, contentType, contentEncoding: "", acceptEncoding: GZIP_ACCEPT, status: 200, method: "GET"
|
|
80
|
+
});
|
|
81
|
+
assert.ok(r, `应压缩 ${contentType}`);
|
|
82
|
+
assert.deepEqual(r.headers, { "content-encoding": "gzip" });
|
|
83
|
+
assert.ok(r.buf.length < BIG.length, `${contentType}: gzip 后应更小 (${r.buf.length} < ${BIG.length})`);
|
|
84
|
+
assert.deepEqual(gunzipSync(r.buf), BIG, `${contentType}: 解压后应与原文一致`);
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test("<1KB 不压缩", async () => {
|
|
89
|
+
const small = Buffer.from("a".repeat(512));
|
|
90
|
+
const r = await maybeCompressResponse({
|
|
91
|
+
buf: small, contentType: "text/plain", contentEncoding: "", acceptEncoding: GZIP_ACCEPT, status: 200, method: "GET"
|
|
92
|
+
});
|
|
93
|
+
assert.equal(r, null);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test("上游已编码(content-encoding 非空)不叠压缩", async () => {
|
|
97
|
+
const r = await maybeCompressResponse({
|
|
98
|
+
buf: BIG, contentType: "application/json", contentEncoding: "gzip", acceptEncoding: GZIP_ACCEPT, status: 200, method: "GET"
|
|
99
|
+
});
|
|
100
|
+
assert.equal(r, null);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("text/event-stream 不压缩(SSE 需流式逐条推)", async () => {
|
|
104
|
+
const r = await maybeCompressResponse({
|
|
105
|
+
buf: BIG, contentType: "text/event-stream", contentEncoding: "", acceptEncoding: GZIP_ACCEPT, status: 200, method: "GET"
|
|
106
|
+
});
|
|
107
|
+
assert.equal(r, null);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test("请求未带 gzip Accept-Encoding 不压缩(保守:手机可能不会解压)", async () => {
|
|
111
|
+
for (const acceptEncoding of ["", "br", "deflate, br", "identity"]) {
|
|
112
|
+
const r = await maybeCompressResponse({
|
|
113
|
+
buf: BIG, contentType: "application/json", contentEncoding: "", acceptEncoding, status: 200, method: "GET"
|
|
114
|
+
});
|
|
115
|
+
assert.equal(r, null, `accept-encoding=${JSON.stringify(acceptEncoding)}`);
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test("gzip 后不比原 buf 小就不采用(不可压数据)", async () => {
|
|
120
|
+
const incompressible = randomBytes(4096); // 随机字节 gzip 只会更大
|
|
121
|
+
const r = await maybeCompressResponse({
|
|
122
|
+
buf: incompressible, contentType: "application/json", contentEncoding: "", acceptEncoding: GZIP_ACCEPT, status: 200, method: "GET"
|
|
123
|
+
});
|
|
124
|
+
assert.equal(r, null);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test("HEAD / 204 / 304 不压缩(无响应体语义)", async () => {
|
|
128
|
+
const cases = [
|
|
129
|
+
{ method: "HEAD", status: 200 },
|
|
130
|
+
{ method: "GET", status: 204 },
|
|
131
|
+
{ method: "GET", status: 304 }
|
|
132
|
+
];
|
|
133
|
+
for (const c of cases) {
|
|
134
|
+
const r = await maybeCompressResponse({
|
|
135
|
+
buf: BIG, contentType: "application/json", contentEncoding: "", acceptEncoding: GZIP_ACCEPT, status: c.status, method: c.method
|
|
136
|
+
});
|
|
137
|
+
assert.equal(r, null, JSON.stringify(c));
|
|
138
|
+
}
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test("content-type 缺失/不可压缩类型不压缩", async () => {
|
|
142
|
+
for (const contentType of ["", "application/octet-stream", "image/png"]) {
|
|
143
|
+
const r = await maybeCompressResponse({
|
|
144
|
+
buf: BIG, contentType, contentEncoding: "", acceptEncoding: GZIP_ACCEPT, status: 200, method: "GET"
|
|
145
|
+
});
|
|
146
|
+
assert.equal(r, null, `content-type=${JSON.stringify(contentType)}`);
|
|
147
|
+
}
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test("非 Buffer 输入不压缩", async () => {
|
|
151
|
+
const r = await maybeCompressResponse({
|
|
152
|
+
buf: "not-a-buffer-".repeat(200), contentType: "text/plain", contentEncoding: "", acceptEncoding: GZIP_ACCEPT, status: 200, method: "GET"
|
|
153
|
+
});
|
|
154
|
+
assert.equal(r, null);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
// ---------- doHttp 全链路集成(handleHttpFrame → 本地上游) ----------
|
|
158
|
+
|
|
159
|
+
/** 发一帧并取回 reply。 */
|
|
160
|
+
async function request(frame) {
|
|
161
|
+
let reply;
|
|
162
|
+
const sender = (obj) => { reply = obj; return true; };
|
|
163
|
+
await handleHttpFrame(sender, { type: "http", ...frame });
|
|
164
|
+
return reply;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
test("集成:大 JSON 响应带 gzip 压缩回传(content-encoding: gzip + 可解压)", async () => {
|
|
168
|
+
const reply = await request({
|
|
169
|
+
id: "g1",
|
|
170
|
+
method: "GET",
|
|
171
|
+
path: "/big-json",
|
|
172
|
+
headers: { "accept-encoding": GZIP_ACCEPT, "user-agent": "phone-browser" }
|
|
173
|
+
});
|
|
174
|
+
assert.equal(reply.status, 200);
|
|
175
|
+
assert.equal(reply.headers["content-encoding"], "gzip", "回包 headers 必须带 content-encoding: gzip(否则手机浏览器不解压 → 乱码)");
|
|
176
|
+
assert.equal(reply.bodyBase64, true);
|
|
177
|
+
const raw = Buffer.from(reply.body, "base64");
|
|
178
|
+
assert.ok(raw.length < Buffer.byteLength(BIG_JSON), `压缩后应更小 (${raw.length} < ${Buffer.byteLength(BIG_JSON)})`);
|
|
179
|
+
assert.equal(gunzipSync(raw).toString("utf8"), BIG_JSON, "gzip body 解压后应与上游原文一致");
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
test("集成:未带 gzip Accept-Encoding 时不压缩,body 为原文", async () => {
|
|
183
|
+
const reply = await request({
|
|
184
|
+
id: "g2",
|
|
185
|
+
method: "GET",
|
|
186
|
+
path: "/big-json",
|
|
187
|
+
headers: { "user-agent": "phone-browser" }
|
|
188
|
+
});
|
|
189
|
+
assert.equal(reply.status, 200);
|
|
190
|
+
assert.equal(reply.headers["content-encoding"], undefined);
|
|
191
|
+
assert.equal(Buffer.from(reply.body, "base64").toString("utf8"), BIG_JSON);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
test("集成:小响应(<1KB)不压缩", async () => {
|
|
195
|
+
const reply = await request({
|
|
196
|
+
id: "g3",
|
|
197
|
+
method: "GET",
|
|
198
|
+
path: "/tiny",
|
|
199
|
+
headers: { "accept-encoding": GZIP_ACCEPT }
|
|
200
|
+
});
|
|
201
|
+
assert.equal(reply.status, 200);
|
|
202
|
+
assert.equal(reply.headers["content-encoding"], undefined);
|
|
203
|
+
assert.equal(Buffer.from(reply.body, "base64").toString("utf8"), TINY_TEXT);
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
test("集成:204 响应不压缩", async () => {
|
|
207
|
+
const reply = await request({
|
|
208
|
+
id: "g4",
|
|
209
|
+
method: "GET",
|
|
210
|
+
path: "/no-content",
|
|
211
|
+
headers: { "accept-encoding": GZIP_ACCEPT }
|
|
212
|
+
});
|
|
213
|
+
assert.equal(reply.status, 204);
|
|
214
|
+
assert.equal(reply.headers["content-encoding"], undefined);
|
|
215
|
+
});
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { spawn } from "node:child_process";
|
|
3
|
+
import http from "node:http";
|
|
4
|
+
import { mkdtemp, rm } from "node:fs/promises";
|
|
5
|
+
import os from "node:os";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
import test from "node:test";
|
|
8
|
+
import { setTimeout as delay } from "node:timers/promises";
|
|
9
|
+
import { fileURLToPath } from "node:url";
|
|
10
|
+
import { WebSocketServer } from "ws";
|
|
11
|
+
|
|
12
|
+
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
|
13
|
+
const BRIDGE = path.join(HERE, "..", "dsh-bridge.mjs");
|
|
14
|
+
|
|
15
|
+
test("router 以 4003 拒绝过期 JWT 后重新登录再连接", async () => {
|
|
16
|
+
let loginCount = 0;
|
|
17
|
+
const api = http.createServer((req, res) => {
|
|
18
|
+
const body = req.url === "/api/device-login"
|
|
19
|
+
? { token: `token-${++loginCount}` }
|
|
20
|
+
: req.url === "/api/devices"
|
|
21
|
+
? { device: { id: "dev-000000000001" } }
|
|
22
|
+
: { error: "not found" };
|
|
23
|
+
res.writeHead(req.url === "/api/devices" ? 201 : 200, { "content-type": "application/json" });
|
|
24
|
+
res.end(JSON.stringify(body));
|
|
25
|
+
});
|
|
26
|
+
await new Promise((resolve) => api.listen(0, "127.0.0.1", resolve));
|
|
27
|
+
|
|
28
|
+
const registrations = [];
|
|
29
|
+
const wss = new WebSocketServer({ host: "127.0.0.1", port: 0 });
|
|
30
|
+
await new Promise((resolve) => wss.once("listening", resolve));
|
|
31
|
+
const gotTwo = new Promise((resolve) => {
|
|
32
|
+
wss.on("connection", (ws) => ws.once("message", (raw) => {
|
|
33
|
+
registrations.push(JSON.parse(raw.toString()).token);
|
|
34
|
+
if (registrations.length === 1) ws.close(4003, "bad token");
|
|
35
|
+
if (registrations.length === 2) resolve();
|
|
36
|
+
}));
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
const tempDir = await mkdtemp(path.join(os.tmpdir(), "dsh-bridge-test-"));
|
|
40
|
+
const child = spawn(process.execPath, [BRIDGE], {
|
|
41
|
+
env: {
|
|
42
|
+
...process.env,
|
|
43
|
+
DSH_BRIDGE_API: `http://127.0.0.1:${api.address().port}`,
|
|
44
|
+
DSH_BRIDGE_TUNNEL_URL: `ws://127.0.0.1:${wss.address().port}`,
|
|
45
|
+
DSH_BRIDGE_PHONE: "test-account",
|
|
46
|
+
DSH_BRIDGE_PASSWORD: "test-password",
|
|
47
|
+
DSH_BRIDGE_DEVICE_ID: "dev-000000000001",
|
|
48
|
+
DSH_BRIDGE_CONFIG: path.join(tempDir, "config.json")
|
|
49
|
+
},
|
|
50
|
+
stdio: "ignore"
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
try {
|
|
54
|
+
await Promise.race([
|
|
55
|
+
gotTwo,
|
|
56
|
+
delay(5_000, null, { ref: false }).then(() => { throw new Error("bridge 未在 5 秒内重连"); })
|
|
57
|
+
]);
|
|
58
|
+
assert.deepEqual(registrations, ["token-1", "token-2"]);
|
|
59
|
+
assert.equal(loginCount, 2);
|
|
60
|
+
} finally {
|
|
61
|
+
child.kill();
|
|
62
|
+
wss.close();
|
|
63
|
+
api.close();
|
|
64
|
+
await rm(tempDir, { recursive: true, force: true });
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("router 不再响应心跳时主动断开半开隧道并重连", async () => {
|
|
69
|
+
const api = http.createServer((req, res) => {
|
|
70
|
+
const body = req.url === "/api/device-login"
|
|
71
|
+
? { token: "token" }
|
|
72
|
+
: { device: { id: "dev-000000000002" } };
|
|
73
|
+
res.writeHead(req.url === "/api/devices" ? 201 : 200, { "content-type": "application/json" });
|
|
74
|
+
res.end(JSON.stringify(body));
|
|
75
|
+
});
|
|
76
|
+
await new Promise((resolve) => api.listen(0, "127.0.0.1", resolve));
|
|
77
|
+
|
|
78
|
+
let registrations = 0;
|
|
79
|
+
const wss = new WebSocketServer({ host: "127.0.0.1", port: 0, autoPong: false });
|
|
80
|
+
await new Promise((resolve) => wss.once("listening", resolve));
|
|
81
|
+
const reconnected = new Promise((resolve) => {
|
|
82
|
+
wss.on("connection", (ws) => ws.once("message", () => {
|
|
83
|
+
if (++registrations === 2) resolve();
|
|
84
|
+
}));
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
const child = spawn(process.execPath, [BRIDGE], {
|
|
88
|
+
env: {
|
|
89
|
+
...process.env,
|
|
90
|
+
DSH_BRIDGE_API: `http://127.0.0.1:${api.address().port}`,
|
|
91
|
+
DSH_BRIDGE_TUNNEL_URL: `ws://127.0.0.1:${wss.address().port}`,
|
|
92
|
+
DSH_BRIDGE_HEARTBEAT_MS: "100",
|
|
93
|
+
DSH_BRIDGE_PHONE: "test-account",
|
|
94
|
+
DSH_BRIDGE_PASSWORD: "test-password",
|
|
95
|
+
DSH_BRIDGE_DEVICE_ID: "dev-000000000002"
|
|
96
|
+
},
|
|
97
|
+
stdio: "ignore"
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
try {
|
|
101
|
+
await Promise.race([
|
|
102
|
+
reconnected,
|
|
103
|
+
delay(4_000, null, { ref: false }).then(() => { throw new Error("bridge 未主动重连半开隧道"); })
|
|
104
|
+
]);
|
|
105
|
+
assert.equal(registrations, 2);
|
|
106
|
+
} finally {
|
|
107
|
+
child.kill();
|
|
108
|
+
wss.close();
|
|
109
|
+
api.close();
|
|
110
|
+
}
|
|
111
|
+
});
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# Self-Hosting Guide
|
|
2
|
+
|
|
3
|
+
dsh-remote is designed so that anyone with a server can run the entire remote-control
|
|
4
|
+
stack themselves. The self-hosted edition needs **no account system**: authentication
|
|
5
|
+
is a simple access key, exchanged for short-lived local JWTs by the router.
|
|
6
|
+
|
|
7
|
+
The self-hosted stack:
|
|
8
|
+
|
|
9
|
+
```
|
|
10
|
+
手机浏览器
|
|
11
|
+
└─ /app/ 访问密钥登录(POST /_login) → /_devices 选设备 → /remote/<deviceId>/…
|
|
12
|
+
│
|
|
13
|
+
你的 nginx (HTTPS)
|
|
14
|
+
├─ /app/ → 静态 PWA (clients/dsh-web/native.html)
|
|
15
|
+
├─ /_devices /_quota /_login /remote/ /_bridge / → relay-router
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
- **relay-router** is the only server component. It registers bridges over
|
|
19
|
+
WebSocket (`/_bridge`), serves the real-time device list (`/_devices`), proxies
|
|
20
|
+
HTTP/WS traffic to the right device (`/remote/<deviceId>/…`), and enforces
|
|
21
|
+
per-plan bandwidth/traffic quotas.
|
|
22
|
+
- Authentication: `DSH_LOCAL_ACCESS_KEYS` (comma-separated) → `POST /_login`
|
|
23
|
+
→ 2h local JWT. The router also accepts SaaS JWTs if
|
|
24
|
+
`DSH_ENTERPRISE_JWT_SECRET` is set, so a router can serve both modes at once.
|
|
25
|
+
|
|
26
|
+
## Requirements
|
|
27
|
+
|
|
28
|
+
- A server with Node.js ≥ 22 and a public HTTPS endpoint (your own domain +
|
|
29
|
+
certificate). The router itself speaks plain HTTP/WS; terminate TLS at nginx
|
|
30
|
+
or Caddy in front of it.
|
|
31
|
+
- The phone and the desktop machine must reach the same HTTPS endpoint.
|
|
32
|
+
|
|
33
|
+
## Option A — Docker (recommended)
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
git clone https://github.com/mrRisega/dsh-remote.git && cd dsh-remote
|
|
37
|
+
|
|
38
|
+
# 生成密钥与访问密钥(建议 >= 32 字节随机串)
|
|
39
|
+
DSH_LOCAL_JWT_SECRET=$(openssl rand -hex 32) \
|
|
40
|
+
DSH_LOCAL_ACCESS_KEYS=$(openssl rand -base64 9) \
|
|
41
|
+
docker compose up -d
|
|
42
|
+
|
|
43
|
+
curl -i http://127.0.0.1:13444/_devices # 401 = 正常(需登录态)
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Then put nginx in front (see `deploy/nginx-13443-remote-router.conf` for the
|
|
47
|
+
location blocks and `map $http_upgrade $connection_upgrade`).
|
|
48
|
+
|
|
49
|
+
## Option B — Bare Node
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
git clone https://github.com/mrRisega/dsh-remote.git && cd dsh-remote
|
|
53
|
+
npm install
|
|
54
|
+
bash deploy/install-open.sh # 生成 open.env(0600)并启动 router
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
`install-open.sh` prints your access key and the public entry URL. `open.env`
|
|
58
|
+
holds `DSH_ENTERPRISE_JWT_SECRET`, `DSH_LOCAL_JWT_SECRET`,
|
|
59
|
+
`DSH_LOCAL_ACCESS_KEYS` and `DSH_ROUTER_PORT` — treat it as a credential file.
|
|
60
|
+
|
|
61
|
+
## Serving the phone app (PWA)
|
|
62
|
+
|
|
63
|
+
`clients/dsh-web/native.html` is a single-file PWA. Point nginx at it:
|
|
64
|
+
|
|
65
|
+
```nginx
|
|
66
|
+
location /app/ {
|
|
67
|
+
alias /srv/dsh-remote/app/;
|
|
68
|
+
index native.html;
|
|
69
|
+
add_header Cache-Control "no-cache";
|
|
70
|
+
}
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
Self-hosted users sign in with the access key (`/app/` login form). The PWA then
|
|
74
|
+
lists online devices from `/_devices` and routes into `dsh web` through the tunnel.
|
|
75
|
+
|
|
76
|
+
## Connecting the desktop (controlled computer)
|
|
77
|
+
|
|
78
|
+
```bash
|
|
79
|
+
npx @mrrisega/dsh-remote setup --server wss://你的域名:端口 --key 你的访问密钥
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
This writes the local config, verifies the key against `/_login`, and installs a
|
|
83
|
+
launchd/systemd service that keeps the bridge alive. The bridge auto-starts when
|
|
84
|
+
`dsh web` (127.0.0.1:3080) is up.
|
|
85
|
+
|
|
86
|
+
## Configuration reference (open.env)
|
|
87
|
+
|
|
88
|
+
| Variable | Meaning |
|
|
89
|
+
|---|---|
|
|
90
|
+
| `DSH_ENTERPRISE_JWT_SECRET` | JWT secret (also accepts SaaS tokens; generate randomly) |
|
|
91
|
+
| `DSH_LOCAL_JWT_SECRET` | JWT secret for local (self-hosted) auth |
|
|
92
|
+
| `DSH_LOCAL_ACCESS_KEYS` | Comma-separated access keys for `/_login` |
|
|
93
|
+
| `DSH_ROUTER_PORT` | Listen port (default 13444) |
|
|
94
|
+
| `DSH_ROUTER_QUOTA_FREE_MAX_BPS` etc. | Optional quota overrides (see `packages/relay-router/README.md`) |
|
|
95
|
+
| `DSH_ROUTER_PRO_MAX_ONLINE` | Max simultaneously online devices (default 3) |
|
|
96
|
+
|
|
97
|
+
## Security notes
|
|
98
|
+
|
|
99
|
+
- The access key is the root credential of your instance. Keep `open.env` at
|
|
100
|
+
mode 0600, never commit or share it; rotate it like a password.
|
|
101
|
+
- Always serve `/_login` and the tunnel over HTTPS/WSS in production.
|
|
102
|
+
- Self-hosted mode has no account system: device access is governed by the
|
|
103
|
+
router's live registry, not by an account database.
|
|
104
|
+
|
|
105
|
+
## Switching back to SaaS
|
|
106
|
+
|
|
107
|
+
In the dsh web plugin panel choose connection mode → **Cloud service**, sign in
|
|
108
|
+
with your phone account. Local keys are cleared from the client config; SaaS
|
|
109
|
+
data is unaffected.
|