@lasting-beauty/chat-hub 0.1.0-beta.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +74 -0
- package/README.md +170 -0
- package/bin/chat-hub.mjs +1794 -0
- package/cli/data-lifecycle.mjs +203 -0
- package/cli/deploy.mjs +569 -0
- package/cli/external-verification.mjs +219 -0
- package/cli/ip-certificate.mjs +331 -0
- package/cli/platform.mjs +173 -0
- package/cli/runtime.mjs +115 -0
- package/cli/server-maintenance.mjs +207 -0
- package/cli/server-verification.mjs +562 -0
- package/cli/windows-data-migration.mjs +145 -0
- package/dist/assets/AiSummaryPage-BUT43KuZ.js +1 -0
- package/dist/assets/index-BNCcoLmC.js +312 -0
- package/dist/assets/index-cxklu1ib.css +1 -0
- package/dist/index.html +14 -0
- package/dist/server/index.js +14796 -0
- package/package.json +118 -0
- package/scripts/install-platform-clis.mjs +163 -0
- package/skills/chat-hub-setup/scripts/gateway.mjs +360 -0
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
import { lookup } from 'node:dns/promises';
|
|
2
|
+
import net, { isIP } from 'node:net';
|
|
3
|
+
|
|
4
|
+
const protectedPorts = [4310, 8080];
|
|
5
|
+
|
|
6
|
+
export async function verifyExternalDeployment({
|
|
7
|
+
domain,
|
|
8
|
+
publicHost = domain,
|
|
9
|
+
publicScheme = 'https',
|
|
10
|
+
origin,
|
|
11
|
+
resolveAddresses = defaultResolveAddresses,
|
|
12
|
+
probePort = defaultProbePort,
|
|
13
|
+
fetchImpl = fetch,
|
|
14
|
+
environment = process.env,
|
|
15
|
+
now = Date.now(),
|
|
16
|
+
} = {}) {
|
|
17
|
+
if (!isValidEndpoint(publicHost, publicScheme)) throw new Error('外部验收公网入口无效');
|
|
18
|
+
if (origin !== undefined && isIP(origin) === 0) {
|
|
19
|
+
throw new Error('--origin 必须是明确的 IPv4 或 IPv6 地址');
|
|
20
|
+
}
|
|
21
|
+
const checks = [];
|
|
22
|
+
let resolvedAddresses = [];
|
|
23
|
+
|
|
24
|
+
if (publicScheme === 'https') {
|
|
25
|
+
await record(checks, 'tls-validation-enabled', 'Node.js 未关闭 TLS 证书校验', () => {
|
|
26
|
+
if (environment.NODE_TLS_REJECT_UNAUTHORIZED === '0') {
|
|
27
|
+
throw new Error('NODE_TLS_REJECT_UNAUTHORIZED=0 会使公网证书验收失真');
|
|
28
|
+
}
|
|
29
|
+
return 'enabled';
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (publicScheme === 'https' && isIP(publicHost) === 0) {
|
|
34
|
+
await record(checks, 'dns-resolution', '公网域名可以解析', async () => {
|
|
35
|
+
resolvedAddresses = [...new Set(await resolveAddresses(publicHost))];
|
|
36
|
+
if (resolvedAddresses.length === 0) throw new Error('域名没有 A/AAAA 解析结果');
|
|
37
|
+
if (resolvedAddresses.length > 16) throw new Error('域名解析地址超过 16 个,拒绝无界探测');
|
|
38
|
+
if (resolvedAddresses.some((address) => isIP(address) === 0)) {
|
|
39
|
+
throw new Error('DNS 解析器返回了无效 IP 地址');
|
|
40
|
+
}
|
|
41
|
+
return resolvedAddresses;
|
|
42
|
+
});
|
|
43
|
+
} else {
|
|
44
|
+
resolvedAddresses = [publicHost];
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (publicScheme === 'https') {
|
|
48
|
+
await record(checks, 'http-redirect', '公网 HTTP 强制跳转到同域 HTTPS', async () => {
|
|
49
|
+
const response = await fetchWithTimeout(fetchImpl, `http://${publicHost}/api/health`, {
|
|
50
|
+
redirect: 'manual',
|
|
51
|
+
});
|
|
52
|
+
const location = response.headers.get('location');
|
|
53
|
+
if (![301, 302, 307, 308].includes(response.status)) {
|
|
54
|
+
throw new Error(`预期重定向,实际 HTTP ${response.status}`);
|
|
55
|
+
}
|
|
56
|
+
if (!location?.startsWith(`https://${publicHost}/`)) {
|
|
57
|
+
throw new Error('重定向目标不是同域 HTTPS');
|
|
58
|
+
}
|
|
59
|
+
return { status: response.status, location };
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
await record(
|
|
64
|
+
checks,
|
|
65
|
+
publicScheme === 'https' ? 'public-https-health' : 'public-http-health',
|
|
66
|
+
publicScheme === 'https'
|
|
67
|
+
? '公网 HTTPS、证书和健康接口有效'
|
|
68
|
+
: '公网 HTTP 健康接口有效(传输未加密)',
|
|
69
|
+
async () => {
|
|
70
|
+
const response = await fetchWithTimeout(
|
|
71
|
+
fetchImpl,
|
|
72
|
+
`${publicScheme}://${publicHost}/api/health`,
|
|
73
|
+
);
|
|
74
|
+
const body = await readJson(response);
|
|
75
|
+
if (!response.ok || body?.ok !== true || body.data?.status !== 'ready') {
|
|
76
|
+
throw new Error(`公网健康接口异常:HTTP ${response.status}`);
|
|
77
|
+
}
|
|
78
|
+
return { status: response.status, application: body.data.status };
|
|
79
|
+
},
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
await record(checks, 'public-session-config', '公网管理员会话配置有效', async () => {
|
|
83
|
+
const response = await fetchWithTimeout(
|
|
84
|
+
fetchImpl,
|
|
85
|
+
`${publicScheme}://${publicHost}/api/session/config`,
|
|
86
|
+
);
|
|
87
|
+
const body = await readJson(response);
|
|
88
|
+
if (
|
|
89
|
+
!response.ok ||
|
|
90
|
+
body?.ok !== true ||
|
|
91
|
+
body.data?.mode !== 'admin' ||
|
|
92
|
+
typeof body.data.initialized !== 'boolean'
|
|
93
|
+
) {
|
|
94
|
+
throw new Error(`管理员会话配置异常:HTTP ${response.status}`);
|
|
95
|
+
}
|
|
96
|
+
return {
|
|
97
|
+
status: response.status,
|
|
98
|
+
mode: body.data.mode,
|
|
99
|
+
initialized: body.data.initialized,
|
|
100
|
+
};
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
const targets = origin ? [origin] : resolvedAddresses;
|
|
104
|
+
for (const port of protectedPorts) {
|
|
105
|
+
await record(
|
|
106
|
+
checks,
|
|
107
|
+
`protected-port-${port}`,
|
|
108
|
+
`内部端口 ${port} 无法从验收网络建立 TCP 连接`,
|
|
109
|
+
async () => {
|
|
110
|
+
if (targets.length === 0) throw new Error('没有可探测的目标地址');
|
|
111
|
+
const outcomes = await Promise.all(
|
|
112
|
+
targets.map(async (target) => ({ target, ...(await probePort(target, port)) })),
|
|
113
|
+
);
|
|
114
|
+
const exposed = outcomes.find((outcome) => outcome.connected);
|
|
115
|
+
if (exposed) throw new Error(`${exposed.target}:${port} 可以从外部连接`);
|
|
116
|
+
return outcomes;
|
|
117
|
+
},
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const failed = checks.filter((check) => check.status === 'fail').length;
|
|
122
|
+
return {
|
|
123
|
+
command: 'verify-external',
|
|
124
|
+
domain: publicHost,
|
|
125
|
+
publicHost,
|
|
126
|
+
publicScheme,
|
|
127
|
+
probeTargets: targets,
|
|
128
|
+
originOverride: origin ?? null,
|
|
129
|
+
checkedAt: new Date(now).toISOString(),
|
|
130
|
+
passed: failed === 0,
|
|
131
|
+
summary: { total: checks.length, passed: checks.length - failed, failed },
|
|
132
|
+
checks,
|
|
133
|
+
boundary:
|
|
134
|
+
'结果只覆盖运行本命令的当前网络到 probeTargets 的可达性;若域名经过 CDN/代理,请用 --origin 指定 ECS 公网 IP 再验收。',
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function isValidEndpoint(publicHost, publicScheme) {
|
|
139
|
+
if (publicScheme === 'https') return isPublicDomain(publicHost) || isIP(publicHost) === 4;
|
|
140
|
+
return publicScheme === 'http' && isIP(publicHost) === 4;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async function defaultResolveAddresses(domain) {
|
|
144
|
+
const records = await lookup(domain, { all: true, verbatim: true });
|
|
145
|
+
return records.map((record) => record.address);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function defaultProbePort(host, port) {
|
|
149
|
+
return new Promise((resolvePromise) => {
|
|
150
|
+
const socket = net.createConnection({ host, port });
|
|
151
|
+
let settled = false;
|
|
152
|
+
const finish = (result) => {
|
|
153
|
+
if (settled) return;
|
|
154
|
+
settled = true;
|
|
155
|
+
socket.destroy();
|
|
156
|
+
resolvePromise(result);
|
|
157
|
+
};
|
|
158
|
+
socket.setTimeout(4_000);
|
|
159
|
+
socket.once('connect', () => finish({ connected: true, outcome: 'connected' }));
|
|
160
|
+
socket.once('timeout', () => finish({ connected: false, outcome: 'timeout' }));
|
|
161
|
+
socket.once('error', (error) =>
|
|
162
|
+
finish({
|
|
163
|
+
connected: false,
|
|
164
|
+
outcome: typeof error?.code === 'string' ? error.code : 'connection_error',
|
|
165
|
+
}),
|
|
166
|
+
);
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
async function fetchWithTimeout(fetchImpl, url, options = {}) {
|
|
171
|
+
return fetchImpl(url, {
|
|
172
|
+
...options,
|
|
173
|
+
signal: AbortSignal.timeout(10_000),
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
async function readJson(response) {
|
|
178
|
+
const text = await response.text();
|
|
179
|
+
if (text.length > 64 * 1024) throw new Error('验收接口响应超过 64 KiB');
|
|
180
|
+
try {
|
|
181
|
+
return JSON.parse(text);
|
|
182
|
+
} catch {
|
|
183
|
+
throw new Error('验收接口未返回有效 JSON');
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
async function record(checks, id, description, operation) {
|
|
188
|
+
try {
|
|
189
|
+
const evidence = await operation();
|
|
190
|
+
checks.push({ id, description, status: 'pass', evidence });
|
|
191
|
+
} catch (error) {
|
|
192
|
+
checks.push({
|
|
193
|
+
id,
|
|
194
|
+
description,
|
|
195
|
+
status: 'fail',
|
|
196
|
+
error: safeError(error),
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function isPublicDomain(domain) {
|
|
202
|
+
return (
|
|
203
|
+
typeof domain === 'string' &&
|
|
204
|
+
domain.length <= 253 &&
|
|
205
|
+
domain.includes('.') &&
|
|
206
|
+
/^[a-z0-9.-]+$/.test(domain) &&
|
|
207
|
+
!domain
|
|
208
|
+
.split('.')
|
|
209
|
+
.some(
|
|
210
|
+
(label) => !label || label.length > 63 || label.startsWith('-') || label.endsWith('-'),
|
|
211
|
+
) &&
|
|
212
|
+
!/^\d+(?:\.\d+){3}$/.test(domain)
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function safeError(error) {
|
|
217
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
218
|
+
return message.replace(/[\r\n]+/g, ' ').slice(0, 500);
|
|
219
|
+
}
|
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import {
|
|
3
|
+
chmodSync,
|
|
4
|
+
existsSync,
|
|
5
|
+
mkdirSync,
|
|
6
|
+
readFileSync,
|
|
7
|
+
renameSync,
|
|
8
|
+
statSync,
|
|
9
|
+
unlinkSync,
|
|
10
|
+
writeFileSync,
|
|
11
|
+
} from 'node:fs';
|
|
12
|
+
import { dirname, isAbsolute, join, relative, resolve } from 'node:path';
|
|
13
|
+
|
|
14
|
+
export const minimumCertbotVersion = Object.freeze({ major: 5, minor: 4, patch: 0 });
|
|
15
|
+
export const ipCertificateAutomationPaths = Object.freeze({
|
|
16
|
+
webroot: '/var/lib/chat-hub/acme-webroot',
|
|
17
|
+
deployHook: '/usr/local/libexec/chat-hub-cert-renew-hook',
|
|
18
|
+
renewRunner: '/usr/local/libexec/chat-hub-cert-renew-runner',
|
|
19
|
+
service: '/etc/systemd/system/chat-hub-cert-renew.service',
|
|
20
|
+
timer: '/etc/systemd/system/chat-hub-cert-renew.timer',
|
|
21
|
+
result: '/var/lib/chat-hub/certificate-renewal.json',
|
|
22
|
+
publishedDirectory: '/etc/caddy/chat-hub-ip-tls',
|
|
23
|
+
publishedReleases: '/etc/caddy/chat-hub-ip-tls/releases',
|
|
24
|
+
publishedCurrent: '/etc/caddy/chat-hub-ip-tls/current',
|
|
25
|
+
publishedFullchain: '/etc/caddy/chat-hub-ip-tls/current/fullchain.pem',
|
|
26
|
+
publishedPrivateKey: '/etc/caddy/chat-hub-ip-tls/current/privkey.pem',
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
export function certbotVersionSupported(output) {
|
|
30
|
+
const match = String(output).match(/certbot\s+(\d+)\.(\d+)\.(\d+)/i);
|
|
31
|
+
if (!match) return false;
|
|
32
|
+
const current = match.slice(1).map(Number);
|
|
33
|
+
const minimum = Object.values(minimumCertbotVersion);
|
|
34
|
+
return (
|
|
35
|
+
current.some((value, index) => value > minimum[index]) ||
|
|
36
|
+
current.every((value, index) => value === minimum[index])
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function ipCertificatePaths(publicIp) {
|
|
41
|
+
assertPublicIpText(publicIp);
|
|
42
|
+
const lineage = `/etc/letsencrypt/live/${publicIp}`;
|
|
43
|
+
return {
|
|
44
|
+
lineage,
|
|
45
|
+
fullchain: `${lineage}/fullchain.pem`,
|
|
46
|
+
privateKey: `${lineage}/privkey.pem`,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function certbotIssueArguments({ publicIp, email, staging = false }) {
|
|
51
|
+
assertPublicIpText(publicIp);
|
|
52
|
+
if (typeof email !== 'string' || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
|
|
53
|
+
throw new Error('IP HTTPS 必须提供有效的证书到期联系邮箱');
|
|
54
|
+
}
|
|
55
|
+
return [
|
|
56
|
+
'certonly',
|
|
57
|
+
'--non-interactive',
|
|
58
|
+
'--agree-tos',
|
|
59
|
+
'--email',
|
|
60
|
+
email,
|
|
61
|
+
'--preferred-profile',
|
|
62
|
+
'shortlived',
|
|
63
|
+
'--webroot',
|
|
64
|
+
'--webroot-path',
|
|
65
|
+
ipCertificateAutomationPaths.webroot,
|
|
66
|
+
'--ip-address',
|
|
67
|
+
publicIp,
|
|
68
|
+
'--cert-name',
|
|
69
|
+
publicIp,
|
|
70
|
+
...(staging ? ['--staging'] : []),
|
|
71
|
+
];
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function ipCertificateChallengeCaddySite(publicIp) {
|
|
75
|
+
assertPublicIpText(publicIp);
|
|
76
|
+
return `http://${publicIp} {
|
|
77
|
+
handle /.well-known/acme-challenge/* {
|
|
78
|
+
root * ${ipCertificateAutomationPaths.webroot}
|
|
79
|
+
file_server
|
|
80
|
+
}
|
|
81
|
+
handle {
|
|
82
|
+
respond "Chat Hub HTTPS certificate is being prepared" 503
|
|
83
|
+
}
|
|
84
|
+
header -Server
|
|
85
|
+
}
|
|
86
|
+
`;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function ipHttpsCaddySite(publicIp) {
|
|
90
|
+
return `http://${publicIp} {
|
|
91
|
+
handle /.well-known/acme-challenge/* {
|
|
92
|
+
root * ${ipCertificateAutomationPaths.webroot}
|
|
93
|
+
file_server
|
|
94
|
+
}
|
|
95
|
+
handle {
|
|
96
|
+
redir https://${publicIp}{uri} permanent
|
|
97
|
+
}
|
|
98
|
+
header -Server
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
https://${publicIp} {
|
|
102
|
+
tls ${ipCertificateAutomationPaths.publishedFullchain} ${ipCertificateAutomationPaths.publishedPrivateKey}
|
|
103
|
+
encode zstd gzip
|
|
104
|
+
reverse_proxy 127.0.0.1:8080 {
|
|
105
|
+
header_up Host {host}
|
|
106
|
+
header_up X-Forwarded-Proto https
|
|
107
|
+
header_up X-Forwarded-For {remote_host}
|
|
108
|
+
}
|
|
109
|
+
header {
|
|
110
|
+
-Server
|
|
111
|
+
X-Content-Type-Options nosniff
|
|
112
|
+
Referrer-Policy no-referrer
|
|
113
|
+
X-Frame-Options DENY
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
`;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function stageIpCertificateChallenge({
|
|
120
|
+
publicIp,
|
|
121
|
+
rootDirectory = '/',
|
|
122
|
+
caddyfile = '/etc/caddy/Caddyfile',
|
|
123
|
+
caddySnippet = '/etc/caddy/chat-hub.caddy',
|
|
124
|
+
}) {
|
|
125
|
+
assertPublicIpText(publicIp);
|
|
126
|
+
const outputPath = (path) => rootedPath(rootDirectory, path);
|
|
127
|
+
mkdirSync(outputPath(ipCertificateAutomationPaths.webroot), { recursive: true, mode: 0o755 });
|
|
128
|
+
mkdirSync(
|
|
129
|
+
outputPath(join(ipCertificateAutomationPaths.webroot, '.well-known', 'acme-challenge')),
|
|
130
|
+
{ recursive: true, mode: 0o755 },
|
|
131
|
+
);
|
|
132
|
+
mkdirSync(dirname(outputPath(caddyfile)), { recursive: true, mode: 0o755 });
|
|
133
|
+
const importLine = `import ${caddySnippet}`;
|
|
134
|
+
const existingCaddyfile = existsSync(outputPath(caddyfile))
|
|
135
|
+
? readFileSync(outputPath(caddyfile), 'utf8')
|
|
136
|
+
: '';
|
|
137
|
+
const nextCaddyfile = existingCaddyfile.split(/\r?\n/).some((line) => line.trim() === importLine)
|
|
138
|
+
? existingCaddyfile
|
|
139
|
+
: `${existingCaddyfile}${existingCaddyfile && !existingCaddyfile.endsWith('\n') ? '\n' : ''}${importLine}\n`;
|
|
140
|
+
return stageFiles(
|
|
141
|
+
new Map([
|
|
142
|
+
[caddyfile, { content: nextCaddyfile, mode: 0o644 }],
|
|
143
|
+
[caddySnippet, { content: ipCertificateChallengeCaddySite(publicIp), mode: 0o644 }],
|
|
144
|
+
]),
|
|
145
|
+
outputPath,
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export function stageIpCertificateAutomation({
|
|
150
|
+
publicIp,
|
|
151
|
+
certbotPath,
|
|
152
|
+
caddyPath,
|
|
153
|
+
installPath,
|
|
154
|
+
nodePath,
|
|
155
|
+
rootDirectory = '/',
|
|
156
|
+
}) {
|
|
157
|
+
assertPublicIpText(publicIp);
|
|
158
|
+
if (
|
|
159
|
+
[certbotPath, caddyPath, installPath, nodePath].some(
|
|
160
|
+
(path) => !isAbsolute(path) || /[\r\n\0]/.test(path),
|
|
161
|
+
)
|
|
162
|
+
) {
|
|
163
|
+
throw new Error('证书工具路径必须是安全的绝对路径');
|
|
164
|
+
}
|
|
165
|
+
const outputPath = (path) => rootedPath(rootDirectory, path);
|
|
166
|
+
const certificate = ipCertificatePaths(publicIp);
|
|
167
|
+
for (const directory of [
|
|
168
|
+
dirname(ipCertificateAutomationPaths.deployHook),
|
|
169
|
+
dirname(ipCertificateAutomationPaths.service),
|
|
170
|
+
ipCertificateAutomationPaths.webroot,
|
|
171
|
+
]) {
|
|
172
|
+
mkdirSync(outputPath(directory), { recursive: true, mode: 0o755 });
|
|
173
|
+
}
|
|
174
|
+
const hook = `#!/bin/sh
|
|
175
|
+
set -eu
|
|
176
|
+
${shellQuote(installPath)} -d -o root -g caddy -m 0750 ${shellQuote(ipCertificateAutomationPaths.publishedDirectory)}
|
|
177
|
+
${shellQuote(installPath)} -d -o root -g caddy -m 0750 ${shellQuote(ipCertificateAutomationPaths.publishedReleases)}
|
|
178
|
+
release_name="$(/usr/bin/date -u +%Y%m%dT%H%M%SZ).$$"
|
|
179
|
+
release_dir=${shellQuote(ipCertificateAutomationPaths.publishedReleases)}/"$release_name"
|
|
180
|
+
current_link=${shellQuote(ipCertificateAutomationPaths.publishedCurrent)}
|
|
181
|
+
next_link="$current_link.next.$$"
|
|
182
|
+
restore_link="$current_link.restore.$$"
|
|
183
|
+
previous_target=""
|
|
184
|
+
if [ -L "$current_link" ]; then
|
|
185
|
+
previous_target="$(/usr/bin/readlink "$current_link")"
|
|
186
|
+
fi
|
|
187
|
+
restore_current() {
|
|
188
|
+
if [ -n "$previous_target" ]; then
|
|
189
|
+
/usr/bin/ln -s "$previous_target" "$restore_link"
|
|
190
|
+
/usr/bin/mv -Tf "$restore_link" "$current_link"
|
|
191
|
+
else
|
|
192
|
+
/usr/bin/rm -f "$current_link"
|
|
193
|
+
fi
|
|
194
|
+
}
|
|
195
|
+
${shellQuote(installPath)} -d -o root -g caddy -m 0750 "$release_dir"
|
|
196
|
+
${shellQuote(installPath)} -o root -g caddy -m 0640 ${shellQuote(certificate.fullchain)} "$release_dir/fullchain.pem"
|
|
197
|
+
${shellQuote(installPath)} -o root -g caddy -m 0640 ${shellQuote(certificate.privateKey)} "$release_dir/privkey.pem"
|
|
198
|
+
/usr/bin/ln -s "releases/$release_name" "$next_link"
|
|
199
|
+
/usr/bin/mv -Tf "$next_link" "$current_link"
|
|
200
|
+
if ! ${shellQuote(caddyPath)} validate --config /etc/caddy/Caddyfile --adapter caddyfile; then
|
|
201
|
+
restore_current
|
|
202
|
+
exit 1
|
|
203
|
+
fi
|
|
204
|
+
if ! /usr/bin/systemctl reload caddy.service; then
|
|
205
|
+
restore_current
|
|
206
|
+
/usr/bin/systemctl reload caddy.service || true
|
|
207
|
+
exit 1
|
|
208
|
+
fi
|
|
209
|
+
`;
|
|
210
|
+
const renewRunner = `#!/bin/sh
|
|
211
|
+
set -eu
|
|
212
|
+
status=failed
|
|
213
|
+
write_result() {
|
|
214
|
+
${shellQuote(nodePath)} -e 'const fs=require("node:fs");const [path,status]=process.argv.slice(1);const now=new Date().toISOString();const value=status==="running"?{status,startedAt:now}:{status,completedAt:now};const temporary=path+"."+process.pid+".tmp";fs.writeFileSync(temporary,JSON.stringify(value)+"\\n",{mode:0o600});fs.renameSync(temporary,path)' ${shellQuote(ipCertificateAutomationPaths.result)} "$1"
|
|
215
|
+
}
|
|
216
|
+
on_exit() {
|
|
217
|
+
exit_code=$?
|
|
218
|
+
trap - EXIT
|
|
219
|
+
if [ "$status" != succeeded ]; then
|
|
220
|
+
write_result failed || true
|
|
221
|
+
fi
|
|
222
|
+
exit "$exit_code"
|
|
223
|
+
}
|
|
224
|
+
trap on_exit EXIT
|
|
225
|
+
write_result running
|
|
226
|
+
${shellQuote(certbotPath)} renew --cert-name ${publicIp} --quiet --deploy-hook ${shellQuote(ipCertificateAutomationPaths.deployHook)}
|
|
227
|
+
status=succeeded
|
|
228
|
+
write_result succeeded
|
|
229
|
+
trap - EXIT
|
|
230
|
+
`;
|
|
231
|
+
const service = `[Unit]
|
|
232
|
+
Description=Renew Chat Hub short-lived IP certificate
|
|
233
|
+
After=network-online.target caddy.service
|
|
234
|
+
Wants=network-online.target
|
|
235
|
+
|
|
236
|
+
[Service]
|
|
237
|
+
Type=oneshot
|
|
238
|
+
ExecStart=${ipCertificateAutomationPaths.renewRunner}
|
|
239
|
+
PrivateTmp=true
|
|
240
|
+
ProtectHome=true
|
|
241
|
+
ProtectSystem=full
|
|
242
|
+
ReadWritePaths=/etc/letsencrypt ${ipCertificateAutomationPaths.webroot} /var/lib/chat-hub ${ipCertificateAutomationPaths.publishedDirectory}
|
|
243
|
+
`;
|
|
244
|
+
const timer = `[Unit]
|
|
245
|
+
Description=Renew Chat Hub short-lived IP certificate regularly
|
|
246
|
+
|
|
247
|
+
[Timer]
|
|
248
|
+
OnBootSec=15m
|
|
249
|
+
OnUnitActiveSec=4h
|
|
250
|
+
RandomizedDelaySec=15m
|
|
251
|
+
Persistent=true
|
|
252
|
+
Unit=chat-hub-cert-renew.service
|
|
253
|
+
|
|
254
|
+
[Install]
|
|
255
|
+
WantedBy=timers.target
|
|
256
|
+
`;
|
|
257
|
+
return stageFiles(
|
|
258
|
+
new Map([
|
|
259
|
+
[ipCertificateAutomationPaths.deployHook, { content: hook, mode: 0o755 }],
|
|
260
|
+
[ipCertificateAutomationPaths.renewRunner, { content: renewRunner, mode: 0o755 }],
|
|
261
|
+
[ipCertificateAutomationPaths.service, { content: service, mode: 0o644 }],
|
|
262
|
+
[ipCertificateAutomationPaths.timer, { content: timer, mode: 0o644 }],
|
|
263
|
+
]),
|
|
264
|
+
outputPath,
|
|
265
|
+
);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function stageFiles(files, outputPath) {
|
|
269
|
+
const previous = new Map();
|
|
270
|
+
try {
|
|
271
|
+
for (const [absolutePath, artifact] of files) {
|
|
272
|
+
const target = outputPath(absolutePath);
|
|
273
|
+
mkdirSync(dirname(target), { recursive: true, mode: 0o755 });
|
|
274
|
+
previous.set(
|
|
275
|
+
absolutePath,
|
|
276
|
+
existsSync(target)
|
|
277
|
+
? { existed: true, content: readFileSync(target), mode: statSync(target).mode & 0o777 }
|
|
278
|
+
: { existed: false },
|
|
279
|
+
);
|
|
280
|
+
writeAtomic(target, artifact.content, artifact.mode);
|
|
281
|
+
}
|
|
282
|
+
} catch (error) {
|
|
283
|
+
restoreFiles(previous, outputPath);
|
|
284
|
+
throw error;
|
|
285
|
+
}
|
|
286
|
+
return {
|
|
287
|
+
outputPaths: [...files.keys()].map(outputPath),
|
|
288
|
+
rollback: () => restoreFiles(previous, outputPath),
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function writeAtomic(path, content, mode) {
|
|
293
|
+
const temporary = `${path}.${randomUUID()}.tmp`;
|
|
294
|
+
writeFileSync(temporary, content, { mode });
|
|
295
|
+
chmodSync(temporary, mode);
|
|
296
|
+
renameSync(temporary, path);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function restoreFiles(previous, outputPath) {
|
|
300
|
+
for (const [absolutePath, state] of [...previous.entries()].reverse()) {
|
|
301
|
+
const target = outputPath(absolutePath);
|
|
302
|
+
if (!state.existed) {
|
|
303
|
+
if (existsSync(target)) unlinkSync(target);
|
|
304
|
+
continue;
|
|
305
|
+
}
|
|
306
|
+
writeFileSync(target, state.content, { mode: state.mode });
|
|
307
|
+
chmodSync(target, state.mode);
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function rootedPath(rootDirectory, absolutePath) {
|
|
312
|
+
const root = resolve(rootDirectory);
|
|
313
|
+
if (!isAbsolute(rootDirectory) || !isAbsolute(absolutePath))
|
|
314
|
+
throw new Error('证书路径必须是绝对路径');
|
|
315
|
+
if (root === resolve('/')) return absolutePath;
|
|
316
|
+
const target = resolve(root, absolutePath.replace(/^[/\\]+/, ''));
|
|
317
|
+
const pathFromRoot = relative(root, target);
|
|
318
|
+
if (pathFromRoot.startsWith('..') || isAbsolute(pathFromRoot))
|
|
319
|
+
throw new Error('证书目标越出测试根目录');
|
|
320
|
+
return target;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function assertPublicIpText(value) {
|
|
324
|
+
if (typeof value !== 'string' || !/^\d{1,3}(?:\.\d{1,3}){3}$/.test(value)) {
|
|
325
|
+
throw new Error('IP 证书只接受已校验的公网 IPv4');
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function shellQuote(value) {
|
|
330
|
+
return `'${String(value).replaceAll("'", `'"'"'`)}'`;
|
|
331
|
+
}
|