Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -258,6 +258,12 @@ LOOPOVER_REVIEW_DRAFT=false
# # for the write tools specifically, flipping the config bind mount in
# # docker-compose.yml from :ro to :rw yourself -- see that file's
# # comment above the mount. See self-hosting-configuration.mdx.
# REDEPLOY_COMPANION_SOCKET_PATH=/run/loopover-redeploy.sock # filesystem path to the host redeploy companion's
# # Unix domain socket (systemd/loopover-redeploy-companion.service.example),
# # bind-mounted into this container at the SAME path. Only matters if
# # you're using loopover_admin_trigger_redeploy (#7723); also requires
# # REDEPLOY_COMPANION_TOKEN_FILE (secrets/README.md) to actually
# # authenticate. See self-hosting-configuration.mdx.
# COMPOSE_PROJECT_NAME=loopover # Docker Compose's own project name; also labels the log stream
# # Promtail ships to Loki. Change it to run two stacks on one host
# # (#4896) -- Compose namespaces container names, named volumes, and
Expand Down
44 changes: 43 additions & 1 deletion apps/loopover-ui/content/docs/self-hosting-configuration.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -345,7 +345,49 @@ Restart the `loopover` service after changing either (`docker compose up -d --no
Drop `dryRun` (or set it to `false`) to write for real once you're happy with the dry-run result. `scope` is `"global"` (the mount-root default file) or `"repo"` (pass `repoFullName`); `loopover_admin_get_config` additionally accepts `"effective"` to read the exact deep-merged view a real review sees, same as the "Private per-repo config" deep-merge described above. `loopover_admin_list_config_backups` takes the same `scope`/`repoFullName` pair and returns each backup's path and timestamp, newest first.

<Callout variant="warn" title="Boundaries">
These tools only read and write `LOOPOVER_REPO_CONFIG_DIR`. They do not trigger a redeploy, and they do not touch the public dashboard or `/v1/app/*` settings surface — `LOOPOVER_MCP_ADMIN_TOKEN` cannot sign into the control panel or call the routes `ADMIN_GITHUB_LOGINS` gates.
These three tools only read and write `LOOPOVER_REPO_CONFIG_DIR`. Triggering a redeploy is a separate tool with its own separate token — see "MCP redeploy trigger" below. None of the admin tools touch the public dashboard or `/v1/app/*` settings surface — `LOOPOVER_MCP_ADMIN_TOKEN` cannot sign into the control panel or call the routes `ADMIN_GITHUB_LOGINS` gates.
</Callout>

## MCP redeploy trigger

A fourth `admin`-category tool, `loopover_admin_trigger_redeploy` (#7723): pulls the published image, restarts this instance, and waits for it to report healthy — the same sequence `./scripts/deploy-selfhost-image.sh` runs by hand, triggered remotely through an MCP client instead. Same `LOOPOVER_MCP_ADMIN_ENABLED` + `LOOPOVER_MCP_ADMIN_TOKEN` gating as the config tools above, plus one more layer specific to this tool.

<Callout variant="warn" title="Why this needs a whole extra moving part, not just another admin tool">
Every other admin tool runs entirely inside this container. Triggering a redeploy can't: this container has to end up running a *new* image, which means something *outside* this container has to do the actual `docker compose pull && up -d`. The obvious shortcut — mount `/var/run/docker.sock` into this container so it can ask Docker to do it — is explicitly the thing this repo tells contributors never to do (see `docker-proxy`'s own comment in `docker-compose.yml`: a bind-mounted socket, even read-only, is "effectively host root"; `review-enrichment/src/analyzers/iac-misconfig.ts` flags exactly this pattern in the PRs this bot reviews for everyone else). Extending the existing `docker-proxy` sidecar (`tecnativa/docker-socket-proxy`) doesn't avoid the problem either — its access-control model has no way to scope a restart to *one* named container, only "any container reachable through the socket," and it has no concept of "pull a new image" at all (that's compose-level orchestration, not a single Docker Engine API call).

The design here instead runs a small **host companion** — a plain Node process, outside Docker entirely, installed as a systemd service (`systemd/loopover-redeploy-companion.service.example`) — that listens on a Unix domain socket and, on an authenticated request, shells out to the real `deploy-selfhost-image.sh`. This container reaches it via a narrow, purpose-built socket bind-mounted in (never the Docker socket itself), authenticated with its own separate shared secret (`REDEPLOY_COMPANION_TOKEN`) so a leaked `LOOPOVER_MCP_ADMIN_TOKEN` alone still can't trigger a redeploy — two independent credentials have to both be compromised, not one.
</Callout>

**Entirely opt-in.** Skip this whole section and the other three admin tools work exactly as documented above — `loopover_admin_trigger_redeploy` just reports `configured: false` until you set it up.

<CodeBlock
filename="shell"
code={`./scripts/selfhost-init-secrets.sh # generates secrets/redeploy_companion_token.txt
sudo useradd --system --no-create-home loopover-redeploy
sudo usermod -aG docker loopover-redeploy
sudo install -m 600 -o loopover-redeploy /dev/null /etc/loopover-redeploy-companion.env
printf 'REDEPLOY_COMPANION_TOKEN=%s\\n' "$(cat secrets/redeploy_companion_token.txt)" | \\
sudo tee /etc/loopover-redeploy-companion.env >/dev/null
sudo cp systemd/loopover-redeploy-companion.service.example /etc/systemd/system/loopover-redeploy-companion.service
# edit User/Group/WorkingDirectory in that file to match your host, then:
sudo systemctl daemon-reload
sudo systemctl enable --now loopover-redeploy-companion.service`}
/>

The `loopover` service's `docker-compose.yml` entry already bind-mounts the companion's socket and reads `REDEPLOY_COMPANION_TOKEN_FILE` — nothing to add there. Restart the `loopover` service once the companion is running so it picks up the token.

<CodeBlock
lang="json"
filename="example: loopover_admin_trigger_redeploy"
code={`{
"image": "ghcr.io/jsonbored/loopover-selfhost:orb-v0.1.0"
}`}
/>

`image` is optional — omit it to redeploy whatever `LOOPOVER_IMAGE` is already configured (the same default `deploy-selfhost-image.sh` itself uses). The tool call doesn't return until the companion's own health-wait finishes (or times out), and streams back every log line the real script printed along the way, so a failed redeploy comes back with the actual reason, not just a bare non-zero exit code.

<Callout variant="note" title="This restarts the very process serving the tool call">
A successful redeploy means this container gets replaced mid-request. The companion waits for the *new* container to report healthy before it responds, so the tool call itself completes normally either way — but expect the connection to briefly drop if you're also watching logs live.
</Callout>

## Config-as-code blocks with no dashboard equivalent
Expand Down
4 changes: 2 additions & 2 deletions apps/loopover-ui/content/docs/self-hosting-security.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ eyebrow: Self-hosting
]}
/>

`docker-compose.yml` ships native Docker Compose `secrets:` mounts for the highest-value secrets (the GitHub App private key, webhook secret, API/MCP/MCP-admin/internal-job tokens, the setup token, the two token-encryption master keys, the Orb enrollment secret, the PagerDuty routing key, and the Claude Code subscription token) — file-mounted at `/run/secrets/<name>`, never exposed via `docker inspect` or `docker compose config` the way a plain `environment:`/`env_file` value is. This is purely additive: an inline `.env` value always takes priority if you set both, so you can migrate one secret at a time, or not at all. See `secrets/README.md` for the full file list.
`docker-compose.yml` ships native Docker Compose `secrets:` mounts for the highest-value secrets (the GitHub App private key, webhook secret, API/MCP/MCP-admin/redeploy-companion/internal-job tokens, the setup token, the two token-encryption master keys, the Orb enrollment secret, the PagerDuty routing key, and the Claude Code subscription token) — file-mounted at `/run/secrets/<name>`, never exposed via `docker inspect` or `docker compose config` the way a plain `environment:`/`env_file` value is. This is purely additive: an inline `.env` value always takes priority if you set both, so you can migrate one secret at a time, or not at all. See `secrets/README.md` for the full file list.

<CodeBlock
filename="shell"
Expand All @@ -50,7 +50,7 @@ category a secret falls into before rotating it.
{
title: "Freely rotatable",
description:
"GITHUB_WEBHOOK_SECRET, LOOPOVER_API_TOKEN, LOOPOVER_MCP_TOKEN, LOOPOVER_MCP_ADMIN_TOKEN, INTERNAL_JOB_TOKEN, SELFHOST_SETUP_TOKEN, REES_SHARED_SECRET. Static bearer comparisons -- nothing is encrypted with them. Generate a new value, restart both sides. Only cost: updating callers holding the old value.",
"GITHUB_WEBHOOK_SECRET, LOOPOVER_API_TOKEN, LOOPOVER_MCP_TOKEN, LOOPOVER_MCP_ADMIN_TOKEN, INTERNAL_JOB_TOKEN, SELFHOST_SETUP_TOKEN, REES_SHARED_SECRET. Static bearer comparisons -- nothing is encrypted with them. Generate a new value, restart both sides. Only cost: updating callers holding the old value. REDEPLOY_COMPANION_TOKEN is the one exception in this list that needs a second manual step: it's shared between this container and the separate host companion process, so rotating it means updating BOTH secrets/redeploy_companion_token.txt and the companion's own /etc/loopover-redeploy-companion.env, not just one file.",
},
{
title: "Externally issued",
Expand Down
10 changes: 10 additions & 0 deletions apps/loopover-ui/src/lib/selfhost-env-reference.ts
Original file line number Diff line number Diff line change
Expand Up @@ -469,6 +469,14 @@ export const SELFHOST_ENV_REFERENCE_ROWS: SelfHostEnvReferenceRow[] = [
name: "QUEUE_STARTUP_JITTER_MIN_JOBS",
firstReference: "src/selfhost/queue-common.ts",
},
{
name: "REDEPLOY_COMPANION_SOCKET_PATH",
firstReference: "src/server.ts",
},
{
name: "REDEPLOY_COMPANION_TOKEN",
firstReference: "src/server.ts",
},
{
name: "REDIS_URL",
firstReference: "src/selfhost/preflight.ts",
Expand Down Expand Up @@ -658,6 +666,8 @@ export const SELFHOST_ENV_REFERENCE_MARKDOWN = [
"| `QUEUE_CONCURRENCY` | `src/selfhost/pg-queue.ts` |",
"| `QUEUE_DEAD_LETTER_AUTO_RETRY_MAX_EXTRA_ATTEMPTS` | `src/selfhost/queue-common.ts` |",
"| `QUEUE_STARTUP_JITTER_MIN_JOBS` | `src/selfhost/queue-common.ts` |",
"| `REDEPLOY_COMPANION_SOCKET_PATH` | `src/server.ts` |",
"| `REDEPLOY_COMPANION_TOKEN` | `src/server.ts` |",
"| `REDIS_URL` | `src/selfhost/preflight.ts` |",
"| `REVIEW_AUDIT_DIR` | `src/server.ts` |",
"| `REVIEW_AUDIT_S3_ACCESS_KEY_ID` | `src/server.ts` |",
Expand Down
17 changes: 17 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,13 @@ services:
# flipping the config mount below (under `volumes:`) from :ro to :rw yourself -- see its own comment.
LOOPOVER_MCP_ADMIN_ENABLED: "${LOOPOVER_MCP_ADMIN_ENABLED:-}"
LOOPOVER_MCP_ADMIN_TOKEN_FILE: "${LOOPOVER_MCP_ADMIN_TOKEN_FILE:-/run/secrets/loopover_mcp_admin_token}"
# loopover_admin_trigger_redeploy (#7723): a SEPARATE credential from LOOPOVER_MCP_ADMIN_TOKEN, checked
# by the host-side companion itself (systemd/loopover-redeploy-companion.service.example), not by this
# app -- defense in depth across the two independent layers (MCP-tool auth here, host-socket auth
# there). Entirely opt-in: absent the companion running on the host with a matching token, the tool call
# just reports connection-refused -- every other admin tool is unaffected.
REDEPLOY_COMPANION_SOCKET_PATH: "${REDEPLOY_COMPANION_SOCKET_PATH:-/run/loopover-redeploy.sock}"
REDEPLOY_COMPANION_TOKEN_FILE: "${REDEPLOY_COMPANION_TOKEN_FILE:-/run/secrets/redeploy_companion_token}"
INTERNAL_JOB_TOKEN_FILE: "${INTERNAL_JOB_TOKEN_FILE:-/run/secrets/internal_job_token}"
SELFHOST_SETUP_TOKEN_FILE: "${SELFHOST_SETUP_TOKEN_FILE:-/run/secrets/selfhost_setup_token}"
TOKEN_ENCRYPTION_SECRET_FILE: "${TOKEN_ENCRYPTION_SECRET_FILE:-/run/secrets/token_encryption_secret}"
Expand All @@ -164,6 +171,13 @@ services:
# anything; the app flag alone never makes this mount secretly writable. Flip it locally:
# - ./loopover-config:/config:rw
- ./loopover-config:/config:ro
# Host redeploy companion socket (#7723, opt-in -- see systemd/loopover-redeploy-companion.service.example
# for the host-side setup). Deliberately a single narrow Unix socket, never /var/run/docker.sock: this
# mount grants the app container reach to exactly one purpose-built request/response protocol on the
# host, not the full Docker Engine API. Absent (companion not installed) ⇒ Docker bind-mounts an empty
# placeholder file here ⇒ the admin tool's connection attempt fails closed with connection-refused, same
# "present but harmless when unconfigured" degradation as the config mount above.
- "${REDEPLOY_COMPANION_SOCKET_PATH:-/run/loopover-redeploy.sock}:${REDEPLOY_COMPANION_SOCKET_PATH:-/run/loopover-redeploy.sock}"
# Mounted read-only at /run/secrets/<name> (Compose's default target). See the top-level `secrets:`
# block above and secrets/README.md.
#
Expand All @@ -182,6 +196,7 @@ services:
- loopover_api_token
- loopover_mcp_token
- loopover_mcp_admin_token
- redeploy_companion_token
- internal_job_token
- selfhost_setup_token
- token_encryption_secret
Expand Down Expand Up @@ -1339,6 +1354,8 @@ secrets:
file: ./secrets/loopover_mcp_token.txt
loopover_mcp_admin_token:
file: ./secrets/loopover_mcp_admin_token.txt
redeploy_companion_token:
file: ./secrets/redeploy_companion_token.txt
internal_job_token:
file: ./secrets/internal_job_token.txt
selfhost_setup_token:
Expand Down
4 changes: 2 additions & 2 deletions scripts/deploy-selfhost-image.sh
Original file line number Diff line number Diff line change
Expand Up @@ -46,8 +46,8 @@ validate_inputs() {
exit 1
fi
case "$image" in
*[[:space:]\"\'\\\$\{\}]*)
echo "error: image contains unsupported whitespace, quote, backslash, or compose interpolation characters" >&2
*[[:space:]\"\'\\\$\{\}\`\;\|\&\<\>]*)
echo "error: image contains unsupported whitespace, quote, backslash, compose interpolation, or shell metacharacters" >&2
exit 1
;;
esac
Expand Down
Loading
Loading