mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-09 13:07:39 +00:00
gateway: harden shared auth resolution across systemd, discord, and node host
This commit is contained in:
@@ -12,6 +12,7 @@ import { parseSystemdExecStart } from "./systemd-unit.js";
|
||||
import {
|
||||
isSystemdUserServiceAvailable,
|
||||
parseSystemdShow,
|
||||
readSystemdServiceExecStart,
|
||||
restartSystemdService,
|
||||
resolveSystemdUserUnitPath,
|
||||
stopSystemdService,
|
||||
@@ -42,6 +43,19 @@ const createWritableStreamMock = () => {
|
||||
};
|
||||
};
|
||||
|
||||
function pathLikeToString(pathname: unknown): string {
|
||||
if (typeof pathname === "string") {
|
||||
return pathname;
|
||||
}
|
||||
if (pathname instanceof URL) {
|
||||
return pathname.pathname;
|
||||
}
|
||||
if (pathname instanceof Uint8Array) {
|
||||
return Buffer.from(pathname).toString("utf8");
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
const assertRestartSuccess = async (env: NodeJS.ProcessEnv) => {
|
||||
const { write, stdout } = createWritableStreamMock();
|
||||
await restartSystemdService({ stdout, env });
|
||||
@@ -297,6 +311,173 @@ describe("parseSystemdExecStart", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("readSystemdServiceExecStart", () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("loads OPENCLAW_GATEWAY_TOKEN from EnvironmentFile", async () => {
|
||||
const readFileSpy = vi.spyOn(fs, "readFile").mockImplementation(async (pathname) => {
|
||||
const pathValue = pathLikeToString(pathname);
|
||||
if (pathValue.endsWith("/openclaw-gateway.service")) {
|
||||
return [
|
||||
"[Service]",
|
||||
"ExecStart=/usr/bin/openclaw gateway run",
|
||||
"EnvironmentFile=%h/.openclaw/.env",
|
||||
].join("\n");
|
||||
}
|
||||
if (pathValue === "/home/test/.openclaw/.env") {
|
||||
return "OPENCLAW_GATEWAY_TOKEN=env-file-token\n";
|
||||
}
|
||||
throw new Error(`unexpected readFile path: ${pathValue}`);
|
||||
});
|
||||
|
||||
const command = await readSystemdServiceExecStart({ HOME: "/home/test" });
|
||||
expect(command?.environment?.OPENCLAW_GATEWAY_TOKEN).toBe("env-file-token");
|
||||
expect(readFileSpy).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("lets inline Environment override EnvironmentFile values", async () => {
|
||||
vi.spyOn(fs, "readFile").mockImplementation(async (pathname) => {
|
||||
const pathValue = pathLikeToString(pathname);
|
||||
if (pathValue.endsWith("/openclaw-gateway.service")) {
|
||||
return [
|
||||
"[Service]",
|
||||
"ExecStart=/usr/bin/openclaw gateway run",
|
||||
"EnvironmentFile=%h/.openclaw/.env",
|
||||
'Environment="OPENCLAW_GATEWAY_TOKEN=inline-token"',
|
||||
].join("\n");
|
||||
}
|
||||
if (pathValue === "/home/test/.openclaw/.env") {
|
||||
return "OPENCLAW_GATEWAY_TOKEN=env-file-token\n";
|
||||
}
|
||||
throw new Error(`unexpected readFile path: ${pathValue}`);
|
||||
});
|
||||
|
||||
const command = await readSystemdServiceExecStart({ HOME: "/home/test" });
|
||||
expect(command?.environment?.OPENCLAW_GATEWAY_TOKEN).toBe("inline-token");
|
||||
});
|
||||
|
||||
it("ignores missing optional EnvironmentFile entries", async () => {
|
||||
vi.spyOn(fs, "readFile").mockImplementation(async (pathname) => {
|
||||
const pathValue = pathLikeToString(pathname);
|
||||
if (pathValue.endsWith("/openclaw-gateway.service")) {
|
||||
return [
|
||||
"[Service]",
|
||||
"ExecStart=/usr/bin/openclaw gateway run",
|
||||
"EnvironmentFile=-%h/.openclaw/missing.env",
|
||||
].join("\n");
|
||||
}
|
||||
throw new Error(`missing: ${pathValue}`);
|
||||
});
|
||||
|
||||
const command = await readSystemdServiceExecStart({ HOME: "/home/test" });
|
||||
expect(command?.programArguments).toEqual(["/usr/bin/openclaw", "gateway", "run"]);
|
||||
expect(command?.environment).toBeUndefined();
|
||||
});
|
||||
|
||||
it("keeps parsing when non-optional EnvironmentFile entries are missing", async () => {
|
||||
vi.spyOn(fs, "readFile").mockImplementation(async (pathname) => {
|
||||
const pathValue = pathLikeToString(pathname);
|
||||
if (pathValue.endsWith("/openclaw-gateway.service")) {
|
||||
return [
|
||||
"[Service]",
|
||||
"ExecStart=/usr/bin/openclaw gateway run",
|
||||
"EnvironmentFile=%h/.openclaw/missing.env",
|
||||
].join("\n");
|
||||
}
|
||||
throw new Error(`missing: ${pathValue}`);
|
||||
});
|
||||
|
||||
const command = await readSystemdServiceExecStart({ HOME: "/home/test" });
|
||||
expect(command?.programArguments).toEqual(["/usr/bin/openclaw", "gateway", "run"]);
|
||||
expect(command?.environment).toBeUndefined();
|
||||
});
|
||||
|
||||
it("supports multiple EnvironmentFile entries and quoted paths", async () => {
|
||||
vi.spyOn(fs, "readFile").mockImplementation(async (pathname) => {
|
||||
const pathValue = pathLikeToString(pathname);
|
||||
if (pathValue.endsWith("/openclaw-gateway.service")) {
|
||||
return [
|
||||
"[Service]",
|
||||
"ExecStart=/usr/bin/openclaw gateway run",
|
||||
'EnvironmentFile=%h/.openclaw/first.env "%h/.openclaw/second env.env"',
|
||||
].join("\n");
|
||||
}
|
||||
if (pathValue === "/home/test/.openclaw/first.env") {
|
||||
return "OPENCLAW_GATEWAY_TOKEN=first-token\n";
|
||||
}
|
||||
if (pathValue === "/home/test/.openclaw/second env.env") {
|
||||
return 'OPENCLAW_GATEWAY_PASSWORD="second password"\n';
|
||||
}
|
||||
throw new Error(`unexpected readFile path: ${pathValue}`);
|
||||
});
|
||||
|
||||
const command = await readSystemdServiceExecStart({ HOME: "/home/test" });
|
||||
expect(command?.environment).toEqual({
|
||||
OPENCLAW_GATEWAY_TOKEN: "first-token",
|
||||
OPENCLAW_GATEWAY_PASSWORD: "second password", // pragma: allowlist secret
|
||||
});
|
||||
});
|
||||
|
||||
it("resolves relative EnvironmentFile paths from the unit directory", async () => {
|
||||
vi.spyOn(fs, "readFile").mockImplementation(async (pathname) => {
|
||||
const pathValue = pathLikeToString(pathname);
|
||||
if (pathValue.endsWith("/openclaw-gateway.service")) {
|
||||
return [
|
||||
"[Service]",
|
||||
"ExecStart=/usr/bin/openclaw gateway run",
|
||||
"EnvironmentFile=./gateway.env ./override.env",
|
||||
].join("\n");
|
||||
}
|
||||
if (pathValue.endsWith("/.config/systemd/user/gateway.env")) {
|
||||
return [
|
||||
"OPENCLAW_GATEWAY_TOKEN=relative-token",
|
||||
"OPENCLAW_GATEWAY_PASSWORD=relative-password",
|
||||
].join("\n");
|
||||
}
|
||||
if (pathValue.endsWith("/.config/systemd/user/override.env")) {
|
||||
return "OPENCLAW_GATEWAY_TOKEN=override-token\n";
|
||||
}
|
||||
throw new Error(`unexpected readFile path: ${pathValue}`);
|
||||
});
|
||||
|
||||
const command = await readSystemdServiceExecStart({ HOME: "/home/test" });
|
||||
expect(command?.environment).toEqual({
|
||||
OPENCLAW_GATEWAY_TOKEN: "override-token",
|
||||
OPENCLAW_GATEWAY_PASSWORD: "relative-password", // pragma: allowlist secret
|
||||
});
|
||||
});
|
||||
|
||||
it("parses EnvironmentFile content with comments and quoted values", async () => {
|
||||
vi.spyOn(fs, "readFile").mockImplementation(async (pathname) => {
|
||||
const pathValue = pathLikeToString(pathname);
|
||||
if (pathValue.endsWith("/openclaw-gateway.service")) {
|
||||
return [
|
||||
"[Service]",
|
||||
"ExecStart=/usr/bin/openclaw gateway run",
|
||||
"EnvironmentFile=%h/.openclaw/gateway.env",
|
||||
].join("\n");
|
||||
}
|
||||
if (pathValue === "/home/test/.openclaw/gateway.env") {
|
||||
return [
|
||||
"# comment",
|
||||
"; another comment",
|
||||
'OPENCLAW_GATEWAY_TOKEN="quoted token"',
|
||||
"OPENCLAW_GATEWAY_PASSWORD=quoted-password",
|
||||
].join("\n");
|
||||
}
|
||||
throw new Error(`unexpected readFile path: ${pathValue}`);
|
||||
});
|
||||
|
||||
const command = await readSystemdServiceExecStart({ HOME: "/home/test" });
|
||||
expect(command?.environment).toEqual({
|
||||
OPENCLAW_GATEWAY_TOKEN: "quoted token",
|
||||
OPENCLAW_GATEWAY_PASSWORD: "quoted-password", // pragma: allowlist secret
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("systemd service control", () => {
|
||||
const assertMachineRestartArgs = (args: string[]) => {
|
||||
expect(args).toEqual(["--machine", "debian@", "--user", "restart", "openclaw-gateway.service"]);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { splitArgsPreservingQuotes } from "./arg-split.js";
|
||||
import {
|
||||
LEGACY_GATEWAY_SYSTEMD_SERVICE_NAMES,
|
||||
resolveGatewayServiceDescription,
|
||||
@@ -65,6 +66,7 @@ export async function readSystemdServiceExecStart(
|
||||
let execStart = "";
|
||||
let workingDirectory = "";
|
||||
const environment: Record<string, string> = {};
|
||||
const environmentFileSpecs: string[] = [];
|
||||
for (const rawLine of content.split("\n")) {
|
||||
const line = rawLine.trim();
|
||||
if (!line || line.startsWith("#")) {
|
||||
@@ -80,16 +82,30 @@ export async function readSystemdServiceExecStart(
|
||||
if (parsed) {
|
||||
environment[parsed.key] = parsed.value;
|
||||
}
|
||||
} else if (line.startsWith("EnvironmentFile=")) {
|
||||
const raw = line.slice("EnvironmentFile=".length).trim();
|
||||
if (raw) {
|
||||
environmentFileSpecs.push(raw);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!execStart) {
|
||||
return null;
|
||||
}
|
||||
const environmentFromFiles = await resolveSystemdEnvironmentFiles({
|
||||
environmentFileSpecs,
|
||||
env,
|
||||
unitPath,
|
||||
});
|
||||
const mergedEnvironment = {
|
||||
...environmentFromFiles,
|
||||
...environment,
|
||||
};
|
||||
const programArguments = parseSystemdExecStart(execStart);
|
||||
return {
|
||||
programArguments,
|
||||
...(workingDirectory ? { workingDirectory } : {}),
|
||||
...(Object.keys(environment).length > 0 ? { environment } : {}),
|
||||
...(Object.keys(mergedEnvironment).length > 0 ? { environment: mergedEnvironment } : {}),
|
||||
sourcePath: unitPath,
|
||||
};
|
||||
} catch {
|
||||
@@ -97,6 +113,89 @@ export async function readSystemdServiceExecStart(
|
||||
}
|
||||
}
|
||||
|
||||
function expandSystemdSpecifier(input: string, env: GatewayServiceEnv): string {
|
||||
// Support the common unit-specifier used in user services.
|
||||
return input.replaceAll("%h", toPosixPath(resolveHomeDir(env)));
|
||||
}
|
||||
|
||||
function parseEnvironmentFileSpecs(raw: string): string[] {
|
||||
return splitArgsPreservingQuotes(raw, { escapeMode: "backslash" })
|
||||
.map((entry) => entry.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function parseEnvironmentFileLine(rawLine: string): { key: string; value: string } | null {
|
||||
const trimmed = rawLine.trim();
|
||||
if (!trimmed || trimmed.startsWith("#") || trimmed.startsWith(";")) {
|
||||
return null;
|
||||
}
|
||||
const eq = trimmed.indexOf("=");
|
||||
if (eq <= 0) {
|
||||
return null;
|
||||
}
|
||||
const key = trimmed.slice(0, eq).trim();
|
||||
if (!key) {
|
||||
return null;
|
||||
}
|
||||
let value = trimmed.slice(eq + 1).trim();
|
||||
if (
|
||||
value.length >= 2 &&
|
||||
((value.startsWith('"') && value.endsWith('"')) ||
|
||||
(value.startsWith("'") && value.endsWith("'")))
|
||||
) {
|
||||
value = value.slice(1, -1);
|
||||
}
|
||||
return { key, value };
|
||||
}
|
||||
|
||||
async function readSystemdEnvironmentFile(pathname: string): Promise<Record<string, string>> {
|
||||
const environment: Record<string, string> = {};
|
||||
const content = await fs.readFile(pathname, "utf8");
|
||||
for (const rawLine of content.split(/\r?\n/)) {
|
||||
const parsed = parseEnvironmentFileLine(rawLine);
|
||||
if (!parsed) {
|
||||
continue;
|
||||
}
|
||||
environment[parsed.key] = parsed.value;
|
||||
}
|
||||
return environment;
|
||||
}
|
||||
|
||||
async function resolveSystemdEnvironmentFiles(params: {
|
||||
environmentFileSpecs: string[];
|
||||
env: GatewayServiceEnv;
|
||||
unitPath: string;
|
||||
}): Promise<Record<string, string>> {
|
||||
const resolved: Record<string, string> = {};
|
||||
if (params.environmentFileSpecs.length === 0) {
|
||||
return resolved;
|
||||
}
|
||||
const unitDir = path.posix.dirname(params.unitPath);
|
||||
for (const specRaw of params.environmentFileSpecs) {
|
||||
for (const token of parseEnvironmentFileSpecs(specRaw)) {
|
||||
const optional = token.startsWith("-");
|
||||
const pathnameRaw = optional ? token.slice(1).trim() : token;
|
||||
if (!pathnameRaw) {
|
||||
continue;
|
||||
}
|
||||
const expanded = expandSystemdSpecifier(pathnameRaw, params.env);
|
||||
const pathname = path.posix.isAbsolute(expanded)
|
||||
? expanded
|
||||
: path.posix.resolve(unitDir, expanded);
|
||||
try {
|
||||
const fromFile = await readSystemdEnvironmentFile(pathname);
|
||||
Object.assign(resolved, fromFile);
|
||||
} catch {
|
||||
// Keep service auditing resilient even when env files are unavailable
|
||||
// in the current runtime context. Both optional and non-optional
|
||||
// EnvironmentFile entries are skipped gracefully for diagnostics.
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
export type SystemdServiceInfo = {
|
||||
activeState?: string;
|
||||
subState?: string;
|
||||
|
||||
Reference in New Issue
Block a user