dsh-mobile 0.4.0 → 0.4.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/CHANGELOG.md +19 -5
- package/README.en.md +29 -11
- package/README.md +22 -11
- package/SECURITY.md +2 -0
- package/docs/SELF_HOSTED_FRP.en.md +75 -0
- package/docs/SELF_HOSTED_FRP.md +3 -1
- package/lib/cli.js +3 -1
- package/lib/cli.js.map +1 -1
- package/lib/client.js +72 -14
- package/lib/client.js.map +1 -1
- package/lib/index.d.mts +3 -1
- package/lib/index.mjs +84 -18
- package/lib/index.mjs.map +1 -1
- package/package.json +18 -9
package/lib/index.d.mts
CHANGED
|
@@ -436,8 +436,10 @@ declare class MobileExtensionError extends Error {
|
|
|
436
436
|
constructor(code: string, message: string, status?: number);
|
|
437
437
|
}
|
|
438
438
|
/** One host-side action exposed by an extension. */
|
|
439
|
+
type CallableActionInput = (value?: never, options?: never) => unknown;
|
|
439
440
|
interface MobileHostAction {
|
|
440
|
-
|
|
441
|
+
/** A callable Schemastery schema or an adapter exposing parse(). */
|
|
442
|
+
readonly input?: CallableActionInput | {
|
|
441
443
|
parse(value: unknown): unknown;
|
|
442
444
|
};
|
|
443
445
|
readonly run: (context: MobileActionContext, input: unknown) => unknown | Promise<unknown>;
|
package/lib/index.mjs
CHANGED
|
@@ -389,7 +389,7 @@ var AccessController = class {
|
|
|
389
389
|
};
|
|
390
390
|
//#endregion
|
|
391
391
|
//#region src/network.ts
|
|
392
|
-
function parseIpv4(address) {
|
|
392
|
+
function parseIpv4$1(address) {
|
|
393
393
|
const parts = address.split(".");
|
|
394
394
|
if (parts.length !== 4) throw new Error(`invalid IPv4 address ${JSON.stringify(address)}`);
|
|
395
395
|
let value = 0n;
|
|
@@ -403,7 +403,7 @@ function parseIpv4(address) {
|
|
|
403
403
|
}
|
|
404
404
|
function parseIpv6Part(part, address) {
|
|
405
405
|
if (part.includes(".")) {
|
|
406
|
-
const ipv4 = parseIpv4(part);
|
|
406
|
+
const ipv4 = parseIpv4$1(part);
|
|
407
407
|
return [Number(ipv4 >> 16n & 65535n), Number(ipv4 & 65535n)];
|
|
408
408
|
}
|
|
409
409
|
if (!/^[\da-f]{1,4}$/iu.test(part)) throw new Error(`invalid IPv6 address ${JSON.stringify(address)}`);
|
|
@@ -433,12 +433,12 @@ function parseIp(address) {
|
|
|
433
433
|
const mapped = mappedIpv4(unwrapped);
|
|
434
434
|
if (mapped !== void 0) return {
|
|
435
435
|
bits: 32,
|
|
436
|
-
value: parseIpv4(mapped)
|
|
436
|
+
value: parseIpv4$1(mapped)
|
|
437
437
|
};
|
|
438
438
|
const version = isIP(unwrapped.split("%", 1)[0] ?? unwrapped);
|
|
439
439
|
if (version === 4) return {
|
|
440
440
|
bits: 32,
|
|
441
|
-
value: parseIpv4(unwrapped)
|
|
441
|
+
value: parseIpv4$1(unwrapped)
|
|
442
442
|
};
|
|
443
443
|
if (version === 6) return {
|
|
444
444
|
bits: 128,
|
|
@@ -1079,6 +1079,46 @@ var MobileAccessGatewayController = class {
|
|
|
1079
1079
|
}
|
|
1080
1080
|
};
|
|
1081
1081
|
//#endregion
|
|
1082
|
+
//#region src/local-admin-host.ts
|
|
1083
|
+
/** Browser-safe desktop-admin Host checks. Do not import Node APIs here. */
|
|
1084
|
+
function unwrapBrackets(hostname) {
|
|
1085
|
+
return hostname.startsWith("[") && hostname.endsWith("]") ? hostname.slice(1, -1) : hostname;
|
|
1086
|
+
}
|
|
1087
|
+
function parseIpv4(hostname) {
|
|
1088
|
+
const parts = hostname.split(".");
|
|
1089
|
+
if (parts.length !== 4) return void 0;
|
|
1090
|
+
const octets = [];
|
|
1091
|
+
for (const part of parts) {
|
|
1092
|
+
if (!/^(?:0|[1-9]\d{0,2})$/u.test(part)) return void 0;
|
|
1093
|
+
const value = Number(part);
|
|
1094
|
+
if (value > 255) return void 0;
|
|
1095
|
+
octets.push(value);
|
|
1096
|
+
}
|
|
1097
|
+
return octets;
|
|
1098
|
+
}
|
|
1099
|
+
function isLoopbackIpv4(octets) {
|
|
1100
|
+
return octets[0] === 127;
|
|
1101
|
+
}
|
|
1102
|
+
function isPrivateIpv4(octets) {
|
|
1103
|
+
return octets[0] === 10 || octets[0] === 172 && octets[1] >= 16 && octets[1] <= 31 || octets[0] === 192 && octets[1] === 168;
|
|
1104
|
+
}
|
|
1105
|
+
function isLinkLocalIpv4(octets) {
|
|
1106
|
+
return octets[0] === 169 && octets[1] === 254;
|
|
1107
|
+
}
|
|
1108
|
+
/**
|
|
1109
|
+
* Hostnames that may appear on the DSH desktop admin surface.
|
|
1110
|
+
* Loopback, RFC1918, and IPv4 link-local are accepted. Public IPs,
|
|
1111
|
+
* CGNAT, and arbitrary DNS names stay rejected so DNS rebinding
|
|
1112
|
+
* cannot reach `/api/mobile-access`.
|
|
1113
|
+
*/
|
|
1114
|
+
function isLocalAdminHostname(hostname) {
|
|
1115
|
+
const host = unwrapBrackets(hostname).trim().toLowerCase();
|
|
1116
|
+
if (host === "localhost" || host === "::1") return true;
|
|
1117
|
+
const octets = parseIpv4(host);
|
|
1118
|
+
if (octets === void 0) return false;
|
|
1119
|
+
return isLoopbackIpv4(octets) || isPrivateIpv4(octets) || isLinkLocalIpv4(octets);
|
|
1120
|
+
}
|
|
1121
|
+
//#endregion
|
|
1082
1122
|
//#region src/http-security.ts
|
|
1083
1123
|
const DEVICE_COOKIE = "dsh_ma_device";
|
|
1084
1124
|
const SESSION_COOKIE = "dsh_ma_session";
|
|
@@ -1122,26 +1162,43 @@ function parseRequestTarget(raw) {
|
|
|
1122
1162
|
search: parsed.search
|
|
1123
1163
|
});
|
|
1124
1164
|
}
|
|
1125
|
-
/**
|
|
1126
|
-
|
|
1165
|
+
/**
|
|
1166
|
+
* The frame sources a proxied GUI document may embed: its own routes, blob:
|
|
1167
|
+
* (the PDF viewer's object URL) and external http(s) pages (the sidebar
|
|
1168
|
+
* browser tab). The framed document runs in the tab's existing sandbox
|
|
1169
|
+
* (opaque origin, no same-origin privileges), so this does not hand the
|
|
1170
|
+
* embedded page anything it did not already have.
|
|
1171
|
+
*/
|
|
1172
|
+
const PROXIED_FRAME_SRC = "frame-src 'self' blob: https: http:";
|
|
1173
|
+
/**
|
|
1174
|
+
* Set the gateway browser protections and non-cacheability.
|
|
1175
|
+
* @param response - the response to decorate.
|
|
1176
|
+
* @param tls - whether the connection is TLS (adds HSTS).
|
|
1177
|
+
* @param framing - the framing policy; defaults to the strict gateway one, so
|
|
1178
|
+
* every existing call site keeps refusing to be framed. Only the two PROXIED
|
|
1179
|
+
* forwarders pass `'proxied'`.
|
|
1180
|
+
*/
|
|
1181
|
+
function setSecurityHeaders(response, tls, framing = "gateway") {
|
|
1182
|
+
const proxied = framing === "proxied";
|
|
1127
1183
|
response.setHeader("Cache-Control", "no-store");
|
|
1128
1184
|
response.setHeader("Content-Security-Policy", [
|
|
1129
1185
|
"default-src 'self'",
|
|
1130
1186
|
"base-uri 'none'",
|
|
1131
1187
|
"object-src 'none'",
|
|
1132
|
-
"frame-ancestors 'none'",
|
|
1188
|
+
proxied ? "frame-ancestors 'self'" : "frame-ancestors 'none'",
|
|
1133
1189
|
"form-action 'self'",
|
|
1134
1190
|
"script-src 'self' 'unsafe-inline' 'unsafe-eval'",
|
|
1135
1191
|
"style-src 'self' 'unsafe-inline'",
|
|
1136
1192
|
"img-src 'self' data: blob:",
|
|
1137
1193
|
"font-src 'self' data:",
|
|
1138
1194
|
"connect-src 'self'",
|
|
1139
|
-
"worker-src 'self' blob:"
|
|
1195
|
+
"worker-src 'self' blob:",
|
|
1196
|
+
...proxied ? [PROXIED_FRAME_SRC] : []
|
|
1140
1197
|
].join("; "));
|
|
1141
1198
|
response.setHeader("Permissions-Policy", "camera=(), microphone=(), geolocation=(), payment=(), usb=()");
|
|
1142
1199
|
response.setHeader("Referrer-Policy", "no-referrer");
|
|
1143
1200
|
response.setHeader("X-Content-Type-Options", "nosniff");
|
|
1144
|
-
response.setHeader("X-Frame-Options", "DENY");
|
|
1201
|
+
response.setHeader("X-Frame-Options", proxied ? "SAMEORIGIN" : "DENY");
|
|
1145
1202
|
response.setHeader("Cross-Origin-Resource-Policy", "same-origin");
|
|
1146
1203
|
if (tls) response.setHeader("Strict-Transport-Security", "max-age=31536000");
|
|
1147
1204
|
}
|
|
@@ -1238,7 +1295,7 @@ function localAuthority(header) {
|
|
|
1238
1295
|
function assertLocalAdminTrust(request, requireBrowserOrigin) {
|
|
1239
1296
|
if (request.socket.remoteAddress === void 0 || !isLoopbackAddress(request.socket.remoteAddress)) throw new HttpError(403, "forbidden");
|
|
1240
1297
|
const host = localAuthority(request.headers.host);
|
|
1241
|
-
if (host === void 0 ||
|
|
1298
|
+
if (host === void 0 || !isLocalAdminHostname(host.hostname)) throw new HttpError(403, "forbidden");
|
|
1242
1299
|
const site = request.headers["sec-fetch-site"];
|
|
1243
1300
|
if (site !== void 0 && site !== "same-origin" && site !== "none") throw new HttpError(403, "forbidden");
|
|
1244
1301
|
const origin = request.headers.origin;
|
|
@@ -1249,7 +1306,8 @@ function assertLocalAdminTrust(request, requireBrowserOrigin) {
|
|
|
1249
1306
|
if (error instanceof HttpError) throw error;
|
|
1250
1307
|
throw new HttpError(403, "forbidden");
|
|
1251
1308
|
}
|
|
1252
|
-
if (requireBrowserOrigin &&
|
|
1309
|
+
if (requireBrowserOrigin && origin === void 0) throw new HttpError(403, "forbidden");
|
|
1310
|
+
if (requireBrowserOrigin && site !== void 0 && site !== "same-origin") throw new HttpError(403, "forbidden");
|
|
1253
1311
|
}
|
|
1254
1312
|
//#endregion
|
|
1255
1313
|
//#region src/version.ts
|
|
@@ -1641,6 +1699,12 @@ function combineSignalLifetime(first, second) {
|
|
|
1641
1699
|
cleanup
|
|
1642
1700
|
};
|
|
1643
1701
|
}
|
|
1702
|
+
/** Validate and normalize an action input through either supported schema form. */
|
|
1703
|
+
function parseActionInput(schema, input) {
|
|
1704
|
+
if (schema === void 0) return input;
|
|
1705
|
+
if (typeof schema === "function") return schema(input);
|
|
1706
|
+
return schema.parse(input) ?? input;
|
|
1707
|
+
}
|
|
1644
1708
|
/** Host registry and service consumed by both npm plugins and local extensions. */
|
|
1645
1709
|
var MobileAccessService = class extends Service {
|
|
1646
1710
|
registered = /* @__PURE__ */ new Map();
|
|
@@ -1772,9 +1836,9 @@ var MobileAccessService = class extends Service {
|
|
|
1772
1836
|
if (extension === void 0) throw new MobileExtensionError("extension_not_found", "extension not found", 404);
|
|
1773
1837
|
const action = ("host" in extension ? extension.host : extension).actions?.[actionName];
|
|
1774
1838
|
if (action === void 0) throw new MobileExtensionError("action_not_found", "action not found", 404);
|
|
1775
|
-
let parsed
|
|
1839
|
+
let parsed;
|
|
1776
1840
|
try {
|
|
1777
|
-
parsed = action.input
|
|
1841
|
+
parsed = parseActionInput(action.input, input);
|
|
1778
1842
|
} catch {
|
|
1779
1843
|
throw new MobileExtensionError("invalid_action_input", "action input is invalid", 400);
|
|
1780
1844
|
}
|
|
@@ -3766,7 +3830,7 @@ var MobileAccessGateway = class {
|
|
|
3766
3830
|
delete headers["content-length"];
|
|
3767
3831
|
delete headers["content-encoding"];
|
|
3768
3832
|
delete headers.etag;
|
|
3769
|
-
setSecurityHeaders(response, this.tlsEnabled);
|
|
3833
|
+
setSecurityHeaders(response, this.tlsEnabled, "proxied");
|
|
3770
3834
|
response.writeHead(200, {
|
|
3771
3835
|
...headers,
|
|
3772
3836
|
"Content-Type": "text/html; charset=utf-8",
|
|
@@ -4018,7 +4082,7 @@ var MobileAccessGateway = class {
|
|
|
4018
4082
|
}
|
|
4019
4083
|
bodyDone.catch(reject);
|
|
4020
4084
|
});
|
|
4021
|
-
setSecurityHeaders(response, this.tlsEnabled);
|
|
4085
|
+
setSecurityHeaders(response, this.tlsEnabled, "proxied");
|
|
4022
4086
|
const headers = sanitizeResponseHeaders(proxied.headers, this.config.upstreamOrigin);
|
|
4023
4087
|
const cacheControl = revisionedStaticCacheControl(request$2);
|
|
4024
4088
|
if (cacheControl !== void 0) headers["cache-control"] = cacheControl;
|
|
@@ -6283,11 +6347,11 @@ const MOBILE_CUSTOMIZATION_GUIDE_BODY = `你在为用户定制 DSH Mobile 的手
|
|
|
6283
6347
|
2. 电脑端能力 —— 手机需要读电脑文件、执行命令或访问硬件时,创建扩展:
|
|
6284
6348
|
- 目录:$DSH_HOME/mobile-access/extensions/<id>/,id 用小写字母数字和连字符(如 media-remote)
|
|
6285
6349
|
- extension.json:{"schemaVersion":1,"id":"<id>","name":"显示名","version":"0.1.0","description":"说明"}
|
|
6286
|
-
- host.mjs:电脑端 Node.js 代码(可信本地代码,可读写文件、执行命令)。导出默认函数 (api) => { ... },用 api.action('名称', { input, run }) 注册动作、api.route({ method, path, handle }) 注册路由、api.effect(fn)
|
|
6350
|
+
- host.mjs:电脑端 Node.js 代码(可信本地代码,可读写文件、执行命令)。导出默认函数 (api) => { ... },用 api.action('名称', { input, run }) 注册动作、api.route({ method, path, handle }) 注册路由、api.effect(fn) 注册清理。input 可以直接使用 api.schema.object(...) 等 Schemastery schema,也可以传入带 parse(value) 的适配器;两种形式都会在动作执行前校验并规范化输入。
|
|
6287
6351
|
- mobile.js:手机端脚本,用 window.dshMobile.define({ apiVersion:1, id:'<id>', activate(api) { ... } }),activate 返回清理函数
|
|
6288
6352
|
- mobile.css:手机端样式(可选)
|
|
6289
6353
|
- assets/:手机端静态资源(可选)
|
|
6290
|
-
- mobile.js 里用 api.host.invoke('动作名', 输入) 调 host.mjs 的 action
|
|
6354
|
+
- mobile.js 里用 api.host.invoke('动作名', 输入) 调 host.mjs 的 action(请求会按 application/json 发送),api.host.fetch('/路由路径') 调 route,api.host.assetUrl('相对路径') 生成与当前版本绑定的资源地址
|
|
6291
6355
|
- 也可以先用命令生成模板:dsh plugin --profile web exec dsh-mobile extension create <id> --name "<名称>",再在模板上改
|
|
6292
6356
|
|
|
6293
6357
|
安全约束:
|
|
@@ -8803,6 +8867,8 @@ const VIRTUAL_INTERFACE_MARKERS = [
|
|
|
8803
8867
|
"wsl",
|
|
8804
8868
|
"zerotier"
|
|
8805
8869
|
];
|
|
8870
|
+
/** Route inspection is advisory; never let a system command hold setup open indefinitely. */
|
|
8871
|
+
const ROUTE_COMMAND_TIMEOUT_MS = 5e3;
|
|
8806
8872
|
function requiredString(value, name) {
|
|
8807
8873
|
if (typeof value !== "string" || value.length === 0) throw new Error(`${name} must be a non-empty string`);
|
|
8808
8874
|
return value;
|
|
@@ -8872,7 +8938,7 @@ function likelyVirtualInterface(name) {
|
|
|
8872
8938
|
async function runRouteCommand(file, args) {
|
|
8873
8939
|
return (await execFileText(file, [...args], {
|
|
8874
8940
|
encoding: "utf8",
|
|
8875
|
-
|
|
8941
|
+
timeout: ROUTE_COMMAND_TIMEOUT_MS
|
|
8876
8942
|
})).stdout;
|
|
8877
8943
|
}
|
|
8878
8944
|
function uniqueLines(output) {
|