mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
Fix local relay auth and community persistence
Signed-off-by: npub13n66s06epmqf2kc3v373ez8hj65cuzyvxzjf93vwpervxqn2u7jq2qd9je <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz>
This commit is contained in:
parent
007d43ee1a
commit
ed5633c6cf
@@ -1481,6 +1481,27 @@ impl Db {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Rebind the durable loopback community to this single-node process's
|
||||
/// current ephemeral authority while preserving its stable UUID and data.
|
||||
/// SQLite-only: production deployments never rewrite tenant host mappings.
|
||||
pub async fn rebind_single_node_community_host(
|
||||
&self,
|
||||
normalized_host: &str,
|
||||
owner_pubkey: &str,
|
||||
) -> Result<Option<CommunityRecord>> {
|
||||
if matches!(&self.backend, DbBackend::SQLite(_)) {
|
||||
return sqlite::rebind_single_node_community_host(
|
||||
self.sqlite_pool()?,
|
||||
normalized_host,
|
||||
owner_pubkey,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
Err(DbError::UnsupportedBackend(
|
||||
"single-node community rebind requires SQLite",
|
||||
))
|
||||
}
|
||||
|
||||
/// Ensure a configured community host exists and return its row.
|
||||
///
|
||||
/// This is the startup/config seeding path for N=1 deployments. Migrations
|
||||
|
||||
@@ -525,6 +525,85 @@ pub(crate) async fn community_of_channel(
|
||||
.transpose()
|
||||
}
|
||||
|
||||
pub(crate) async fn rebind_single_node_community_host(
|
||||
pool: &SqlitePool,
|
||||
normalized_host: &str,
|
||||
owner_pubkey: &str,
|
||||
) -> Result<Option<CommunityRecord>> {
|
||||
let mut tx = pool.begin().await?;
|
||||
|
||||
// Defect-era databases may contain several loopback communities, one per
|
||||
// ephemeral port. Prefer a community owned by this desktop identity, then
|
||||
// the one carrying the most events. This preserves the most useful UUID and
|
||||
// its channels/messages while making the current Host header resolvable.
|
||||
let row = sqlx::query(
|
||||
r#"SELECT c.id, c.host
|
||||
FROM communities c
|
||||
LEFT JOIN relay_members rm
|
||||
ON rm.community_id = c.id
|
||||
AND lower(rm.pubkey) = lower(?1)
|
||||
AND rm.role = 'owner'
|
||||
LEFT JOIN events e ON e.community_id = c.id
|
||||
WHERE c.archived_at IS NULL
|
||||
AND c.host LIKE '127.0.0.1:%'
|
||||
GROUP BY c.id, c.host, c.created_at
|
||||
ORDER BY (rm.pubkey IS NOT NULL) DESC, count(e.id) DESC, c.created_at ASC, c.id ASC
|
||||
LIMIT 1"#,
|
||||
)
|
||||
.bind(owner_pubkey)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?;
|
||||
let Some(row) = row else {
|
||||
tx.commit().await?;
|
||||
return Ok(None);
|
||||
};
|
||||
let record = community_record(row)?;
|
||||
if record.host.eq_ignore_ascii_case(normalized_host) {
|
||||
tx.commit().await?;
|
||||
return Ok(Some(record));
|
||||
}
|
||||
|
||||
// The OS may reuse a port that belongs to another stale defect-era row.
|
||||
// Swap that collision to the selected row's old authority transactionally,
|
||||
// using a temporary unique host to satisfy the case-insensitive constraint.
|
||||
if let Some(collision_id) = sqlx::query_scalar::<_, String>(
|
||||
"SELECT id FROM communities WHERE host = ?1 COLLATE NOCASE AND id <> ?2",
|
||||
)
|
||||
.bind(normalized_host)
|
||||
.bind(record.id.as_uuid().to_string())
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?
|
||||
{
|
||||
let temporary_host = format!("rebind-{}.invalid", Uuid::new_v4());
|
||||
sqlx::query("UPDATE communities SET host = ?2 WHERE id = ?1")
|
||||
.bind(&collision_id)
|
||||
.bind(&temporary_host)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
sqlx::query("UPDATE communities SET host = ?2 WHERE id = ?1")
|
||||
.bind(record.id.as_uuid().to_string())
|
||||
.bind(normalized_host)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
sqlx::query("UPDATE communities SET host = ?2 WHERE id = ?1")
|
||||
.bind(collision_id)
|
||||
.bind(&record.host)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
} else {
|
||||
sqlx::query("UPDATE communities SET host = ?2 WHERE id = ?1")
|
||||
.bind(record.id.as_uuid().to_string())
|
||||
.bind(normalized_host)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
}
|
||||
tx.commit().await?;
|
||||
Ok(Some(CommunityRecord {
|
||||
id: record.id,
|
||||
host: normalized_host.to_string(),
|
||||
}))
|
||||
}
|
||||
|
||||
pub(crate) async fn ensure_configured_community(
|
||||
pool: &SqlitePool,
|
||||
normalized_host: &str,
|
||||
@@ -4340,6 +4419,110 @@ mod tests {
|
||||
use super::*;
|
||||
use nostr::{EventBuilder, Keys, Kind, Tag, Timestamp};
|
||||
|
||||
#[tokio::test]
|
||||
async fn rebind_single_node_community_preserves_best_owner_uuid() {
|
||||
let pool = connect("sqlite::memory:").await.unwrap();
|
||||
let owner = "ab".repeat(32);
|
||||
let older = ensure_configured_community(&pool, "127.0.0.1:41001")
|
||||
.await
|
||||
.unwrap();
|
||||
let best = ensure_configured_community(&pool, "127.0.0.1:41002")
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query(
|
||||
"INSERT INTO relay_members (community_id, pubkey, role) VALUES (?1, ?2, 'owner')",
|
||||
)
|
||||
.bind(best.id.as_uuid().to_string())
|
||||
.bind(&owner)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
for id in [vec![1_u8; 32], vec![2_u8; 32]] {
|
||||
sqlx::query("INSERT INTO events (community_id,id,pubkey,created_at,kind,tags_json,content,sig,received_at,event_json) VALUES (?1,?2,?3,1,9,'[]','marker',?4,1,'{}')")
|
||||
.bind(best.id.as_uuid().to_string())
|
||||
.bind(id)
|
||||
.bind(vec![3_u8; 32])
|
||||
.bind(vec![4_u8; 64])
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
let rebound = rebind_single_node_community_host(&pool, "127.0.0.1:41999", &owner)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(rebound.id, best.id);
|
||||
assert_eq!(rebound.host, "127.0.0.1:41999");
|
||||
assert!(lookup_community_by_host(&pool, "127.0.0.1:41002")
|
||||
.await
|
||||
.unwrap()
|
||||
.is_none());
|
||||
assert_eq!(
|
||||
lookup_community_by_host(&pool, "127.0.0.1:41001")
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.id,
|
||||
older.id
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rebind_single_node_community_is_idempotent_for_current_host() {
|
||||
let pool = connect("sqlite::memory:").await.unwrap();
|
||||
let current = ensure_configured_community(&pool, "127.0.0.1:42000")
|
||||
.await
|
||||
.unwrap();
|
||||
let rebound = rebind_single_node_community_host(&pool, "127.0.0.1:42000", &"cd".repeat(32))
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(rebound.id, current.id);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rebind_single_node_community_swaps_stale_port_collision() {
|
||||
let pool = connect("sqlite::memory:").await.unwrap();
|
||||
let owner = "ef".repeat(32);
|
||||
let selected = ensure_configured_community(&pool, "127.0.0.1:43001")
|
||||
.await
|
||||
.unwrap();
|
||||
let stale = ensure_configured_community(&pool, "127.0.0.1:43002")
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query(
|
||||
"INSERT INTO relay_members (community_id, pubkey, role) VALUES (?1, ?2, 'owner')",
|
||||
)
|
||||
.bind(selected.id.as_uuid().to_string())
|
||||
.bind(&owner)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let rebound = rebind_single_node_community_host(&pool, "127.0.0.1:43002", &owner)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(rebound.id, selected.id);
|
||||
assert_eq!(
|
||||
lookup_community_by_host(&pool, "127.0.0.1:43002")
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.id,
|
||||
selected.id
|
||||
);
|
||||
assert_eq!(
|
||||
lookup_community_by_host(&pool, "127.0.0.1:43001")
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.id,
|
||||
stale.id
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn upgrades_phase_1_core_schema_before_dm_use() {
|
||||
let path = std::env::temp_dir().join(format!("buzz-db-upgrade-{}.sqlite", Uuid::new_v4()));
|
||||
|
||||
@@ -1232,7 +1232,14 @@ async fn run_single_node(config: Config, tracer_init: telemetry::TracerInit) ->
|
||||
"Cannot derive community host from BUZZ_RELAY_URL"
|
||||
));
|
||||
}
|
||||
let community = db.ensure_configured_community(&host).await?.id;
|
||||
let community = if let Some(owner) = config.relay_owner_pubkey.as_deref() {
|
||||
match db.rebind_single_node_community_host(&host, owner).await? {
|
||||
Some(record) => record.id,
|
||||
None => db.ensure_configured_community(&host).await?.id,
|
||||
}
|
||||
} else {
|
||||
db.ensure_configured_community(&host).await?.id
|
||||
};
|
||||
if let Some(owner) = config.relay_owner_pubkey.as_deref() {
|
||||
db.bootstrap_owner(community, owner).await?;
|
||||
}
|
||||
|
||||
@@ -18,6 +18,46 @@ use crate::{
|
||||
util::now_iso,
|
||||
};
|
||||
|
||||
pub(super) fn backfill_missing_agent_auth_tags(
|
||||
app: &AppHandle,
|
||||
state: &AppState,
|
||||
) -> Result<usize, String> {
|
||||
let owner_keys = state.signing_keys()?;
|
||||
let compat_owner = nostr::Keys::parse(&owner_keys.secret_key().to_secret_hex())
|
||||
.map_err(|e| format!("failed to bridge owner keys: {e}"))?;
|
||||
let _store_guard = state
|
||||
.managed_agents_store_lock
|
||||
.lock()
|
||||
.map_err(|e| e.to_string())?;
|
||||
let mut records = load_managed_agents(app)?;
|
||||
let mut changed = 0;
|
||||
for record in &mut records {
|
||||
if record.auth_tag.is_some()
|
||||
|| record
|
||||
.pubkey
|
||||
.eq_ignore_ascii_case(&owner_keys.public_key().to_hex())
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let agent = nostr::PublicKey::from_hex(&record.pubkey)
|
||||
.map_err(|e| format!("invalid managed agent pubkey {}: {e}", record.pubkey))?;
|
||||
record.auth_tag = Some(
|
||||
buzz_sdk_pkg::nip_oa::compute_auth_tag(&compat_owner, &agent, "").map_err(|e| {
|
||||
format!(
|
||||
"failed to compute NIP-OA auth tag for {}: {e}",
|
||||
record.pubkey
|
||||
)
|
||||
})?,
|
||||
);
|
||||
record.updated_at = now_iso();
|
||||
changed += 1;
|
||||
}
|
||||
if changed > 0 {
|
||||
save_managed_agents(app, &records)?;
|
||||
}
|
||||
Ok(changed)
|
||||
}
|
||||
|
||||
/// Read the workspace owner pubkey without holding the lock. Used to populate `BUZZ_ACP_AGENT_OWNER`
|
||||
/// as a fallback for legacy agent records that have no NIP-OA `auth_tag`.
|
||||
pub(super) fn workspace_owner_hex(state: &AppState) -> Result<String, String> {
|
||||
|
||||
@@ -145,7 +145,8 @@ pub async fn apply_workspace(
|
||||
None => None,
|
||||
};
|
||||
|
||||
if crate::local_relay::is_local_relay_url(&relay_url) {
|
||||
let is_local_workspace = crate::local_relay::is_local_relay_url(&relay_url);
|
||||
if is_local_workspace {
|
||||
// Only the UI-created local sentinel can launch the bundled relay.
|
||||
// An arbitrary loopback URL remains a normal remote community.
|
||||
let keys = parsed_keys.clone().unwrap_or(state.signing_keys()?);
|
||||
@@ -228,6 +229,12 @@ pub async fn apply_workspace(
|
||||
}
|
||||
|
||||
try_regenerate_nest(&app);
|
||||
if is_local_workspace {
|
||||
let changed = crate::commands::agents::backfill_missing_agent_auth_tags(&app, &state)?;
|
||||
if changed > 0 {
|
||||
eprintln!("buzz-desktop: backfilled NIP-OA auth tags for {changed} local agent(s)");
|
||||
}
|
||||
}
|
||||
|
||||
Ok::<(), String>(())
|
||||
})
|
||||
|
||||
@@ -53,6 +53,7 @@ impl LocalRelayConfig {
|
||||
("BUZZ_LOCAL_DB", sqlite_url(&db_path)),
|
||||
("BUZZ_LOCAL_MEDIA_DIR", media_dir.display().to_string()),
|
||||
("BUZZ_REQUIRE_RELAY_MEMBERSHIP", "true".to_string()),
|
||||
("BUZZ_ALLOW_NIP_OA_AUTH", "true".to_string()),
|
||||
("RELAY_OWNER_PUBKEY", self.owner_pubkey.clone()),
|
||||
("BUZZ_RELAY_PRIVATE_KEY", self.relay_private_key.clone()),
|
||||
]
|
||||
@@ -394,6 +395,7 @@ mod tests {
|
||||
env.get("BUZZ_REQUIRE_RELAY_MEMBERSHIP"),
|
||||
Some(&"true".to_string())
|
||||
);
|
||||
assert_eq!(env.get("BUZZ_ALLOW_NIP_OA_AUTH"), Some(&"true".to_string()));
|
||||
assert_eq!(env.get("RELAY_OWNER_PUBKEY"), Some(&"owner".to_string()));
|
||||
assert_eq!(
|
||||
env.get("BUZZ_RELAY_PRIVATE_KEY"),
|
||||
|
||||
Reference in New Issue
Block a user