mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-16 16:01:38 +00:00
* Add Vault SecretRef plugin Signed-off-by: sallyom <somalley@redhat.com> * expand Vault setup to registered SecretRef targets Signed-off-by: sallyom <somalley@redhat.com> * fix(vault): use sdk secret target seam * fix(vault): preserve auth profile target paths * docs(vault): document plugin enable step * fix(vault): make status provider-alias aware * fix(vault): reject noncanonical secret ids * fix(vault): separate resolver timeout deadlines * fix(vault): forward private CA trust settings * fix(secrets): preserve plugin policy boundaries --------- Signed-off-by: sallyom <somalley@redhat.com> Co-authored-by: joshavant <830519+joshavant@users.noreply.github.com>
295 lines
8.2 KiB
JavaScript
295 lines
8.2 KiB
JavaScript
#!/usr/bin/env node
|
|
|
|
import { readFile } from "node:fs/promises";
|
|
import { parseVaultSecretId } from "./vault-secret-id.js";
|
|
|
|
const KUBERNETES_SERVICE_ACCOUNT_TOKEN_PATH = "/var/run/secrets/kubernetes.io/serviceaccount/token";
|
|
const VAULT_FETCH_TIMEOUT_MS = 5000;
|
|
|
|
function readStdin() {
|
|
return new Promise((resolve, reject) => {
|
|
let input = "";
|
|
process.stdin.setEncoding("utf8");
|
|
process.stdin.on("data", (chunk) => {
|
|
input += String(chunk);
|
|
});
|
|
process.stdin.on("error", reject);
|
|
process.stdin.on("end", () => resolve(input));
|
|
});
|
|
}
|
|
|
|
function writeResponse(response) {
|
|
process.stdout.write(`${JSON.stringify(response)}\n`);
|
|
}
|
|
|
|
function parseRequest(input) {
|
|
const parsed = JSON.parse(input);
|
|
if (!parsed || typeof parsed !== "object" || !Array.isArray(parsed.ids)) {
|
|
throw new Error("invalid exec SecretRef request");
|
|
}
|
|
return {
|
|
protocolVersion: 1,
|
|
ids: parsed.ids.filter((id) => typeof id === "string" && id.length > 0),
|
|
};
|
|
}
|
|
|
|
function normalizeVaultAddress() {
|
|
const raw = process.env.VAULT_ADDR?.trim();
|
|
if (!raw) {
|
|
throw new Error("VAULT_ADDR is required.");
|
|
}
|
|
const address = raw.replace(/\/+$/u, "");
|
|
let parsed;
|
|
try {
|
|
parsed = new URL(address);
|
|
} catch {
|
|
throw new Error("VAULT_ADDR must be a valid http or https URL.");
|
|
}
|
|
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
|
throw new Error("VAULT_ADDR must be a valid http or https URL.");
|
|
}
|
|
return address;
|
|
}
|
|
|
|
function normalizeOptionalString(value) {
|
|
return value?.trim() || undefined;
|
|
}
|
|
|
|
function resolveVaultAuthMethod() {
|
|
const method = normalizeOptionalString(process.env.OPENCLAW_VAULT_AUTH_METHOD) ?? "token";
|
|
if (
|
|
method === "token" ||
|
|
method === "token_file" ||
|
|
method === "jwt" ||
|
|
method === "kubernetes"
|
|
) {
|
|
return method;
|
|
}
|
|
throw new Error("OPENCLAW_VAULT_AUTH_METHOD must be token, token_file, jwt, or kubernetes.");
|
|
}
|
|
|
|
function resolveVaultTokenEnv() {
|
|
const token = process.env.VAULT_TOKEN?.trim();
|
|
if (!token) {
|
|
throw new Error("VAULT_TOKEN is required.");
|
|
}
|
|
return token;
|
|
}
|
|
|
|
async function resolveVaultTokenFile() {
|
|
const tokenFile = normalizeOptionalString(process.env.VAULT_TOKEN_FILE);
|
|
if (!tokenFile) {
|
|
throw new Error("VAULT_TOKEN_FILE is required.");
|
|
}
|
|
const token = (await readFile(tokenFile, "utf8")).trim();
|
|
if (!token) {
|
|
throw new Error("VAULT_TOKEN_FILE did not contain a token.");
|
|
}
|
|
return token;
|
|
}
|
|
|
|
function resolveKvMount() {
|
|
return process.env.OPENCLAW_VAULT_KV_MOUNT?.trim().replace(/^\/+|\/+$/gu, "") || "secret";
|
|
}
|
|
|
|
function resolveKvVersion() {
|
|
const raw = process.env.OPENCLAW_VAULT_KV_VERSION?.trim();
|
|
if (!raw || raw === "2") {
|
|
return 2;
|
|
}
|
|
if (raw === "1") {
|
|
return 1;
|
|
}
|
|
throw new Error("OPENCLAW_VAULT_KV_VERSION must be 1 or 2.");
|
|
}
|
|
|
|
function encodePath(pathValue) {
|
|
return pathValue
|
|
.split("/")
|
|
.map((segment) => encodeURIComponent(segment))
|
|
.join("/");
|
|
}
|
|
|
|
function buildVaultUrl(baseUrl, params) {
|
|
const mount = encodePath(resolveKvMount());
|
|
const secretPath = encodePath(params.secretPath);
|
|
if (resolveKvVersion() === 2) {
|
|
return `${baseUrl}/v1/${mount}/data/${secretPath}`;
|
|
}
|
|
return `${baseUrl}/v1/${mount}/${secretPath}`;
|
|
}
|
|
|
|
function assertVaultRequestUrl(baseUrl, requestUrl) {
|
|
const base = new URL(baseUrl);
|
|
const target = new URL(requestUrl);
|
|
if (target.protocol !== "http:" && target.protocol !== "https:") {
|
|
throw new Error("Vault request URL must be a valid http or https URL.");
|
|
}
|
|
if (target.origin !== base.origin) {
|
|
throw new Error("Vault request URL must stay on the configured VAULT_ADDR origin.");
|
|
}
|
|
}
|
|
|
|
async function fetchVault(baseUrl, url, init) {
|
|
assertVaultRequestUrl(baseUrl, url);
|
|
const abortController = new AbortController();
|
|
const timeout = setTimeout(() => abortController.abort(), VAULT_FETCH_TIMEOUT_MS);
|
|
try {
|
|
return await fetch(url, {
|
|
...init,
|
|
redirect: "manual",
|
|
signal: abortController.signal,
|
|
});
|
|
} finally {
|
|
clearTimeout(timeout);
|
|
}
|
|
}
|
|
|
|
function addVaultNamespaceHeader(headers) {
|
|
const namespace = process.env.VAULT_NAMESPACE?.trim();
|
|
if (namespace) {
|
|
headers["X-Vault-Namespace"] = namespace;
|
|
}
|
|
}
|
|
|
|
function resolveVaultAuthMount(method) {
|
|
return process.env.OPENCLAW_VAULT_AUTH_MOUNT?.trim().replace(/^\/+|\/+$/gu, "") || method;
|
|
}
|
|
|
|
function resolveVaultAuthRole(method) {
|
|
const role = normalizeOptionalString(process.env.OPENCLAW_VAULT_AUTH_ROLE);
|
|
if (!role) {
|
|
throw new Error(`OPENCLAW_VAULT_AUTH_ROLE is required for ${method} auth.`);
|
|
}
|
|
return role;
|
|
}
|
|
|
|
async function resolveVaultJwt(method) {
|
|
const jwtFile =
|
|
normalizeOptionalString(process.env.OPENCLAW_VAULT_JWT_FILE) ??
|
|
(method === "kubernetes" ? KUBERNETES_SERVICE_ACCOUNT_TOKEN_PATH : undefined);
|
|
if (!jwtFile) {
|
|
throw new Error("OPENCLAW_VAULT_JWT_FILE is required for jwt auth.");
|
|
}
|
|
const jwt = (await readFile(jwtFile, "utf8")).trim();
|
|
if (!jwt) {
|
|
throw new Error("OPENCLAW_VAULT_JWT_FILE did not contain a JWT.");
|
|
}
|
|
return jwt;
|
|
}
|
|
|
|
function readVaultLoginToken(payload, method) {
|
|
const token = payload?.auth?.client_token;
|
|
if (typeof token !== "string" || !token.trim()) {
|
|
throw new Error(`Vault ${method} login response did not include auth.client_token.`);
|
|
}
|
|
return token;
|
|
}
|
|
|
|
async function resolveVaultTokenFromJwt(baseUrl, method) {
|
|
const mount = encodePath(resolveVaultAuthMount(method));
|
|
const headers = {
|
|
"Content-Type": "application/json",
|
|
};
|
|
addVaultNamespaceHeader(headers);
|
|
const response = await fetchVault(baseUrl, `${baseUrl}/v1/auth/${mount}/login`, {
|
|
method: "POST",
|
|
headers,
|
|
body: JSON.stringify({
|
|
role: resolveVaultAuthRole(method),
|
|
jwt: await resolveVaultJwt(method),
|
|
}),
|
|
});
|
|
if (!response.ok) {
|
|
throw new Error(`Vault ${method} login failed (${response.status}).`);
|
|
}
|
|
return readVaultLoginToken(await response.json(), method);
|
|
}
|
|
|
|
async function resolveVaultClientToken(baseUrl) {
|
|
switch (resolveVaultAuthMethod()) {
|
|
case "token":
|
|
return resolveVaultTokenEnv();
|
|
case "token_file":
|
|
return await resolveVaultTokenFile();
|
|
case "jwt":
|
|
return await resolveVaultTokenFromJwt(baseUrl, "jwt");
|
|
case "kubernetes":
|
|
return await resolveVaultTokenFromJwt(baseUrl, "kubernetes");
|
|
}
|
|
throw new Error("Unsupported Vault auth method.");
|
|
}
|
|
|
|
function readStringField(payload, parsedId) {
|
|
const record = payload;
|
|
const data = resolveKvVersion() === 2 ? record?.data?.data : record?.data;
|
|
const value = data?.[parsedId.field];
|
|
if (typeof value !== "string") {
|
|
throw new Error(
|
|
`Vault secret "${parsedId.secretPath}/${parsedId.field}" did not contain a string field "${parsedId.field}".`,
|
|
);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
async function readVaultSecret(baseUrl, vaultToken, id) {
|
|
const parsedId = parseVaultSecretId(id);
|
|
const headers = {
|
|
"X-Vault-Token": vaultToken,
|
|
};
|
|
addVaultNamespaceHeader(headers);
|
|
const response = await fetchVault(baseUrl, buildVaultUrl(baseUrl, parsedId), { headers });
|
|
if (!response.ok) {
|
|
throw new Error(`Vault read failed for "${id}" (${response.status}).`);
|
|
}
|
|
return readStringField(await response.json(), parsedId);
|
|
}
|
|
|
|
async function resolveFromVault(ids) {
|
|
const response = { protocolVersion: 1, values: {}, errors: {} };
|
|
if (ids.length === 0) {
|
|
return response;
|
|
}
|
|
const contextPromise = Promise.resolve().then(async () => {
|
|
const baseUrl = normalizeVaultAddress();
|
|
return {
|
|
baseUrl,
|
|
vaultToken: await resolveVaultClientToken(baseUrl),
|
|
};
|
|
});
|
|
await Promise.all(
|
|
ids.map(async (id) => {
|
|
try {
|
|
const { baseUrl, vaultToken } = await contextPromise;
|
|
response.values[id] = await readVaultSecret(baseUrl, vaultToken, id);
|
|
} catch (error) {
|
|
response.errors[id] = {
|
|
message: error instanceof Error ? error.message : String(error),
|
|
};
|
|
}
|
|
}),
|
|
);
|
|
return response;
|
|
}
|
|
|
|
async function main() {
|
|
const input = await readStdin();
|
|
const request = parseRequest(input);
|
|
writeResponse(await resolveFromVault(request.ids));
|
|
}
|
|
|
|
/** @param {unknown} error */
|
|
function handleFatalError(error) {
|
|
writeResponse({
|
|
protocolVersion: 1,
|
|
values: {},
|
|
errors: {
|
|
request: {
|
|
message: error instanceof Error ? error.message : String(error),
|
|
},
|
|
},
|
|
});
|
|
}
|
|
|
|
main().catch(handleFatalError);
|