feat(doctor): audit supervisor config + docs
This commit is contained in:
165
src/daemon/service-audit.ts
Normal file
165
src/daemon/service-audit.ts
Normal file
@@ -0,0 +1,165 @@
|
||||
import fs from "node:fs/promises";
|
||||
import { resolveLaunchAgentPlistPath } from "./launchd.js";
|
||||
import { resolveSystemdUserUnitPath } from "./systemd.js";
|
||||
|
||||
export type GatewayServiceCommand = {
|
||||
programArguments: string[];
|
||||
workingDirectory?: string;
|
||||
environment?: Record<string, string>;
|
||||
sourcePath?: string;
|
||||
} | null;
|
||||
|
||||
export type ServiceConfigIssue = {
|
||||
code: string;
|
||||
message: string;
|
||||
detail?: string;
|
||||
};
|
||||
|
||||
export type ServiceConfigAudit = {
|
||||
ok: boolean;
|
||||
issues: ServiceConfigIssue[];
|
||||
};
|
||||
|
||||
function hasGatewaySubcommand(programArguments?: string[]): boolean {
|
||||
return Boolean(programArguments?.some((arg) => arg === "gateway"));
|
||||
}
|
||||
|
||||
function parseSystemdUnit(content: string): {
|
||||
after: Set<string>;
|
||||
wants: Set<string>;
|
||||
restartSec?: string;
|
||||
} {
|
||||
const after = new Set<string>();
|
||||
const wants = new Set<string>();
|
||||
let restartSec: string | undefined;
|
||||
|
||||
for (const rawLine of content.split(/\r?\n/)) {
|
||||
const line = rawLine.trim();
|
||||
if (!line) continue;
|
||||
if (line.startsWith("#") || line.startsWith(";")) continue;
|
||||
if (line.startsWith("[")) continue;
|
||||
const idx = line.indexOf("=");
|
||||
if (idx <= 0) continue;
|
||||
const key = line.slice(0, idx).trim();
|
||||
const value = line.slice(idx + 1).trim();
|
||||
if (!value) continue;
|
||||
if (key === "After") {
|
||||
for (const entry of value.split(/\s+/)) {
|
||||
if (entry) after.add(entry);
|
||||
}
|
||||
} else if (key === "Wants") {
|
||||
for (const entry of value.split(/\s+/)) {
|
||||
if (entry) wants.add(entry);
|
||||
}
|
||||
} else if (key === "RestartSec") {
|
||||
restartSec = value;
|
||||
}
|
||||
}
|
||||
|
||||
return { after, wants, restartSec };
|
||||
}
|
||||
|
||||
function isRestartSecPreferred(value: string | undefined): boolean {
|
||||
if (!value) return false;
|
||||
const parsed = Number.parseFloat(value);
|
||||
if (!Number.isFinite(parsed)) return false;
|
||||
return Math.abs(parsed - 5) < 0.01;
|
||||
}
|
||||
|
||||
async function auditSystemdUnit(
|
||||
env: Record<string, string | undefined>,
|
||||
issues: ServiceConfigIssue[],
|
||||
) {
|
||||
const unitPath = resolveSystemdUserUnitPath(env);
|
||||
let content = "";
|
||||
try {
|
||||
content = await fs.readFile(unitPath, "utf8");
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
const parsed = parseSystemdUnit(content);
|
||||
if (!parsed.after.has("network-online.target")) {
|
||||
issues.push({
|
||||
code: "systemd-after-network-online",
|
||||
message: "Missing systemd After=network-online.target",
|
||||
detail: unitPath,
|
||||
});
|
||||
}
|
||||
if (!parsed.wants.has("network-online.target")) {
|
||||
issues.push({
|
||||
code: "systemd-wants-network-online",
|
||||
message: "Missing systemd Wants=network-online.target",
|
||||
detail: unitPath,
|
||||
});
|
||||
}
|
||||
if (!isRestartSecPreferred(parsed.restartSec)) {
|
||||
issues.push({
|
||||
code: "systemd-restart-sec",
|
||||
message: "RestartSec does not match the recommended 5s",
|
||||
detail: unitPath,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function auditLaunchdPlist(
|
||||
env: Record<string, string | undefined>,
|
||||
issues: ServiceConfigIssue[],
|
||||
) {
|
||||
const plistPath = resolveLaunchAgentPlistPath(env);
|
||||
let content = "";
|
||||
try {
|
||||
content = await fs.readFile(plistPath, "utf8");
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
const hasRunAtLoad = /<key>RunAtLoad<\/key>\s*<true\s*\/>/i.test(content);
|
||||
const hasKeepAlive = /<key>KeepAlive<\/key>\s*<true\s*\/>/i.test(content);
|
||||
if (!hasRunAtLoad) {
|
||||
issues.push({
|
||||
code: "launchd-run-at-load",
|
||||
message: "LaunchAgent is missing RunAtLoad=true",
|
||||
detail: plistPath,
|
||||
});
|
||||
}
|
||||
if (!hasKeepAlive) {
|
||||
issues.push({
|
||||
code: "launchd-keep-alive",
|
||||
message: "LaunchAgent is missing KeepAlive=true",
|
||||
detail: plistPath,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function auditGatewayCommand(
|
||||
programArguments: string[] | undefined,
|
||||
issues: ServiceConfigIssue[],
|
||||
) {
|
||||
if (!programArguments || programArguments.length === 0) return;
|
||||
if (!hasGatewaySubcommand(programArguments)) {
|
||||
issues.push({
|
||||
code: "gateway-command-missing",
|
||||
message: "Service command does not include the gateway subcommand",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function auditGatewayServiceConfig(params: {
|
||||
env: Record<string, string | undefined>;
|
||||
command: GatewayServiceCommand;
|
||||
platform?: NodeJS.Platform;
|
||||
}): Promise<ServiceConfigAudit> {
|
||||
const issues: ServiceConfigIssue[] = [];
|
||||
const platform = params.platform ?? process.platform;
|
||||
|
||||
auditGatewayCommand(params.command?.programArguments, issues);
|
||||
|
||||
if (platform === "linux") {
|
||||
await auditSystemdUnit(params.env, issues);
|
||||
} else if (platform === "darwin") {
|
||||
await auditLaunchdPlist(params.env, issues);
|
||||
}
|
||||
|
||||
return { ok: issues.length === 0, issues };
|
||||
}
|
||||
@@ -33,6 +33,12 @@ function resolveSystemdUnitPath(
|
||||
return resolveSystemdUnitPathForName(env, GATEWAY_SYSTEMD_SERVICE_NAME);
|
||||
}
|
||||
|
||||
export function resolveSystemdUserUnitPath(
|
||||
env: Record<string, string | undefined>,
|
||||
): string {
|
||||
return resolveSystemdUnitPath(env);
|
||||
}
|
||||
|
||||
function resolveLoginctlUser(
|
||||
env: Record<string, string | undefined>,
|
||||
): string | null {
|
||||
|
||||
Reference in New Issue
Block a user