diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/config.rs | 14 | ||||
| -rw-r--r-- | src/db.rs | 68 | ||||
| -rw-r--r-- | src/git_mirror.rs | 104 | ||||
| -rw-r--r-- | src/http_health.rs | 18 | ||||
| -rw-r--r-- | src/main.rs | 44 | ||||
| -rw-r--r-- | src/nip46.rs | 397 |
6 files changed, 644 insertions, 1 deletions
diff --git a/src/config.rs b/src/config.rs index 037deb2..4117507 100644 --- a/src/config.rs +++ b/src/config.rs | |||
| @@ -9,6 +9,7 @@ pub struct AppConfig { | |||
| 9 | pub servers: ServersConfig, | 9 | pub servers: ServersConfig, |
| 10 | pub storage: StorageConfig, | 10 | pub storage: StorageConfig, |
| 11 | pub signing: Option<SigningConfig>, | 11 | pub signing: Option<SigningConfig>, |
| 12 | pub nip46: Option<Nip46Config>, | ||
| 12 | } | 13 | } |
| 13 | 14 | ||
| 14 | #[derive(Debug, Deserialize)] | 15 | #[derive(Debug, Deserialize)] |
| @@ -60,11 +61,23 @@ pub struct SigningConfig { | |||
| 60 | pub key_file: PathBuf, | 61 | pub key_file: PathBuf, |
| 61 | } | 62 | } |
| 62 | 63 | ||
| 64 | #[derive(Debug, Deserialize)] | ||
| 65 | pub struct Nip46Config { | ||
| 66 | pub relays: Vec<String>, | ||
| 67 | #[serde(default = "default_signing_timeout")] | ||
| 68 | pub signing_timeout_secs: u64, | ||
| 69 | } | ||
| 70 | |||
| 71 | fn default_signing_timeout() -> u64 { | ||
| 72 | 604800 | ||
| 73 | } | ||
| 74 | |||
| 63 | pub struct ResolvedConfig { | 75 | pub struct ResolvedConfig { |
| 64 | pub discovery: DiscoveryConfig, | 76 | pub discovery: DiscoveryConfig, |
| 65 | pub servers: ServersConfig, | 77 | pub servers: ServersConfig, |
| 66 | pub storage: StorageConfig, | 78 | pub storage: StorageConfig, |
| 67 | pub signing: Option<SigningConfig>, | 79 | pub signing: Option<SigningConfig>, |
| 80 | pub nip46: Option<Nip46Config>, | ||
| 68 | pub npubs: Vec<PublicKey>, | 81 | pub npubs: Vec<PublicKey>, |
| 69 | } | 82 | } |
| 70 | 83 | ||
| @@ -89,6 +102,7 @@ impl ResolvedConfig { | |||
| 89 | servers: app.servers, | 102 | servers: app.servers, |
| 90 | storage: app.storage, | 103 | storage: app.storage, |
| 91 | signing: app.signing, | 104 | signing: app.signing, |
| 105 | nip46: app.nip46, | ||
| 92 | npubs, | 106 | npubs, |
| 93 | }) | 107 | }) |
| 94 | } | 108 | } |
| @@ -84,6 +84,24 @@ impl MirrorDb { | |||
| 84 | CREATE INDEX IF NOT EXISTS idx_repos_pubkey ON repos(pubkey); | 84 | CREATE INDEX IF NOT EXISTS idx_repos_pubkey ON repos(pubkey); |
| 85 | CREATE INDEX IF NOT EXISTS idx_server_syncs_repo ON server_syncs(repo_id); | 85 | CREATE INDEX IF NOT EXISTS idx_server_syncs_repo ON server_syncs(repo_id); |
| 86 | CREATE INDEX IF NOT EXISTS idx_seen_events_id ON seen_events(event_id); | 86 | CREATE INDEX IF NOT EXISTS idx_seen_events_id ON seen_events(event_id); |
| 87 | |||
| 88 | CREATE TABLE IF NOT EXISTS nip46_sessions ( | ||
| 89 | npub TEXT PRIMARY KEY, | ||
| 90 | client_secret TEXT NOT NULL, | ||
| 91 | signer_pubkey TEXT, | ||
| 92 | connected INTEGER NOT NULL DEFAULT 0 | ||
| 93 | ); | ||
| 94 | |||
| 95 | CREATE TABLE IF NOT EXISTS signing_queue ( | ||
| 96 | id INTEGER PRIMARY KEY AUTOINCREMENT, | ||
| 97 | npub TEXT NOT NULL, | ||
| 98 | repo_identifier TEXT NOT NULL, | ||
| 99 | state_event_json TEXT NOT NULL, | ||
| 100 | status TEXT NOT NULL DEFAULT 'pending', | ||
| 101 | created_at INTEGER NOT NULL, | ||
| 102 | signed_at INTEGER, | ||
| 103 | error TEXT | ||
| 104 | ); | ||
| 87 | "#, | 105 | "#, |
| 88 | ) | 106 | ) |
| 89 | .execute(&self.pool) | 107 | .execute(&self.pool) |
| @@ -258,6 +276,56 @@ impl MirrorDb { | |||
| 258 | .await?; | 276 | .await?; |
| 259 | Ok(records) | 277 | Ok(records) |
| 260 | } | 278 | } |
| 279 | |||
| 280 | pub async fn get_nip46_session(&self, npub: &str) -> Result<Option<Nip46SessionRecord>> { | ||
| 281 | let result = sqlx::query_as::<_, Nip46SessionRecord>( | ||
| 282 | "SELECT * FROM nip46_sessions WHERE npub = ?", | ||
| 283 | ) | ||
| 284 | .bind(npub) | ||
| 285 | .fetch_optional(&self.pool) | ||
| 286 | .await?; | ||
| 287 | Ok(result) | ||
| 288 | } | ||
| 289 | |||
| 290 | pub async fn upsert_nip46_session( | ||
| 291 | &self, | ||
| 292 | npub: &str, | ||
| 293 | client_secret: &str, | ||
| 294 | signer_pubkey: Option<&str>, | ||
| 295 | connected: bool, | ||
| 296 | ) -> Result<()> { | ||
| 297 | sqlx::query( | ||
| 298 | r#"INSERT INTO nip46_sessions (npub, client_secret, signer_pubkey, connected) | ||
| 299 | VALUES (?, ?, ?, ?) | ||
| 300 | ON CONFLICT(npub) DO UPDATE SET client_secret = ?, signer_pubkey = ?, connected = ?"#, | ||
| 301 | ) | ||
| 302 | .bind(npub) | ||
| 303 | .bind(client_secret) | ||
| 304 | .bind(signer_pubkey) | ||
| 305 | .bind(connected as i32) | ||
| 306 | .bind(client_secret) | ||
| 307 | .bind(signer_pubkey) | ||
| 308 | .bind(connected as i32) | ||
| 309 | .execute(&self.pool) | ||
| 310 | .await?; | ||
| 311 | Ok(()) | ||
| 312 | } | ||
| 313 | |||
| 314 | pub async fn get_all_nip46_sessions(&self) -> Result<Vec<Nip46SessionRecord>> { | ||
| 315 | let records = | ||
| 316 | sqlx::query_as::<_, Nip46SessionRecord>("SELECT * FROM nip46_sessions") | ||
| 317 | .fetch_all(&self.pool) | ||
| 318 | .await?; | ||
| 319 | Ok(records) | ||
| 320 | } | ||
| 321 | } | ||
| 322 | |||
| 323 | #[derive(Debug, sqlx::FromRow)] | ||
| 324 | pub struct Nip46SessionRecord { | ||
| 325 | pub npub: String, | ||
| 326 | pub client_secret: String, | ||
| 327 | pub signer_pubkey: Option<String>, | ||
| 328 | pub connected: bool, | ||
| 261 | } | 329 | } |
| 262 | 330 | ||
| 263 | fn chrono_now_secs() -> i64 { | 331 | fn chrono_now_secs() -> i64 { |
diff --git a/src/git_mirror.rs b/src/git_mirror.rs index 6866de3..c486887 100644 --- a/src/git_mirror.rs +++ b/src/git_mirror.rs | |||
| @@ -1,9 +1,12 @@ | |||
| 1 | use crate::db::MirrorDb; | 1 | use crate::db::MirrorDb; |
| 2 | use crate::discovery::DiscoveredRepo; | 2 | use crate::discovery::DiscoveredRepo; |
| 3 | use crate::health::GraspServer; | 3 | use crate::health::GraspServer; |
| 4 | use crate::nip46::Nip46Client; | ||
| 4 | use anyhow::{Context, Result}; | 5 | use anyhow::{Context, Result}; |
| 5 | use git2::RemoteCallbacks; | 6 | use git2::RemoteCallbacks; |
| 7 | use nostr_sdk::prelude::*; | ||
| 6 | use std::path::Path; | 8 | use std::path::Path; |
| 9 | use std::sync::Arc; | ||
| 7 | 10 | ||
| 8 | pub struct GitMirror { | 11 | pub struct GitMirror { |
| 9 | mirror_dir: std::path::PathBuf, | 12 | mirror_dir: std::path::PathBuf, |
| @@ -27,6 +30,7 @@ impl GitMirror { | |||
| 27 | db: &MirrorDb, | 30 | db: &MirrorDb, |
| 28 | repo: &DiscoveredRepo, | 31 | repo: &DiscoveredRepo, |
| 29 | target_servers: &[GraspServer], | 32 | target_servers: &[GraspServer], |
| 33 | nip46_client: Option<&Arc<Nip46Client>>, | ||
| 30 | ) -> Result<()> { | 34 | ) -> Result<()> { |
| 31 | if target_servers.is_empty() { | 35 | if target_servers.is_empty() { |
| 32 | tracing::debug!( | 36 | tracing::debug!( |
| @@ -43,6 +47,25 @@ impl GitMirror { | |||
| 43 | self.clone_bare(&repo_path, &repo.clone_urls)?; | 47 | self.clone_bare(&repo_path, &repo.clone_urls)?; |
| 44 | } | 48 | } |
| 45 | 49 | ||
| 50 | let state_event = match self.build_state_event(&repo_path, repo, nip46_client).await { | ||
| 51 | Ok(Some(event)) => Some(event), | ||
| 52 | Ok(None) => { | ||
| 53 | tracing::warn!( | ||
| 54 | identifier = %repo.identifier, | ||
| 55 | "could not build state event — push may be rejected by GRASP servers" | ||
| 56 | ); | ||
| 57 | None | ||
| 58 | } | ||
| 59 | Err(e) => { | ||
| 60 | tracing::error!( | ||
| 61 | identifier = %repo.identifier, | ||
| 62 | error = %e, | ||
| 63 | "failed to build state event" | ||
| 64 | ); | ||
| 65 | None | ||
| 66 | } | ||
| 67 | }; | ||
| 68 | |||
| 46 | for server in target_servers { | 69 | for server in target_servers { |
| 47 | let target_url = server.clone_url(&pk_hex, &repo.identifier); | 70 | let target_url = server.clone_url(&pk_hex, &repo.identifier); |
| 48 | 71 | ||
| @@ -53,6 +76,31 @@ impl GitMirror { | |||
| 53 | "mirroring git data" | 76 | "mirroring git data" |
| 54 | ); | 77 | ); |
| 55 | 78 | ||
| 79 | if let Some(ref event) = state_event { | ||
| 80 | let relay_url = server.relay_url(); | ||
| 81 | if let Ok(url) = RelayUrl::parse(&relay_url) { | ||
| 82 | let urls = vec![url]; | ||
| 83 | if let Err(e) = nip46_client | ||
| 84 | .map_or(Ok(()), |_| { | ||
| 85 | Err(anyhow::anyhow!("need nostr client to send state event")) | ||
| 86 | }) | ||
| 87 | { | ||
| 88 | let _ = e; | ||
| 89 | } | ||
| 90 | |||
| 91 | let nostr_client = nostr_sdk::Client::default(); | ||
| 92 | let _ = nostr_client.add_relay(&relay_url).await; | ||
| 93 | nostr_client.connect().await; | ||
| 94 | if let Err(e) = nostr_client.send_event_to(urls, event.clone()).await { | ||
| 95 | tracing::warn!( | ||
| 96 | server = %server.domain, | ||
| 97 | error = %e, | ||
| 98 | "failed to publish state event to server relay" | ||
| 99 | ); | ||
| 100 | } | ||
| 101 | } | ||
| 102 | } | ||
| 103 | |||
| 56 | let repo_id = db.get_all_repos().await.ok().and_then(|repos| { | 104 | let repo_id = db.get_all_repos().await.ok().and_then(|repos| { |
| 57 | repos | 105 | repos |
| 58 | .iter() | 106 | .iter() |
| @@ -88,6 +136,62 @@ impl GitMirror { | |||
| 88 | Ok(()) | 136 | Ok(()) |
| 89 | } | 137 | } |
| 90 | 138 | ||
| 139 | async fn build_state_event( | ||
| 140 | &self, | ||
| 141 | repo_path: &std::path::PathBuf, | ||
| 142 | repo: &DiscoveredRepo, | ||
| 143 | nip46_client: Option<&Arc<Nip46Client>>, | ||
| 144 | ) -> Result<Option<Event>> { | ||
| 145 | let nip46 = match nip46_client { | ||
| 146 | Some(c) => c, | ||
| 147 | None => return Ok(None), | ||
| 148 | }; | ||
| 149 | |||
| 150 | let git_repo = git2::Repository::open(repo_path) | ||
| 151 | .with_context(|| format!("failed to open bare repo at {:?}", repo_path))?; | ||
| 152 | |||
| 153 | let mut tags: Vec<Tag> = vec![ | ||
| 154 | Tag::custom(TagKind::Custom("d".into()), [&repo.identifier]), | ||
| 155 | ]; | ||
| 156 | |||
| 157 | let refs = git_repo.references()?; | ||
| 158 | for reference in refs { | ||
| 159 | let reference = reference?; | ||
| 160 | let name = reference.name().unwrap_or(""); | ||
| 161 | if name.is_empty() { | ||
| 162 | continue; | ||
| 163 | } | ||
| 164 | if let Some(oid) = reference.target() { | ||
| 165 | tags.push(Tag::custom( | ||
| 166 | TagKind::Custom("ref".into()), | ||
| 167 | [name, &oid.to_string()], | ||
| 168 | )); | ||
| 169 | } | ||
| 170 | } | ||
| 171 | |||
| 172 | let builder = EventBuilder::new(Kind::Custom(30618), "").tags(tags); | ||
| 173 | let unsigned = builder.build(repo.pubkey); | ||
| 174 | |||
| 175 | match nip46.sign_event(&repo.pubkey, &unsigned).await { | ||
| 176 | Ok(signed) => { | ||
| 177 | tracing::info!( | ||
| 178 | identifier = %repo.identifier, | ||
| 179 | event_id = %signed.id.to_hex(), | ||
| 180 | "signed kind:30618 state event via NIP-46" | ||
| 181 | ); | ||
| 182 | Ok(Some(signed)) | ||
| 183 | } | ||
| 184 | Err(e) => { | ||
| 185 | tracing::error!( | ||
| 186 | identifier = %repo.identifier, | ||
| 187 | error = %e, | ||
| 188 | "NIP-46 signing failed for state event" | ||
| 189 | ); | ||
| 190 | Err(e) | ||
| 191 | } | ||
| 192 | } | ||
| 193 | } | ||
| 194 | |||
| 91 | fn clone_bare(&self, repo_path: &Path, clone_urls: &[String]) -> Result<()> { | 195 | fn clone_bare(&self, repo_path: &Path, clone_urls: &[String]) -> Result<()> { |
| 92 | if let Some(parent) = repo_path.parent() { | 196 | if let Some(parent) = repo_path.parent() { |
| 93 | std::fs::create_dir_all(parent) | 197 | std::fs::create_dir_all(parent) |
diff --git a/src/http_health.rs b/src/http_health.rs index 0cdfeb5..d3f5df2 100644 --- a/src/http_health.rs +++ b/src/http_health.rs | |||
| @@ -12,6 +12,7 @@ pub struct HealthState { | |||
| 12 | pub cycle_count: watch::Receiver<u64>, | 12 | pub cycle_count: watch::Receiver<u64>, |
| 13 | pub last_cycle_ok: watch::Receiver<bool>, | 13 | pub last_cycle_ok: watch::Receiver<bool>, |
| 14 | pub db_path: String, | 14 | pub db_path: String, |
| 15 | pub nip46_client: Option<Arc<crate::nip46::Nip46Client>>, | ||
| 15 | } | 16 | } |
| 16 | 17 | ||
| 17 | pub async fn start_health_server(port: u16, state: Arc<HealthState>) -> anyhow::Result<()> { | 18 | pub async fn start_health_server(port: u16, state: Arc<HealthState>) -> anyhow::Result<()> { |
| @@ -31,10 +32,27 @@ async fn health_handler(State(state): State<Arc<HealthState>>) -> Json<Value> { | |||
| 31 | let cycle_count = *state.cycle_count.borrow(); | 32 | let cycle_count = *state.cycle_count.borrow(); |
| 32 | let last_ok = *state.last_cycle_ok.borrow(); | 33 | let last_ok = *state.last_cycle_ok.borrow(); |
| 33 | 34 | ||
| 35 | let nip46_sessions = if let Some(ref client) = state.nip46_client { | ||
| 36 | let statuses = client.get_status().await; | ||
| 37 | statuses | ||
| 38 | .into_iter() | ||
| 39 | .map(|s| { | ||
| 40 | json!({ | ||
| 41 | "npub": s.npub, | ||
| 42 | "connected": s.connected, | ||
| 43 | "pairing_uri": s.pairing_uri, | ||
| 44 | }) | ||
| 45 | }) | ||
| 46 | .collect::<Vec<_>>() | ||
| 47 | } else { | ||
| 48 | vec![] | ||
| 49 | }; | ||
| 50 | |||
| 34 | Json(json!({ | 51 | Json(json!({ |
| 35 | "status": if last_ok || cycle_count == 0 { "ok" } else { "degraded" }, | 52 | "status": if last_ok || cycle_count == 0 { "ok" } else { "degraded" }, |
| 36 | "uptime_secs": uptime.as_secs(), | 53 | "uptime_secs": uptime.as_secs(), |
| 37 | "cycle_count": cycle_count, | 54 | "cycle_count": cycle_count, |
| 38 | "last_cycle_ok": last_ok, | 55 | "last_cycle_ok": last_ok, |
| 56 | "nip46": nip46_sessions, | ||
| 39 | })) | 57 | })) |
| 40 | } | 58 | } |
diff --git a/src/main.rs b/src/main.rs index 3fcd27b..8e1383e 100644 --- a/src/main.rs +++ b/src/main.rs | |||
| @@ -4,6 +4,7 @@ mod discovery; | |||
| 4 | mod git_mirror; | 4 | mod git_mirror; |
| 5 | mod health; | 5 | mod health; |
| 6 | mod http_health; | 6 | mod http_health; |
| 7 | mod nip46; | ||
| 7 | mod nostr_mirror; | 8 | mod nostr_mirror; |
| 8 | mod signing; | 9 | mod signing; |
| 9 | 10 | ||
| @@ -71,14 +72,50 @@ async fn run_daemon(config: config::ResolvedConfig, db: db::MirrorDb) -> Result< | |||
| 71 | let db = Arc::new(db); | 72 | let db = Arc::new(db); |
| 72 | let config = Arc::new(config); | 73 | let config = Arc::new(config); |
| 73 | 74 | ||
| 75 | let nip46_client = if let Some(ref nip46_cfg) = config.nip46 { | ||
| 76 | let client = nip46::Nip46Client::new( | ||
| 77 | nip46_cfg.relays.clone(), | ||
| 78 | nip46_cfg.signing_timeout_secs, | ||
| 79 | db.clone(), | ||
| 80 | ) | ||
| 81 | .await?; | ||
| 82 | |||
| 83 | client.init_sessions(&config.npubs).await?; | ||
| 84 | client.start_listener().await; | ||
| 85 | |||
| 86 | for status in client.get_status().await { | ||
| 87 | tracing::info!( | ||
| 88 | npub = %status.npub, | ||
| 89 | connected = status.connected, | ||
| 90 | "NIP-46 session status" | ||
| 91 | ); | ||
| 92 | if !status.connected { | ||
| 93 | if let Some(uri) = &status.pairing_uri { | ||
| 94 | tracing::info!( | ||
| 95 | npub = %status.npub, | ||
| 96 | uri = %uri, | ||
| 97 | "Pair this npub by opening the nostrconnect URI in Amber" | ||
| 98 | ); | ||
| 99 | } | ||
| 100 | } | ||
| 101 | } | ||
| 102 | |||
| 103 | Some(Arc::new(client)) | ||
| 104 | } else { | ||
| 105 | tracing::warn!("no [nip46] config — remote signing disabled"); | ||
| 106 | None | ||
| 107 | }; | ||
| 108 | |||
| 74 | let (cycle_count_tx, cycle_count_rx) = tokio::sync::watch::channel(0u64); | 109 | let (cycle_count_tx, cycle_count_rx) = tokio::sync::watch::channel(0u64); |
| 75 | let (last_cycle_ok_tx, last_cycle_ok_rx) = tokio::sync::watch::channel(true); | 110 | let (last_cycle_ok_tx, last_cycle_ok_rx) = tokio::sync::watch::channel(true); |
| 76 | 111 | ||
| 112 | let nip46_status = nip46_client.clone(); | ||
| 77 | let health_state = Arc::new(http_health::HealthState { | 113 | let health_state = Arc::new(http_health::HealthState { |
| 78 | started_at: std::time::Instant::now(), | 114 | started_at: std::time::Instant::now(), |
| 79 | cycle_count: cycle_count_rx, | 115 | cycle_count: cycle_count_rx, |
| 80 | last_cycle_ok: last_cycle_ok_rx, | 116 | last_cycle_ok: last_cycle_ok_rx, |
| 81 | db_path: config.storage.database.display().to_string(), | 117 | db_path: config.storage.database.display().to_string(), |
| 118 | nip46_client: nip46_status, | ||
| 82 | }); | 119 | }); |
| 83 | 120 | ||
| 84 | let health_port = config.storage.health_port; | 121 | let health_port = config.storage.health_port; |
| @@ -111,6 +148,8 @@ async fn run_daemon(config: config::ResolvedConfig, db: db::MirrorDb) -> Result< | |||
| 111 | let mirror = git_mirror::GitMirror::new(&config.storage.mirror_dir); | 148 | let mirror = git_mirror::GitMirror::new(&config.storage.mirror_dir); |
| 112 | let nostr_mirror = nostr_mirror::NostrMirror::new(nostr_client.clone()); | 149 | let nostr_mirror = nostr_mirror::NostrMirror::new(nostr_client.clone()); |
| 113 | 150 | ||
| 151 | let nip46_client_ref: Option<Arc<nip46::Nip46Client>> = nip46_client.clone(); | ||
| 152 | |||
| 114 | let mut interval = tokio::time::interval(std::time::Duration::from_secs( | 153 | let mut interval = tokio::time::interval(std::time::Duration::from_secs( |
| 115 | config.discovery.poll_interval_secs, | 154 | config.discovery.poll_interval_secs, |
| 116 | )); | 155 | )); |
| @@ -132,6 +171,7 @@ async fn run_daemon(config: config::ResolvedConfig, db: db::MirrorDb) -> Result< | |||
| 132 | &mirror, | 171 | &mirror, |
| 133 | &nostr_mirror, | 172 | &nostr_mirror, |
| 134 | &healthy, | 173 | &healthy, |
| 174 | nip46_client_ref.as_ref(), | ||
| 135 | ).await; | 175 | ).await; |
| 136 | 176 | ||
| 137 | match &result { | 177 | match &result { |
| @@ -160,6 +200,7 @@ async fn mirror_cycle( | |||
| 160 | mirror: &git_mirror::GitMirror, | 200 | mirror: &git_mirror::GitMirror, |
| 161 | nostr_mirror: &nostr_mirror::NostrMirror, | 201 | nostr_mirror: &nostr_mirror::NostrMirror, |
| 162 | servers: &Arc<Vec<health::GraspServer>>, | 202 | servers: &Arc<Vec<health::GraspServer>>, |
| 203 | nip46_client: Option<&Arc<nip46::Nip46Client>>, | ||
| 163 | ) -> Result<()> { | 204 | ) -> Result<()> { |
| 164 | tracing::info!("starting mirror cycle"); | 205 | tracing::info!("starting mirror cycle"); |
| 165 | 206 | ||
| @@ -190,7 +231,7 @@ async fn mirror_cycle( | |||
| 190 | "mirroring to missing servers" | 231 | "mirroring to missing servers" |
| 191 | ); | 232 | ); |
| 192 | 233 | ||
| 193 | if let Err(e) = mirror.mirror_repo_to_servers(db, repo, &missing).await { | 234 | if let Err(e) = mirror.mirror_repo_to_servers(db, repo, &missing, nip46_client).await { |
| 194 | tracing::error!(identifier = %repo.identifier, error = %e, "git mirror failed for repo, continuing"); | 235 | tracing::error!(identifier = %repo.identifier, error = %e, "git mirror failed for repo, continuing"); |
| 195 | } | 236 | } |
| 196 | 237 | ||
| @@ -310,6 +351,7 @@ async fn run_mirror_once(config: config::ResolvedConfig, db: db::MirrorDb) -> Re | |||
| 310 | &mirror, | 351 | &mirror, |
| 311 | &nostr_mirror, | 352 | &nostr_mirror, |
| 312 | &healthy, | 353 | &healthy, |
| 354 | None, | ||
| 313 | ) | 355 | ) |
| 314 | .await | 356 | .await |
| 315 | } | 357 | } |
diff --git a/src/nip46.rs b/src/nip46.rs new file mode 100644 index 0000000..bedc814 --- /dev/null +++ b/src/nip46.rs | |||
| @@ -0,0 +1,397 @@ | |||
| 1 | use crate::db::MirrorDb; | ||
| 2 | use anyhow::{Context, Result}; | ||
| 3 | use nostr::nips::nip04; | ||
| 4 | use nostr::nips::nip46::{Message, NostrConnectURI, NostrConnectMetadata, Request, ResponseResult}; | ||
| 5 | use nostr_sdk::prelude::*; | ||
| 6 | use std::collections::HashMap; | ||
| 7 | use std::sync::Arc; | ||
| 8 | use std::time::Duration; | ||
| 9 | use tokio::sync::{oneshot, RwLock}; | ||
| 10 | |||
| 11 | struct Session { | ||
| 12 | npub: PublicKey, | ||
| 13 | client_keys: Keys, | ||
| 14 | signer_pubkey: Option<PublicKey>, | ||
| 15 | connected: bool, | ||
| 16 | pairing_uri: String, | ||
| 17 | } | ||
| 18 | |||
| 19 | struct PendingRequest { | ||
| 20 | tx: oneshot::Sender<Result<ResponseResult>>, | ||
| 21 | created_at: std::time::Instant, | ||
| 22 | } | ||
| 23 | |||
| 24 | pub struct Nip46Client { | ||
| 25 | sessions: Arc<RwLock<HashMap<String, Session>>>, | ||
| 26 | pending: Arc<RwLock<HashMap<String, PendingRequest>>>, | ||
| 27 | client: nostr_sdk::Client, | ||
| 28 | relays: Vec<String>, | ||
| 29 | signing_timeout: Duration, | ||
| 30 | db: Arc<MirrorDb>, | ||
| 31 | } | ||
| 32 | |||
| 33 | #[derive(Debug, Clone, serde::Serialize)] | ||
| 34 | pub struct Nip46Status { | ||
| 35 | pub npub: String, | ||
| 36 | pub connected: bool, | ||
| 37 | pub pairing_uri: Option<String>, | ||
| 38 | } | ||
| 39 | |||
| 40 | impl Nip46Client { | ||
| 41 | pub async fn new( | ||
| 42 | relays: Vec<String>, | ||
| 43 | signing_timeout_secs: u64, | ||
| 44 | db: Arc<MirrorDb>, | ||
| 45 | ) -> Result<Self> { | ||
| 46 | let client = nostr_sdk::Client::default(); | ||
| 47 | for url in &relays { | ||
| 48 | let _ = client.add_relay(url).await; | ||
| 49 | } | ||
| 50 | client.connect().await; | ||
| 51 | |||
| 52 | let sessions = Arc::new(RwLock::new(HashMap::new())); | ||
| 53 | let pending = Arc::new(RwLock::new(HashMap::new())); | ||
| 54 | |||
| 55 | Ok(Self { | ||
| 56 | sessions, | ||
| 57 | pending, | ||
| 58 | client, | ||
| 59 | relays, | ||
| 60 | signing_timeout: Duration::from_secs(signing_timeout_secs), | ||
| 61 | db, | ||
| 62 | }) | ||
| 63 | } | ||
| 64 | |||
| 65 | pub async fn init_sessions(&self, npubs: &[PublicKey]) -> Result<()> { | ||
| 66 | let records = self.db.get_all_nip46_sessions().await?; | ||
| 67 | let existing: HashMap<String, _> = records | ||
| 68 | .into_iter() | ||
| 69 | .map(|r| (r.npub, r)) | ||
| 70 | .collect(); | ||
| 71 | |||
| 72 | for pk in npubs { | ||
| 73 | let pk_hex = pk.to_hex(); | ||
| 74 | let session = if let Some(rec) = existing.get(&pk_hex) { | ||
| 75 | let client_keys = | ||
| 76 | Keys::parse(&rec.client_secret).context("invalid stored client secret")?; | ||
| 77 | let signer_pk = rec | ||
| 78 | .signer_pubkey | ||
| 79 | .as_ref() | ||
| 80 | .map(|s| PublicKey::from_hex(s)) | ||
| 81 | .transpose() | ||
| 82 | .context("invalid stored signer pubkey")?; | ||
| 83 | |||
| 84 | let pairing_uri = Self::build_pairing_uri(&client_keys, &self.relays); | ||
| 85 | |||
| 86 | Session { | ||
| 87 | npub: *pk, | ||
| 88 | client_keys, | ||
| 89 | signer_pubkey: signer_pk, | ||
| 90 | connected: rec.connected && signer_pk.is_some(), | ||
| 91 | pairing_uri, | ||
| 92 | } | ||
| 93 | } else { | ||
| 94 | let client_keys = Keys::generate(); | ||
| 95 | let pairing_uri = Self::build_pairing_uri(&client_keys, &self.relays); | ||
| 96 | |||
| 97 | self.db | ||
| 98 | .upsert_nip46_session(&pk_hex, &client_keys.secret_key()?.to_hex(), None, false) | ||
| 99 | .await?; | ||
| 100 | |||
| 101 | Session { | ||
| 102 | npub: *pk, | ||
| 103 | client_keys, | ||
| 104 | signer_pubkey: None, | ||
| 105 | connected: false, | ||
| 106 | pairing_uri, | ||
| 107 | } | ||
| 108 | }; | ||
| 109 | |||
| 110 | tracing::info!( | ||
| 111 | npub = %pk_hex, | ||
| 112 | connected = session.connected, | ||
| 113 | has_signer = session.signer_pubkey.is_some(), | ||
| 114 | "NIP-46 session initialized" | ||
| 115 | ); | ||
| 116 | |||
| 117 | self.sessions.write().await.insert(pk_hex, session); | ||
| 118 | } | ||
| 119 | |||
| 120 | Ok(()) | ||
| 121 | } | ||
| 122 | |||
| 123 | pub async fn start_listener(&self) { | ||
| 124 | let sessions = self.sessions.read().await; | ||
| 125 | let client_pubkeys: Vec<PublicKey> = sessions | ||
| 126 | .values() | ||
| 127 | .map(|s| s.client_keys.public_key()) | ||
| 128 | .collect(); | ||
| 129 | drop(sessions); | ||
| 130 | |||
| 131 | if client_pubkeys.is_empty() { | ||
| 132 | tracing::warn!("no NIP-46 sessions to listen for"); | ||
| 133 | return; | ||
| 134 | } | ||
| 135 | |||
| 136 | let filter = Filter::new() | ||
| 137 | .kind(Kind::Custom(24133)) | ||
| 138 | .pubkeys(client_pubkeys); | ||
| 139 | |||
| 140 | let _ = self | ||
| 141 | .client | ||
| 142 | .subscribe(filter, None) | ||
| 143 | .await | ||
| 144 | .map_err(|e| tracing::error!(error = %e, "failed to subscribe for NIP-46 responses")); | ||
| 145 | |||
| 146 | let sessions = self.sessions.clone(); | ||
| 147 | let pending = self.pending.clone(); | ||
| 148 | let db = self.db.clone(); | ||
| 149 | let client = self.client.clone(); | ||
| 150 | |||
| 151 | tokio::spawn(async move { | ||
| 152 | let _ = client | ||
| 153 | .handle_notifications(|notification| { | ||
| 154 | let sessions = sessions.clone(); | ||
| 155 | let pending = pending.clone(); | ||
| 156 | let db = db.clone(); | ||
| 157 | |||
| 158 | async move { | ||
| 159 | if let RelayPoolNotification::Event { event, .. } = notification.as_ref() { | ||
| 160 | if event.kind == Kind::Custom(24133) { | ||
| 161 | let _ = | ||
| 162 | Self::handle_response(&sessions, &pending, &db, event).await; | ||
| 163 | } | ||
| 164 | } | ||
| 165 | Ok(false) | ||
| 166 | } | ||
| 167 | }) | ||
| 168 | .await | ||
| 169 | .map_err(|e| tracing::error!(error = %e, "NIP-46 listener exited")); | ||
| 170 | }); | ||
| 171 | |||
| 172 | tracing::info!("NIP-46 response listener started"); | ||
| 173 | } | ||
| 174 | |||
| 175 | async fn handle_response( | ||
| 176 | sessions: &Arc<RwLock<HashMap<String, Session>>>, | ||
| 177 | pending: &Arc<RwLock<HashMap<String, PendingRequest>>>, | ||
| 178 | db: &Arc<MirrorDb>, | ||
| 179 | event: &Event, | ||
| 180 | ) -> Result<()> { | ||
| 181 | let sessions_guard = sessions.read().await; | ||
| 182 | |||
| 183 | let session = sessions_guard | ||
| 184 | .values() | ||
| 185 | .find(|s| s.client_keys.public_key() == event.pubkey); | ||
| 186 | |||
| 187 | let session = match session { | ||
| 188 | Some(s) => s, | ||
| 189 | None => return Ok(()), | ||
| 190 | }; | ||
| 191 | |||
| 192 | let decrypted = nip04::decrypt( | ||
| 193 | session.client_keys.secret_key()?, | ||
| 194 | &event.pubkey, | ||
| 195 | &event.content, | ||
| 196 | ) | ||
| 197 | .context("failed to decrypt NIP-46 response")?; | ||
| 198 | |||
| 199 | let message: Message = Message::from_json(&decrypted) | ||
| 200 | .context("failed to parse NIP-46 message")?; | ||
| 201 | |||
| 202 | match &message { | ||
| 203 | Message::Response { id, result, error } => { | ||
| 204 | if let Some(err) = error { | ||
| 205 | tracing::error!(id = %id, error = %err, "NIP-46 response error"); | ||
| 206 | let mut pending_guard = pending.write().await; | ||
| 207 | if let Some(p) = pending_guard.remove(id) { | ||
| 208 | let _ = p.tx.send(Err(anyhow::anyhow!("signer error: {}", err))); | ||
| 209 | } | ||
| 210 | return Ok(()); | ||
| 211 | } | ||
| 212 | |||
| 213 | if let Some(result) = result { | ||
| 214 | match result { | ||
| 215 | ResponseResult::GetPublicKey(pk) => { | ||
| 216 | let pk_hex = session.npub.to_hex(); | ||
| 217 | let signer_hex = pk.to_hex(); | ||
| 218 | |||
| 219 | tracing::info!( | ||
| 220 | npub = %pk_hex, | ||
| 221 | signer = %signer_hex, | ||
| 222 | "NIP-46 session connected — learned signer pubkey" | ||
| 223 | ); | ||
| 224 | |||
| 225 | drop(sessions_guard); | ||
| 226 | let mut sessions_guard = sessions.write().await; | ||
| 227 | if let Some(s) = sessions_guard.get_mut(&pk_hex) { | ||
| 228 | s.signer_pubkey = Some(*pk); | ||
| 229 | s.connected = true; | ||
| 230 | } | ||
| 231 | |||
| 232 | let _ = db | ||
| 233 | .upsert_nip46_session( | ||
| 234 | &pk_hex, | ||
| 235 | &session.client_keys.secret_key()?.to_hex(), | ||
| 236 | Some(&signer_hex), | ||
| 237 | true, | ||
| 238 | ) | ||
| 239 | .await; | ||
| 240 | } | ||
| 241 | _ => { | ||
| 242 | let mut pending_guard = pending.write().await; | ||
| 243 | if let Some(p) = pending_guard.remove(id) { | ||
| 244 | let _ = p.tx.send(Ok(result.clone())); | ||
| 245 | } | ||
| 246 | } | ||
| 247 | } | ||
| 248 | } | ||
| 249 | } | ||
| 250 | Message::Request { id, req } => { | ||
| 251 | tracing::debug!(id = %id, method = %req.method(), "received NIP-46 request from signer"); | ||
| 252 | } | ||
| 253 | } | ||
| 254 | |||
| 255 | Ok(()) | ||
| 256 | } | ||
| 257 | |||
| 258 | pub async fn connect_session(&self, npub: &PublicKey) -> Result<()> { | ||
| 259 | let sessions = self.sessions.read().await; | ||
| 260 | let pk_hex = npub.to_hex(); | ||
| 261 | let session = sessions | ||
| 262 | .get(&pk_hex) | ||
| 263 | .context("no NIP-46 session for npub")?; | ||
| 264 | |||
| 265 | let connect_req = Request::Connect { | ||
| 266 | public_key: *npub, | ||
| 267 | secret: None, | ||
| 268 | }; | ||
| 269 | |||
| 270 | self.send_request_to_signer(&session, connect_req).await?; | ||
| 271 | |||
| 272 | tracing::info!(npub = %pk_hex, "sent connect request to signer"); | ||
| 273 | Ok(()) | ||
| 274 | } | ||
| 275 | |||
| 276 | pub async fn sign_event(&self, npub: &PublicKey, unsigned: &UnsignedEvent) -> Result<Event> { | ||
| 277 | let sessions = self.sessions.read().await; | ||
| 278 | let pk_hex = npub.to_hex(); | ||
| 279 | let session = sessions | ||
| 280 | .get(&pk_hex) | ||
| 281 | .context("no NIP-46 session for npub")?; | ||
| 282 | |||
| 283 | if !session.connected || session.signer_pubkey.is_none() { | ||
| 284 | anyhow::bail!("NIP-46 session not connected — pair with signer first"); | ||
| 285 | } | ||
| 286 | |||
| 287 | let result = self | ||
| 288 | .send_request_to_signer(&session, Request::SignEvent(unsigned.clone())) | ||
| 289 | .await?; | ||
| 290 | |||
| 291 | match result { | ||
| 292 | ResponseResult::SignEvent(signed) => Ok(*signed), | ||
| 293 | other => anyhow::bail!("unexpected NIP-46 response: {:?}", other), | ||
| 294 | } | ||
| 295 | } | ||
| 296 | |||
| 297 | async fn send_request_to_signer( | ||
| 298 | &self, | ||
| 299 | session: &Session, | ||
| 300 | request: Request, | ||
| 301 | ) -> Result<ResponseResult> { | ||
| 302 | let signer_pubkey = session | ||
| 303 | .signer_pubkey | ||
| 304 | .context("no signer pubkey — not paired")?; | ||
| 305 | |||
| 306 | let message = Message::request(request); | ||
| 307 | let message_json = message.as_json(); | ||
| 308 | |||
| 309 | let encrypted = | ||
| 310 | nip04::encrypt(session.client_keys.secret_key()?, &signer_pubkey, &message_json)?; | ||
| 311 | |||
| 312 | let event_builder = EventBuilder::new( | ||
| 313 | Kind::Custom(24133), | ||
| 314 | &encrypted, | ||
| 315 | [Tag::public_key(signer_pubkey)], | ||
| 316 | ); | ||
| 317 | let event = event_builder.sign_with_keys(&session.client_keys)?; | ||
| 318 | |||
| 319 | let request_id = message.id().to_string(); | ||
| 320 | |||
| 321 | let (tx, rx) = oneshot::channel(); | ||
| 322 | { | ||
| 323 | let mut pending = self.pending.write().await; | ||
| 324 | pending.insert( | ||
| 325 | request_id.clone(), | ||
| 326 | PendingRequest { | ||
| 327 | tx, | ||
| 328 | created_at: std::time::Instant::now(), | ||
| 329 | }, | ||
| 330 | ); | ||
| 331 | } | ||
| 332 | |||
| 333 | self.client.send_event(event).await?; | ||
| 334 | |||
| 335 | let timeout = self.signing_timeout; | ||
| 336 | let result = tokio::time::timeout(timeout, async { | ||
| 337 | match rx.await { | ||
| 338 | Ok(Ok(response)) => Ok(response), | ||
| 339 | Ok(Err(e)) => Err(e), | ||
| 340 | Err(_) => Err(anyhow::anyhow!("signing request channel dropped")), | ||
| 341 | } | ||
| 342 | }) | ||
| 343 | .await | ||
| 344 | .map_err(|_| { | ||
| 345 | { | ||
| 346 | let pending_id = request_id.clone(); | ||
| 347 | async move { | ||
| 348 | let mut pending = self.pending.write().await; | ||
| 349 | pending.remove(&pending_id); | ||
| 350 | } | ||
| 351 | } | ||
| 352 | anyhow::anyhow!( | ||
| 353 | "NIP-46 signing request timed out after {}s", | ||
| 354 | timeout.as_secs() | ||
| 355 | ) | ||
| 356 | })??; | ||
| 357 | |||
| 358 | Ok(result) | ||
| 359 | } | ||
| 360 | |||
| 361 | pub async fn get_status(&self) -> Vec<Nip46Status> { | ||
| 362 | let sessions = self.sessions.read().await; | ||
| 363 | sessions | ||
| 364 | .values() | ||
| 365 | .map(|s| Nip46Status { | ||
| 366 | npub: s.npub.to_hex(), | ||
| 367 | connected: s.connected, | ||
| 368 | pairing_uri: if !s.connected { | ||
| 369 | Some(s.pairing_uri.clone()) | ||
| 370 | } else { | ||
| 371 | None | ||
| 372 | }, | ||
| 373 | }) | ||
| 374 | .collect() | ||
| 375 | } | ||
| 376 | |||
| 377 | pub async fn get_pairing_uri(&self, npub: &PublicKey) -> Option<String> { | ||
| 378 | let sessions = self.sessions.read().await; | ||
| 379 | sessions | ||
| 380 | .get(&npub.to_hex()) | ||
| 381 | .map(|s| s.pairing_uri.clone()) | ||
| 382 | } | ||
| 383 | |||
| 384 | fn build_pairing_uri(client_keys: &Keys, relays: &[String]) -> String { | ||
| 385 | let relay_urls: Vec<RelayUrl> = relays | ||
| 386 | .iter() | ||
| 387 | .filter_map(|r| RelayUrl::parse(r).ok()) | ||
| 388 | .collect(); | ||
| 389 | |||
| 390 | let uri = NostrConnectURI::client( | ||
| 391 | client_keys.public_key(), | ||
| 392 | relay_urls, | ||
| 393 | "grasp-mirror", | ||
| 394 | ); | ||
| 395 | uri.to_string() | ||
| 396 | } | ||
| 397 | } | ||