relay: build and deploy the relay under a path of the existing host
All checks were successful
container / relay (push) Successful in 17m20s
container / remote (push) Successful in 36m11s

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.
This commit is contained in:
rob thijssen
2026-07-21 17:29:27 +03:00
parent db061184bc
commit e06652a57a
3 changed files with 118 additions and 17 deletions

View File

@@ -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)"

View File

@@ -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

View File

@@ -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);
}