diff --git a/.gitea/workflows/container.yml b/.gitea/workflows/container.yml index 22e522f0..c3f0851c 100644 --- a/.gitea/workflows/container.yml +++ b/.gitea/workflows/container.yml @@ -1,15 +1,18 @@ name: container -# Build the self-hosted remote-server image from THIS repo and publish it to the -# Gitea registry as git.lair.cafe/lair/vibe-kanban-remote. +# Build the self-hosted images from THIS repo and publish them to the Gitea +# registry: +# +# git.lair.cafe/lair/vibe-kanban-remote crates/remote/Dockerfile :8081 +# git.lair.cafe/lair/vibe-kanban-relay crates/relay-tunnel/Dockerfile :8082 # # This build used to live in lair/containers, which is for *third-party* images # built from someone else's source. We own this fork now, so the build belongs # next to the code it builds — a source change and its image are one commit. # -# vibe-kanban ships its own Dockerfile (crates/remote/Dockerfile, context = repo -# root), so there is no vendored Containerfile here. Nothing in this workflow -# reaches out to GitHub: upstream is sunset and may disappear, and a build that -# asks github.com what to build would disappear with it. +# Both crates ship their own Dockerfile (context = repo root), so there is no +# vendored Containerfile here. Nothing in this workflow reaches out to GitHub: +# upstream is sunset and may disappear, and a build that asks github.com what to +# build would disappear with it. on: push: branches: [main] @@ -28,19 +31,19 @@ on: - ".gitea/workflows/container.yml" workflow_dispatch: -# Never let two builds race: they would fight over the :latest tag and the +# Never let two builds race: they would fight over the :latest tags and the # loser's digest would win at random. concurrency: group: container cancel-in-progress: false jobs: - vibe-kanban-remote: + remote: runs-on: - metal - podman - # The Rust workspace builds in release mode from a cold cache on a fresh - # runner; the default job timeout is not enough headroom. + # The Rust workspace builds in release mode; from a cold cache the default + # job timeout is not enough headroom. timeout-minutes: 120 steps: - uses: actions/checkout@v4 @@ -54,7 +57,6 @@ jobs: # Immutable tag first. :latest is a convenience for humans, never a # deployment target -- a floating tag decides which build you are # running based on whenever something last pulled. - echo "version=${version}" >> "$GITHUB_OUTPUT" echo "immutable=${version}-g${short}" >> "$GITHUB_OUTPUT" - name: login to registry @@ -69,12 +71,18 @@ jobs: # FEATURES is deliberately unset: the Dockerfile strips the private # billing crate only when it is empty (the self-host path), and we have # no access to BloopAI/vibe-kanban-private. - # VITE_RELAY_API_BASE_URL is baked into the SPA at build time; empty - # disables the relay/tunnel features, which we do not deploy. Changing - # it requires a rebuild, not a restart. + # + # VITE_RELAY_API_BASE_URL is baked into the SPA at build time and is + # ORIGIN-RELATIVE on purpose. It is not a switch that disables the + # relay -- leaving it empty makes Bootstrap.tsx fall back to + # window.location.origin, which aims relay calls at the remote API, + # which does not serve them. "/relay-api" instead means the SPA calls + # whichever host served it, so one build works unchanged behind both + # kanban.internal and a future public name, with no CORS and no second + # certificate. nginx strips the prefix and forwards to the relay. podman build --pull=newer \ -f crates/remote/Dockerfile \ - --build-arg VITE_RELAY_API_BASE_URL= \ + --build-arg VITE_RELAY_API_BASE_URL=/relay-api \ -t "${IMAGE}:${IMMUTABLE}" \ -t "${IMAGE}:latest" \ . @@ -108,3 +116,48 @@ jobs: echo echo "pin the quadlet to the immutable tag:" echo " Image=${IMAGE}:${IMMUTABLE}" + + relay: + runs-on: + - metal + - podman + timeout-minutes: 120 + steps: + - uses: actions/checkout@v4 + + - name: derive image tags + id: meta + run: | + set -euo pipefail + version=$(jq -r .version package.json) + short=$(git rev-parse --short HEAD) + echo "immutable=${version}-g${short}" >> "$GITHUB_OUTPUT" + + - name: login to registry + run: podman login -u ${{ gitea.actor }} -p ${{ secrets.REGISTRY_TOKEN }} git.lair.cafe + + - name: build + env: + IMMUTABLE: ${{ steps.meta.outputs.immutable }} + run: | + set -euo pipefail + IMAGE=git.lair.cafe/lair/vibe-kanban-relay + # No build args: the relay takes all of its configuration from the + # environment at runtime (DATABASE_URL, RELAY_LISTEN_ADDR and the JWT + # secret it shares with the remote server). It has no notion of its own + # public address, which is what makes mounting it under a path safe. + podman build --pull=newer \ + -f crates/relay-tunnel/Dockerfile \ + -t "${IMAGE}:${IMMUTABLE}" \ + -t "${IMAGE}:latest" \ + . + + - name: push + env: + IMMUTABLE: ${{ steps.meta.outputs.immutable }} + run: | + set -euo pipefail + IMAGE=git.lair.cafe/lair/vibe-kanban-relay + podman push "${IMAGE}:${IMMUTABLE}" + podman push "${IMAGE}:latest" + echo "published ${IMAGE}:${IMMUTABLE} (and :latest)" diff --git a/deploy/vibe-kanban-relay.container b/deploy/vibe-kanban-relay.container new file mode 100644 index 00000000..17f3c0de --- /dev/null +++ b/deploy/vibe-kanban-relay.container @@ -0,0 +1,44 @@ +# vibe-kanban relay — brokers pairing and the browser<->host data path. +# +# Hosts run the ordinary local vibe-kanban server (`npx vibe-kanban`, the +# `server` crate). It registers here over a websocket control channel and the +# browser reaches the host's local API through this relay via WebRTC, so this is +# the data path and not merely discovery. Without it the UI can list hosts +# (remote's GET /hosts) but can never pair one. +# +# Mounted on a PATH of the existing name rather than its own domain: +# https://kanban.internal/relay-api/ -> here, with nginx stripping the prefix. +# Every relay URL on both sides is composed as {base}/v1/..., and the relay has +# no notion of its own public address (RelayServerConfig is only database_url, +# listen_addr and jwt_secret), so a prefix is invisible to it. Same-origin also +# means no CORS, one less certificate and one less step@ renewal timer — and +# when kanban.l4ir.net lands it inherits the relay by copying one location +# block, with the SPA unchanged because its relay base is origin-relative. +# +# The JWT secret is deliberately the SAME as the remote server's: that is how +# the relay trusts tokens the remote issued. Both read /etc/vibe-kanban/env. +# +# Ordering: it needs the database, and it authenticates tokens minted by the +# remote server, so it starts after both. +[Unit] +Description=vibe-kanban relay server +After=network-online.target vibe-kanban-db.service vibe-kanban.service +Wants=network-online.target +Requires=vibe-kanban-db.service + +[Container] +Image=git.lair.cafe/lair/vibe-kanban-relay:REPLACE_WITH_IMMUTABLE_TAG +ContainerName=vibe-kanban-relay +Network=vibe-kanban.network +# Published to the LAN so the hanzalova edge proxy can reach it; the browser and +# the hosts both arrive through nginx, never directly. +PublishPort=27181:8082 +EnvironmentFile=/etc/vibe-kanban/env +Environment=RELAY_LISTEN_ADDR=0.0.0.0:8082 + +[Service] +Restart=always +TimeoutStartSec=120 + +[Install] +WantedBy=multi-user.target default.target diff --git a/packages/remote-web/src/shared/lib/relayHostApi.ts b/packages/remote-web/src/shared/lib/relayHostApi.ts index df1c48f5..e899ab59 100644 --- a/packages/remote-web/src/shared/lib/relayHostApi.ts +++ b/packages/remote-web/src/shared/lib/relayHostApi.ts @@ -154,11 +154,15 @@ export async function openRelayHostWebSocket( ); const signedPath = appendSignatureToPath(normalizedPath, signature); - const wsUrl = `${base_url}${signedPath}`.replace(/^http/i, "ws"); + // lair fork: the relay base may be origin-relative (e.g. "/relay-api") so one + // build can be served from more than one hostname. String-replacing http->ws + // silently leaves a relative URL, which the WebSocket constructor rejects. + // openBrowserWebSocket already resolves all three forms and is imported above. + const wsUrl = `${base_url}${signedPath}`; const signingContext = await createRelayWsSigningContext( context.pairedHost, signature, ); - return createRelaySignedWebSocket(new WebSocket(wsUrl), signingContext); + return createRelaySignedWebSocket(openBrowserWebSocket(wsUrl), signingContext); }