diff --git a/apps/loopover-ui/src/routes/docs.self-hosting-security.tsx b/apps/loopover-ui/src/routes/docs.self-hosting-security.tsx index 8821faec68..775e57e7ce 100644 --- a/apps/loopover-ui/src/routes/docs.self-hosting-security.tsx +++ b/apps/loopover-ui/src/routes/docs.self-hosting-security.tsx @@ -70,6 +70,92 @@ printf '%s' 'your-real-secret-value' > secrets/github_webhook_secret.txt docker compose up -d --no-deps loopover`} /> +

Optional: Infisical secrets management

+

+ The hardened default above — .env plus Docker Compose secrets: — + has no rotation, audit trail, or RBAC. If you want secrets-manager-grade rotation, audit + logging, and access control on top of that default, you can opt into{" "} + + Infisical + {" "} + — an open-source, self-hostable secrets manager. This is{" "} + strictly optional and additive: skip this section entirely and the hardened{" "} + .env/Docker secrets default keeps working unchanged. +

+ + Infisical wires in at the deploy-script level via its own{" "} + infisical run -- <command> wrapper, which injects secrets as real process + environment variables at container launch. Nothing under src/ knows or cares + whether a given env.SOMETHING value came from Infisical, .env, or + a Docker secret file. + + +

Setup: cloud or self-hosted

+
    +
  1. + Install the{" "} + + Infisical CLI + {" "} + on the machine that runs the deploy script (not inside the app container). +
  2. +
  3. + Pick where your secrets live: Infisical Cloud (the default, zero infrastructure of your + own) or a self-hosted Infisical instance — if you're already self-hosting LoopOver, you + can self-host Infisical alongside it. Either way, run infisical login once, + then infisical init from the repo root to link a local{" "} + .infisical.json to an Infisical project. +
  4. +
  5. + Create an environment inside that project (e.g. prod) matching how you think + about this deployment, and add the secrets you want Infisical to manage — same variable + names your .env/docker-compose.yml already use ( + GITHUB_APP_PRIVATE_KEY, GITHUB_WEBHOOK_SECRET, provider API + keys, and so on). +
  6. +
  7. Opt in when deploying:
  8. +
+ +

+ With the flag unset (the default), neither script touches Infisical at all — not even a + presence check — so an operator who has never heard of Infisical is completely unaffected. + With it set, the restart step (the one that actually launches the container) runs through{" "} + infisical run --; a missing infisical binary fails the deploy + immediately with a clear error rather than silently deploying without the secrets you asked + for. +

+ +

Interaction with .env and Docker secrets — do not mix the same variable

+ + infisical run -- injects secrets into its own child process's environment — in + this case, the docker compose up invocation. Docker Compose only lets a host + shell variable reach the container for an environment: entry written as{" "} + {`SOMEVAR: "\${SOMEVAR}"`}. It does not reach a plain{" "} + env_file: .env block, which reads that file's literal contents at container + runtime and is never affected by the deploying shell's environment. The GitHub App private + key, webhook secret, API/MCP tokens, and the rest of the native-secrets list above are wired + through the _FILE convention and env_file: .env, not through{" "} + environment: interpolation — an Infisical value for one of those exact names, + by itself, will not reach the container today. Infisical is the right fit + for other variables you reference via {`"\${VAR}"`} interpolation in + your own docker-compose.override.yml (a provider API key you add yourself, for + example) — not a drop-in override for the pre-wired native-secrets list. + +

+ The safest rule of thumb: for any given variable, pick one source — Infisical or a + plain .env/Docker secret file, never both for the same name. Setting the same + name in both places doesn't error; whichever mechanism the container actually reads for that + variable (see the callout above) wins silently, which is easy to misdiagnose later. +

+

Private policy

Keep sensitive review thresholds, autonomy, maintainer notes, and repo-specific rules in diff --git a/scripts/deploy-selfhost-image.sh b/scripts/deploy-selfhost-image.sh index 7c5e552737..acac51ca3c 100755 --- a/scripts/deploy-selfhost-image.sh +++ b/scripts/deploy-selfhost-image.sh @@ -12,6 +12,9 @@ # # The image itself carries official release metadata. Set SENTRY_RELEASE only for custom images whose # source maps were uploaded under that exact id. +# +# Optional Infisical secrets (#5120), see docs: +# SELFHOST_USE_INFISICAL=1 ./scripts/deploy-selfhost-image.sh set -euo pipefail ENV_FILE="${SELFHOST_ENV_FILE:-.env}" @@ -115,7 +118,7 @@ echo "selfhost image deploy: pulling $IMAGE" docker compose "${compose_args[@]}" pull --policy always "$SERVICE" echo "selfhost image deploy: restarting $SERVICE" -docker compose "${compose_args[@]}" up -d --no-build --no-deps "$SERVICE" +maybe_infisical_run docker compose "${compose_args[@]}" up -d --no-build --no-deps "$SERVICE" wait_for_healthy env_put LOOPOVER_IMAGE "$IMAGE" diff --git a/scripts/deploy-selfhost-prebuilt.sh b/scripts/deploy-selfhost-prebuilt.sh index a198bbb3e4..92c42c1e2a 100755 --- a/scripts/deploy-selfhost-prebuilt.sh +++ b/scripts/deploy-selfhost-prebuilt.sh @@ -10,6 +10,7 @@ # SENTRY_RELEASE=gittensory-selfhost@edge-abc123 ./scripts/deploy-selfhost-prebuilt.sh # SELFHOST_COMPOSE_FILES="docker-compose.yml docker-compose.override.yml" ./scripts/deploy-selfhost-prebuilt.sh # SELFHOST_SKIP_SENTRY_UPLOAD=1 ./scripts/deploy-selfhost-prebuilt.sh +# SELFHOST_USE_INFISICAL=1 ./scripts/deploy-selfhost-prebuilt.sh # opt-in Infisical secrets (#5120), see docs set -euo pipefail ENV_FILE="${SELFHOST_ENV_FILE:-.env}" @@ -110,7 +111,7 @@ YAML docker compose "${compose_args[@]}" build "$SERVICE" echo "selfhost deploy: restarting $SERVICE" - docker compose "${compose_args[@]}" up -d --no-deps "$SERVICE" + maybe_infisical_run docker compose "${compose_args[@]}" up -d --no-deps "$SERVICE" } require_cmd docker diff --git a/scripts/lib/selfhost-deploy-common.sh b/scripts/lib/selfhost-deploy-common.sh index 3a9f25ec38..2b663c11fe 100644 --- a/scripts/lib/selfhost-deploy-common.sh +++ b/scripts/lib/selfhost-deploy-common.sh @@ -79,6 +79,27 @@ env_put() { rm -f "$tmp" } +# Optional Infisical wrapper (#5120): when SELFHOST_USE_INFISICAL=1 (opt-in, off by default), prefixes the +# given command with `infisical run --` so Infisical-sourced secrets are injected as real process env vars at +# launch -- Infisical's own intended integration shape, requiring zero changes to how src/ reads env.SOMETHING. +# Strictly additive: with the flag unset/0, this is a transparent passthrough and the existing .env/Docker +# Compose secrets: path is completely unaffected. +# +# `infisical run --` only injects vars into ITS OWN child process's environment, so this must wrap the compose +# `up` invocation directly (the container's actual process launch), not some earlier step -- a var it injects +# is visible to `docker compose up` for interpolating `${VAR}` in docker-compose.yml's own `environment:` +# blocks, but NOT to a blanket `env_file: .env` passthrough (that reads the FILE's literal contents at +# container-runtime, unaffected by the calling shell's environment). See the self-hosting docs for which +# variables can actually be Infisical-sourced today given that distinction. +maybe_infisical_run() { + if [ "${SELFHOST_USE_INFISICAL:-0}" = "1" ]; then + require_cmd infisical + infisical run -- "$@" + else + "$@" + fi +} + compose_file_args() { local files=() local file diff --git a/test/unit/selfhost-deploy-common.test.ts b/test/unit/selfhost-deploy-common.test.ts new file mode 100644 index 0000000000..ef2b800742 --- /dev/null +++ b/test/unit/selfhost-deploy-common.test.ts @@ -0,0 +1,117 @@ +import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { spawnSync } from "node:child_process"; +import { describe, expect, it } from "vitest"; + +const libPath = resolve("scripts/lib/selfhost-deploy-common.sh"); + +function readOptional(path: string): string { + try { + return readFileSync(path, "utf8"); + } catch { + return ""; + } +} + +function createHarness() { + const dir = mkdtempSync(join(tmpdir(), "gittensory-selfhost-deploy-common-")); + const binDir = join(dir, "bin"); + const infisicalLog = join(dir, "infisical-calls.log"); + mkdirSync(binDir); + + // Wraps a plain `echo` so a test can tell whether the wrapped command actually ran (its own stdout) and, + // separately, whether it ran directly or via the fake infisical binary below (that binary's own log). + const wrapperPath = join(dir, "wrapper.sh"); + writeFileSync( + wrapperPath, + `#!/usr/bin/env bash +set -euo pipefail +. "${libPath.replace(/\\/g, "/")}" +maybe_infisical_run echo actual-command-ran +`, + ); + chmodSync(wrapperPath, 0o755); + + function writeFakeInfisical() { + writeFileSync( + join(binDir, "infisical"), + `#!/usr/bin/env bash +printf '%s\\n' "$*" >> "${infisicalLog.replace(/\\/g, "/")}" +if [ "\${1:-}" = "run" ] && [ "\${2:-}" = "--" ]; then + shift 2 + exec "$@" +fi +exit 1 +`, + ); + chmodSync(join(binDir, "infisical"), 0o755); + } + + return { + dir, + cleanup: () => rmSync(dir, { recursive: true, force: true }), + readInfisicalCalls: () => readOptional(infisicalLog), + writeFakeInfisical, + run(env: Record = {}) { + return spawnSync("bash", [wrapperPath], { + cwd: dir, + encoding: "utf8", + env: { ...process.env, PATH: `${binDir}:${process.env.PATH ?? ""}`, ...env }, + }); + }, + }; +} + +describe("maybe_infisical_run (#5120)", () => { + it("runs the wrapped command directly when SELFHOST_USE_INFISICAL is unset -- the zero-dependency default path", () => { + const harness = createHarness(); + try { + const result = harness.run(); + expect(result.status, result.stderr).toBe(0); + expect(result.stdout).toContain("actual-command-ran"); + expect(harness.readInfisicalCalls()).toBe(""); + } finally { + harness.cleanup(); + } + }); + + it("runs the wrapped command directly when SELFHOST_USE_INFISICAL=0 (explicit opt-out, same as default)", () => { + const harness = createHarness(); + try { + const result = harness.run({ SELFHOST_USE_INFISICAL: "0" }); + expect(result.status, result.stderr).toBe(0); + expect(result.stdout).toContain("actual-command-ran"); + expect(harness.readInfisicalCalls()).toBe(""); + } finally { + harness.cleanup(); + } + }); + + it("prefixes the command with `infisical run --` when SELFHOST_USE_INFISICAL=1 and infisical is available", () => { + const harness = createHarness(); + harness.writeFakeInfisical(); + try { + const result = harness.run({ SELFHOST_USE_INFISICAL: "1" }); + expect(result.status, result.stderr).toBe(0); + // The wrapped command still genuinely ran (via infisical's own exec passthrough)... + expect(result.stdout).toContain("actual-command-ran"); + // ...and it ran THROUGH infisical, not directly -- proving the opt-in actually wires the wrapper in. + expect(harness.readInfisicalCalls()).toBe("run -- echo actual-command-ran\n"); + } finally { + harness.cleanup(); + } + }); + + it("fails closed with a clear error when SELFHOST_USE_INFISICAL=1 but infisical is not installed", () => { + const harness = createHarness(); + try { + const result = harness.run({ SELFHOST_USE_INFISICAL: "1" }); + expect(result.status).not.toBe(0); + expect(result.stderr).toContain("required command not found: infisical"); + expect(result.stdout).not.toContain("actual-command-ran"); + } finally { + harness.cleanup(); + } + }); +}); diff --git a/test/unit/selfhost-image-deploy.test.ts b/test/unit/selfhost-image-deploy.test.ts index 3ae62f1621..60fb6a87aa 100644 --- a/test/unit/selfhost-image-deploy.test.ts +++ b/test/unit/selfhost-image-deploy.test.ts @@ -32,6 +32,7 @@ function createHarness() { const binDir = join(dir, "bin"); const dockerCalls = join(dir, "docker-calls.log"); const dockerImages = join(dir, "docker-images.log"); + const infisicalCalls = join(dir, "infisical-calls.log"); const envPath = join(dir, ".env"); mkdirSync(binDir); @@ -83,12 +84,29 @@ exit 1 ); chmodSync(join(binDir, "docker"), 0o755); + function writeFakeInfisical() { + writeFileSync( + join(binDir, "infisical"), + `#!/usr/bin/env bash +printf '%s\\n' "$*" >> "$INFISICAL_CALLS" +if [ "\${1:-}" = "run" ] && [ "\${2:-}" = "--" ]; then + shift 2 + exec "$@" +fi +exit 1 +`, + ); + chmodSync(join(binDir, "infisical"), 0o755); + } + return { dir, envPath, cleanup: () => rmSync(dir, { recursive: true, force: true }), readCalls: () => readOptional(dockerCalls), readImages: () => readOptional(dockerImages), + readInfisicalCalls: () => readOptional(infisicalCalls), + writeFakeInfisical, run(options: RunOptions = {}) { if (options.envFile !== undefined) writeFileSync(envPath, options.envFile); const result = spawnSync("bash", [scriptPath, ...(options.args ?? [])], { @@ -102,6 +120,7 @@ exit 1 DOCKER_CALLS: dockerCalls, DOCKER_IMAGES: dockerImages, DOCKER_INSPECT_STATUS: options.dockerStatus ?? "healthy", + INFISICAL_CALLS: infisicalCalls, ...(options.env ?? {}), }, }); @@ -235,4 +254,46 @@ describe("self-host image deploy script", () => { harness.cleanup(); } }); + + describe("optional Infisical wrapper (#5120)", () => { + it("does not invoke infisical by default -- the restart step runs docker compose directly", () => { + const { harness, result } = runHarness(); + try { + expect(result.status, result.stderr).toBe(0); + expect(harness.readCalls()).toContain("up -d --no-build --no-deps loopover"); + expect(harness.readInfisicalCalls()).toBe(""); + } finally { + harness.cleanup(); + } + }); + + it("wraps only the restart (up) step with `infisical run --` when SELFHOST_USE_INFISICAL=1", () => { + const harness = createHarness(); + harness.writeFakeInfisical(); + try { + const result = harness.run({ env: { SELFHOST_USE_INFISICAL: "1" } }); + expect(result.status, result.stderr).toBe(0); + // The real docker compose invocation still happened (infisical's fake execs through to it)... + expect(harness.readCalls()).toContain("up -d --no-build --no-deps loopover"); + // ...but only the restart step went through infisical -- pull is a plain image fetch, not a process + // launch that needs injected secrets, so it must NOT be wrapped. + const infisicalCalls = harness.readInfisicalCalls(); + expect(infisicalCalls).toContain("run -- docker compose"); + expect(infisicalCalls).toContain("up -d --no-build --no-deps loopover"); + expect(infisicalCalls).not.toContain("pull --policy always"); + } finally { + harness.cleanup(); + } + }); + + it("fails closed with a clear error when SELFHOST_USE_INFISICAL=1 but infisical is not installed", () => { + const { harness, result } = runHarness({ env: { SELFHOST_USE_INFISICAL: "1" } }); + try { + expect(result.status).not.toBe(0); + expect(result.stderr).toContain("required command not found: infisical"); + } finally { + harness.cleanup(); + } + }); + }); });