mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
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:
co-authored by
npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757
parent
d38a7ef775
commit
9b47c8548f
@@ -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
@@ -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
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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> {
|
||||
|
||||
Reference in New Issue
Block a user