Add optional standalone pairing relay to Helm chart (#1799)

Signed-off-by: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
Tyler
2026-07-13 11:36:04 -04:00
committed by GitHub
co-authored by npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757
parent d38a7ef775
commit 9b47c8548f
15 changed files with 454 additions and 64 deletions
@@ -1,11 +1,14 @@
name: Auto-tag on Release PR Merge
# Four release lanes share this one workflow — adding a lane is one more branch
# prefix, never a forked copy:
# Four release lanes share this one workflow. Three use an explicit branch
# prefix; the chart lane also auto-detects a Chart.yaml version bump so a chart
# feature PR can publish its own new version when merged:
#
# version-bump/<v> → tag v<v> → dispatch release.yml (desktop app)
# relay-release/<v> → tag relay-v<v> → dispatch docker.yml (relay image)
# chart-release/<v> → tag chart-v<v> → dispatch helm-chart.yml (helm chart)
# any internal PR that bumps deploy/charts/buzz/Chart.yaml `version`
# → tag chart-v<v> → dispatch helm-chart.yml (helm chart)
# mobile-release/<v> → tag mobile-v<v> → (manual sprout_ref for buzz-releases build — see below)
#
# The desktop, relay, and chart lanes dispatch their build workflow rather than
@@ -36,10 +39,6 @@ jobs:
auto-tag:
if: >
github.event.pull_request.merged == true &&
(startsWith(github.event.pull_request.head.ref, 'version-bump/') ||
startsWith(github.event.pull_request.head.ref, 'relay-release/') ||
startsWith(github.event.pull_request.head.ref, 'chart-release/') ||
startsWith(github.event.pull_request.head.ref, 'mobile-release/')) &&
github.event.pull_request.head.repo.full_name == github.repository
runs-on: ubuntu-latest
steps:
@@ -48,12 +47,14 @@ jobs:
ref: ${{ github.event.pull_request.merge_commit_sha }}
fetch-depth: 0
- name: Resolve lane and version from branch name
- name: Resolve release lane and version
id: release
env:
BRANCH: ${{ github.event.pull_request.head.ref }}
run: |
# Lane is decided by branch prefix: the tag prefix and whether a
# downstream workflow_dispatch is needed both follow from it.
# Explicit release branches retain their established behavior. For an
# ordinary internal PR, publish only when Chart.yaml itself changed
# and its version differs from the PR's base commit.
case "$BRANCH" in
version-bump/*)
VERSION="${BRANCH#version-bump/}"
@@ -72,21 +73,33 @@ jobs:
TAG_PREFIX="mobile-v"
DISPATCH="" ;;
*)
echo "::error::Unhandled branch prefix: '$BRANCH'"
exit 1 ;;
parent_sha="$(git rev-parse HEAD^)"
old_version="$(git show "${parent_sha}:deploy/charts/buzz/Chart.yaml" 2>/dev/null | awk '/^version:/ {print $2}')"
VERSION="$(awk '/^version:/ {print $2}' deploy/charts/buzz/Chart.yaml)"
if [ -z "$old_version" ] || [ -z "$VERSION" ] || [ "$old_version" = "$VERSION" ]; then
echo "No release branch or chart version bump — nothing to tag"
echo "enabled=false" >> "$GITHUB_OUTPUT"
exit 0
fi
TAG_PREFIX="chart-v"
DISPATCH="helm-chart" ;;
esac
if ! echo "$VERSION" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$'; then
echo "::error::Invalid version in branch name: '$VERSION'"
echo "::error::Invalid release version: '$VERSION'"
exit 1
fi
echo "version=$VERSION" >> "$GITHUB_ENV"
echo "tag=${TAG_PREFIX}${VERSION}" >> "$GITHUB_ENV"
echo "dispatch=$DISPATCH" >> "$GITHUB_ENV"
{
echo "enabled=true"
echo "version=$VERSION"
echo "tag=${TAG_PREFIX}${VERSION}"
echo "dispatch=$DISPATCH"
} >> "$GITHUB_OUTPUT"
echo "Tagging ${TAG_PREFIX}${VERSION}"
- name: Create and push tag
if: steps.release.outputs.enabled == 'true'
env:
TAG: ${{ env.tag }}
TAG: ${{ steps.release.outputs.tag }}
run: |
EXISTING_SHA="$(git ls-remote --tags origin "refs/tags/$TAG" | awk '{print $1}')"
if [ -n "$EXISTING_SHA" ]; then
@@ -104,16 +117,16 @@ jobs:
git push origin "$TAG"
- name: Trigger release build
# The desktop and relay lanes dispatch their build workflow because the
# consumer's on:push:tags trigger is dead for auto-pushed tags (default
# GITHUB_TOKEN, recursion guard — see header comment). Mobile has no
# The desktop, relay, and chart lanes dispatch their build workflow
# because the consumer's on:push:tags trigger is dead for tags pushed
# by GITHUB_TOKEN (recursion guard — see header comment). Mobile has no
# consumer yet, so dispatch="" skips this step entirely.
if: ${{ env.dispatch != '' }}
if: steps.release.outputs.enabled == 'true' && steps.release.outputs.dispatch != ''
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
DISPATCH: ${{ env.dispatch }}
VERSION: ${{ env.version }}
TAG: ${{ env.tag }}
DISPATCH: ${{ steps.release.outputs.dispatch }}
VERSION: ${{ steps.release.outputs.version }}
TAG: ${{ steps.release.outputs.tag }}
run: |
# Both workflows take the bare version (for the build) and the tag ref
# (so the dispatch builds the tagged commit, not main).
+4 -1
View File
@@ -45,8 +45,10 @@ RUN cargo chef cook --release --recipe-path recipe.json
COPY . .
RUN cargo build --release --locked -p buzz-relay --bin buzz-relay \
-p buzz-admin --bin buzz-admin \
-p buzz-pair-relay --bin buzz-pair-relay \
&& strip target/release/buzz-relay \
&& strip target/release/buzz-admin
&& strip target/release/buzz-admin \
&& strip target/release/buzz-pair-relay
# ─── Stage 4: web bundle (pnpm + vite) ──────────────────────────────────────
# Independent of the Rust layers so a CSS change doesn't bust Rust cache and
@@ -87,6 +89,7 @@ RUN apt-get update \
COPY --from=builder /build/target/release/buzz-relay /usr/local/bin/buzz-relay
COPY --from=builder /build/target/release/buzz-admin /usr/local/bin/buzz-admin
COPY --from=builder /build/target/release/buzz-pair-relay /usr/local/bin/buzz-pair-relay
COPY --from=web-builder /build/web/dist /srv/buzz/web
ENV BUZZ_WEB_DIR=/srv/buzz/web
+9 -1
View File
@@ -6,7 +6,15 @@ use tokio::net::TcpListener;
#[tokio::main]
async fn main() {
let addr: SocketAddr = ([127, 0, 0, 1], 5000).into();
let addr_raw =
std::env::var("BUZZ_PAIR_RELAY_BIND_ADDR").unwrap_or_else(|_| "127.0.0.1:5000".to_string());
let addr: SocketAddr = match addr_raw.parse() {
Ok(addr) => addr,
Err(e) => {
eprintln!("fatal: invalid BUZZ_PAIR_RELAY_BIND_ADDR {addr_raw:?}: {e}");
std::process::exit(1);
}
};
let listener = match TcpListener::bind(addr).await {
Ok(l) => l,
Err(e) => {
+41
View File
@@ -33,6 +33,8 @@ pub struct Config {
pub redis_url: String,
/// Public WebSocket URL of this relay, advertised in NIP-11.
pub relay_url: String,
/// Public WebSocket URL of the dedicated device-pairing relay, when configured.
pub pairing_relay_url: Option<String>,
/// Maximum number of concurrent WebSocket connections.
pub max_connections: usize,
/// Maximum number of concurrently executing message handlers.
@@ -234,6 +236,25 @@ impl Config {
let relay_url =
std::env::var("RELAY_URL").unwrap_or_else(|_| "ws://localhost:3000".to_string());
let pairing_relay_url = std::env::var("BUZZ_PAIRING_RELAY_URL")
.ok()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
.map(|value| {
let parsed = url::Url::parse(&value).map_err(|e| {
ConfigError::InvalidValue(format!(
"BUZZ_PAIRING_RELAY_URL must be a valid ws:// or wss:// URL: {e}"
))
})?;
if !matches!(parsed.scheme(), "ws" | "wss") || parsed.host_str().is_none() {
return Err(ConfigError::InvalidValue(
"BUZZ_PAIRING_RELAY_URL must be a valid ws:// or wss:// URL".to_string(),
));
}
Ok(value)
})
.transpose()?;
let max_connections = std::env::var("BUZZ_MAX_CONNECTIONS")
.ok()
.and_then(|v| v.parse().ok())
@@ -508,6 +529,7 @@ impl Config {
database_url,
redis_url,
relay_url,
pairing_relay_url,
max_connections,
max_concurrent_handlers,
send_buffer_size,
@@ -676,6 +698,25 @@ mod tests {
));
}
#[test]
fn pairing_relay_url_accepts_websocket_urls_and_rejects_http() {
let _guard = ENV_MUTEX.lock().unwrap();
std::env::set_var("BUZZ_PAIRING_RELAY_URL", "wss://pairing.buzz.xyz");
let config = Config::from_env().expect("config");
assert_eq!(
config.pairing_relay_url.as_deref(),
Some("wss://pairing.buzz.xyz")
);
std::env::set_var("BUZZ_PAIRING_RELAY_URL", "https://pairing.buzz.xyz");
let result = Config::from_env();
std::env::remove_var("BUZZ_PAIRING_RELAY_URL");
assert!(matches!(
result,
Err(ConfigError::InvalidValue(ref msg)) if msg.contains("BUZZ_PAIRING_RELAY_URL")
));
}
#[test]
fn max_frame_bytes_can_be_configured() {
let _guard = ENV_MUTEX.lock().unwrap();
+37 -7
View File
@@ -46,6 +46,9 @@ pub struct RelayInfo {
pub version: String,
/// Protocol and resource limits advertised to clients.
pub limitation: Option<RelayLimitation>,
/// Public WebSocket URL of the dedicated NIP-AB device-pairing relay.
#[serde(skip_serializing_if = "Option::is_none")]
pub pairing_relay_url: Option<String>,
/// Relay's own signing pubkey (NIP-11 `self` field, NIP-43).
#[serde(rename = "self", skip_serializing_if = "Option::is_none")]
pub relay_self: Option<String>,
@@ -132,6 +135,7 @@ impl RelayInfo {
icon: Option<&str>,
advertise_nip43: bool,
max_message_length: usize,
pairing_relay_url: Option<&str>,
) -> Self {
debug_assert!(
!advertise_nip43 || relay_self.is_some(),
@@ -154,6 +158,7 @@ impl RelayInfo {
software: "https://github.com/block/buzz".to_string(),
version: env!("CARGO_PKG_VERSION").to_string(),
limitation: Some(relay_limitation(max_message_length)),
pairing_relay_url: pairing_relay_url.map(str::to_string),
relay_self: relay_self.map(|s| s.to_string()),
}
}
@@ -185,6 +190,7 @@ pub(crate) async fn nip11_document(state: &crate::state::AppState, raw_host: &st
icon.as_deref(),
advertise_nip43,
state.config.max_frame_bytes,
state.config.pairing_relay_url.as_deref(),
)
}
@@ -248,11 +254,13 @@ pub(crate) fn nip11_facts(state: &crate::state::AppState) -> (Option<String>, bo
/// hard build break, the same way a deny-lint would. If you must change this
/// signature, you are changing the conformance contract: update the conformance
/// doc and prove the new input is host-scoped, not unscoped, first.
#[allow(clippy::type_complexity)]
const _RELAY_INFO_BUILD_STATIC_INPUT_FENCE: fn(
Option<&str>,
Option<&str>,
bool,
usize,
Option<&str>,
) -> RelayInfo = RelayInfo::build;
#[cfg(test)]
@@ -291,10 +299,31 @@ mod tests {
#[test]
fn build_advertises_buzz_repository_url() {
let info = RelayInfo::build(None, None, false, DEFAULT_MAX_FRAME_BYTES);
let info = RelayInfo::build(None, None, false, DEFAULT_MAX_FRAME_BYTES, None);
assert_eq!(info.software, "https://github.com/block/buzz");
}
#[test]
fn configured_pairing_relay_is_advertised_and_unset_value_is_omitted() {
let info = RelayInfo::build(
None,
None,
false,
DEFAULT_MAX_FRAME_BYTES,
Some("wss://pairing.buzz.xyz"),
);
let json = serde_json::to_value(&info).expect("serialize");
assert_eq!(
json.get("pairing_relay_url")
.and_then(|value| value.as_str()),
Some("wss://pairing.buzz.xyz")
);
let info = RelayInfo::build(None, None, false, DEFAULT_MAX_FRAME_BYTES, None);
let json = serde_json::to_value(&info).expect("serialize");
assert!(json.get("pairing_relay_url").is_none());
}
/// NIP-WP → NIP-11 mirror: a set workspace icon is served in the standard
/// `icon` field; no icon (or a cleared, empty icon) omits the field
/// entirely so the JSON matches pre-icon documents byte-for-byte.
@@ -305,6 +334,7 @@ mod tests {
Some("data:image/webp;base64,UklGRg=="),
false,
DEFAULT_MAX_FRAME_BYTES,
None,
);
assert_eq!(
info.icon.as_deref(),
@@ -317,7 +347,7 @@ mod tests {
);
for icon in [None, Some("")] {
let info = RelayInfo::build(None, icon, false, DEFAULT_MAX_FRAME_BYTES);
let info = RelayInfo::build(None, icon, false, DEFAULT_MAX_FRAME_BYTES, None);
assert!(info.icon.is_none());
let json = serde_json::to_value(&info).expect("serialize");
assert!(
@@ -337,7 +367,7 @@ mod tests {
#[test]
fn max_message_length_uses_configured_frame_limit() {
let info = RelayInfo::build(None, None, false, 262_144);
let info = RelayInfo::build(None, None, false, 262_144, None);
let limitation = info.limitation.expect("limitation");
assert_eq!(limitation.max_message_length, Some(262_144));
}
@@ -368,7 +398,7 @@ mod tests {
/// Open relay, ephemeral key — both `self` and NIP-43 are absent.
#[test]
fn build_open_relay_ephemeral_key_omits_self_and_nip43() {
let info = RelayInfo::build(None, None, false, DEFAULT_MAX_FRAME_BYTES);
let info = RelayInfo::build(None, None, false, DEFAULT_MAX_FRAME_BYTES, None);
assert!(info.relay_self.is_none());
assert!(!info.supported_nips.contains(&NIP_RELAY_MEMBERSHIP));
}
@@ -381,7 +411,7 @@ mod tests {
#[test]
fn build_open_relay_stable_key_advertises_self_but_not_nip43() {
let pk = "0000000000000000000000000000000000000000000000000000000000000001";
let info = RelayInfo::build(Some(pk), None, false, DEFAULT_MAX_FRAME_BYTES);
let info = RelayInfo::build(Some(pk), None, false, DEFAULT_MAX_FRAME_BYTES, None);
assert_eq!(info.relay_self.as_deref(), Some(pk));
assert!(!info.supported_nips.contains(&NIP_RELAY_MEMBERSHIP));
}
@@ -390,7 +420,7 @@ mod tests {
#[test]
fn build_membership_relay_advertises_self_and_nip43() {
let pk = "0000000000000000000000000000000000000000000000000000000000000001";
let info = RelayInfo::build(Some(pk), None, true, DEFAULT_MAX_FRAME_BYTES);
let info = RelayInfo::build(Some(pk), None, true, DEFAULT_MAX_FRAME_BYTES, None);
assert_eq!(info.relay_self.as_deref(), Some(pk));
assert!(info.supported_nips.contains(&NIP_RELAY_MEMBERSHIP));
}
@@ -401,6 +431,6 @@ mod tests {
#[test]
#[should_panic(expected = "advertise_nip43=true requires relay_self=Some")]
fn build_nip43_without_self_panics_in_debug() {
let _ = RelayInfo::build(None, None, true, DEFAULT_MAX_FRAME_BYTES);
let _ = RelayInfo::build(None, None, true, DEFAULT_MAX_FRAME_BYTES, None);
}
}
+1 -1
View File
@@ -7,7 +7,7 @@ description: |
PostgreSQL and Redis. Configurable for single-node evaluation
(subcharts on) and HA production (external services, existingSecret).
type: application
version: 0.1.2
version: 0.1.3
appVersion: "0.1.0"
home: https://github.com/block/buzz
sources:
+16
View File
@@ -52,6 +52,22 @@ See:
The chart fails at `helm install` / `helm template` time with a clear message if any of these are missing or malformed (see `templates/_validate.tpl`).
## Device pairing relay
The chart can run Buzz's stateless pairing WebSocket relay as an independent
Deployment and Service using the same image as the main relay:
```yaml
pairingRelay:
enabled: true
url: wss://pairing.example.com
```
`pairingRelay.url` is advertised in the main relay's NIP-11 document so Buzz
clients connect directly to the dedicated endpoint. The chart does not create
an Ingress or HTTPRoute for the pairing Service; route the public hostname to
`<release>-buzz-pairing:5000` with your platform's ingress configuration.
## HA (production)
`replicaCount > 1` hard-requires Redis:
@@ -121,3 +121,8 @@ secrets.existingSecret, use that. Otherwise use the chart-managed one.
{{- include "buzz.minioEndpoint" . -}}
{{- end -}}
{{- end -}}
{{- define "buzz.pairingRelaySelectorLabels" -}}
{{ include "buzz.selectorLabels" . }}
app.kubernetes.io/component: pairing-relay
{{- end -}}
@@ -46,6 +46,11 @@ surface at template time regardless of which manifest helm renders first.
{{- end -}}
{{- end -}}
{{/* Pairing relay deployment must have an advertised public URL. */}}
{{- if and .Values.pairingRelay.enabled (not .Values.pairingRelay.url) -}}
{{- fail "pairingRelay.url is required when pairingRelay.enabled=true" -}}
{{- end -}}
{{/* ingress + httproute mutually exclusive */}}
{{- if and .Values.ingress.enabled .Values.httproute.enabled -}}
{{- fail "ingress.enabled and httproute.enabled cannot both be true — choose one." -}}
@@ -69,6 +69,9 @@ spec:
- { name: BUZZ_HEALTH_PORT, value: {{ .Values.service.healthPort | quote }} }
- { name: BUZZ_METRICS_PORT, value: {{ .Values.service.metricsPort | quote }} }
- { name: RELAY_URL, value: {{ .Values.relayUrl | quote }} }
{{- if .Values.pairingRelay.url }}
- { name: BUZZ_PAIRING_RELAY_URL, value: {{ .Values.pairingRelay.url | quote }} }
{{- end }}
- { name: BUZZ_MEDIA_BASE_URL, value: {{ include "buzz.mediaBaseUrl" . | quote }} }
# ── Behavior ─────────────────────────────────────────────
@@ -0,0 +1,72 @@
{{- include "buzz.validate" . -}}
{{- if .Values.pairingRelay.enabled -}}
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "buzz.fullname" . }}-pairing
labels:
{{- include "buzz.labels" . | nindent 4 }}
app.kubernetes.io/component: pairing-relay
spec:
replicas: {{ .Values.pairingRelay.replicaCount }}
selector:
matchLabels:
{{- include "buzz.pairingRelaySelectorLabels" . | nindent 6 }}
template:
metadata:
labels:
{{- include "buzz.pairingRelaySelectorLabels" . | nindent 8 }}
{{- with .Values.pairingRelay.podLabels }}
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.pairingRelay.podAnnotations }}
annotations:
{{- toYaml . | nindent 8 }}
{{- end }}
spec:
securityContext:
{{- toYaml .Values.relay.securityContext | nindent 8 }}
containers:
- name: pairing-relay
image: {{ include "buzz.image" . }}
imagePullPolicy: {{ .Values.image.pullPolicy }}
command: ["/usr/local/bin/buzz-pair-relay"]
securityContext:
{{- toYaml .Values.relay.containerSecurityContext | nindent 12 }}
env:
- name: BUZZ_PAIR_RELAY_BIND_ADDR
value: "0.0.0.0:{{ .Values.pairingRelay.service.port }}"
ports:
- name: websocket
containerPort: {{ .Values.pairingRelay.service.port }}
protocol: TCP
readinessProbe:
tcpSocket:
port: websocket
livenessProbe:
tcpSocket:
port: websocket
resources:
{{- toYaml .Values.pairingRelay.resources | nindent 12 }}
---
apiVersion: v1
kind: Service
metadata:
name: {{ include "buzz.fullname" . }}-pairing
labels:
{{- include "buzz.labels" . | nindent 4 }}
app.kubernetes.io/component: pairing-relay
{{- with .Values.pairingRelay.service.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
type: {{ .Values.pairingRelay.service.type }}
selector:
{{- include "buzz.pairingRelaySelectorLabels" . | nindent 4 }}
ports:
- name: websocket
port: {{ .Values.pairingRelay.service.port }}
targetPort: websocket
protocol: TCP
{{- end }}
@@ -0,0 +1,54 @@
suite: optional pairing relay
templates:
- templates/deployment.yaml
- templates/secret-chart.yaml
- templates/pairing-relay.yaml
- templates/serviceaccount.yaml
tests:
- it: does not render the pairing relay by default
set:
relayUrl: wss://buzz.example.com
ownerPubkey: "0000000000000000000000000000000000000000000000000000000000000000"
externalPostgresql.url: postgres://u:p@h:5432/d
externalRedis.url: redis://h:6379
s3.endpoint: http://minio:9000
s3.accessKey: a
s3.secretKey: s
asserts:
- hasDocuments:
count: 0
template: templates/pairing-relay.yaml
- notContains:
path: spec.template.spec.containers[0].env
content:
name: BUZZ_PAIRING_RELAY_URL
template: templates/deployment.yaml
- it: renders and advertises the configured pairing relay
set:
relayUrl: wss://buzz.example.com
ownerPubkey: "0000000000000000000000000000000000000000000000000000000000000000"
externalPostgresql.url: postgres://u:p@h:5432/d
externalRedis.url: redis://h:6379
s3.endpoint: http://minio:9000
s3.accessKey: a
s3.secretKey: s
pairingRelay.enabled: true
pairingRelay.url: wss://pairing.buzz.xyz
asserts:
- equal:
path: kind
value: Deployment
documentIndex: 0
template: templates/pairing-relay.yaml
- equal:
path: kind
value: Service
documentIndex: 1
template: templates/pairing-relay.yaml
- contains:
path: spec.template.spec.containers[0].env
content:
name: BUZZ_PAIRING_RELAY_URL
value: wss://pairing.buzz.xyz
template: templates/deployment.yaml
+25
View File
@@ -230,6 +230,31 @@
"labels": { "type": "object" }
}
},
"pairingRelay": {
"type": "object",
"additionalProperties": false,
"properties": {
"enabled": { "type": "boolean" },
"url": {
"type": "string",
"pattern": "^(wss?://.+)?$",
"description": "Public WebSocket URL advertised to clients for NIP-AB device pairing."
},
"replicaCount": { "type": "integer", "minimum": 1 },
"service": {
"type": "object",
"additionalProperties": false,
"properties": {
"type": { "type": "string", "enum": ["ClusterIP", "NodePort", "LoadBalancer"] },
"port": { "type": "integer", "minimum": 1, "maximum": 65535 },
"annotations": { "type": "object" }
}
},
"podAnnotations": { "type": "object" },
"podLabels": { "type": "object" },
"resources": { "type": "object" }
}
},
"extraManifests": {
"type": "array"
}
+22
View File
@@ -150,6 +150,28 @@ relay:
extraEnv: []
extraEnvFrom: []
# ── Device pairing relay ─────────────────────────────────────────────────────
# Optional, stateless NIP-AB relay. When enabled, the main relay advertises
# pairingRelay.url in NIP-11 and Buzz clients use it instead of the legacy
# same-host /pair convention.
pairingRelay:
enabled: false
url: ""
replicaCount: 1
service:
type: ClusterIP
port: 5000
annotations: {}
podAnnotations: {}
podLabels: {}
resources:
requests:
cpu: "50m"
memory: "32Mi"
limits:
cpu: "250m"
memory: "128Mi"
# ── Service ──────────────────────────────────────────────────────────────────
service:
type: ClusterIP
+124 -31
View File
@@ -92,13 +92,16 @@ pub async fn start_pairing(
// own NIP-11 declaration of NIP-43 support rather than `auth_required`,
// which is also true for plain NIP-42 / NIP-OA relays where the main
// relay is reachable.
let qr_relay_url = if probe_relay_supports_nip43(&ws_url).await {
let mut url = url::Url::parse(&ws_url).map_err(|e| format!("invalid relay URL: {e}"))?;
let path = url.path().trim_end_matches('/').to_string();
url.set_path(&format!("{path}/pair"));
url.to_string()
} else {
ws_url.clone()
let qr_relay_url = match probe_pairing_relay(&ws_url).await {
PairingRelay::Configured(url) => url,
PairingRelay::LegacyPath => {
let mut url =
url::Url::parse(&ws_url).map_err(|e| format!("invalid relay URL: {e}"))?;
let path = url.path().trim_end_matches('/').to_string();
url.set_path(&format!("{path}/pair"));
url.to_string()
}
PairingRelay::MainRelay => ws_url.clone(),
};
let (session, qr_payload) = PairingSession::new_source(qr_relay_url);
@@ -413,27 +416,24 @@ fn parse_relay_event(text: &str, sub_id: &str) -> Option<nostr::Event> {
serde_json::from_value(arr[2].clone()).ok()
}
/// Check the relay's NIP-11 document to determine whether it advertises
/// NIP-43 (relay membership). Returns `true` only if NIP-43 appears in the
/// relay's `supported_nips`. Unreachable relays, malformed responses, and
/// non-`ws(s)://` URLs all return `false`: we'd rather fail loudly against
/// the main relay than misroute pairing to an undeployed `/pair` sidecar.
///
/// Converts the WebSocket URL to HTTP(S) and fetches `GET /` with
/// `Accept: application/nostr+json` per NIP-11.
///
/// We test for NIP-43 specifically rather than the broader
/// `limitation.auth_required` flag because the latter is also set on plain
/// NIP-42 / NIP-OA relays, which accept unpaired peers on the main relay
/// and have no `/pair` sidecar.
async fn probe_relay_supports_nip43(relay_url: &str) -> bool {
// Convert ws(s):// to http(s):// for the NIP-11 fetch.
/// Pairing route discovered from the main relay's NIP-11 document.
#[derive(Debug, PartialEq, Eq)]
enum PairingRelay {
Configured(String),
LegacyPath,
MainRelay,
}
/// Prefer the relay-advertised dedicated pairing URL. The legacy `/pair`
/// convention remains as a compatibility fallback for NIP-43 relays that do
/// not advertise the extension yet.
async fn probe_pairing_relay(relay_url: &str) -> PairingRelay {
let http_url = if let Some(rest) = relay_url.strip_prefix("wss://") {
format!("https://{rest}")
} else if let Some(rest) = relay_url.strip_prefix("ws://") {
format!("http://{rest}")
} else {
return false;
return PairingRelay::MainRelay;
};
let client = reqwest::Client::builder()
@@ -447,19 +447,112 @@ async fn probe_relay_supports_nip43(relay_url: &str) -> bool {
.send()
.await
{
Ok(r) => r,
Err(_) => return false, // can't reach relay — assume open
Ok(response) => response,
Err(_) => return PairingRelay::MainRelay,
};
let json: serde_json::Value = match resp.json().await {
Ok(v) => v,
Err(_) => return false,
Ok(value) => value,
Err(_) => return PairingRelay::MainRelay,
};
json.get("supported_nips")
.and_then(|v| v.as_array())
.map(|arr| arr.iter().any(|n| n.as_u64() == Some(43)))
.unwrap_or(false)
pairing_relay_from_nip11(&json)
}
fn pairing_relay_from_nip11(json: &serde_json::Value) -> PairingRelay {
if let Some(value) = json
.get("pairing_relay_url")
.and_then(|value| value.as_str())
{
if let Ok(url) = url::Url::parse(value) {
if matches!(url.scheme(), "ws" | "wss") && url.host_str().is_some() {
return PairingRelay::Configured(value.to_string());
}
}
}
if json
.get("supported_nips")
.and_then(|value| value.as_array())
.is_some_and(|nips| nips.iter().any(|nip| nip.as_u64() == Some(43)))
{
PairingRelay::LegacyPath
} else {
PairingRelay::MainRelay
}
}
#[cfg(test)]
mod pairing_relay_tests {
use super::{pairing_relay_from_nip11, probe_pairing_relay, PairingRelay};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
#[tokio::test]
async fn live_nip11_probe_discovers_configured_pairing_relay() {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
.await
.expect("bind test NIP-11 server");
let addr = listener.local_addr().expect("test server address");
let server = tokio::spawn(async move {
let (mut stream, _) = listener.accept().await.expect("accept NIP-11 request");
let mut request = vec![0; 2048];
let bytes_read = stream.read(&mut request).await.expect("read request");
let request = String::from_utf8_lossy(&request[..bytes_read]);
assert!(request.starts_with("GET / HTTP/1.1"));
assert!(request
.to_ascii_lowercase()
.contains("accept: application/nostr+json"));
let body = r#"{"pairing_relay_url":"ws://127.0.0.1:5000"}"#;
let response = format!(
"HTTP/1.1 200 OK\r\nContent-Type: application/nostr+json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
body.len()
);
stream
.write_all(response.as_bytes())
.await
.expect("write response");
});
assert_eq!(
probe_pairing_relay(&format!("ws://{addr}")).await,
PairingRelay::Configured("ws://127.0.0.1:5000".to_string())
);
server.await.expect("NIP-11 server task");
}
#[test]
fn configured_pairing_relay_takes_precedence_over_legacy_path() {
let document = serde_json::json!({
"pairing_relay_url": "wss://pairing.buzz.xyz",
"supported_nips": [43]
});
assert_eq!(
pairing_relay_from_nip11(&document),
PairingRelay::Configured("wss://pairing.buzz.xyz".to_string())
);
}
#[test]
fn invalid_pairing_relay_url_falls_back_to_legacy_path() {
let document = serde_json::json!({
"pairing_relay_url": "https://pairing.buzz.xyz",
"supported_nips": [43]
});
assert_eq!(
pairing_relay_from_nip11(&document),
PairingRelay::LegacyPath
);
}
#[test]
fn document_without_pairing_configuration_uses_main_relay() {
let document = serde_json::json!({ "supported_nips": [1, 11] });
assert_eq!(pairing_relay_from_nip11(&document), PairingRelay::MainRelay);
}
}
fn parse_auth_challenge(text: &str) -> Option<String> {