Files
NetMesh/electron/bridges/systemManager/serviceOps.cjs
zhaolei 3c72efcb7f
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
[Init] Initial commit - NetMesh terminal manager
2026-09-13 18:24:01 +08:00

335 lines
12 KiB
JavaScript

/* eslint-disable no-undef */
"use strict";
const {
SERVICE_LIST_PS_COMMAND,
buildServiceActionPsCommand,
} = require("./windowsPowerShell.cjs");
/**
* systemd service management over SSH exec.
* List/status via systemctl; mutate with optional sudo (same session password
* pattern as dockerOps).
*/
function shQuote(str) {
return `'${String(str).replace(/'/g, `'\"'\"'`)}'`;
}
function sanitizeUnitName(name) {
const trimmed = String(name || "").trim().slice(0, 256);
// systemd unit names: letters, digits, : . _ @ - and must end with a type suffix ideally
if (!trimmed || !/^[A-Za-z0-9:._@\\-]+$/.test(trimmed)) return null;
return trimmed;
}
const ALLOWED_ACTIONS = new Set(["start", "stop", "restart", "enable", "disable", "reload"]);
function normalizeActiveState(raw) {
const text = String(raw || "").trim().toLowerCase();
if (
text === "active"
|| text === "inactive"
|| text === "failed"
|| text === "activating"
|| text === "deactivating"
|| text === "reloading"
) {
return text;
}
return "unknown";
}
function normalizeLoadState(raw) {
const text = String(raw || "").trim().toLowerCase();
if (
text === "loaded"
|| text === "not-found"
|| text === "bad-setting"
|| text === "error"
|| text === "masked"
) {
return text;
}
return "unknown";
}
function parseSystemctlListUnits(stdout, scope) {
const units = [];
for (const line of String(stdout || "").split("\n")) {
const trimmed = line.trim();
if (!trimmed) continue;
const cleaned = trimmed.replace(/^●\s*/, "");
if (!cleaned || /^UNIT\b/i.test(cleaned) || /^Legend:/i.test(cleaned)) continue;
if (/^\d+ loaded units listed/i.test(cleaned)) continue;
if (/^To show all/i.test(cleaned)) continue;
// UNIT LOAD ACTIVE SUB [DESCRIPTION] — description may be empty
const m = cleaned.match(/^(\S+)\s+(\S+)\s+(\S+)\s+(\S+)(?:\s+(.*))?$/);
if (!m) continue;
const name = m[1];
if (!name.includes(".")) continue;
units.push({
name,
loadState: normalizeLoadState(m[2]),
activeState: normalizeActiveState(m[3]),
subState: m[4],
description: (m[5] || "").trim(),
scope,
});
}
return units;
}
function getSessionSudoPassword(session) {
return typeof session?.systemManagerSudoPassword === "string" && session.systemManagerSudoPassword.length > 0
? session.systemManagerSudoPassword
: null;
}
function isSuccessfulCommandResult(result) {
return result?.success && (result.code === 0 || result.code === null || result.code === undefined);
}
function commandError(result, fallback) {
return (result?.stderr || result?.error || "").trim() || fallback;
}
function isPermissionDenied(result) {
const text = `${result?.stderr || ""}\n${result?.stdout || ""}\n${result?.error || ""}`.toLowerCase();
return text.includes("permission denied")
|| text.includes("access denied")
|| text.includes("authentication is required")
|| text.includes("interactive authentication required")
|| text.includes("not authorized");
}
const LIST_UNITS_INNER = [
'printf "%s\\n" "__NC_SERVICES_BEGIN__"; ',
'if command -v systemctl >/dev/null 2>&1; then ',
'printf "%s\\n" "__NC_SYSTEM__"; ',
// --plain needs systemd >= ~230; fall back for RHEL/CentOS 7-era hosts.
"systemctl list-units --type=service --all --no-pager --no-legend --plain 2>/dev/null ",
"|| systemctl list-units --type=service --all --no-pager --no-legend 2>/dev/null ",
"|| true; ",
'printf "%s\\n" "__NC_USER__"; ',
"systemctl --user list-units --type=service --all --no-pager --no-legend --plain 2>/dev/null ",
"|| systemctl --user list-units --type=service --all --no-pager --no-legend 2>/dev/null ",
"|| true; ",
"fi; ",
'printf "%s\\n" "__NC_SERVICES_END__"',
].join("");
const LIST_UNITS_SCRIPT = `exec sh -c ${JSON.stringify(LIST_UNITS_INNER)}`;
function extractBetween(stdout, startMarker, endMarkers) {
const text = String(stdout || "");
const begin = text.indexOf(startMarker);
if (begin < 0) return "";
const after = text.slice(begin + startMarker.length);
let end = -1;
for (const marker of endMarkers) {
const idx = after.indexOf(marker);
if (idx >= 0 && (end < 0 || idx < end)) end = idx;
}
return end >= 0 ? after.slice(0, end) : after;
}
function parseServiceList(stdout) {
const text = String(stdout || "");
const systemPart = extractBetween(text, "__NC_SYSTEM__", ["__NC_USER__", "__NC_SERVICES_END__"]);
const userPart = extractBetween(text, "__NC_USER__", ["__NC_SERVICES_END__"]);
const systemUnits = parseSystemctlListUnits(systemPart, "system");
const userUnits = parseSystemctlListUnits(userPart, "user");
// Prefer system unit when names collide
const seen = new Set(systemUnits.map((u) => u.name));
const merged = systemUnits.slice();
for (const unit of userUnits) {
if (seen.has(unit.name)) continue;
seen.add(unit.name);
merged.push(unit);
}
merged.sort((a, b) => {
if (a.activeState === "failed" && b.activeState !== "failed") return -1;
if (b.activeState === "failed" && a.activeState !== "failed") return 1;
return a.name.localeCompare(b.name);
});
return merged;
}
/** Parse Windows Get-Service JSON output (from SERVICE_LIST_PS_COMMAND). */
function parseWindowsServices(stdout) {
const text = String(stdout || "").trim();
if (!text) return [];
let raw;
try {
raw = JSON.parse(text);
} catch {
return [];
}
const list = Array.isArray(raw) ? raw : raw ? [raw] : [];
// Windows ServiceControllerStatus enum:
// 0=Stopped, 1=StartPending, 2=StopPending, 3=Running,
// 4=ContinuePending, 5=PausePending, 6=Paused
const STATUS_NUMBER_TO_NAME = {
0: "stopped",
1: "startpending",
2: "stoppending",
3: "running",
4: "continuepending",
5: "pausepending",
6: "paused",
};
const units = list.map((s) => {
const name = String(s.Name || "");
if (!name) return null;
let status = String(s.Status || "").toLowerCase();
// Handle numeric enum values from ConvertTo-Json (older PS / direct enum cast)
if (!status || /^\d+$/.test(status)) {
const num = Number(status);
status = STATUS_NUMBER_TO_NAME[num] || "unknown";
}
const startType = String(s.StartType || "").toLowerCase();
let activeState = "unknown";
if (status === "running" || status === "continuepending") activeState = "active";
else if (status === "stopped" || status === "stoppending" || status === "paused" || status === "pausepending" || status === "startpending") activeState = "inactive";
let loadState = "loaded";
if (startType === "disabled") loadState = "loaded";
const subState = status || "unknown";
return {
name,
loadState,
activeState,
subState,
description: String(s.DisplayName || ""),
scope: "system",
};
}).filter(Boolean);
units.sort((a, b) => {
if (a.activeState === "failed" && b.activeState !== "failed") return -1;
if (b.activeState === "failed" && a.activeState !== "failed") return 1;
return a.name.localeCompare(b.name);
});
return units;
}
function createServiceOpsApi({
execOnSession,
getSession,
}) {
async function listServices(event, sessionId) {
if (!sessionId) return { success: false, error: "Missing sessionId" };
// POSIX first (systemd).
const posixResult = await execOnSession(event, sessionId, LIST_UNITS_SCRIPT, 20000, {
maxBuffer: 16 * 1024 * 1024,
});
if (posixResult.pending) return { success: false, pending: true };
const posixOk = posixResult.success;
const posixUnits = posixOk ? parseServiceList(posixResult.stdout) : [];
// POSIX gave zero services — try Windows PowerShell Get-Service.
if (!posixOk || posixUnits.length === 0) {
console.log(`[Services] POSIX failed=${!posixOk}, got ${posixUnits.length} units → trying PowerShell fallback.`);
const psResult = await execOnSession(event, sessionId, SERVICE_LIST_PS_COMMAND, 15000, {
maxBuffer: 16 * 1024 * 1024,
});
if (psResult.pending) return { success: false, pending: true };
console.log(`[Services] PowerShell result: success=${psResult.success}, code=${psResult.code}, error=${JSON.stringify(psResult.error||'').slice(0,120)}, stdoutSnippet=${JSON.stringify((psResult.stdout||'').slice(0,300))}`);
if (psResult.success) {
const winUnits = parseWindowsServices(psResult.stdout);
console.log(`[Services] PowerShell parsed ${winUnits.length} services.`);
if (winUnits.length > 0) {
return { success: true, units: winUnits };
}
}
}
if (!posixOk) return { success: false, error: posixResult.error || "Failed to list services" };
return { success: true, units: posixUnits };
}
async function serviceAction(event, payload) {
const sessionId = payload?.sessionId;
const unitName = sanitizeUnitName(payload?.unitName);
const action = String(payload?.action || "").toLowerCase();
const scope = payload?.scope === "user" ? "user" : "system";
if (!sessionId || !unitName) return { success: false, error: "Missing sessionId or unitName" };
if (!ALLOWED_ACTIONS.has(action)) return { success: false, error: "Invalid action" };
const userFlag = scope === "user" ? "--user " : "";
const baseCmd = `systemctl ${userFlag}${action} ${shQuote(unitName)}`;
const wrapped = `exec sh -c ${JSON.stringify(baseCmd)}`;
let result = await execOnSession(event, sessionId, wrapped, 30000);
if (result.pending) return { success: false, pending: true };
if (isSuccessfulCommandResult(result)) return { success: true };
// User-scope units should not escalate via sudo.
if (scope !== "user" && isPermissionDenied(result)) {
const sudoPassword = getSessionSudoPassword(getSession?.(sessionId));
const passwordless = `exec sh -c ${JSON.stringify(`sudo systemctl ${action} ${shQuote(unitName)}`)}`;
const passwordlessResult = await execOnSession(event, sessionId, passwordless, 30000);
if (passwordlessResult.pending) return { success: false, pending: true };
if (isSuccessfulCommandResult(passwordlessResult)) return { success: true };
if (sudoPassword) {
const withPassword = `exec sh -c ${JSON.stringify(`sudo -S -p '' systemctl ${action} ${shQuote(unitName)}`)}`;
const sudoResult = await execOnSession(event, sessionId, withPassword, 30000, {
stdin: `${sudoPassword}\n`,
});
if (sudoResult.pending) return { success: false, pending: true };
if (isSuccessfulCommandResult(sudoResult)) return { success: true };
return { success: false, error: commandError(sudoResult, `sudo systemctl ${action} failed`) };
}
return {
success: false,
error: commandError(
passwordlessResult.success === false ? passwordlessResult : result,
`systemctl ${action} failed (sudo required)`,
),
};
}
// systemctl failed for a non-POSIX reason (Windows host, systemctl not found).
// Try Windows PowerShell service action.
if (!isPermissionDenied(result)) {
const psCmd = buildServiceActionPsCommand(action, unitName);
if (psCmd) {
const psResult = await execOnSession(event, sessionId, psCmd, 30000);
if (psResult.pending) return { success: false, pending: true };
if (isSuccessfulCommandResult(psResult)) return { success: true };
if (!psResult.success) {
return {
success: false,
error: commandError(psResult, `PowerShell ${action} service failed`),
};
}
}
}
return { success: false, error: commandError(result, `systemctl ${action} failed`) };
}
return {
listServices,
serviceAction,
parseServiceList,
parseSystemctlListUnits,
parseWindowsServices,
sanitizeUnitName,
};
}
module.exports = {
createServiceOpsApi,
parseServiceList,
parseSystemctlListUnits,
parseWindowsServices,
sanitizeUnitName,
LIST_UNITS_SCRIPT,
};