From c87e4fd5bfb9e2cb604dd79c20562713e4c33115 Mon Sep 17 00:00:00 2001 From: Devinder Singh Date: Mon, 26 Oct 2026 16:52:34 +0100 Subject: [PATCH 1/4] infra: add relay namespace and a limited deployer account --- k8s/platform/deployer.yaml | 63 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 63 insertions(+) create mode 100644 k8s/platform/deployer.yaml diff --git a/k8s/platform/deployer.yaml b/k8s/platform/deployer.yaml new file mode 100644 index 0000000..7df4811 --- /dev/null +++ b/k8s/platform/deployer.yaml @@ -0,0 +1,63 @@ +# The deploy path's Kubernetes side (step 1.6a). +# The pipeline's deploy script runs as the ServiceAccount "deployer". It may update the app +# inside the "relay" namespace and nothing else: no Secrets, no database, no other namespaces. +# Applied by hand by one of us (platform files are never applied by the pipeline). + +# The folder our app lives in, apart from system parts like kube-system and cert-manager. +apiVersion: v1 +kind: Namespace +metadata: + name: relay +--- +# An identity for a program (the deploy script), not for a person. +apiVersion: v1 +kind: ServiceAccount +metadata: + name: deployer + namespace: relay +--- +# What "deployer" may do, only inside the "relay" namespace. +# Just what steps 1.7–1.8 need. Later phases add rules when they need them: +# jobs (migrations, phase 2), networkpolicies (phase 4), horizontalpodautoscalers (phase 8). +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + name: deployer + namespace: relay +rules: + # Create and update the app's Deployments: kubectl apply needs get + create + patch. + # No "delete": a deploy replaces things, it never removes them. + - apiGroups: ["apps"] + resources: ["deployments"] + verbs: ["get", "list", "watch", "create", "update", "patch"] + # Read-only: "kubectl rollout status" and "rollout undo" look at the ReplicaSets + # (one per version) and the pods to follow and reverse a rollout. + - apiGroups: ["apps"] + resources: ["replicasets"] + verbs: ["get", "list", "watch"] + - apiGroups: [""] + resources: ["pods"] + verbs: ["get", "list", "watch"] + # The stable name in front of the pods, and the app's non-secret settings. + - apiGroups: [""] + resources: ["services", "configmaps"] + verbs: ["get", "list", "watch", "create", "update", "patch"] + # The route from api.myrelay.tech to the Service. + - apiGroups: ["networking.k8s.io"] + resources: ["ingresses"] + verbs: ["get", "list", "watch", "create", "update", "patch"] +--- +# Gives the Role above to the ServiceAccount. +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + name: deployer + namespace: relay +subjects: + - kind: ServiceAccount + name: deployer + namespace: relay +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: Role + name: deployer From e21ac1dba749f8d171ba5da7990cd80c22b0e5ef Mon Sep 17 00:00:00 2001 From: Devinder Singh Date: Tue, 27 Oct 2026 15:07:49 +0100 Subject: [PATCH 2/4] infra: add one-time setup script for the deploy user --- infra/server/setup-deploy-user.sh | 73 +++++++++++++++++++++++++++++++ 1 file changed, 73 insertions(+) create mode 100644 infra/server/setup-deploy-user.sh diff --git a/infra/server/setup-deploy-user.sh b/infra/server/setup-deploy-user.sh new file mode 100644 index 0000000..03d74c2 --- /dev/null +++ b/infra/server/setup-deploy-user.sh @@ -0,0 +1,73 @@ +#!/usr/bin/env bash +# One-time setup of the "deploy" user that GitHub Actions logs in as (step 1.6b). +# Safe to run again: each part checks what already exists. +# +# Run it on the VPS as root, with the public half of the deploy key: +# sudo bash setup-deploy-user.sh "ssh-ed25519 AAAA... relay-github-deploy" +set -euo pipefail + +PUBKEY="${1:?usage: setup-deploy-user.sh ''}" +KUBECTL="kubectl --kubeconfig /etc/rancher/k3s/k3s.yaml" # the admin kubeconfig, root only +HOME_DIR=/home/deploy + +# 1. The user: its own home folder, no password (so it can't log in with one), no sudo. +if ! id deploy >/dev/null 2>&1; then + useradd --create-home --shell /bin/bash deploy +fi + +# 2. The key, locked to one program. "restrict" also turns off terminals and forwarding, +# and command= runs relay-deploy whatever the client asks for (installed in step 1.6c). +install -d -m 700 -o deploy -g deploy "$HOME_DIR/.ssh" +echo "restrict,command=\"/usr/local/bin/relay-deploy\" $PUBKEY" > "$HOME_DIR/.ssh/authorized_keys" +chown deploy:deploy "$HOME_DIR/.ssh/authorized_keys" +chmod 600 "$HOME_DIR/.ssh/authorized_keys" + +# 3. A token for the "deployer" ServiceAccount (k8s/platform/deployer.yaml). +# Kubernetes fills in the token by itself; deleting this Secret revokes it. +$KUBECTL apply -f - <<'YAML' +apiVersion: v1 +kind: Secret +metadata: + name: deployer-token + namespace: relay + annotations: + kubernetes.io/service-account.name: deployer +type: kubernetes.io/service-account-token +YAML +TOKEN="" +for _ in $(seq 1 20); do + TOKEN=$($KUBECTL -n relay get secret deployer-token -o jsonpath='{.data.token}' | base64 -d || true) + [ -n "$TOKEN" ] && break + sleep 1 +done +[ -n "$TOKEN" ] || { echo "the deployer token never appeared" >&2; exit 1; } + +# 4. The deploy script's kubeconfig: the "deployer" badge, nothing more. +CA=$($KUBECTL config view --raw -o jsonpath='{.clusters[0].cluster.certificate-authority-data}') +install -d -m 700 -o deploy -g deploy "$HOME_DIR/.kube" +cat > "$HOME_DIR/.kube/config" < Date: Tue, 27 Oct 2026 16:43:28 +0100 Subject: [PATCH 3/4] infra: add the deploy script that only deploys commits on main --- infra/server/relay-deploy.sh | 55 ++++++++++++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) create mode 100644 infra/server/relay-deploy.sh diff --git a/infra/server/relay-deploy.sh b/infra/server/relay-deploy.sh new file mode 100644 index 0000000..af2fa71 --- /dev/null +++ b/infra/server/relay-deploy.sh @@ -0,0 +1,55 @@ +#!/usr/bin/env bash +# relay-deploy: the one program the deploy key may run (step 1.6c). +# +# GitHub Actions runs: ssh deploy@ +# SSH ignores that command and starts this script instead (the forced command in +# /home/deploy/.ssh/authorized_keys). What the client asked for arrives in +# $SSH_ORIGINAL_COMMAND, so for us it is the commit to deploy. Treat it as untrusted. +# +# Installed by root as /usr/local/bin/relay-deploy, owned by root, so the deploy user +# can run it but not change it. Everything it prints shows up in the GitHub Actions log. +set -euo pipefail + +REPO=/home/deploy/relay # our repo, cloned by setup-deploy-user.sh +export KUBECONFIG=/home/deploy/.kube/config # the "deployer" badge + +fail() { echo "relay-deploy: $*" >&2; exit 1; } + +# 0. One deploy at a time: a second deploy waits here for up to 5 minutes. +# The lock is released by itself when the script ends, even if it crashes. +exec 9>/home/deploy/.relay-deploy.lock +flock --wait 300 9 || fail "another deploy is still running" + +SHA="${SSH_ORIGINAL_COMMAND:-}" + +# 1. Gate 1: the input must be a full commit SHA, exactly 40 characters of 0-9 and a-f. +# It runs first, because $SHA is used in git and sed below. +if ! [[ "$SHA" =~ ^[0-9a-f]{40}$ ]]; then + fail "invalid commit SHA: $SHA" +fi + +# 2. Gate 2: the commit must be on main, which means it was merged through a reviewed PR. +git -C "$REPO" fetch --quiet origin main +if ! git -C "$REPO" merge-base --is-ancestor "$SHA" "origin/main"; then + fail "commit $SHA is not part of origin/main's history" +fi + +# 3. Move our copy of the repo to exactly that commit, so the manifests match the image. +# --force: this copy is only for deploys, so any stray local change is thrown away. +git -C "$REPO" checkout --quiet --force --detach "$SHA" +[ -d "$REPO/k8s/app" ] || fail "no k8s/app folder at commit $SHA" + +# 4. Apply every manifest in k8s/app, with the placeholder __GIT_SHA__ replaced by the commit. +# The "---" line between files keeps them separate YAML documents. +echo "deploying $SHA" +for file in "$REPO"/k8s/app/*.yaml; do + printf '\n---\n' + sed "s/__GIT_SHA__/$SHA/g" "$file" +done | kubectl apply -f - + +# 5. Wait until the new pods are ready. If they never are, go back to the previous version. +if ! kubectl rollout status deployment/relay-api --timeout=180s; then + kubectl rollout undo deployment/relay-api || echo "relay-deploy: nothing to roll back to" + fail "rollout of $SHA failed" +fi +echo "deployed $SHA" From b53d77976eeec4ea2c073cdf8158745b6e427d41 Mon Sep 17 00:00:00 2001 From: Devinder Singh Date: Tue, 27 Oct 2026 16:46:52 +0100 Subject: [PATCH 4/4] docs: explain how manifests are applied and record the deploy path --- docs/CONTEXT.md | 13 ++++++--- k8s/README.md | 71 +++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 81 insertions(+), 3 deletions(-) create mode 100644 k8s/README.md diff --git a/docs/CONTEXT.md b/docs/CONTEXT.md index 765cb83..9ae4d95 100644 --- a/docs/CONTEXT.md +++ b/docs/CONTEXT.md @@ -126,6 +126,10 @@ encrypting the signing-secret column (we protect the backups instead), OpenAPI t - **Firewalls:** one.com's network firewall + ufw. ufw allows 22, 80, 443 and the k3s networks 10.42.0.0/16 and 10.43.0.0/16. one.com's firewall allows 22, 80 and 443. - **kubectl:** only on the server (`ssh relay`, then `kubectl …`). Port 6443 stays closed. +- **Deploy path:** GitHub Actions (environment `production`, main only) logs in as the Linux + user `deploy`, whose key can only start `/usr/local/bin/relay-deploy`. The script deploys a + commit only if it is on main, using the Kubernetes account `deployer` (namespace `relay` + only, no Secrets). Which folder is applied how: [`k8s/README.md`](../k8s/README.md). - **Domain:** `myrelay.tech`, DNS at get.tech. A records for `api`, `cdn` (the Relay dashboard) and `grafana` (the ops dashboard: metrics and logs) all point to the VPS. **No AAAA records**: the cluster is IPv4-only, and Let's Encrypt prefers IPv6 when it exists. @@ -148,9 +152,12 @@ Phase 0 (server foundation) is done: VPS hardened, k3s, cert-manager with Let's issuers, and a temporary hello-world app on https://api.myrelay.tech with a trusted certificate. -Phase 1 (walking skeleton) has started: HTTP → HTTPS redirect (1.1) and DNS for cdn and -grafana (1.2) are done. Next: 1.3, the API skeleton in `api/` (Bun 1.4.4 + Fastify) with -`/health/live`, `/health/ready`, `/version` and the first `bun test`. +Phase 1 (walking skeleton) is well under way. Done: HTTP → HTTPS redirect (1.1), DNS for cdn +and grafana (1.2), the API skeleton in `api/` with its Docker image (1.3–1.4, PR #11), the CI +pipeline that tests every PR and pushes the image from main (1.5, PR #12), and the deploy path +(1.6): a `deploy` user whose key can only run `relay-deploy`, and a `deployer` account limited +to namespace `relay`. Next: 1.7, the API manifests in `k8s/app/`, then 1.8, the first deploy +through the pipeline. The plan in order: 1 walking skeleton · 2 database foundation · 3 core API · 4 worker · 5 dashboard on cdn (Abdikhani, in parallel) · 6 backups + Terraform (Abdikhani, in parallel) · diff --git a/k8s/README.md b/k8s/README.md new file mode 100644 index 0000000..f82ca19 --- /dev/null +++ b/k8s/README.md @@ -0,0 +1,71 @@ +# Kubernetes manifests + +How each folder gets onto the cluster, and who may apply it. + +| Folder | What's in it | Applied by | +|---|---|---| +| `k8s/platform/` | Cluster setup: Let's Encrypt issuers, Traefik settings, the `relay` namespace and the `deployer` account | One of us, by hand, after the PR is reviewed | +| `k8s/app/` (from step 1.7) | Our app in namespace `relay`: Deployments, Services, Ingresses, ConfigMaps | Only the pipeline, through `relay-deploy` | +| `k8s/hello/` | The temporary hello-world test app | By hand. Removed when the API takes over in step 1.7 | + +## Rules + +- **Platform files are never applied by the pipeline.** They decide who may do what, so a + person applies them after review. Otherwise the pipeline could widen its own rights. +- **`k8s/app/` holds only what the `deployer` account may change:** Deployments, Services, + Ingresses and ConfigMaps. A Namespace or a Secret there would make the deploy fail with + "forbidden". +- **Images in `k8s/app/` use the tag `__GIT_SHA__`.** `relay-deploy` replaces it with the + commit it deploys, so manifests and image always come from the same commit. Don't apply + these files by hand: the tag would be wrong. +- **No Secrets in git.** They are created on the server with `kubectl create secret`. +- **Git and the cluster must match.** Everything applied by hand is committed in the same PR. + +## Applying a platform file + +From the repo root on your laptop. Check first with a server-side dry run (nothing is saved), +then apply: + +```bash +ssh relay 'kubectl --kubeconfig ~/.kube/config apply --dry-run=server -f -' < k8s/platform/deployer.yaml +ssh relay 'kubectl --kubeconfig ~/.kube/config apply -f -' < k8s/platform/deployer.yaml +``` + +## How `k8s/app/` gets applied: the deploy path + +``` +merge to main ─► GitHub Actions (environment "production", main only) + 1. test, build, push ghcr.io/devinder-dev/relay-api: + 2. ssh deploy@ + │ +VPS Linux user "deploy": its key may only start /usr/local/bin/relay-deploy + │ + relay-deploy: must be 40 hex characters and part of main's history, + then check out that commit, apply k8s/app/ with the tag, + wait for the rollout, or undo it if it fails + │ every kubectl call uses the "deployer" account: + │ namespace relay only, no Secrets + ▼ + Deployment relay-api runs the new pods +``` + +The server-side parts live in `infra/server/` and are installed by hand, by an admin: + +| File | What it does | Install or update | +|---|---|---| +| `setup-deploy-user.sh` | One time: the `deploy` user, its forced command, the `deployer` kubeconfig, a clone of the repo | `ssh relay 'cat > /tmp/setup.sh' < infra/server/setup-deploy-user.sh`, then `ssh -t relay "sudo bash /tmp/setup.sh ''"` | +| `relay-deploy.sh` | The only program the deploy key may run | `ssh relay 'cat > /tmp/relay-deploy' < infra/server/relay-deploy.sh`, then `ssh -t relay 'sudo install -o root -g root -m 755 /tmp/relay-deploy /usr/local/bin/relay-deploy'` | + +`relay-deploy` is owned by root, so the `deploy` user can run it but not change it. If the +script changes in a PR, install it again after the merge, so the server matches git. + +**Deploying by hand** (admins only, for example to redeploy an older commit from main): + +```bash +ssh -t relay 'sudo -u deploy env SSH_ORIGINAL_COMMAND= /usr/local/bin/relay-deploy' +``` + +**If the deploy key leaks:** delete the `DEPLOY_SSH_KEY` secret in GitHub, empty +`/home/deploy/.ssh/authorized_keys` on the server, then make a new key and run +`setup-deploy-user.sh` again. To cut off the `deployer` account itself, delete the Secret +`deployer-token` in namespace `relay`; its token stops working at once.