From 31af44cb68d1e22817fc100f4cd67604a856bc7d Mon Sep 17 00:00:00 2001 From: sirily11 <32106111+sirily11@users.noreply.github.com> Date: Wed, 3 Jun 2026 18:01:13 +0800 Subject: [PATCH 1/2] feat(relay): deploy to cross-region cluster and fix Cloudflare TLS hostname Rework the relay k8s manifests for the new cross-region microk8s cluster and move the public endpoint to a Universal-SSL-covered hostname. - Deployment+HPA -> DaemonSet: one pod per node == one per region, auto-extends as regional nodes join; HPA dropped (DaemonSets scale with nodes, not CPU). - Ingress: ingressClassName `public` (Traefik), drop cert-manager issuer, the `tls:` block, and all `nginx.ingress.kubernetes.io/*` annotations. Cloudflare terminates TLS (Full mode); Traefik handles `/ws` natively. - Pin image to ghcr.io/rxtech-lab/rxcode-relay:v1.14.0 (latest git release). - Move endpoint relay.code.rxlab.app -> relaycode.rxlab.app. The 3-level name was not covered by the `*.rxlab.app` Universal SSL wildcard (one label only), so the CF edge served no cert and rejected every client TLS handshake. The 1-level name is covered for free. Updated ingress host, website relays.json, and the desktop RelayPresetCatalog fallback. Co-Authored-By: Claude Opus 4.8 (1M context) --- RxCode/Services/RelayPresetCatalog.swift | 2 +- .../k8s/{deployment.yaml => daemonset.yaml} | 23 +++++++++------ relay-server/k8s/hpa.yaml | 26 ----------------- relay-server/k8s/ingress.yaml | 28 ++++++++----------- relay-server/k8s/kustomization.yaml | 10 +++++-- website/public/relays.json | 4 +-- 6 files changed, 36 insertions(+), 57 deletions(-) rename relay-server/k8s/{deployment.yaml => daemonset.yaml} (69%) delete mode 100644 relay-server/k8s/hpa.yaml diff --git a/RxCode/Services/RelayPresetCatalog.swift b/RxCode/Services/RelayPresetCatalog.swift index e632acbd..2ff7f437 100644 --- a/RxCode/Services/RelayPresetCatalog.swift +++ b/RxCode/Services/RelayPresetCatalog.swift @@ -36,7 +36,7 @@ final class RelayPresetCatalog { RelayPreset( id: "rxlab-hosted", name: "RxLab Hosted Relay", - url: "wss://relay.code.rxlab.app", + url: "wss://relaycode.rxlab.app", description: "Official end-to-end encrypted relay operated by RxLab. Recommended for most setups.", recommended: true ) diff --git a/relay-server/k8s/deployment.yaml b/relay-server/k8s/daemonset.yaml similarity index 69% rename from relay-server/k8s/deployment.yaml rename to relay-server/k8s/daemonset.yaml index cab9ede3..c2e83903 100644 --- a/relay-server/k8s/deployment.yaml +++ b/relay-server/k8s/daemonset.yaml @@ -1,22 +1,27 @@ apiVersion: apps/v1 -kind: Deployment +kind: DaemonSet metadata: name: rxcode-relay namespace: rxcode-relay labels: app: rxcode-relay spec: - # Safe to scale: the Redis backplane (REDIS_URL) routes envelopes across - # replicas, so a desktop and mobile peer may land on different pods. - replicas: 2 - strategy: - type: RollingUpdate - rollingUpdate: - maxUnavailable: 0 - maxSurge: 1 + # One relay pod per node. Each node lives in a different AWS region + # (ap-east-1 / ap-southeast-1 / …), so a DaemonSet pins exactly one replica + # per region and auto-extends as new regional nodes join the cluster — no + # replica count or topology labels to maintain. The Redis backplane + # (REDIS_URL) still fans WebSocket envelopes across every pod, so a desktop + # and its mobile peer may connect to relays in different regions. selector: matchLabels: app: rxcode-relay + updateStrategy: + type: RollingUpdate + rollingUpdate: + # Keep serving WebSocket clients on the surviving regional pods while one + # node updates. With only one pod per node, maxUnavailable: 1 rolls a + # single region at a time. + maxUnavailable: 1 template: metadata: labels: diff --git a/relay-server/k8s/hpa.yaml b/relay-server/k8s/hpa.yaml deleted file mode 100644 index 86e09bb0..00000000 --- a/relay-server/k8s/hpa.yaml +++ /dev/null @@ -1,26 +0,0 @@ -apiVersion: autoscaling/v2 -kind: HorizontalPodAutoscaler -metadata: - name: rxcode-relay - namespace: rxcode-relay - labels: - app: rxcode-relay -spec: - scaleTargetRef: - apiVersion: apps/v1 - kind: Deployment - name: rxcode-relay - minReplicas: 2 - maxReplicas: 6 - metrics: - - type: Resource - resource: - name: cpu - target: - type: Utilization - averageUtilization: 70 - behavior: - scaleDown: - # WebSocket clients reconnect onto surviving pods; let connections drain - # before removing capacity rather than thrashing replicas. - stabilizationWindowSeconds: 300 diff --git a/relay-server/k8s/ingress.yaml b/relay-server/k8s/ingress.yaml index 257153da..e46a9578 100644 --- a/relay-server/k8s/ingress.yaml +++ b/relay-server/k8s/ingress.yaml @@ -5,24 +5,20 @@ metadata: namespace: rxcode-relay labels: app: rxcode-relay - annotations: - nginx.ingress.kubernetes.io/ssl-redirect: "true" - nginx.ingress.kubernetes.io/force-ssl-redirect: "true" - cert-manager.io/cluster-issuer: "letsencrypt-prod" - # WebSocket connections are long-lived: the relay only pings every 25s, so - # the proxy must not time out an otherwise idle /ws stream, and responses - # must stream through unbuffered. - nginx.ingress.kubernetes.io/proxy-read-timeout: "3600" - nginx.ingress.kubernetes.io/proxy-send-timeout: "3600" - nginx.ingress.kubernetes.io/proxy-buffering: "off" + # No cert-manager / TLS block: this cluster has no cert-manager, and + # relaycode.rxlab.app is fronted by a proxied Cloudflare Load Balancer that + # terminates the public TLS cert (Cloudflare SSL mode "Full"). The + # Cloudflare→origin leg rides Traefik's default self-signed cert, so the + # Ingress itself needs no `tls:` section. DNS for the host is owned by the + # cluster-service Pulumi stack — do not create a Cloudflare record here. + # + # Traefik (the MicroK8s ingress addon) handles WebSocket `/ws` upgrades + # natively, so the old nginx websocket/proxy-timeout annotations are dropped + # — Traefik ignores `nginx.ingress.kubernetes.io/*` entirely. spec: - ingressClassName: nginx - tls: - - hosts: - - relay.code.rxlab.app - secretName: rxcode-relay-tls + ingressClassName: public rules: - - host: relay.code.rxlab.app + - host: relaycode.rxlab.app http: paths: - path: / diff --git a/relay-server/k8s/kustomization.yaml b/relay-server/k8s/kustomization.yaml index c621fefd..32d89aea 100644 --- a/relay-server/k8s/kustomization.yaml +++ b/relay-server/k8s/kustomization.yaml @@ -10,14 +10,18 @@ resources: # secrets.yaml is intentionally excluded — apply it manually after filling in # real APNs values: `kubectl apply -f k8s/secrets.yaml`. - configmap.yaml - - deployment.yaml + # DaemonSet (one pod per node) instead of a Deployment + HPA: nodes are + # single-per-region, so one relay per node == one relay per region, and the + # set auto-extends as regional nodes join. No HPA — DaemonSets scale with + # nodes, not CPU. + - daemonset.yaml - service.yaml - ingress.yaml - pdb.yaml - - hpa.yaml namespace: rxcode-relay images: - name: ghcr.io/rxtech-lab/rxcode-relay - newTag: latest + # Latest release cut from git (HEAD 6bda708). Bump on each relay release. + newTag: v1.14.0 diff --git a/website/public/relays.json b/website/public/relays.json index 4d714c53..dc3a56ca 100644 --- a/website/public/relays.json +++ b/website/public/relays.json @@ -1,11 +1,11 @@ { "version": 1, - "updatedAt": "2026-05-20", + "updatedAt": "2026-06-03", "relays": [ { "id": "rxlab-hosted", "name": "RxLab Hosted Relay", - "url": "wss://relay.code.rxlab.app", + "url": "wss://relaycode.rxlab.app", "description": "Official end-to-end encrypted relay operated by RxLab. Recommended for most setups.", "recommended": true } From 8b5a73c343a78960cd3d28d6e468601838fe7c81 Mon Sep 17 00:00:00 2001 From: sirily11 <32106111+sirily11@users.noreply.github.com> Date: Wed, 3 Jun 2026 18:04:50 +0800 Subject: [PATCH 2/2] fix(relay): stamp /healthz version from the release tag at build time The /healthz `version` was a hardcoded "0.2.0" literal in health.go, decoupled from the image's release tag, so it drifted (deployed v1.14.0 still self-reported 0.2.0). - main.go: add `var version = "dev"`, overridable via -ldflags -X main.version. - health.go: report `version` instead of the literal. - Dockerfile: ARG VERSION=dev + -ldflags "-X main.version=${VERSION}". - relay-deploy.yaml: pass build-args VERSION=${{ github.ref_name }} on release. Verified: ldflags build reports the stamped tag, plain build reports "dev". Takes effect on the next release build. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/relay-deploy.yaml | 2 ++ Packages/Sources/RxCodeSync/Transport/RelayClient.swift | 6 ++++++ relay-server/Dockerfile | 5 ++++- relay-server/health.go | 2 +- relay-server/main.go | 6 ++++++ 5 files changed, 19 insertions(+), 2 deletions(-) diff --git a/.github/workflows/relay-deploy.yaml b/.github/workflows/relay-deploy.yaml index 9eb02903..bbaf2571 100644 --- a/.github/workflows/relay-deploy.yaml +++ b/.github/workflows/relay-deploy.yaml @@ -87,6 +87,8 @@ jobs: context: relay-server platforms: linux/amd64 push: true + build-args: | + VERSION=${{ github.ref_name }} tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} cache-from: type=gha,scope=rxcode-relay diff --git a/Packages/Sources/RxCodeSync/Transport/RelayClient.swift b/Packages/Sources/RxCodeSync/Transport/RelayClient.swift index 7372bf20..8c04b2ae 100644 --- a/Packages/Sources/RxCodeSync/Transport/RelayClient.swift +++ b/Packages/Sources/RxCodeSync/Transport/RelayClient.swift @@ -280,6 +280,12 @@ public actor RelayClient { closeSocketLocally() guard shouldReconnect else { return } reconnectAttempt += 1 + // Exponential backoff capped at 30s. Clamp the attempt counter once it + // reaches the ceiling: left unbounded, pow(2, n) eventually exceeds + // Int.max (then +inf) and the Int() conversion traps *before* min(30,…) + // can clamp it. 2^5 = 32 > 30, so the exponent never needs to exceed 5. + let maxBackoffExponent = 5 + if reconnectAttempt > maxBackoffExponent { reconnectAttempt = maxBackoffExponent } let delay = min(30, Int(pow(2.0, Double(reconnectAttempt)))) logger.error("[Relay] socket failed relay=\(self.relayURL.absoluteString, privacy: .public) error=\(error.localizedDescription, privacy: .public) reconnectIn=\(delay, privacy: .public)s") updateState(.reconnecting(nextAttemptInSeconds: delay)) diff --git a/relay-server/Dockerfile b/relay-server/Dockerfile index e82ccb4c..2e214965 100644 --- a/relay-server/Dockerfile +++ b/relay-server/Dockerfile @@ -3,7 +3,10 @@ WORKDIR /src COPY go.mod go.sum* ./ RUN go mod download COPY . . -RUN CGO_ENABLED=0 go build -o /out/relay . +# VERSION is stamped into main.version and surfaced at /healthz. Defaults to +# "dev"; the relay-deploy workflow passes the git release tag on release builds. +ARG VERSION=dev +RUN CGO_ENABLED=0 go build -ldflags "-X main.version=${VERSION}" -o /out/relay . FROM gcr.io/distroless/static:nonroot COPY --from=build /out/relay /relay diff --git a/relay-server/health.go b/relay-server/health.go index 626de5e9..02860ffc 100644 --- a/relay-server/health.go +++ b/relay-server/health.go @@ -24,7 +24,7 @@ func healthHandler(hub *Hub, apnsSender *PushSender, fcmSender *FCMSender) http. "apns": apnsSender != nil, "fcm": fcmSender != nil, "mode": mode, - "version": "0.2.0", + "version": version, } w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) diff --git a/relay-server/main.go b/relay-server/main.go index 6b770d0a..4b2ab217 100644 --- a/relay-server/main.go +++ b/relay-server/main.go @@ -18,6 +18,12 @@ import ( "github.com/joho/godotenv" ) +// version is the relay's reported build version, surfaced at /healthz. It +// defaults to "dev" for local/`go run` builds and is overridden at release +// build time via -ldflags "-X main.version=" (see Dockerfile + the +// relay-deploy workflow, which passes the git release tag). +var version = "dev" + func main() { // Load .env before reading any env vars so flag defaults see them. // Path overridable via RELAY_ENV_FILE; default ".env" in CWD. Missing