CH-J Server Managerspráva serverů přes SSH
Menu
Vydané zdroje

CH-J Server Manager

Procházejte adresáře a soubory konkrétního vydání aplikace.

Stáhnout zdroje ZIP
CH-J Proprietary Software License 1.14

Zdroje jsou zveřejněny pod CH-J Proprietary Software License 1.14. Jejich dostupnost nemění licenční podmínky ani neposkytuje další oprávnění.

16,5 KB · 382 řádkůStáhnout soubor
1"use strict";
3const crypto = require("node:crypto");
4const fs = require("node:fs");
5const path = require("node:path");
6const { Readable, Transform } = require("node:stream");
7const { pipeline } = require("node:stream/promises");
8const { compareVersions } = require("../../shared/version");
9const { assertAllowedUrl, normalizeBaseUrl } = require("../security/urlPolicy");
10const { cleanDownloadCache } = require("../storage/downloadCache");
11const {
12 assertSupportedUpdateTarget,
13 normalizeArchitecture,
14 normalizePlatform,
15 serverChannel,
16 validateReleaseListResponse,
17 validateReleaseResponse
18} = require("../../shared/updateContract");
20const MAX_MANIFEST_BYTES = 256 * 1024;
21const MAX_ARTIFACT_BYTES = 2 * 1024 * 1024 * 1024;
22const MAX_SIGNATURE_BYTES = 128 * 1024;
24function timeoutSignal(timeoutMs) {
25 const controller = new AbortController();
26 const timer = setTimeout(() => controller.abort(new Error("Request timeout.")), timeoutMs);
27 timer.unref?.();
28 return { signal: controller.signal, clear: () => clearTimeout(timer) };
31function safeFilename(value) {
32 const basename = path.basename(String(value || "update.bin"));
33 const safe = basename.replace(/[^A-Za-z0-9._-]+/g, "-").replace(/^[-.]+/, "");
34 return safe || "update.bin";
37async function sha512File(filePath) {
38 const hash = crypto.createHash("sha512");
39 const stream = fs.createReadStream(filePath);
40 for await (const chunk of stream) hash.update(chunk);
41 return hash.digest("hex");
44class HashUrlProvider {
45 constructor(options) {
46 this.baseUrls = (options.baseUrls || []).map((value) => normalizeBaseUrl(value).toString());
47 if (this.baseUrls.length === 0) throw new Error("At least one update base URL is required.");
48 this.fetch = options.fetchImpl || globalThis.fetch;
49 if (typeof this.fetch !== "function") throw new Error("Fetch API is unavailable.");
50 this.downloadRoot = options.downloadRoot;
51 this.logger = options.logger;
52 this.signatureVerifier = options.signatureVerifier;
53 this.requestTimeoutMs = Number(options.requestTimeoutMs || 5000);
54 this.downloadTimeoutMs = Number(options.downloadTimeoutMs || 15 * 60 * 1000);
55 }
57 async checkForUpdates(options) {
58 const currentVersion = String(options.currentVersion || "");
59 const platform = normalizePlatform(options.platform);
60 const arch = normalizeArchitecture(options.arch);
61 const channel = String(options.channel || "alpha");
62 const errors = [];
64 assertSupportedUpdateTarget(platform, arch);
66 for (const baseUrl of this.baseUrls) {
67 try {
68 const url = new URL("api/latest.php", baseUrl);
69 url.searchParams.set("platform", platform);
70 url.searchParams.set("arch", arch);
71 url.searchParams.set("channel", serverChannel(channel));
72 url.searchParams.set("current_version", currentVersion);
73 const response = await this._fetchJson(url);
74 if (response.status === 404) {
75 return { updateAvailable: false, currentVersion, release: null, sourceBaseUrl: baseUrl };
76 }
77 if (response.status !== 200) throw new Error(`Update server returned HTTP ${response.status}.`);
78 const result = validateReleaseResponse(response.payload, { currentVersion, platform, arch, channel });
79 if (result.release) {
80 result.release.downloadUrl = this._resolveArtifactUrl(result.release.downloadUrl, baseUrl).toString();
81 result.release.signatureUrl = this._resolveSignatureUrl(
82 result.release.signatureUrl,
83 result.release.downloadUrl,
84 baseUrl
85 ).toString();
86 }
87 return { ...result, sourceBaseUrl: baseUrl };
88 } catch (error) {
89 errors.push({ baseUrl, message: error?.message || String(error) });
90 this.logger?.warn("Update endpoint failed.", errors.at(-1));
91 }
92 }
93 const error = new Error("No configured update endpoint is reachable.");
94 error.code = "UPDATE_ENDPOINT_UNREACHABLE";
95 error.details = errors;
96 throw error;
97 }
99 async listReleases(options) {
100 const currentVersion = String(options.currentVersion || "");
101 const platform = normalizePlatform(options.platform);
102 const arch = normalizeArchitecture(options.arch);
103 const channel = String(options.channel || "alpha");
104 const errors = [];
105 const channels = channel === "all" ? ["alpha", "beta", "stable"] : [serverChannel(channel)];
107 assertSupportedUpdateTarget(platform, arch);
108 for (const baseUrl of this.baseUrls) {
109 try {
110 const releasesById = new Map();
111 for (const selectedChannel of channels) {
112 const url = new URL("api/releases.php", baseUrl);
113 url.searchParams.set("platform", platform);
114 url.searchParams.set("arch", arch);
115 url.searchParams.set("channel", selectedChannel);
116 const response = await this._fetchJson(url);
117 if (response.status !== 200) throw new Error(`Update server returned HTTP ${response.status}.`);
118 const result = validateReleaseListResponse(response.payload, { currentVersion, platform, arch, channel: selectedChannel });
119 for (const release of result.releases) {
120 release.downloadUrl = this._resolveArtifactUrl(release.downloadUrl, baseUrl).toString();
121 release.signatureUrl = this._resolveSignatureUrl(
122 release.signatureUrl,
123 release.downloadUrl,
124 baseUrl
125 ).toString();
126 releasesById.set(release.id, release);
127 }
128 }
129 const releases = [...releasesById.values()].sort((a, b) => compareVersions(b.version, a.version) || Date.parse(b.publishedAt) - Date.parse(a.publishedAt));
130 return { currentVersion, releases, sourceBaseUrl: baseUrl };
131 } catch (error) {
132 errors.push({ baseUrl, message: error?.message || String(error) });
133 this.logger?.warn("Update catalog endpoint failed.", errors.at(-1));
134 }
135 }
136 const error = new Error("No configured update catalog endpoint is reachable.");
137 error.code = "UPDATE_ENDPOINT_UNREACHABLE";
138 error.details = errors;
139 throw error;
140 }
142 async downloadAndVerify(release) {
143 if (!release || typeof release !== "object") throw new Error("No update release selected.");
144 if (!this.signatureVerifier?.verifyFile) {
145 throw new Error("OpenPGP update verification is unavailable.");
146 }
147 if (!Number.isSafeInteger(release.size) || release.size <= 0 || release.size > MAX_ARTIFACT_BYTES) {
148 throw new Error("Update artifact size is outside the allowed range.");
149 }
151 const url = this._resolveArtifactUrl(release.downloadUrl, this.baseUrls[0]);
152 const signatureUrl = this._resolveSignatureUrl(release.signatureUrl, url, this.baseUrls[0]);
153 const filename = safeFilename(release.filename);
154 const versionDir = path.join(this.downloadRoot, safeFilename(`${release.version}-${release.id || "release"}`));
155 const destination = path.join(versionDir, filename);
156 const signaturePath = `${destination}.asc`;
157 this._ensurePrivateDirectory(this.downloadRoot, true);
158 this._ensurePrivateDirectory(versionDir, false);
160 let reused = false;
161 if (fs.existsSync(destination)) {
162 const stat = fs.lstatSync(destination);
163 if (stat.isFile() && !stat.isSymbolicLink() && stat.nlink === 1
164 && stat.size === release.size && await sha512File(destination) === release.sha512) {
165 reused = true;
166 } else {
167 fs.unlinkSync(destination);
168 }
169 }
171 try {
172 if (!reused) await this._downloadArtifact(url, release, destination, versionDir, filename);
173 await this._downloadSignature(signatureUrl, signaturePath, versionDir, filename);
174 this._assertSafeDownloadedFile(destination, release.size, "Update artifact");
175 this._assertSafeDownloadedFile(signaturePath, null, "Detached OpenPGP signature");
176 const signature = await this.signatureVerifier.verifyFile(destination, signaturePath);
177 if (signature?.valid !== true) throw new Error("Mandatory OpenPGP verification did not return a valid result.");
178 try { fs.chmodSync(destination, 0o400); } catch {}
179 try { fs.chmodSync(signaturePath, 0o400); } catch {}
180 const stat = fs.lstatSync(destination);
181 return {
182 path: destination,
183 signaturePath,
184 filename,
185 size: stat.size,
186 sha512: release.sha512,
187 reused,
188 signatureVerified: signature.valid === true,
189 primaryFingerprint: signature.primaryFingerprint,
190 signingFingerprints: signature.signingFingerprints,
191 signatureCreatedAt: signature.signatureCreatedAt
192 };
193 } catch (error) {
194 try { fs.unlinkSync(signaturePath); } catch {}
195 try { fs.unlinkSync(destination); } catch {}
196 throw error;
197 }
198 }
200 async reverifyForInstall(download, release) {
201 if (!download?.signatureVerified || !this.signatureVerifier?.verifyFile) {
202 throw new Error("No OpenPGP-verified update is ready to install.");
203 }
204 const filename = safeFilename(release?.filename);
205 const versionDir = path.join(this.downloadRoot, safeFilename(`${release?.version}-${release?.id || "release"}`));
206 const expectedPath = path.resolve(versionDir, filename);
207 const expectedSignaturePath = path.resolve(`${expectedPath}.asc`);
208 if (path.resolve(download.path) !== expectedPath || path.resolve(download.signaturePath) !== expectedSignaturePath) {
209 throw new Error("Verified update paths do not match the selected release.");
210 }
212 this._ensurePrivateDirectory(this.downloadRoot, true);
213 this._ensurePrivateDirectory(versionDir, false);
214 const artifactStat = this._assertSafeDownloadedFile(expectedPath, release.size, "Update artifact");
215 this._assertSafeDownloadedFile(expectedSignaturePath, null, "Detached OpenPGP signature");
216 const actualHash = await sha512File(expectedPath);
217 if (actualHash !== release.sha512 || artifactStat.size !== release.size) {
218 throw new Error("The downloaded update changed after verification.");
219 }
220 const signature = await this.signatureVerifier.verifyFile(expectedPath, expectedSignaturePath);
221 if (signature.valid !== true) throw new Error("The downloaded update signature is no longer valid.");
222 return expectedPath;
223 }
225 cleanupDownloads() {
226 return cleanDownloadCache(this.downloadRoot);
227 }
229 _resolveArtifactUrl(value, sourceBaseUrl) {
230 const raw = String(value || "").trim();
231 const resolved = new URL(raw.replace(/^\/+/, ""), sourceBaseUrl).toString();
232 return assertAllowedUrl(resolved, this.baseUrls, { pathSegment: "files/" });
233 }
235 _resolveSignatureUrl(value, artifactUrl, sourceBaseUrl) {
236 const raw = String(value || "").trim();
237 const resolved = assertAllowedUrl(
238 new URL(raw.replace(/^\/+/, ""), sourceBaseUrl).toString(),
239 this.baseUrls,
240 { pathSegment: "files/" }
241 );
242 const artifact = new URL(artifactUrl);
243 if (resolved.origin !== artifact.origin || resolved.pathname !== `${artifact.pathname}.asc` || resolved.search || resolved.hash) {
244 throw new Error("Detached signature URL must be the artifact URL followed by .asc.");
245 }
246 return resolved;
247 }
249 _assertSafeDownloadedFile(filePath, expectedSize, label) {
250 const stat = fs.lstatSync(filePath);
251 if (!stat.isFile() || stat.isSymbolicLink() || stat.nlink !== 1) {
252 throw new Error(`${label} is not a safe regular file.`);
253 }
254 if (expectedSize != null && stat.size !== expectedSize) {
255 throw new Error(`${label} size changed after verification.`);
256 }
257 const realRoot = fs.realpathSync(this.downloadRoot);
258 const realFile = fs.realpathSync(filePath);
259 const relative = path.relative(realRoot, realFile);
260 if (!relative || relative.startsWith("..") || path.isAbsolute(relative)) {
261 throw new Error(`${label} resolves outside the private update cache.`);
262 }
263 return stat;
264 }
266 _ensurePrivateDirectory(directoryPath, recursive) {
267 if (!fs.existsSync(directoryPath)) fs.mkdirSync(directoryPath, { recursive, mode: 0o700 });
268 const stat = fs.lstatSync(directoryPath);
269 if (!stat.isDirectory() || stat.isSymbolicLink()) {
270 throw new Error("Update download directory is unsafe.");
271 }
272 try { fs.chmodSync(directoryPath, 0o700); } catch {}
273 }
275 async _downloadArtifact(url, release, destination, versionDir, filename) {
276 const tempPath = path.join(versionDir, `.${filename}.${crypto.randomBytes(6).toString("hex")}.part`);
277 const timeout = timeoutSignal(this.downloadTimeoutMs);
278 try {
279 const response = await this.fetch(url, {
280 method: "GET",
281 redirect: "manual",
282 signal: timeout.signal,
283 headers: { Accept: "application/octet-stream" }
284 });
285 if (response.status !== 200) throw new Error(`Download server returned HTTP ${response.status}.`);
286 if (!response.body) throw new Error("Download response has no body.");
287 const declaredLength = Number(response.headers.get("content-length") || 0);
288 if (declaredLength > 0 && declaredLength !== release.size) throw new Error("Download Content-Length does not match the catalog.");
290 const hash = crypto.createHash("sha512");
291 let received = 0;
292 const verifier = new Transform({
293 transform(chunk, _encoding, callback) {
294 received += chunk.length;
295 if (received > release.size || received > MAX_ARTIFACT_BYTES) {
296 callback(new Error("Downloaded artifact exceeds the expected size."));
297 return;
298 }
299 hash.update(chunk);
300 callback(null, chunk);
301 }
302 });
304 await pipeline(Readable.fromWeb(response.body), verifier, fs.createWriteStream(tempPath, { flags: "wx", mode: 0o600 }));
305 const actualHash = hash.digest("hex");
306 if (received !== release.size) throw new Error(`Downloaded size mismatch: expected ${release.size}, received ${received}.`);
307 if (actualHash !== release.sha512) throw new Error("Downloaded SHA-512 does not match the catalog.");
308 fs.renameSync(tempPath, destination);
309 } catch (error) {
310 try { fs.unlinkSync(tempPath); } catch {}
311 throw error;
312 } finally {
313 timeout.clear();
314 }
315 }
317 async _downloadSignature(url, destination, versionDir, filename) {
318 try { fs.unlinkSync(destination); } catch {}
319 const tempPath = path.join(versionDir, `.${filename}.${crypto.randomBytes(6).toString("hex")}.asc.part`);
320 const timeout = timeoutSignal(this.requestTimeoutMs);
321 try {
322 const response = await this.fetch(url, {
323 method: "GET",
324 redirect: "manual",
325 signal: timeout.signal,
326 headers: { Accept: "application/pgp-signature, application/octet-stream, text/plain" }
327 });
328 if (response.status !== 200) throw new Error(`Signature server returned HTTP ${response.status}.`);
329 if (!response.body) throw new Error("Signature response has no body.");
330 const declaredLength = Number(response.headers.get("content-length") || 0);
331 if (declaredLength > MAX_SIGNATURE_BYTES) throw new Error("Detached signature is too large.");
332 let received = 0;
333 const limiter = new Transform({
334 transform(chunk, _encoding, callback) {
335 received += chunk.length;
336 if (received > MAX_SIGNATURE_BYTES) return callback(new Error("Detached signature is too large."));
337 callback(null, chunk);
338 }
339 });
340 await pipeline(Readable.fromWeb(response.body), limiter, fs.createWriteStream(tempPath, { flags: "wx", mode: 0o600 }));
341 if (received <= 0) throw new Error("Detached signature is empty.");
342 fs.renameSync(tempPath, destination);
343 } catch (error) {
344 try { fs.unlinkSync(tempPath); } catch {}
345 throw error;
346 } finally {
347 timeout.clear();
348 }
349 }
351 async _fetchJson(url) {
352 assertAllowedUrl(url.toString(), this.baseUrls, { pathSegment: "api/" });
353 const timeout = timeoutSignal(this.requestTimeoutMs);
354 try {
355 const response = await this.fetch(url, {
356 method: "GET",
357 redirect: "manual",
358 signal: timeout.signal,
359 headers: { Accept: "application/json" }
360 });
361 if (response.status === 404) return { status: 404, payload: null };
362 const declaredLength = Number(response.headers.get("content-length") || 0);
363 if (declaredLength > MAX_MANIFEST_BYTES) throw new Error("Update response is too large.");
364 const text = await response.text();
365 if (Buffer.byteLength(text, "utf8") > MAX_MANIFEST_BYTES) throw new Error("Update response is too large.");
366 let payload;
367 try { payload = JSON.parse(text); } catch { throw new Error("Update server returned invalid JSON."); }
368 return { status: response.status, payload };
369 } finally {
370 timeout.clear();
371 }
372 }
375module.exports = {
376 HashUrlProvider,
377 MAX_ARTIFACT_BYTES,
378 MAX_MANIFEST_BYTES,
379 MAX_SIGNATURE_BYTES,
380 safeFilename,
381 sha512File
382};

SHA-256: b92e768194ed6a2709f5bc4ee9b707c14aafca0afdce4abb445a04374c7bb13b

SHA-256 archivu: 5ac91caf4fa32a6fdb114f2430deed486fbe7489d5eea343d1f034169fafb5e0