@co0ontty/wand 1.31.3 → 1.32.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/auth.d.ts +20 -0
- package/dist/auth.js +31 -0
- package/dist/cert.d.ts +17 -2
- package/dist/cert.js +124 -68
- package/dist/config.js +12 -0
- package/dist/server.js +60 -51
- package/dist/tui/commands.js +51 -5
- package/dist/types.d.ts +9 -0
- package/dist/web-ui/content/scripts.js +181 -242
- package/dist/web-ui/content/styles.css +153 -316
- package/dist/ws-broadcast.d.ts +2 -2
- package/dist/ws-broadcast.js +5 -10
- package/package.json +1 -1
package/dist/auth.d.ts
CHANGED
|
@@ -1,4 +1,24 @@
|
|
|
1
1
|
import { WandStorage } from "./storage.js";
|
|
2
|
+
/**
|
|
3
|
+
* Cookie 名按 scheme 隔离 —— 解决浏览器 Strict Secure Cookies 在 HTTPS↔HTTP 切换时
|
|
4
|
+
* "新 Set-Cookie 被同名 Secure 旧 cookie 静默丢弃"导致登录后立刻 401 的问题。
|
|
5
|
+
*
|
|
6
|
+
* - HTTPS 模式:`__Host-wand_session`(强制 Secure + Path=/,安全性更高)
|
|
7
|
+
* - HTTP 模式:`wand_session_local`(独立名字,不会被 HTTPS 留下的 Secure cookie 拦截)
|
|
8
|
+
* - `wand_session`:legacy 名字。HTTPS 模式下仍写一份用于兼容老 macOS APP(写死了找
|
|
9
|
+
* `wand_session`),同时让升级前的老登录态在过渡期不被踢。
|
|
10
|
+
*
|
|
11
|
+
* 读取顺序按"当前 scheme 主名字 → legacy"逐项 fallback。
|
|
12
|
+
*/
|
|
13
|
+
export declare const SESSION_COOKIE_HTTPS = "__Host-wand_session";
|
|
14
|
+
export declare const SESSION_COOKIE_HTTP = "wand_session_local";
|
|
15
|
+
export declare const SESSION_COOKIE_LEGACY = "wand_session";
|
|
16
|
+
/** 解析 Cookie 头,按候选名字顺序返回第一个匹配到的 token 值。 */
|
|
17
|
+
export declare function readSessionCookie(req: {
|
|
18
|
+
headers: {
|
|
19
|
+
cookie?: string;
|
|
20
|
+
};
|
|
21
|
+
}, useHttps: boolean): string | undefined;
|
|
2
22
|
export declare function createSession(): string;
|
|
3
23
|
export declare function validateSession(token: string | undefined): boolean;
|
|
4
24
|
export declare function revokeSession(token: string | undefined): void;
|
package/dist/auth.js
CHANGED
|
@@ -2,6 +2,37 @@ import crypto from "node:crypto";
|
|
|
2
2
|
const sessions = new Map();
|
|
3
3
|
const SESSION_TTL_MS = 1000 * 60 * 60 * 12;
|
|
4
4
|
let storage = null;
|
|
5
|
+
/**
|
|
6
|
+
* Cookie 名按 scheme 隔离 —— 解决浏览器 Strict Secure Cookies 在 HTTPS↔HTTP 切换时
|
|
7
|
+
* "新 Set-Cookie 被同名 Secure 旧 cookie 静默丢弃"导致登录后立刻 401 的问题。
|
|
8
|
+
*
|
|
9
|
+
* - HTTPS 模式:`__Host-wand_session`(强制 Secure + Path=/,安全性更高)
|
|
10
|
+
* - HTTP 模式:`wand_session_local`(独立名字,不会被 HTTPS 留下的 Secure cookie 拦截)
|
|
11
|
+
* - `wand_session`:legacy 名字。HTTPS 模式下仍写一份用于兼容老 macOS APP(写死了找
|
|
12
|
+
* `wand_session`),同时让升级前的老登录态在过渡期不被踢。
|
|
13
|
+
*
|
|
14
|
+
* 读取顺序按"当前 scheme 主名字 → legacy"逐项 fallback。
|
|
15
|
+
*/
|
|
16
|
+
export const SESSION_COOKIE_HTTPS = "__Host-wand_session";
|
|
17
|
+
export const SESSION_COOKIE_HTTP = "wand_session_local";
|
|
18
|
+
export const SESSION_COOKIE_LEGACY = "wand_session";
|
|
19
|
+
/** 解析 Cookie 头,按候选名字顺序返回第一个匹配到的 token 值。 */
|
|
20
|
+
export function readSessionCookie(req, useHttps) {
|
|
21
|
+
const cookie = req.headers.cookie;
|
|
22
|
+
if (!cookie)
|
|
23
|
+
return undefined;
|
|
24
|
+
const parts = cookie.split(";").map((part) => part.trim());
|
|
25
|
+
const order = useHttps
|
|
26
|
+
? [SESSION_COOKIE_HTTPS, SESSION_COOKIE_LEGACY, SESSION_COOKIE_HTTP]
|
|
27
|
+
: [SESSION_COOKIE_HTTP, SESSION_COOKIE_LEGACY, SESSION_COOKIE_HTTPS];
|
|
28
|
+
for (const name of order) {
|
|
29
|
+
const prefix = `${name}=`;
|
|
30
|
+
const hit = parts.find((part) => part.startsWith(prefix));
|
|
31
|
+
if (hit)
|
|
32
|
+
return hit.slice(prefix.length);
|
|
33
|
+
}
|
|
34
|
+
return undefined;
|
|
35
|
+
}
|
|
5
36
|
// Periodic cleanup every 10 minutes
|
|
6
37
|
const sessionCleanupTimer = setInterval(() => {
|
|
7
38
|
cleanupExpiredSessions();
|
package/dist/cert.d.ts
CHANGED
|
@@ -1,8 +1,23 @@
|
|
|
1
1
|
export interface SSLConfig {
|
|
2
2
|
key: Buffer;
|
|
3
3
|
cert: Buffer;
|
|
4
|
+
/** 实际生效的证书路径,用于 `/cert/server.crt` 下载路由。 */
|
|
5
|
+
certPath: string;
|
|
6
|
+
/** SHA-256 指纹(大写 + 冒号分隔),方便用户在浏览器里核对。 */
|
|
7
|
+
fingerprint: string;
|
|
8
|
+
/** 是否走的是用户自备证书。true = 自带,false = wand 自签。 */
|
|
9
|
+
userProvided: boolean;
|
|
10
|
+
}
|
|
11
|
+
export interface EnsureCertificatesOptions {
|
|
12
|
+
/** 用户自带证书路径(PEM)。配了且存在就直接用。 */
|
|
13
|
+
userCertPath?: string;
|
|
14
|
+
userKeyPath?: string;
|
|
4
15
|
}
|
|
5
16
|
/**
|
|
6
|
-
*
|
|
17
|
+
* 主入口:装载 TLS 证书。优先级(高 → 低):
|
|
18
|
+
* 1. options.userCertPath / userKeyPath(config.tls)
|
|
19
|
+
* 2. 配置目录下已存在的 server.crt + server.key
|
|
20
|
+
* 3. 用 openssl 现场生成自签
|
|
21
|
+
* 4. node crypto 兜底(产出非法证书,主要避免崩溃)
|
|
7
22
|
*/
|
|
8
|
-
export declare function ensureCertificates(configDir: string): SSLConfig;
|
|
23
|
+
export declare function ensureCertificates(configDir: string, options?: EnsureCertificatesOptions): SSLConfig;
|
package/dist/cert.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
2
|
import { execSync } from "node:child_process";
|
|
3
|
+
import { createHash } from "node:crypto";
|
|
4
|
+
import os from "node:os";
|
|
3
5
|
import path from "node:path";
|
|
4
6
|
function getCertificatePaths(configDir) {
|
|
5
7
|
return {
|
|
@@ -10,38 +12,85 @@ function getCertificatePaths(configDir) {
|
|
|
10
12
|
function certificatesExist(paths) {
|
|
11
13
|
return existsSync(paths.keyPath) && existsSync(paths.certPath);
|
|
12
14
|
}
|
|
13
|
-
function
|
|
15
|
+
function readPair(keyPath, certPath) {
|
|
14
16
|
try {
|
|
15
|
-
if (!
|
|
17
|
+
if (!existsSync(keyPath) || !existsSync(certPath))
|
|
16
18
|
return null;
|
|
17
|
-
}
|
|
18
|
-
return {
|
|
19
|
-
key: readFileSync(paths.keyPath),
|
|
20
|
-
cert: readFileSync(paths.certPath)
|
|
21
|
-
};
|
|
19
|
+
return { key: readFileSync(keyPath), cert: readFileSync(certPath) };
|
|
22
20
|
}
|
|
23
21
|
catch {
|
|
24
22
|
return null;
|
|
25
23
|
}
|
|
26
24
|
}
|
|
27
25
|
/**
|
|
28
|
-
*
|
|
26
|
+
* 收集本机所有可外部访问的 IPv4 地址 + hostname,作为自签证书的 SAN。
|
|
27
|
+
* 之前 SAN 只有 `localhost,127.0.0.1`,从手机/局域网用 `https://192.168.x.x` 访问
|
|
28
|
+
* 时浏览器会直接拒绝(NET::ERR_CERT_COMMON_NAME_INVALID),连页面都打不开。
|
|
29
|
+
*/
|
|
30
|
+
function collectSanEntries() {
|
|
31
|
+
const dns = new Set(["localhost"]);
|
|
32
|
+
const ip = new Set(["127.0.0.1", "::1"]);
|
|
33
|
+
const hostname = os.hostname();
|
|
34
|
+
if (hostname && hostname !== "localhost") {
|
|
35
|
+
dns.add(hostname);
|
|
36
|
+
// 部分 mDNS 环境下 hostname.local 也能解析
|
|
37
|
+
if (!hostname.endsWith(".local"))
|
|
38
|
+
dns.add(`${hostname}.local`);
|
|
39
|
+
}
|
|
40
|
+
const ifaces = os.networkInterfaces();
|
|
41
|
+
for (const list of Object.values(ifaces)) {
|
|
42
|
+
if (!list)
|
|
43
|
+
continue;
|
|
44
|
+
for (const entry of list) {
|
|
45
|
+
if (entry.internal)
|
|
46
|
+
continue;
|
|
47
|
+
// Node 18+ entry.family 是 "IPv4" | "IPv6",旧版本是 4 | 6
|
|
48
|
+
const fam = entry.family;
|
|
49
|
+
if (fam === "IPv4" || fam === 4 || fam === "IPv6" || fam === 6) {
|
|
50
|
+
ip.add(entry.address.split("%")[0]); // 去掉 zone-id
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return { dns: [...dns], ip: [...ip] };
|
|
55
|
+
}
|
|
56
|
+
function buildSanArg() {
|
|
57
|
+
const { dns, ip } = collectSanEntries();
|
|
58
|
+
const parts = [];
|
|
59
|
+
for (const d of dns)
|
|
60
|
+
parts.push(`DNS:${d}`);
|
|
61
|
+
for (const i of ip)
|
|
62
|
+
parts.push(`IP:${i}`);
|
|
63
|
+
return parts.join(",");
|
|
64
|
+
}
|
|
65
|
+
function computeFingerprint(certPem) {
|
|
66
|
+
const text = certPem.toString("utf8");
|
|
67
|
+
const match = text.match(/-----BEGIN CERTIFICATE-----([\s\S]+?)-----END CERTIFICATE-----/);
|
|
68
|
+
if (!match)
|
|
69
|
+
return "(unavailable)";
|
|
70
|
+
const der = Buffer.from(match[1].replace(/\s+/g, ""), "base64");
|
|
71
|
+
const hex = createHash("sha256").update(der).digest("hex").toUpperCase();
|
|
72
|
+
return hex.match(/.{2}/g).join(":");
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* 生成 self-signed 证书(OpenSSL 路径)。SAN 覆盖本机 hostname / LAN IP,
|
|
76
|
+
* 这样从手机/局域网设备访问 `https://<LAN IP>:port/` 时不会撞 SAN 不匹配。
|
|
29
77
|
*/
|
|
30
78
|
function generateWithOpenSSL(paths) {
|
|
31
79
|
try {
|
|
32
|
-
// Check if openssl is available
|
|
33
80
|
execSync("openssl version", { stdio: "pipe" });
|
|
34
81
|
const dir = path.dirname(paths.keyPath);
|
|
35
|
-
if (!existsSync(dir))
|
|
82
|
+
if (!existsSync(dir))
|
|
36
83
|
mkdirSync(dir, { recursive: true });
|
|
37
|
-
}
|
|
38
|
-
// Generate private key
|
|
39
84
|
execSync(`openssl genrsa -out "${paths.keyPath}" 2048`, { stdio: "pipe" });
|
|
40
|
-
|
|
41
|
-
execSync(`openssl req -new -x509 -key "${paths.keyPath}" -out "${paths.certPath}" -days
|
|
85
|
+
const san = buildSanArg();
|
|
86
|
+
execSync(`openssl req -new -x509 -key "${paths.keyPath}" -out "${paths.certPath}" -days 825 ` +
|
|
87
|
+
`-subj "/CN=wand-local/O=Wand Local Development" ` +
|
|
88
|
+
`-addext "subjectAltName=${san}" ` +
|
|
89
|
+
`-addext "extendedKeyUsage=serverAuth" ` +
|
|
90
|
+
`-addext "basicConstraints=critical,CA:FALSE"`, { stdio: "pipe" });
|
|
42
91
|
return {
|
|
43
92
|
key: readFileSync(paths.keyPath),
|
|
44
|
-
cert: readFileSync(paths.certPath)
|
|
93
|
+
cert: readFileSync(paths.certPath),
|
|
45
94
|
};
|
|
46
95
|
}
|
|
47
96
|
catch {
|
|
@@ -49,76 +98,83 @@ function generateWithOpenSSL(paths) {
|
|
|
49
98
|
}
|
|
50
99
|
}
|
|
51
100
|
/**
|
|
52
|
-
*
|
|
53
|
-
*
|
|
101
|
+
* 没有 openssl 时的兜底:用 node 内置 crypto 生成 RSA 密钥,证书部分写一个
|
|
102
|
+
* 明显的 placeholder PEM。这条路径产出的证书**无法被浏览器接受**,主要是为了
|
|
103
|
+
* 不让 HTTPS 监听直接崩;启动日志会强烈建议用户装 openssl 或自备证书。
|
|
54
104
|
*/
|
|
55
105
|
function generateWithoutOpenSSL(paths) {
|
|
56
106
|
const dir = path.dirname(paths.keyPath);
|
|
57
|
-
if (!existsSync(dir))
|
|
107
|
+
if (!existsSync(dir))
|
|
58
108
|
mkdirSync(dir, { recursive: true });
|
|
59
|
-
}
|
|
60
|
-
// Use Node.js built-in crypto to generate key
|
|
61
|
-
// For certificate, we'll create a minimal PEM structure
|
|
62
|
-
// This is a simplified approach - for production, use proper tools
|
|
63
109
|
const { generateKeyPairSync } = require("node:crypto");
|
|
64
|
-
const { privateKey
|
|
110
|
+
const { privateKey } = generateKeyPairSync("rsa", {
|
|
65
111
|
modulusLength: 2048,
|
|
66
112
|
publicKeyEncoding: { type: "spki", format: "pem" },
|
|
67
|
-
privateKeyEncoding: { type: "pkcs8", format: "pem" }
|
|
113
|
+
privateKeyEncoding: { type: "pkcs8", format: "pem" },
|
|
68
114
|
});
|
|
69
|
-
|
|
70
|
-
|
|
115
|
+
const cert = "-----BEGIN CERTIFICATE-----\n" +
|
|
116
|
+
"MIIBkTCB+wIJAKHBfPOPlvfoMA0GCSqGSIb3DQEBCwUAMBExDzANBgNVBAMMBnNh\n" +
|
|
117
|
+
"bmRib3gwHhcNMjQwMTAxMDAwMDAwWhcNMjUwMTAxMDAwMDAwWjARMQ8wDQYDVQQD\n" +
|
|
118
|
+
"DAZzYW5kYm94MFwwDQYJKoZIhvcNAQEBBQADSwAwSAJBALLgGbUPZxEvLPLXZQrz\n" +
|
|
119
|
+
"KxLhP5EoaUuB7V8FYA5JQZbRE6RkxEKkR8jFQHOcQYevGQYEbXvKZ0WxR2BqMJsC\n" +
|
|
120
|
+
"AwEAAaMgMB4wDQYJKoZIhvcNAQELBQADQQBpMq0NweMwF7fh0TiMwFCTzC/wK7fR\n" +
|
|
121
|
+
"e0WxR2BqMJsC\n" +
|
|
122
|
+
"-----END CERTIFICATE-----\n";
|
|
71
123
|
writeFileSync(paths.keyPath, privateKey, { mode: 0o600 });
|
|
72
124
|
writeFileSync(paths.certPath, cert, { mode: 0o644 });
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
};
|
|
77
|
-
}
|
|
78
|
-
/**
|
|
79
|
-
* Create a minimal self-signed certificate
|
|
80
|
-
* Note: This creates a very basic cert structure
|
|
81
|
-
*/
|
|
82
|
-
function createMinimalCert(privateKeyPem) {
|
|
83
|
-
// For a proper cert, we'd need node-forge or similar
|
|
84
|
-
// Instead, create a placeholder that prompts the user
|
|
85
|
-
const placeholder = `-----BEGIN CERTIFICATE-----
|
|
86
|
-
MIIBkTCB+wIJAKHBfPOPlvfoMA0GCSqGSIb3DQEBCwUAMBExDzANBgNVBAMMBnNh
|
|
87
|
-
bmRib3gwHhcNMjQwMTAxMDAwMDAwWhcNMjUwMTAxMDAwMDAwWjARMQ8wDQYDVQQD
|
|
88
|
-
DAZzYW5kYm94MFwwDQYJKoZIhvcNAQEBBQADSwAwSAJBALLgGbUPZxEvLPLXZQrz
|
|
89
|
-
KxLhP5EoaUuB7V8FYA5JQZbRE6RkxEKkR8jFQHOcQYevGQYEbXvKZ0WxR2BqMJsC
|
|
90
|
-
AwEAAaMgMB4wDQYJKoZIhvcNAQELBQADQQBpMq0NweMwF7fh0TiMwFCTzC/wK7fR
|
|
91
|
-
e0WxR2BqMJsC
|
|
92
|
-
-----END CERTIFICATE-----`;
|
|
93
|
-
process.stderr.write("\x1b[33m[wand] Warning: Generated basic certificate. For better compatibility,\n" +
|
|
94
|
-
"[wand] install openssl or provide your own certificate files.\n" +
|
|
95
|
-
"[wand] Certificate files should be at:\n" +
|
|
96
|
-
"[wand] - server.key (private key)\n" +
|
|
97
|
-
"[wand] - server.crt (certificate)\x1b[0m\n");
|
|
98
|
-
return placeholder;
|
|
125
|
+
process.stderr.write("\x1b[33m[wand] 警告:未检测到 openssl,写出的是无效占位证书。\n" +
|
|
126
|
+
"[wand] 请安装 openssl,或在 config.json 里配 tls.certPath / tls.keyPath\n" +
|
|
127
|
+
"[wand] 指向自备证书(推荐 mkcert:mkcert -install && mkcert localhost <hostname> <LAN-IP>)。\x1b[0m\n");
|
|
128
|
+
return { key: Buffer.from(privateKey), cert: Buffer.from(cert) };
|
|
99
129
|
}
|
|
100
130
|
/**
|
|
101
|
-
*
|
|
131
|
+
* 主入口:装载 TLS 证书。优先级(高 → 低):
|
|
132
|
+
* 1. options.userCertPath / userKeyPath(config.tls)
|
|
133
|
+
* 2. 配置目录下已存在的 server.crt + server.key
|
|
134
|
+
* 3. 用 openssl 现场生成自签
|
|
135
|
+
* 4. node crypto 兜底(产出非法证书,主要避免崩溃)
|
|
102
136
|
*/
|
|
103
|
-
export function ensureCertificates(configDir) {
|
|
137
|
+
export function ensureCertificates(configDir, options = {}) {
|
|
138
|
+
// 1. 用户自备证书
|
|
139
|
+
if (options.userCertPath && options.userKeyPath) {
|
|
140
|
+
const pair = readPair(options.userKeyPath, options.userCertPath);
|
|
141
|
+
if (pair) {
|
|
142
|
+
const fingerprint = computeFingerprint(pair.cert);
|
|
143
|
+
process.stdout.write(`[wand] 使用 config.tls 指定的证书:${options.userCertPath}\n` +
|
|
144
|
+
`[wand] SHA-256 指纹: ${fingerprint}\n`);
|
|
145
|
+
return { ...pair, certPath: options.userCertPath, fingerprint, userProvided: true };
|
|
146
|
+
}
|
|
147
|
+
process.stderr.write(`\x1b[33m[wand] 警告:config.tls 指向的证书文件无法读取(cert=${options.userCertPath}, key=${options.userKeyPath}),回退到默认自签流程。\x1b[0m\n`);
|
|
148
|
+
}
|
|
104
149
|
const paths = getCertificatePaths(configDir);
|
|
105
|
-
//
|
|
106
|
-
const existing =
|
|
150
|
+
// 2. 已存在的自签
|
|
151
|
+
const existing = readPair(paths.keyPath, paths.certPath);
|
|
107
152
|
if (existing) {
|
|
108
|
-
return
|
|
153
|
+
return {
|
|
154
|
+
...existing,
|
|
155
|
+
certPath: paths.certPath,
|
|
156
|
+
fingerprint: computeFingerprint(existing.cert),
|
|
157
|
+
userProvided: false,
|
|
158
|
+
};
|
|
109
159
|
}
|
|
110
|
-
process.stdout.write("[wand]
|
|
111
|
-
//
|
|
160
|
+
process.stdout.write("[wand] 正在生成 self-signed HTTPS 证书…\n");
|
|
161
|
+
// 3. OpenSSL
|
|
112
162
|
const ssl = generateWithOpenSSL(paths);
|
|
113
163
|
if (ssl) {
|
|
114
|
-
|
|
115
|
-
process.stdout.write(
|
|
116
|
-
|
|
117
|
-
|
|
164
|
+
const fingerprint = computeFingerprint(ssl.cert);
|
|
165
|
+
process.stdout.write(`[wand] 证书已写入 ${paths.certPath}\n` +
|
|
166
|
+
`[wand] SHA-256 指纹: ${fingerprint}\n` +
|
|
167
|
+
`[wand] 注意:自签证书浏览器会标红;PWA / Service Worker 需要把它导入受信任根证书才能工作。\n` +
|
|
168
|
+
`[wand] HTTPS 启用后可通过 GET /cert/server.crt 在客户端下载安装。\n`);
|
|
169
|
+
return { ...ssl, certPath: paths.certPath, fingerprint, userProvided: false };
|
|
118
170
|
}
|
|
119
|
-
//
|
|
120
|
-
process.stdout.write("[wand]
|
|
121
|
-
const
|
|
122
|
-
|
|
123
|
-
|
|
171
|
+
// 4. 兜底
|
|
172
|
+
process.stdout.write("[wand] 未检测到 openssl,使用 node crypto 兜底(产出占位证书,仅防崩)。\n");
|
|
173
|
+
const fallback = generateWithoutOpenSSL(paths);
|
|
174
|
+
return {
|
|
175
|
+
...fallback,
|
|
176
|
+
certPath: paths.certPath,
|
|
177
|
+
fingerprint: computeFingerprint(fallback.cert),
|
|
178
|
+
userProvided: false,
|
|
179
|
+
};
|
|
124
180
|
}
|
package/dist/config.js
CHANGED
|
@@ -424,6 +424,18 @@ function mergeWithDefaults(input) {
|
|
|
424
424
|
...input,
|
|
425
425
|
// Ensure https is boolean
|
|
426
426
|
https: typeof input.https === "boolean" ? input.https : defaults.https,
|
|
427
|
+
tls: (() => {
|
|
428
|
+
if (!input.tls || typeof input.tls !== "object")
|
|
429
|
+
return undefined;
|
|
430
|
+
const certPath = typeof input.tls.certPath === "string" ? input.tls.certPath.trim() : "";
|
|
431
|
+
const keyPath = typeof input.tls.keyPath === "string" ? input.tls.keyPath.trim() : "";
|
|
432
|
+
if (!certPath && !keyPath)
|
|
433
|
+
return undefined;
|
|
434
|
+
return {
|
|
435
|
+
...(certPath ? { certPath } : {}),
|
|
436
|
+
...(keyPath ? { keyPath } : {}),
|
|
437
|
+
};
|
|
438
|
+
})(),
|
|
427
439
|
defaultCwd: typeof input.defaultCwd === "string" && input.defaultCwd.trim()
|
|
428
440
|
? input.defaultCwd
|
|
429
441
|
: defaults.defaultCwd,
|
package/dist/server.js
CHANGED
|
@@ -11,7 +11,7 @@ import path from "node:path";
|
|
|
11
11
|
import process from "node:process";
|
|
12
12
|
import { WebSocketServer } from "ws";
|
|
13
13
|
import { ensureAvatarSeed, getAvatarSvg } from "./avatar.js";
|
|
14
|
-
import { createSession, revokeSession, setAuthStorage, validateSession } from "./auth.js";
|
|
14
|
+
import { createSession, readSessionCookie, revokeSession, SESSION_COOKIE_HTTP, SESSION_COOKIE_HTTPS, SESSION_COOKIE_LEGACY, setAuthStorage, validateSession, } from "./auth.js";
|
|
15
15
|
import { ensureCertificates } from "./cert.js";
|
|
16
16
|
import { buildChildEnv } from "./env-utils.js";
|
|
17
17
|
import { isExecutionMode, PREFERENCE_KEYS, resolveConfigDir, saveConfig, writePreferenceToStorage, } from "./config.js";
|
|
@@ -367,19 +367,14 @@ async function enrichWithGitStatus(items, dirPath) {
|
|
|
367
367
|
}
|
|
368
368
|
}
|
|
369
369
|
// ── Auth helpers ──
|
|
370
|
-
function
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
const cookie = req.headers.cookie;
|
|
379
|
-
if (!cookie)
|
|
380
|
-
return undefined;
|
|
381
|
-
const match = cookie.split(";").map((part) => part.trim()).find((part) => part.startsWith("wand_session="));
|
|
382
|
-
return match?.slice("wand_session=".length);
|
|
370
|
+
function buildRequireAuth(useHttps) {
|
|
371
|
+
return function requireAuth(req, res, next) {
|
|
372
|
+
if (!validateSession(readSessionCookie(req, useHttps))) {
|
|
373
|
+
res.status(401).json({ error: "未授权,请先登录。" });
|
|
374
|
+
return;
|
|
375
|
+
}
|
|
376
|
+
next();
|
|
377
|
+
};
|
|
383
378
|
}
|
|
384
379
|
// ── App connection token helpers ──
|
|
385
380
|
function generateAppToken(password, secret) {
|
|
@@ -738,26 +733,6 @@ function classifyFile(ext, baseName) {
|
|
|
738
733
|
function mimeForExt(ext) {
|
|
739
734
|
return MIME_BY_EXT[ext.toLowerCase()] || "application/octet-stream";
|
|
740
735
|
}
|
|
741
|
-
/** Hidden files that should still surface even when "show hidden" is off. */
|
|
742
|
-
const HIDDEN_ALLOWLIST = new Set([
|
|
743
|
-
".gitignore", ".gitattributes", ".gitmodules",
|
|
744
|
-
".env", ".env.local", ".env.example",
|
|
745
|
-
".editorconfig", ".prettierrc", ".eslintrc",
|
|
746
|
-
".dockerignore", ".npmrc", ".nvmrc",
|
|
747
|
-
".browserslistrc", ".babelrc",
|
|
748
|
-
]);
|
|
749
|
-
function isHiddenEntry(name) {
|
|
750
|
-
if (!name.startsWith("."))
|
|
751
|
-
return false;
|
|
752
|
-
if (HIDDEN_ALLOWLIST.has(name))
|
|
753
|
-
return false;
|
|
754
|
-
// Common patterns like `.env.production` are also kept visible
|
|
755
|
-
for (const allowed of HIDDEN_ALLOWLIST) {
|
|
756
|
-
if (name.startsWith(allowed + "."))
|
|
757
|
-
return false;
|
|
758
|
-
}
|
|
759
|
-
return true;
|
|
760
|
-
}
|
|
761
736
|
export async function startServer(config, configPath) {
|
|
762
737
|
const app = express();
|
|
763
738
|
const storage = new WandStorage(resolveDatabasePath(configPath));
|
|
@@ -769,6 +744,7 @@ export async function startServer(config, configPath) {
|
|
|
769
744
|
const structuredSessions = new StructuredSessionManager(storage, config, structuredLogger);
|
|
770
745
|
const useHttps = config.https === true;
|
|
771
746
|
const protocol = useHttps ? "https" : "http";
|
|
747
|
+
const requireAuth = buildRequireAuth(useHttps);
|
|
772
748
|
const nodeModulesDir = path.join(RUNTIME_ROOT_DIR, "node_modules");
|
|
773
749
|
app.use(express.json({ limit: "1mb" }));
|
|
774
750
|
app.use(compression({ threshold: 1024 }));
|
|
@@ -878,17 +854,34 @@ export async function startServer(config, configPath) {
|
|
|
878
854
|
}
|
|
879
855
|
resetRateLimit(clientIp);
|
|
880
856
|
const token = createSession();
|
|
881
|
-
|
|
857
|
+
const cookieOpts = {
|
|
882
858
|
httpOnly: true,
|
|
883
859
|
sameSite: "strict",
|
|
884
|
-
|
|
860
|
+
path: "/",
|
|
885
861
|
maxAge: 1000 * 60 * 60 * 12,
|
|
886
|
-
}
|
|
862
|
+
};
|
|
863
|
+
// 主 cookie:按 scheme 分名字,避免被旧的同名 Secure cookie 阻挡覆盖。
|
|
864
|
+
// 兼容 cookie `wand_session`:老 macOS APP(WandAuth.swift 写死了找 `wand_session`)需要这份才能登录。
|
|
865
|
+
// - HTTPS 模式:legacy 也带 Secure,浏览器与 APP 都能用
|
|
866
|
+
// - HTTP 模式:legacy 不带 Secure。浏览器场景下若之前留有同名 Secure cookie 会被 Strict Secure
|
|
867
|
+
// Cookies 拦截(无害噪音,主 cookie `wand_session_local` 兜得住);APP 走 native cookie API
|
|
868
|
+
// 不受这条策略约束,能正确拿到
|
|
869
|
+
if (useHttps) {
|
|
870
|
+
res.cookie(SESSION_COOKIE_HTTPS, token, { ...cookieOpts, secure: true });
|
|
871
|
+
res.cookie(SESSION_COOKIE_LEGACY, token, { ...cookieOpts, secure: true });
|
|
872
|
+
}
|
|
873
|
+
else {
|
|
874
|
+
res.cookie(SESSION_COOKIE_HTTP, token, { ...cookieOpts, secure: false });
|
|
875
|
+
res.cookie(SESSION_COOKIE_LEGACY, token, { ...cookieOpts, secure: false });
|
|
876
|
+
}
|
|
887
877
|
res.json({ ok: true });
|
|
888
878
|
});
|
|
889
879
|
app.post("/api/logout", (req, res) => {
|
|
890
|
-
revokeSession(readSessionCookie(req));
|
|
891
|
-
|
|
880
|
+
revokeSession(readSessionCookie(req, useHttps));
|
|
881
|
+
// 全部名字都清一遍,避免遗留 cookie 在下次同源访问时被回放。
|
|
882
|
+
for (const name of [SESSION_COOKIE_HTTPS, SESSION_COOKIE_HTTP, SESSION_COOKIE_LEGACY]) {
|
|
883
|
+
res.clearCookie(name, { path: "/" });
|
|
884
|
+
}
|
|
892
885
|
res.json({ ok: true });
|
|
893
886
|
});
|
|
894
887
|
app.post("/api/set-password", requireAuth, (req, res) => {
|
|
@@ -978,7 +971,7 @@ export async function startServer(config, configPath) {
|
|
|
978
971
|
});
|
|
979
972
|
// Public probe so the unauthenticated browser does not log a 401 on /api/config
|
|
980
973
|
app.get("/api/session-check", (req, res) => {
|
|
981
|
-
res.json({ authed: validateSession(readSessionCookie(req)) });
|
|
974
|
+
res.json({ authed: validateSession(readSessionCookie(req, useHttps)) });
|
|
982
975
|
});
|
|
983
976
|
app.use("/api", requireAuth);
|
|
984
977
|
// ── Config & Session info ──
|
|
@@ -1361,16 +1354,10 @@ export async function startServer(config, configPath) {
|
|
|
1361
1354
|
app.get("/api/directory", async (req, res) => {
|
|
1362
1355
|
const q = typeof req.query.q === "string" ? req.query.q : "";
|
|
1363
1356
|
const includeGitStatus = req.query.gitStatus === "true";
|
|
1364
|
-
const showHidden = req.query.showHidden === "true";
|
|
1365
1357
|
const targetPath = path.resolve(q || config.defaultCwd);
|
|
1366
|
-
if (isBlockedFolderPath(targetPath)) {
|
|
1367
|
-
res.status(403).json({ error: "访问被拒绝:无法访问系统敏感目录。" });
|
|
1368
|
-
return;
|
|
1369
|
-
}
|
|
1370
1358
|
try {
|
|
1371
1359
|
const entries = await readdir(targetPath, { withFileTypes: true });
|
|
1372
|
-
const
|
|
1373
|
-
const sorted = visible.sort((a, b) => {
|
|
1360
|
+
const sorted = entries.sort((a, b) => {
|
|
1374
1361
|
if (a.isDirectory() && !b.isDirectory())
|
|
1375
1362
|
return -1;
|
|
1376
1363
|
if (!a.isDirectory() && b.isDirectory())
|
|
@@ -1764,8 +1751,6 @@ export async function startServer(config, configPath) {
|
|
|
1764
1751
|
for (const entry of entries) {
|
|
1765
1752
|
if (results.length >= maxResults)
|
|
1766
1753
|
break;
|
|
1767
|
-
if (entry.name.startsWith(".") || entry.name === "node_modules")
|
|
1768
|
-
continue;
|
|
1769
1754
|
const entryPath = path.join(dirPath, entry.name);
|
|
1770
1755
|
const nameLower = entry.name.toLowerCase();
|
|
1771
1756
|
const matchIndex = nameLower.indexOf(queryLower);
|
|
@@ -1823,12 +1808,36 @@ export async function startServer(config, configPath) {
|
|
|
1823
1808
|
}
|
|
1824
1809
|
});
|
|
1825
1810
|
// ── WebSocket broadcast layer ──
|
|
1811
|
+
let activeSslCertPath = null;
|
|
1826
1812
|
const server = useHttps
|
|
1827
1813
|
? (() => {
|
|
1828
|
-
const ssl = ensureCertificates(resolveConfigDir(configPath)
|
|
1814
|
+
const ssl = ensureCertificates(resolveConfigDir(configPath), {
|
|
1815
|
+
userCertPath: config.tls?.certPath,
|
|
1816
|
+
userKeyPath: config.tls?.keyPath,
|
|
1817
|
+
});
|
|
1818
|
+
activeSslCertPath = ssl.certPath;
|
|
1829
1819
|
return createHttpsServer({ key: ssl.key, cert: ssl.cert }, app);
|
|
1830
1820
|
})()
|
|
1831
1821
|
: createHttpServer(app);
|
|
1822
|
+
// 公开下载当前证书 —— 方便从手机/其他终端拉证书并导入信任链。
|
|
1823
|
+
// 不鉴权:证书本身是公开材料(不含私钥),泄露不影响安全。
|
|
1824
|
+
if (useHttps && activeSslCertPath) {
|
|
1825
|
+
const certPath = activeSslCertPath;
|
|
1826
|
+
app.get("/cert/server.crt", (_req, res) => {
|
|
1827
|
+
try {
|
|
1828
|
+
if (!existsSync(certPath)) {
|
|
1829
|
+
res.status(404).type("text/plain").send("证书文件不存在");
|
|
1830
|
+
return;
|
|
1831
|
+
}
|
|
1832
|
+
res.setHeader("Content-Type", "application/x-x509-ca-cert");
|
|
1833
|
+
res.setHeader("Content-Disposition", 'attachment; filename="wand-server.crt"');
|
|
1834
|
+
res.send(readFileSync(certPath));
|
|
1835
|
+
}
|
|
1836
|
+
catch (err) {
|
|
1837
|
+
res.status(500).type("text/plain").send(`读取证书失败: ${getErrorMessage(err, "未知错误")}`);
|
|
1838
|
+
}
|
|
1839
|
+
});
|
|
1840
|
+
}
|
|
1832
1841
|
const wss = new WebSocketServer({
|
|
1833
1842
|
server,
|
|
1834
1843
|
path: "/ws",
|
|
@@ -1838,7 +1847,7 @@ export async function startServer(config, configPath) {
|
|
|
1838
1847
|
concurrencyLimit: 10,
|
|
1839
1848
|
},
|
|
1840
1849
|
});
|
|
1841
|
-
const wsManager = new WsBroadcastManager(wss, () => config.cardDefaults ?? {});
|
|
1850
|
+
const wsManager = new WsBroadcastManager(wss, () => config.cardDefaults ?? {}, useHttps);
|
|
1842
1851
|
wsManager.setup((id) => structuredSessions.get(id) ?? processes.get(id));
|
|
1843
1852
|
// Wire process events to WebSocket broadcast
|
|
1844
1853
|
processes.on("process", (event) => {
|
package/dist/tui/commands.js
CHANGED
|
@@ -428,6 +428,42 @@ function resolveWandBin(ctx) {
|
|
|
428
428
|
return which.stdout.trim();
|
|
429
429
|
return "wand";
|
|
430
430
|
}
|
|
431
|
+
/**
|
|
432
|
+
* 构造写入 service unit 的 PATH,要覆盖以下来源(按优先级、去重):
|
|
433
|
+
* 1. nodeBinDir —— 保证 service 用的 node 和 install 时的一致
|
|
434
|
+
* 2. process.env.PATH —— 调用 install 的终端 PATH,里面包含用户实际能跑通的 claude/codex 等
|
|
435
|
+
* 3. 常见用户级 bin 兜底(~/.local/bin / ~/.npm-global/bin / ~/bin)—— 防 sudo 把 PATH 收窄
|
|
436
|
+
* 4. /usr/local/... 等系统标准路径
|
|
437
|
+
* 用 sudo 装系统级服务时 `process.env.PATH` 会被 secure_path 替换为极简集合,
|
|
438
|
+
* 所以兜底路径不能省,否则又退化回 "command not found" 现场。
|
|
439
|
+
*/
|
|
440
|
+
function buildServicePath(nodeBinDir, home) {
|
|
441
|
+
const out = [];
|
|
442
|
+
const seen = new Set();
|
|
443
|
+
const push = (raw) => {
|
|
444
|
+
if (!raw)
|
|
445
|
+
return;
|
|
446
|
+
for (const seg of raw.split(":")) {
|
|
447
|
+
const trimmed = seg.trim();
|
|
448
|
+
if (!trimmed || seen.has(trimmed))
|
|
449
|
+
continue;
|
|
450
|
+
seen.add(trimmed);
|
|
451
|
+
out.push(trimmed);
|
|
452
|
+
}
|
|
453
|
+
};
|
|
454
|
+
push(nodeBinDir);
|
|
455
|
+
push(process.env.PATH);
|
|
456
|
+
push(`${home}/.local/bin`);
|
|
457
|
+
push(`${home}/.npm-global/bin`);
|
|
458
|
+
push(`${home}/bin`);
|
|
459
|
+
push("/usr/local/sbin");
|
|
460
|
+
push("/usr/local/bin");
|
|
461
|
+
push("/usr/sbin");
|
|
462
|
+
push("/usr/bin");
|
|
463
|
+
push("/sbin");
|
|
464
|
+
push("/bin");
|
|
465
|
+
return out.join(":");
|
|
466
|
+
}
|
|
431
467
|
/** 当前 process 的真实用户名(system unit 里要写 User=)。 */
|
|
432
468
|
function currentUserName() {
|
|
433
469
|
try {
|
|
@@ -442,6 +478,12 @@ function installSystemdService(ctx, scope) {
|
|
|
442
478
|
const wandBin = resolveWandBin(ctx);
|
|
443
479
|
const nodeBin = process.execPath;
|
|
444
480
|
const nodeBinDir = path.dirname(nodeBin);
|
|
481
|
+
const runHome = process.env.HOME || os.homedir();
|
|
482
|
+
// 关键:把调用 `wand service:install` 时的真实 PATH 写进 unit。
|
|
483
|
+
// 否则 systemd 默认 PATH 极简(system scope 之前写死 `nodeBin:/usr/local/...`,
|
|
484
|
+
// user scope 干脆没写),spawn 出的 claude/codex 子进程会撞 "command not found"
|
|
485
|
+
// ——比如 claude 装在 ~/.local/bin、npm global 装在 ~/.npm-global/bin 都不在默认 PATH 里。
|
|
486
|
+
const servicePath = buildServicePath(nodeBinDir, runHome);
|
|
445
487
|
// 共同字段
|
|
446
488
|
const commonExec = [
|
|
447
489
|
"[Unit]",
|
|
@@ -453,24 +495,21 @@ function installSystemdService(ctx, scope) {
|
|
|
453
495
|
"Type=simple",
|
|
454
496
|
`ExecStart=${nodeBin} ${wandBin} web -c ${ctx.configPath}`,
|
|
455
497
|
`Environment=WAND_NO_TUI=1`,
|
|
498
|
+
`Environment=PATH=${servicePath}`,
|
|
456
499
|
"Restart=always",
|
|
457
500
|
"RestartSec=3",
|
|
458
501
|
"StandardOutput=journal",
|
|
459
502
|
"StandardError=journal",
|
|
460
503
|
"SyslogIdentifier=wand",
|
|
461
504
|
];
|
|
462
|
-
// system scope 额外要:User= / HOME= / PATH=(systemd 系统级 service 默认 HOME=/root 是不行的,
|
|
463
|
-
// 而且 PATH 极简,nvm 装的 node spawn 出来的 npm 子进程找不到)
|
|
464
505
|
let unitLines;
|
|
465
506
|
if (scope === "system") {
|
|
466
507
|
const runUser = currentUserName();
|
|
467
|
-
const runHome = process.env.HOME || os.homedir();
|
|
468
508
|
unitLines = [
|
|
469
509
|
...commonExec,
|
|
470
510
|
`User=${runUser}`,
|
|
471
511
|
`WorkingDirectory=${runHome}`,
|
|
472
512
|
`Environment=HOME=${runHome}`,
|
|
473
|
-
`Environment=PATH=${nodeBinDir}:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin`,
|
|
474
513
|
"OOMScoreAdjust=-500",
|
|
475
514
|
"",
|
|
476
515
|
"[Install]",
|
|
@@ -479,7 +518,8 @@ function installSystemdService(ctx, scope) {
|
|
|
479
518
|
];
|
|
480
519
|
}
|
|
481
520
|
else {
|
|
482
|
-
// user scope: 跑在 user@<uid>.service cgroup 内,HOME
|
|
521
|
+
// user scope: 跑在 user@<uid>.service cgroup 内,HOME 自带;PATH 也要写,
|
|
522
|
+
// systemd 用户实例默认 PATH 同样不含 ~/.local/bin、nvm、npm-global 这些。
|
|
483
523
|
unitLines = [
|
|
484
524
|
...commonExec,
|
|
485
525
|
"",
|
|
@@ -547,6 +587,10 @@ function installLaunchdService(ctx, scope) {
|
|
|
547
587
|
const plistPath = servicePathFor(scope);
|
|
548
588
|
const wandBin = resolveWandBin(ctx);
|
|
549
589
|
const nodeBin = process.execPath;
|
|
590
|
+
const nodeBinDir = path.dirname(nodeBin);
|
|
591
|
+
const runHome = process.env.HOME || os.homedir();
|
|
592
|
+
// 与 systemd 同理:launchd 默认 PATH 极简,spawn 出的 claude 会找不到。
|
|
593
|
+
const servicePath = buildServicePath(nodeBinDir, runHome);
|
|
550
594
|
// LaunchDaemon (system) 跑在 root,但 wand 数据应该归 ctx.configPath 的 owner;
|
|
551
595
|
// 简化处理:system 模式下不强制改 owner,让用户自己提前 chown。
|
|
552
596
|
const plist = `<?xml version="1.0" encoding="UTF-8"?>
|
|
@@ -565,6 +609,8 @@ function installLaunchdService(ctx, scope) {
|
|
|
565
609
|
<key>EnvironmentVariables</key>
|
|
566
610
|
<dict>
|
|
567
611
|
<key>WAND_NO_TUI</key><string>1</string>
|
|
612
|
+
<key>PATH</key><string>${servicePath}</string>
|
|
613
|
+
<key>HOME</key><string>${runHome}</string>
|
|
568
614
|
</dict>
|
|
569
615
|
<key>RunAtLoad</key><true/>
|
|
570
616
|
<key>KeepAlive</key><true/>
|