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/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/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/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/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 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 }