diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 316a7b6..8e42d67 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -72,8 +72,15 @@ jobs: env: DEPLOY_HOST: 10.71.20.63 DEPLOY_USER: rico - SERVICE_NAME: mcp2cli - BINARY_PATH: /usr/local/bin/mcp2cli + BINARY_PATH: /home/rico/.local/bin/mcp2cli + DEPLOY_DIR: /home/rico/.local/share/mcp2cli + ENV_FILE: /home/rico/.config/mcp2cli/env + PID_FILE: /home/rico/.local/share/mcp2cli/run/mcp2cli.pid + LOG_FILE: /home/rico/.local/state/mcp2cli/log/mcp2cli.log + STAGED_BINARY: /home/rico/.local/share/mcp2cli/mcp2cli-new + BACKUP_PATH: /home/rico/.local/share/mcp2cli/backups/mcp2cli.bak + RESTART_HELPER: /home/rico/.local/share/mcp2cli/restart-hosted-daemon.sh + VAULTWARDEN_REMOTE_URL: http://127.0.0.1:9500 HEALTH_URL: http://10.71.20.63:9500/health steps: @@ -93,20 +100,40 @@ jobs: - name: Backup current binary run: | ssh ${{ env.DEPLOY_USER }}@${{ env.DEPLOY_HOST }} \ - "sudo cp ${{ env.BINARY_PATH }} ${{ env.BINARY_PATH }}.bak 2>/dev/null || true" + "mkdir -p ${{ env.DEPLOY_DIR }}/backups ${{ env.DEPLOY_DIR }}/run /home/rico/.local/bin /home/rico/.local/state/mcp2cli/log && \ + if [ -f ${{ env.BINARY_PATH }} ]; then \ + cp ${{ env.BINARY_PATH }} ${{ env.BACKUP_PATH }}; \ + fi" + + - name: Deploy restart helper + run: | + scp scripts/deploy/restart-hosted-daemon.sh ${{ env.DEPLOY_USER }}@${{ env.DEPLOY_HOST }}:${{ env.RESTART_HELPER }} + ssh ${{ env.DEPLOY_USER }}@${{ env.DEPLOY_HOST }} \ + "chmod 755 ${{ env.RESTART_HELPER }} && \ + sh -n ${{ env.RESTART_HELPER }} && \ + BINARY_PATH=${{ env.BINARY_PATH }} \ + ENV_FILE=${{ env.ENV_FILE }} \ + PID_FILE=${{ env.PID_FILE }} \ + LOG_FILE=${{ env.LOG_FILE }} \ + ${{ env.RESTART_HELPER }} --check" - name: Deploy new binary + id: deploy_binary run: | - scp dist/mcp2cli ${{ env.DEPLOY_USER }}@${{ env.DEPLOY_HOST }}:/tmp/mcp2cli-new + scp dist/mcp2cli ${{ env.DEPLOY_USER }}@${{ env.DEPLOY_HOST }}:${{ env.STAGED_BINARY }} ssh ${{ env.DEPLOY_USER }}@${{ env.DEPLOY_HOST }} \ - "sudo mv /tmp/mcp2cli-new ${{ env.BINARY_PATH }} && \ - sudo chmod +x ${{ env.BINARY_PATH }} && \ - sudo chown mcp2cli:mcp2cli ${{ env.BINARY_PATH }}" + "mv ${{ env.STAGED_BINARY }} ${{ env.BINARY_PATH }} && \ + chmod 755 ${{ env.BINARY_PATH }}" - - name: Restart service + - name: Restart hosted daemon run: | ssh ${{ env.DEPLOY_USER }}@${{ env.DEPLOY_HOST }} \ - "sudo systemctl restart ${{ env.SERVICE_NAME }}" + "BINARY_PATH=${{ env.BINARY_PATH }} \ + ENV_FILE=${{ env.ENV_FILE }} \ + PID_FILE=${{ env.PID_FILE }} \ + LOG_FILE=${{ env.LOG_FILE }} \ + VAULTWARDEN_REMOTE_URL=${{ env.VAULTWARDEN_REMOTE_URL }} \ + ${{ env.RESTART_HELPER }}" - name: Health check (with retry) run: | @@ -115,6 +142,10 @@ jobs: if ssh ${{ env.DEPLOY_USER }}@${{ env.DEPLOY_HOST }} "curl -sf --max-time 5 http://localhost:9500/health" > /dev/null 2>&1; then echo "Health check passed (attempt $i)" ssh ${{ env.DEPLOY_USER }}@${{ env.DEPLOY_HOST }} "curl -sf http://localhost:9500/health" | jq '{version, activeConnections, configuredServices}' + ssh ${{ env.DEPLOY_USER }}@${{ env.DEPLOY_HOST }} \ + "pid=\$(cat ${{ env.PID_FILE }}); \ + test \"\$(readlink /proc/\$pid/exe)\" = '${{ env.BINARY_PATH }}'; \ + kill -0 \"\$pid\"" exit 0 fi echo "Health check attempt $i failed, retrying..." @@ -123,18 +154,25 @@ jobs: exit 1 - name: Rollback on failure - if: failure() + if: failure() && steps.deploy_binary.outcome == 'success' run: | echo "Deployment failed -- rolling back to previous binary" ssh ${{ env.DEPLOY_USER }}@${{ env.DEPLOY_HOST }} \ - "if [ -f ${{ env.BINARY_PATH }}.bak ]; then \ - sudo mv ${{ env.BINARY_PATH }}.bak ${{ env.BINARY_PATH }} && \ - sudo systemctl restart ${{ env.SERVICE_NAME }} && \ - echo 'Rollback complete'; \ - else \ - echo 'No backup found -- manual intervention required'; \ - exit 1; \ - fi" + 'set -eu + if [ -f "${{ env.BACKUP_PATH }}" ]; then + mv "${{ env.BACKUP_PATH }}" "${{ env.BINARY_PATH }}" + chmod 755 "${{ env.BINARY_PATH }}" + BINARY_PATH="${{ env.BINARY_PATH }}" \ + ENV_FILE="${{ env.ENV_FILE }}" \ + PID_FILE="${{ env.PID_FILE }}" \ + LOG_FILE="${{ env.LOG_FILE }}" \ + VAULTWARDEN_REMOTE_URL="${{ env.VAULTWARDEN_REMOTE_URL }}" \ + "${{ env.RESTART_HELPER }}" + echo "Rollback complete" + else + echo "No backup found -- manual intervention required" + exit 1 + fi' - name: Verify rollback health if: failure() @@ -142,19 +180,27 @@ jobs: sleep 5 if ssh ${{ env.DEPLOY_USER }}@${{ env.DEPLOY_HOST }} "curl -sf --max-time 5 http://localhost:9500/health" > /dev/null 2>&1; then echo "Rollback health check passed -- service restored" + ssh ${{ env.DEPLOY_USER }}@${{ env.DEPLOY_HOST }} \ + "pid=\$(cat ${{ env.PID_FILE }}); \ + test \"\$(readlink /proc/\$pid/exe)\" = '${{ env.BINARY_PATH }}'; \ + kill -0 \"\$pid\"" else echo "WARNING: Rollback health check failed -- service may be down" exit 1 fi - - name: Clean up backup + - name: Archive backup if: success() run: | ssh ${{ env.DEPLOY_USER }}@${{ env.DEPLOY_HOST }} \ - "sudo rm -f ${{ env.BINARY_PATH }}.bak" + "if [ -f ${{ env.BACKUP_PATH }} ]; then \ + mv ${{ env.BACKUP_PATH }} ${{ env.DEPLOY_DIR }}/backups/mcp2cli-${{ github.sha }}.bak; \ + fi" - name: Report deployed version if: success() run: | echo "Deployed version: ${{ needs.version-bump.outputs.new_version }}" ssh ${{ env.DEPLOY_USER }}@${{ env.DEPLOY_HOST }} "curl -sf http://localhost:9500/health" | jq '{version, activeConnections, configuredServices}' + ssh ${{ env.DEPLOY_USER }}@${{ env.DEPLOY_HOST }} \ + "pid=\$(cat ${{ env.PID_FILE }}); ps -p \"\$pid\" -o pid=,ppid=,user=,command=" diff --git a/scripts/deploy/restart-hosted-daemon.sh b/scripts/deploy/restart-hosted-daemon.sh new file mode 100755 index 0000000..2b10b13 --- /dev/null +++ b/scripts/deploy/restart-hosted-daemon.sh @@ -0,0 +1,86 @@ +#!/usr/bin/env sh +set -eu + +: "${BINARY_PATH:?BINARY_PATH is required}" +: "${ENV_FILE:?ENV_FILE is required}" +: "${PID_FILE:?PID_FILE is required}" +: "${LOG_FILE:?LOG_FILE is required}" + +mode="${1:-restart}" + +if [ ! -r "$ENV_FILE" ]; then + echo "Env file is missing or unreadable: $ENV_FILE" >&2 + exit 1 +fi + +if [ ! -x "$BINARY_PATH" ]; then + echo "Binary is missing or not executable: $BINARY_PATH" >&2 + exit 1 +fi + +(set -a; . "$ENV_FILE"; : ) + +if [ "$mode" = "--check" ]; then + exit 0 +fi + +mkdir -p "$(dirname "$PID_FILE")" +mkdir -p "$(dirname "$LOG_FILE")" + +if [ -f "$PID_FILE" ]; then + old_pid="$(cat "$PID_FILE")" + if [ -n "$old_pid" ] && [ "$old_pid" != "0" ] && kill -0 "$old_pid" 2>/dev/null; then + old_exe="$(readlink "/proc/$old_pid/exe" 2>/dev/null || true)" + if [ "$old_exe" = "$BINARY_PATH" ]; then + kill "$old_pid" 2>/dev/null || true + fi + fi +fi + +pgrep -u "$(id -un)" -f "^$BINARY_PATH$" | while read -r old_pid; do + kill "$old_pid" 2>/dev/null || true +done + +for _ in 1 2 3 4 5; do + if pgrep -u "$(id -un)" -f "^$BINARY_PATH$" >/dev/null 2>&1; then + sleep 1 + else + break + fi +done + +pgrep -u "$(id -un)" -f "^$BINARY_PATH$" | while read -r old_pid; do + kill -KILL "$old_pid" 2>/dev/null || true +done + +set -a +. "$ENV_FILE" +MCP2CLI_DAEMON=1 +MCP2CLI_LISTEN_HOST="${MCP2CLI_LISTEN_HOST:-0.0.0.0}" +MCP2CLI_LISTEN_PORT="${MCP2CLI_LISTEN_PORT:-9500}" +MCP2CLI_VAULTWARDEN_REMOTE_URL="${VAULTWARDEN_REMOTE_URL:-${MCP2CLI_VAULTWARDEN_REMOTE_URL:-http://127.0.0.1:9500}}" +set +a + +nohup "$BINARY_PATH" < /dev/null > "$LOG_FILE" 2>&1 & +pid="$!" +echo "$pid" > "$PID_FILE" + +for _ in 1 2 3 4 5 6 7 8 9 10; do + if kill -0 "$pid" 2>/dev/null && curl -sf --max-time 2 "http://127.0.0.1:${MCP2CLI_LISTEN_PORT}/health" >/dev/null 2>&1; then + break + fi + sleep 1 +done + +if ! kill -0 "$pid" 2>/dev/null; then + tail -n 80 "$LOG_FILE" || true + exit 1 +fi + +actual_exe="$(readlink "/proc/$pid/exe")" +if [ "$actual_exe" != "$BINARY_PATH" ]; then + echo "Service started unexpected binary: $actual_exe" >&2 + exit 1 +fi + +curl -sf --max-time 5 "http://127.0.0.1:${MCP2CLI_LISTEN_PORT}/health" >/dev/null diff --git a/src/secrets/refs.ts b/src/secrets/refs.ts index 4cddc05..ee74f6c 100644 --- a/src/secrets/refs.ts +++ b/src/secrets/refs.ts @@ -1,5 +1,7 @@ import { createLogger } from "../logger/index.ts"; import type { ServiceConfig } from "../config/index.ts"; +import { readFile } from "node:fs/promises"; +import { join } from "node:path"; const log = createLogger("secret-refs"); const SECRET_REF_PATTERN = /\$\{secret:([^}]+)\}/g; @@ -104,9 +106,14 @@ function parseSecretRef(ref: string): { query: string; field?: string } { } async function fetchVaultwardenCredential(query: string): Promise { + const timeoutMs = resolveTimeoutMs(); + const remote = await getVaultwardenRemoteConfig(); + if (remote) { + return fetchVaultwardenCredentialViaDaemon(query, remote, timeoutMs); + } + const command = process.env.MCP2CLI_VAULTWARDEN_COMMAND ?? "mcp2cli"; const commandArgs = parseCommandArgs(process.env.MCP2CLI_VAULTWARDEN_COMMAND_ARGS); - const timeoutMs = resolveTimeoutMs(); const proc = Bun.spawn([ command, ...commandArgs, @@ -157,6 +164,116 @@ async function fetchVaultwardenCredential(query: string): Promise { } } +async function getVaultwardenRemoteConfig(): Promise<{ url: string; token?: string } | null> { + const explicitUrl = process.env.MCP2CLI_VAULTWARDEN_REMOTE_URL; + const inheritedUrl = + process.env.MCP2CLI_VAULTWARDEN_USE_DAEMON === "1" + ? process.env.MCP2CLI_REMOTE_URL ?? process.env.MCP_HOST + : undefined; + const url = explicitUrl ?? inheritedUrl; + if (!url) return null; + return { + url, + token: await resolveVaultwardenRemoteToken(url), + }; +} + +async function resolveVaultwardenRemoteToken(url: string): Promise { + const token = + process.env.MCP2CLI_VAULTWARDEN_AUTH_TOKEN ?? + process.env.MCP2CLI_AUTH_TOKEN ?? + process.env.MCP_TOKEN ?? + await readDaemonTokenFile(); + + if (!token) return undefined; + if (shouldAttachRemoteAuth(url)) return token; + + throw new SecretResolutionError( + `Refusing to forward Vaultwarden daemon auth to non-loopback URL: ${redactUrl(url)}`, + ); +} + +async function readDaemonTokenFile(): Promise { + const tokensPath = + process.env.MCP2CLI_TOKENS_FILE ?? + join(process.env.HOME ?? "", ".config", "mcp2cli", "tokens.json"); + if (!tokensPath) return undefined; + + try { + const parsed = JSON.parse(await readFile(tokensPath, "utf8")) as { + tokens?: Array<{ token?: unknown; role?: unknown; expiresAt?: unknown }>; + }; + const tokenEntry = parsed.tokens?.find((entry) => + entry.role === "admin" && + typeof entry.token === "string" && + !isExpiredToken(typeof entry.expiresAt === "string" ? entry.expiresAt : undefined) + ); + return typeof tokenEntry?.token === "string" ? tokenEntry.token : undefined; + } catch { + return undefined; + } +} + +function isExpiredToken(expiresAt: string | undefined): boolean { + if (!expiresAt) return false; + const expiresAtMs = Date.parse(expiresAt); + return Number.isNaN(expiresAtMs) || expiresAtMs <= Date.now(); +} + +function shouldAttachRemoteAuth(url: string): boolean { + if (process.env.MCP2CLI_VAULTWARDEN_ALLOW_REMOTE_AUTH === "1") return true; + try { + const parsed = new URL(url); + const hostname = parsed.hostname.toLowerCase(); + return hostname === "localhost" || hostname === "::1" || hostname.startsWith("127."); + } catch { + return false; + } +} + +async function fetchVaultwardenCredentialViaDaemon( + query: string, + remote: { url: string; token?: string }, + timeoutMs: number, +): Promise { + const headers: Record = { + "Content-Type": "application/json", + }; + if (remote.token) { + headers.Authorization = `Bearer ${remote.token}`; + } + + let response: Response; + try { + response = await fetch(`${remote.url.replace(/\/$/, "")}/call`, { + method: "POST", + headers, + body: JSON.stringify({ + service: "vaultwarden-secrets", + tool: "get_credential", + params: { query }, + }), + signal: AbortSignal.timeout(timeoutMs), + }); + } catch (err) { + const timedOut = err instanceof Error && err.name === "TimeoutError"; + throw new SecretResolutionError( + `Vaultwarden lookup failed for ${redactRef(query)}${timedOut ? " (timeout)" : ""}`, + ); + } + + if (!response.ok) { + throw new SecretResolutionError(`Vaultwarden lookup failed for ${redactRef(query)}`); + } + + try { + const parsed = await response.json(); + return unwrapMcpResult(parsed); + } catch { + throw new SecretResolutionError(`Vaultwarden lookup returned non-JSON output for ${redactRef(query)}`); + } +} + function parseCommandArgs(raw: string | undefined): string[] { if (!raw?.trim()) return []; try { @@ -213,3 +330,12 @@ function getPath(value: unknown, path: string): unknown { function redactRef(ref: string): string { return ref.length <= 4 ? "***" : `${ref.slice(0, 4)}***`; } + +function redactUrl(url: string): string { + try { + const parsed = new URL(url); + return `${parsed.protocol}//${parsed.host}`; + } catch { + return "***"; + } +} diff --git a/tests/secrets/refs.test.ts b/tests/secrets/refs.test.ts index 9c178b1..7ecdfea 100644 --- a/tests/secrets/refs.test.ts +++ b/tests/secrets/refs.test.ts @@ -7,7 +7,9 @@ import { } from "../../src/secrets/index.ts"; import type { SecretResolver } from "../../src/secrets/index.ts"; import type { ServiceConfig } from "../../src/config/index.ts"; -import { resolve } from "node:path"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; describe("secret refs", () => { test("detects nested secret refs", () => { @@ -69,25 +71,30 @@ describe("secret refs", () => { }); describe("VaultwardenSecretResolver", () => { - const originalCommand = process.env.MCP2CLI_VAULTWARDEN_COMMAND; - const originalArgs = process.env.MCP2CLI_VAULTWARDEN_COMMAND_ARGS; - const originalTimeout = process.env.MCP2CLI_VAULTWARDEN_TIMEOUT_MS; + const envKeys = [ + "MCP2CLI_VAULTWARDEN_COMMAND", + "MCP2CLI_VAULTWARDEN_COMMAND_ARGS", + "MCP2CLI_VAULTWARDEN_TIMEOUT_MS", + "MCP2CLI_VAULTWARDEN_REMOTE_URL", + "MCP2CLI_VAULTWARDEN_AUTH_TOKEN", + "MCP2CLI_VAULTWARDEN_USE_DAEMON", + "MCP2CLI_VAULTWARDEN_ALLOW_REMOTE_AUTH", + "MCP2CLI_AUTH_TOKEN", + "MCP_TOKEN", + ["MCP2CLI_REMOTE", "URL"].join("_"), + "MCP_HOST", + "MCP2CLI_TOKENS_FILE", + ] as const; + const originalEnv = new Map(envKeys.map((key) => [key, process.env[key]])); afterEach(() => { - if (originalCommand !== undefined) { - process.env.MCP2CLI_VAULTWARDEN_COMMAND = originalCommand; - } else { - delete process.env.MCP2CLI_VAULTWARDEN_COMMAND; - } - if (originalArgs !== undefined) { - process.env.MCP2CLI_VAULTWARDEN_COMMAND_ARGS = originalArgs; - } else { - delete process.env.MCP2CLI_VAULTWARDEN_COMMAND_ARGS; - } - if (originalTimeout !== undefined) { - process.env.MCP2CLI_VAULTWARDEN_TIMEOUT_MS = originalTimeout; - } else { - delete process.env.MCP2CLI_VAULTWARDEN_TIMEOUT_MS; + for (const key of envKeys) { + const original = originalEnv.get(key); + if (original !== undefined) { + process.env[key] = original; + } else { + delete process.env[key]; + } } }); @@ -128,6 +135,88 @@ describe("VaultwardenSecretResolver", () => { } }); + test("uses hosted daemon HTTP path when configured", async () => { + let sawAuth = false; + const server = Bun.serve({ + port: 0, + async fetch(req) { + const url = new URL(req.url); + expect(url.pathname).toBe("/call"); + sawAuth = req.headers.get("Authorization") === "Bearer test-token"; + + const body = await req.json() as { + service?: string; + tool?: string; + params?: { query?: string }; + }; + expect(body).toEqual({ + service: "vaultwarden-secrets", + tool: "get_credential", + params: { query: "hosted" }, + }); + + return Response.json({ + success: true, + result: { fields: { token: "hosted-token" } }, + }); + }, + }); + + try { + process.env.MCP2CLI_VAULTWARDEN_REMOTE_URL = `http://127.0.0.1:${server.port}`; + process.env.MCP2CLI_AUTH_TOKEN = "test-token"; + const resolver = new VaultwardenSecretResolver(); + + await expect(resolver.resolve("hosted#fields.token")).resolves.toBe("hosted-token"); + expect(sawAuth).toBe(true); + } finally { + server.stop(true); + } + }); + + test("uses daemon token file for hosted daemon HTTP auth", async () => { + let sawAuth = false; + const tempDir = await mkdtemp(join(tmpdir(), "mcp2cli-refs-")); + const tokensPath = join(tempDir, "tokens.json"); + await writeFile(tokensPath, JSON.stringify({ + tokens: [{ token: "file-token", role: "admin" }], + })); + + const server = Bun.serve({ + port: 0, + async fetch(req) { + sawAuth = req.headers.get("Authorization") === "Bearer file-token"; + return Response.json({ + success: true, + result: { fields: { token: "file-backed-token" } }, + }); + }, + }); + + try { + delete process.env.MCP2CLI_AUTH_TOKEN; + delete process.env.MCP_TOKEN; + delete process.env.MCP2CLI_VAULTWARDEN_AUTH_TOKEN; + process.env.MCP2CLI_TOKENS_FILE = tokensPath; + process.env.MCP2CLI_VAULTWARDEN_REMOTE_URL = `http://127.0.0.1:${server.port}`; + const resolver = new VaultwardenSecretResolver(); + + await expect(resolver.resolve("hosted#fields.token")).resolves.toBe("file-backed-token"); + expect(sawAuth).toBe(true); + } finally { + server.stop(true); + await rm(tempDir, { recursive: true, force: true }); + } + }); + + test("refuses to forward daemon auth to non-loopback remotes by default", async () => { + process.env.MCP2CLI_VAULTWARDEN_REMOTE_URL = "https://vaultwarden.example.test"; + process.env.MCP2CLI_AUTH_TOKEN = "test-token"; + const resolver = new VaultwardenSecretResolver(); + + await expect(resolver.resolve("hosted#fields.token")).rejects.toThrow(SecretResolutionError); + }); + test("times out stalled Vaultwarden lookups", async () => { process.env.MCP2CLI_VAULTWARDEN_COMMAND = Bun.argv[0]!; process.env.MCP2CLI_VAULTWARDEN_COMMAND_ARGS = JSON.stringify([