From e06652a57a2f97f176d656089d098d728c18fe9d Mon Sep 17 00:00:00 2001 From: rob thijssen Date: Tue, 21 Jul 2026 17:29:27 +0300 Subject: [PATCH] relay: build and deploy the relay under a path of the existing host Pairing needs a third service we never deployed. Hosts run the ordinary local vibe-kanban server (the `server` crate, shipped as `npx vibe-kanban`); it registers with a relay over a websocket control channel, and the browser reaches the host's local API through that relay over WebRTC. The remote server we deploy contains none of it -- its hosts.rs is a single read-only GET /hosts -- so the UI could list hosts and never pair one. Mount it on a path of the existing name (https://kanban.internal/relay-api/) rather than giving it a domain. Every relay URL on both sides is composed as {base}/v1/..., the host turns the base into wss:// by stripping only the scheme, and RelayServerConfig is just database_url/listen_addr/jwt_secret with no notion of its own public address and no redirects -- so a prefix is invisible to it. That buys same-origin (no CORS), one less certificate and one less renewal timer, and when kanban.l4ir.net lands it inherits the relay by copying one nginx location block. For that second hostname to work from the SAME build, the SPA's relay base is origin-relative ("/relay-api") rather than an absolute URL baked at build time. Two consequences: - The previous empty value was not "relay disabled", as the comment inherited from lair/containers claimed. Empty makes Bootstrap.tsx fall back to window.location.origin, aiming relay calls at the remote API, which does not serve them. Comment corrected. - fetch() paths take a relative base fine (plain concatenation), but relayHostApi built its websocket by string-replacing http->ws, which silently leaves a relative URL that the WebSocket constructor rejects. It now uses openBrowserWebSocket, already imported in that file, which resolves wss://, https:// and relative alike. The relay shares the remote's JWT secret -- that is how it trusts tokens the remote issued -- so both read /etc/vibe-kanban/env. --- .gitea/workflows/container.yml | 83 +++++++++++++++---- deploy/vibe-kanban-relay.container | 44 ++++++++++ .../remote-web/src/shared/lib/relayHostApi.ts | 8 +- 3 files changed, 118 insertions(+), 17 deletions(-) create mode 100644 deploy/vibe-kanban-relay.container 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); }