[Init] Initial commit - NetMesh terminal manager
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
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
This commit is contained in:
334
electron/bridges/systemManager/serviceOps.cjs
Normal file
334
electron/bridges/systemManager/serviceOps.cjs
Normal file
@@ -0,0 +1,334 @@
|
||||
/* 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,
|
||||
};
|
||||
Reference in New Issue
Block a user