CH-J Server Managerserver management over SSH
Menu
Published source

CH-J Server Manager

Browse directories and files for a specific application release.

Download source ZIP
CH-J Proprietary Software License 1.14

Source is provided under the CH-J Proprietary Software License 1.14. Its availability does not change the license terms or grant additional rights.

6,3 KB · 166 linesDownload file
1"use strict";
3const fs = require("node:fs");
4const path = require("node:path");
5const { spawn } = require("node:child_process");
7const PKEXEC_PATH = "/usr/bin/pkexec";
8const APT_GET_PATH = "/usr/bin/apt-get";
9const DEFAULT_INSTALL_TIMEOUT_MS = 30 * 60 * 1000;
10const MAX_OUTPUT_CHARS = 32 * 1024;
12class UpdateInstallError extends Error {
13 constructor(message, code = "UPDATE_INSTALL_FAILED", options = {}) {
14 super(message, options);
15 this.name = "UpdateInstallError";
16 this.code = code;
17 }
20function assertSafeRegularFile(filePath, label) {
21 if (!path.isAbsolute(filePath)) {
22 throw new UpdateInstallError(`${label} path must be absolute.`, "UPDATE_INSTALL_UNSAFE_PATH");
23 }
24 let stat;
25 try {
26 stat = fs.lstatSync(filePath);
27 } catch (error) {
28 throw new UpdateInstallError(`${label} is missing.`, "UPDATE_INSTALL_FILE_MISSING", { cause: error });
29 }
30 if (!stat.isFile() || stat.isSymbolicLink() || stat.nlink !== 1 || stat.size <= 0) {
31 throw new UpdateInstallError(`${label} must be a non-empty regular file.`, "UPDATE_INSTALL_UNSAFE_PATH");
32 }
33 return stat;
36function assertTrustedSystemExecutable(filePath) {
37 const stat = assertSafeRegularFile(filePath, "System installer executable");
38 if (typeof stat.uid === "number" && stat.uid !== 0) {
39 throw new UpdateInstallError("System installer executable is not owned by root.", "UPDATE_INSTALL_UNSAFE_EXECUTABLE");
40 }
41 if ((stat.mode & 0o111) === 0) {
42 throw new UpdateInstallError("System installer executable is not executable.", "UPDATE_INSTALL_UNSAFE_EXECUTABLE");
43 }
44 if ((stat.mode & 0o022) !== 0) {
45 throw new UpdateInstallError("System installer executable is writable by an untrusted account.", "UPDATE_INSTALL_UNSAFE_EXECUTABLE");
46 }
49function appendBounded(current, chunk) {
50 const next = `${current}${String(chunk || "")}`;
51 return next.length > MAX_OUTPUT_CHARS ? next.slice(-MAX_OUTPUT_CHARS) : next;
54class UpdateInstallerLauncher {
55 constructor(options = {}) {
56 this.platform = options.platform || process.platform;
57 this.shell = options.shell;
58 this.spawnImpl = options.spawnImpl || spawn;
59 this.logger = options.logger;
60 this.installTimeoutMs = Number(options.installTimeoutMs || DEFAULT_INSTALL_TIMEOUT_MS);
61 this.pkexecPath = options.pkexecPath || PKEXEC_PATH;
62 this.aptGetPath = options.aptGetPath || APT_GET_PATH;
63 if (!Number.isFinite(this.installTimeoutMs) || this.installTimeoutMs <= 0) {
64 throw new Error("The update installation timeout is invalid.");
65 }
66 }
68 async install(filePath) {
69 if (this.platform === "linux") return this._installDeb(filePath);
70 if (!this.shell?.openPath) throw new UpdateInstallError("System installer integration is unavailable.");
71 const error = await this.shell.openPath(filePath);
72 if (error) throw new UpdateInstallError(`The installer could not be opened: ${error}`);
73 return { launched: true, installed: false, restartApplication: false };
74 }
76 async _installDeb(filePath) {
77 if (!path.isAbsolute(filePath)) {
78 throw new UpdateInstallError("Verified Debian update package path must be absolute.", "UPDATE_INSTALL_UNSAFE_PATH");
79 }
80 const artifactPath = path.resolve(filePath);
81 if (path.extname(artifactPath).toLowerCase() !== ".deb") {
82 throw new UpdateInstallError("Linux updates must use a Debian package.", "UPDATE_INSTALL_UNSUPPORTED_PACKAGE");
83 }
84 assertSafeRegularFile(artifactPath, "Verified Debian update package");
85 assertTrustedSystemExecutable(this.pkexecPath);
86 assertTrustedSystemExecutable(this.aptGetPath);
88 // Both executables and all options are fixed by the main process. The only
89 // variable argument is the private-cache artifact that was re-hashed and
90 // OpenPGP-verified immediately before this method is called.
91 const args = [this.aptGetPath, "install", "--reinstall", "--yes", artifactPath];
92 this.logger?.info("Starting privileged Debian update installation.", {
93 filename: path.basename(artifactPath)
94 });
96 return new Promise((resolve, reject) => {
97 let stdout = "";
98 let stderr = "";
99 let settled = false;
100 let child;
101 let timer;
102 const finish = (callback) => {
103 if (settled) return;
104 settled = true;
105 clearTimeout(timer);
106 callback();
107 };
109 try {
110 child = this.spawnImpl(this.pkexecPath, args, {
111 shell: false,
112 windowsHide: true,
113 stdio: ["ignore", "pipe", "pipe"]
114 });
115 } catch (error) {
116 reject(new UpdateInstallError("The privileged installer could not be started.", "UPDATE_INSTALL_LAUNCH_FAILED", { cause: error }));
117 return;
118 }
120 child.stdout?.on("data", (chunk) => { stdout = appendBounded(stdout, chunk); });
121 child.stderr?.on("data", (chunk) => { stderr = appendBounded(stderr, chunk); });
122 child.once("error", (error) => finish(() => reject(new UpdateInstallError(
123 "The privileged installer could not be started.",
124 "UPDATE_INSTALL_LAUNCH_FAILED",
125 { cause: error }
126 ))));
127 child.once("close", (code, signal) => finish(() => {
128 if (code === 0) {
129 this.logger?.info("Debian update installation completed.", { filename: path.basename(artifactPath) });
130 resolve({ launched: true, installed: true, restartApplication: true });
131 return;
132 }
133 const authorizationDenied = code === 126 || code === 127;
134 const failure = new UpdateInstallError(
135 authorizationDenied
136 ? "Administrator authorization for the update was canceled or denied."
137 : "The Debian update installation failed.",
138 authorizationDenied ? "UPDATE_INSTALL_AUTHORIZATION_DENIED" : "UPDATE_INSTALL_FAILED"
139 );
140 this.logger?.warn("Debian update installation failed.", {
141 code,
142 signal: signal || null,
143 output: `${stdout}\n${stderr}`.trim().slice(-4096)
144 });
145 reject(failure);
146 }));
148 timer = setTimeout(() => {
149 try { child.kill("SIGTERM"); } catch {}
150 finish(() => reject(new UpdateInstallError(
151 "The Debian update installation timed out.",
152 "UPDATE_INSTALL_TIMEOUT"
153 )));
154 }, this.installTimeoutMs);
155 timer.unref?.();
156 });
157 }
160module.exports = {
161 APT_GET_PATH,
162 PKEXEC_PATH,
163 UpdateInstallError,
164 UpdateInstallerLauncher,
165 assertSafeRegularFile
166};

SHA-256: d04c292544819be1adefdcafd926bd62dd0c09b3df8a3d884e43a313c5be68b6

Archive SHA-256: 5ac91caf4fa32a6fdb114f2430deed486fbe7489d5eea343d1f034169fafb5e0