diff --git a/.dockerignore b/.dockerignore index 0d10235..4db4f20 100644 --- a/.dockerignore +++ b/.dockerignore @@ -2,6 +2,7 @@ !Dockerfile !entrypoint.sh !healthcheck.sh +!openchamber-healthcheck.sh !AGENTS.md !skills/ !opencode.jsonc diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 5371c1e..eaeb2ff 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -17,8 +17,9 @@ jobs: run: | sh -n entrypoint.sh sh -n healthcheck.sh + sh -n openchamber-healthcheck.sh bash -n scripts/smoke-test.sh - shellcheck entrypoint.sh healthcheck.sh scripts/smoke-test.sh + shellcheck entrypoint.sh healthcheck.sh openchamber-healthcheck.sh scripts/smoke-test.sh - name: Set up pnpm uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0 - name: Set up Node.js @@ -32,4 +33,5 @@ jobs: - name: Validate Compose env: OPENCODE_PASSWORD: validation-only-not-a-deployment-password + OPENCHAMBER_UI_PASSWORD: validation-only-not-a-deployment-password run: docker compose config --quiet diff --git a/Dockerfile b/Dockerfile index cbc3adf..9603ec1 100644 --- a/Dockerfile +++ b/Dockerfile @@ -26,6 +26,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ && ln -s /usr/bin/fdfind /usr/local/bin/fd ARG OPENCODE_VERSION=2.0.27 +ARG OPENCHAMBER_VERSION=2.2.0 ARG TYPESCRIPT_VERSION=7.0.2 ARG PNPM_VERSION=12.11.2 ARG TARGETARCH @@ -38,9 +39,10 @@ RUN case "$TARGETARCH" in amd64) arch=x64 ;; arm64) arch=arm64 ;; *) exit 1 ;; e "https://registry.npmjs.org/@pnpm/exe.linux-${arch}/-/exe.linux-${arch}-${PNPM_VERSION}.tgz" \ | tar -xz --strip-components=1 -C /opt/pnpm-cli \ && ln -s /opt/pnpm-cli/pnpm /usr/local/bin/pnpm \ - && XDG_CONFIG_HOME=/opt/pnpm-config pnpm --allow-build=@opencode/cli add --global \ - "@opencode/cli@${OPENCODE_VERSION}" "typescript@${TYPESCRIPT_VERSION}" \ - && opencode --version + && XDG_CONFIG_HOME=/opt/pnpm-config pnpm --allow-build=@opencode/cli --allow-build=node-pty add --global \ + "@opencode/cli@${OPENCODE_VERSION}" "@openchamber/web@${OPENCHAMBER_VERSION}" "typescript@${TYPESCRIPT_VERSION}" \ + && opencode --version \ + && openchamber --version FROM tools AS config WORKDIR /build @@ -66,14 +68,14 @@ ENV HOME=/data \ COPY --from=config /build/runtime.json /etc/opencode/opencode.json COPY AGENTS.md /etc/opencode/AGENTS.md COPY skills /etc/opencode/skills -COPY --chmod=755 entrypoint.sh healthcheck.sh /usr/local/bin/ +COPY --chmod=755 entrypoint.sh healthcheck.sh openchamber-healthcheck.sh /usr/local/bin/ RUN mkdir -p /data/workspace /data/.config/opencode "$PNPM_HOME" \ && usermod --home /data node \ && chown -R node:node /data USER node WORKDIR /data/workspace -EXPOSE 4096 +EXPOSE 4096 3000 HEALTHCHECK --interval=30s --timeout=5s --start-period=120s --retries=3 \ CMD ["/usr/local/bin/healthcheck.sh"] ENTRYPOINT ["/usr/bin/tini", "--", "/usr/local/bin/entrypoint.sh"] diff --git a/README.md b/README.md index 137e907..99ef32a 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # OpenCode Server -Self-hosted [OpenCode V2](https://opencode.ai/v2/docs/) web UI and API. The image `ghcr.io/icco/opencode-server:main` supports amd64 and arm64. +Self-hosted [OpenCode V2](https://opencode.ai/v2/docs/) behind the [OpenChamber](https://docs.openchamber.dev/) web UI. The image `ghcr.io/icco/opencode-server:main` supports amd64 and arm64. ## Run @@ -9,10 +9,11 @@ From this checkout, with Docker Compose and OpenSSL installed: ```sh mkdir -p "$HOME/.ssh" export OPENCODE_PASSWORD="$(openssl rand -hex 32)" +export OPENCHAMBER_UI_PASSWORD="$(openssl rand -hex 32)" docker compose up -d ``` -Open and sign in as `opencode` with that password. Keep the password in your deployment's secret store and export it for later Compose commands; startup requires at least 32 characters. +Open and sign in with `OPENCHAMBER_UI_PASSWORD`. Keep both passwords in your deployment's secret store and export them for later Compose commands; startup requires at least 32 characters for each. OpenCode has no host port; only OpenChamber reaches it, over the Compose network. The `/data` volume preserves repositories, sessions, credentials, and caches. Keep repositories in `/data/workspace` and back up the volume. Bind mounts must be writable by UID/GID 1000. Compose also mounts the host's `~/.ssh` read-only; keys must be readable by UID 1000, and `known_hosts` must be maintained on the host. @@ -20,7 +21,7 @@ To update the image or apply changed environment variables: ```sh docker compose pull -docker compose up -d --force-recreate opencode +docker compose up -d --force-recreate ``` ## Connect accounts @@ -70,7 +71,18 @@ To replace the image defaults, install dependencies, edit `opencode.jsonc`, run ## Hosting -Compose binds only to `127.0.0.1:4096`. Put an HTTPS reverse proxy in front for remote access. A containerized proxy can share a network and use `opencode:4096` instead of the host port mapping. +Compose binds OpenChamber only to `127.0.0.1:3000`. Put an HTTPS reverse proxy in front for remote access, following [OpenChamber's reverse proxy guide](https://docs.openchamber.dev/reverse-proxy/). With Caddy: + +```caddyfile +code.example.com { + respond /health* 404 + reverse_proxy 127.0.0.1:3000 { + flush_interval -1 + } +} +``` + +`/health` is unauthenticated and reports diagnostics, so keep it off the internet. OpenChamber runs in [enterprise mode](https://docs.openchamber.dev/security/#enterprise-mode), which disables the relay and untrusted extensions. Login is rate limited; add a passkey under Settings after first sign-in. The container runs as UID 1000 with dropped capabilities and resource limits. Login grants code execution and access to stored credentials; this is a trusted-user service, not a multi-tenant sandbox. diff --git a/compose.yml b/compose.yml index 6642028..4cf4d4a 100644 --- a/compose.yml +++ b/compose.yml @@ -7,7 +7,7 @@ services: - ALL security_opt: - no-new-privileges:true - pids_limit: 512 + pids_limit: 2048 mem_limit: 4g cpus: 2 environment: @@ -17,11 +17,58 @@ services: LUNCHMONEY_API_TOKEN: ${LUNCHMONEY_API_TOKEN:-} GRAFANA_URL: ${GRAFANA_URL:-} GRAFANA_SERVICE_ACCOUNT_TOKEN: ${GRAFANA_SERVICE_ACCOUNT_TOKEN:-} - KARAKEEP_API_ADDR: ${KARAKEEP_API_ADDR:-https://hoard.natwelch.com} + KARAKEEP_API_ADDR: ${KARAKEEP_API_ADDR:-} KARAKEEP_API_KEY: ${KARAKEEP_API_KEY:-} OPENCODE_PASSWORD: ${OPENCODE_PASSWORD:?Set OPENCODE_PASSWORD} + volumes: + - data:/data + - type: bind + source: ${HOME}/.ssh + target: /data/.ssh + read_only: true + bind: + create_host_path: false + openchamber: + image: ghcr.io/icco/opencode-server:main + restart: unless-stopped + stop_grace_period: 30s + command: + - openchamber + - serve + - --foreground + - --host + - 0.0.0.0 + - --port + - "3000" + cap_drop: + - ALL + security_opt: + - no-new-privileges:true + pids_limit: 2048 + mem_limit: 4g + cpus: 2 + environment: + GH_TOKEN: ${GH_TOKEN:-} + OPENCODE_HOST: http://opencode:4096 + OPENCODE_SKIP_START: "true" + OPENCODE_PASSWORD: ${OPENCODE_PASSWORD:?Set OPENCODE_PASSWORD} + OPENCODE_SERVER_PASSWORD: ${OPENCODE_PASSWORD:?Set OPENCODE_PASSWORD} + OPENCHAMBER_UI_PASSWORD: ${OPENCHAMBER_UI_PASSWORD:?Set OPENCHAMBER_UI_PASSWORD} + OPENCHAMBER_ENTERPRISE_MODE: "1" + OPENCHAMBER_ALLOW_NETWORK_ACCESS: "1" + depends_on: + opencode: + condition: service_healthy + healthcheck: + test: + - CMD + - /usr/local/bin/openchamber-healthcheck.sh + interval: 30s + timeout: 5s + start_period: 60s + retries: 3 ports: - - 127.0.0.1:4096:4096 + - 127.0.0.1:3000:3000 volumes: - data:/data - type: bind diff --git a/entrypoint.sh b/entrypoint.sh index 650f541..5dc185f 100644 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -12,6 +12,13 @@ if [ "${#OPENCODE_PASSWORD}" -lt 32 ]; then exit 1 fi +# OpenChamber trims the password before use, so measure what it will store. +ui_password=$(printf "%s" "${OPENCHAMBER_UI_PASSWORD:-}" | sed -e "s/^[[:space:]]*//" -e "s/[[:space:]]*\$//") +if [ "${1:-}" = openchamber ] && [ "${#ui_password}" -lt 32 ]; then + echo "OPENCHAMBER_UI_PASSWORD must contain at least 32 characters; generate it with openssl rand -hex 32" >&2 + exit 1 +fi + mkdir -p "$XDG_CONFIG_HOME/opencode" "$XDG_CONFIG_HOME/gh" "$XDG_DATA_HOME/opencode" \ "$XDG_STATE_HOME/opencode" "$XDG_CACHE_HOME/opencode" "$HOME/workspace/.worktrees" diff --git a/openchamber-healthcheck.sh b/openchamber-healthcheck.sh new file mode 100755 index 0000000..1a38451 --- /dev/null +++ b/openchamber-healthcheck.sh @@ -0,0 +1,6 @@ +#!/bin/sh +set -eu + +# Healthy only when OpenChamber reaches OpenCode with its password. +curl --fail --silent --show-error --max-time 4 http://127.0.0.1:3000/health | + jq -e '.isOpenCodeReady == true and .openCodeSecureConnection == true' >/dev/null diff --git a/scripts/smoke-test.sh b/scripts/smoke-test.sh index 1807ca6..596dc11 100644 --- a/scripts/smoke-test.sh +++ b/scripts/smoke-test.sh @@ -50,7 +50,7 @@ test "$status" = 1 docker run -d --name "$name" \ --cap-drop ALL --security-opt no-new-privileges:true \ - --pids-limit 512 --memory 4g --cpus 2 \ + --pids-limit 2048 --memory 4g --cpus 2 \ -p 127.0.0.1::4096 \ -e "OPENCODE_PASSWORD=$password" \ -e LUNCHMONEY_API_TOKEN=catalog-test-only \ @@ -96,3 +96,48 @@ docker exec "$name" sh -ec ' /usr/local/bin/entrypoint.sh true cmp /etc/opencode/AGENTS.md "$XDG_CONFIG_HOME/opencode/AGENTS.md" ' + +# OpenChamber must refuse to start without a strong UI password. +for ui in "" short " a "; do + status=0 + timeout 15 docker run --rm -e "OPENCODE_PASSWORD=$password" -e "OPENCHAMBER_UI_PASSWORD=$ui" \ + "$image" openchamber serve --foreground || status=$? + test "$status" = 1 +done + +network="opencode-test-net-$$" +chamber="openchamber-test-$$" +ui_password=$(openssl rand -hex 24) +docker network create "$network" +docker network connect --alias opencode "$network" "$name" +docker run -d --name "$chamber" --network "$network" \ + --cap-drop ALL --security-opt no-new-privileges:true \ + -p 127.0.0.1::3000 \ + -e OPENCODE_HOST=http://opencode:4096 \ + -e OPENCODE_SKIP_START=true \ + -e "OPENCODE_PASSWORD=$password" \ + -e "OPENCODE_SERVER_PASSWORD=$password" \ + -e "OPENCHAMBER_UI_PASSWORD=$ui_password" \ + -e OPENCHAMBER_ENTERPRISE_MODE=1 \ + -e OPENCHAMBER_ALLOW_NETWORK_ACCESS=1 \ + "$image" openchamber serve --foreground --host 0.0.0.0 --port 3000 +trap 'docker logs "$chamber" || true; docker rm -f "$chamber" || true; cleanup; docker network rm "$network" || true' EXIT +chamber_url="http://127.0.0.1:$(docker port "$chamber" 3000/tcp | cut -d: -f2)" + +for attempt in $(seq 1 40); do + if docker exec "$chamber" /usr/local/bin/openchamber-healthcheck.sh; then + break + fi + printf 'Waiting for OpenChamber (%s/40)\n' "$attempt" + sleep 3 +done +docker exec "$chamber" /usr/local/bin/openchamber-healthcheck.sh + +test "$(curl -s -o /dev/null -w '%{http_code}' "$chamber_url/api/session")" = 401 +test "$(curl -s -o /dev/null -w '%{http_code}' -H 'Content-Type: application/json' \ + -d '{"password":"wrong-password"}' "$chamber_url/auth/session")" = 401 +jar=$(mktemp) +curl --fail -s -c "$jar" -H 'Content-Type: application/json' \ + -d "{\"password\":\"$ui_password\"}" "$chamber_url/auth/session" >/dev/null +curl --fail -s -b "$jar" "$chamber_url/api/session" | jq -e 'type == "array" or type == "object"' >/dev/null +rm -f "$jar"