diff options
| author | Your Name <you@example.com> | 2026-05-26 16:11:05 +0530 |
|---|---|---|
| committer | Your Name <you@example.com> | 2026-05-26 16:11:05 +0530 |
| commit | 8816a192c95cf539b65975469a2d61aed46f0414 (patch) | |
| tree | 7590318244a56fabbfa6919ef6d0fab5be529134 /src/main.rs | |
feat: initial implementation of grasp-mirror daemon
GRASP mirror daemon that discovers repos from watched npubs and mirrors
git data + Nostr events across all known GRASP servers for redundancy.
Features:
- Configurable npub watch list via .env (MIRROR_NPUBS)
- TOML config for GRASP server list, index relays, storage paths
- NIP-11 verification of GRASP servers on startup
- Discovery of repos via kind:30617 announcements on index relays
- Git mirroring (bare clone + push --mirror) to missing GRASP servers
- Nostr event forwarding to all GRASP server embedded relays
- SQLite state tracking for sync status and event dedup
- Optional signing key for updating announcements with new clone URLs
- CLI subcommands: daemon, status, verify, mirror-once
Architecture:
config.rs - TOML + .env config loading
db.rs - SQLite state tracking
health.rs - NIP-11 GRASP server verification
discovery.rs - Relay subscription, kind:30617 parsing
git_mirror.rs - Bare clone + push to GRASP servers
nostr_mirror.rs - Event forwarding to all GRASP relays
signing.rs - Optional announcement updates
main.rs - CLI entry point, daemon loop
Diffstat (limited to 'src/main.rs')
| -rw-r--r-- | src/main.rs | 279 |
1 files changed, 279 insertions, 0 deletions
diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..b709d44 --- /dev/null +++ b/src/main.rs | |||
| @@ -0,0 +1,279 @@ | |||
| 1 | mod config; | ||
| 2 | mod db; | ||
| 3 | mod discovery; | ||
| 4 | mod git_mirror; | ||
| 5 | mod health; | ||
| 6 | mod nostr_mirror; | ||
| 7 | mod signing; | ||
| 8 | |||
| 9 | use anyhow::Result; | ||
| 10 | use clap::Parser; | ||
| 11 | use std::path::PathBuf; | ||
| 12 | use std::sync::Arc; | ||
| 13 | |||
| 14 | #[derive(Parser, Debug)] | ||
| 15 | #[command(name = "grasp-mirror", about = "GRASP mirror daemon")] | ||
| 16 | struct Cli { | ||
| 17 | #[arg(short, long, default_value = "config.toml")] | ||
| 18 | config: PathBuf, | ||
| 19 | |||
| 20 | #[command(subcommand)] | ||
| 21 | command: Option<Command>, | ||
| 22 | } | ||
| 23 | |||
| 24 | #[derive(clap::Subcommand, Debug)] | ||
| 25 | enum Command { | ||
| 26 | Daemon, | ||
| 27 | Status, | ||
| 28 | Verify, | ||
| 29 | MirrorOnce, | ||
| 30 | } | ||
| 31 | |||
| 32 | #[tokio::main] | ||
| 33 | async fn main() -> Result<()> { | ||
| 34 | tracing_subscriber::fmt() | ||
| 35 | .with_env_filter( | ||
| 36 | tracing_subscriber::EnvFilter::try_from_default_env() | ||
| 37 | .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")), | ||
| 38 | ) | ||
| 39 | .init(); | ||
| 40 | |||
| 41 | let cli = Cli::parse(); | ||
| 42 | let config = config::ResolvedConfig::load(&cli.config)?; | ||
| 43 | |||
| 44 | tracing::info!( | ||
| 45 | npubs = config.npubs.len(), | ||
| 46 | servers = config.servers.known.len(), | ||
| 47 | index_relays = config.discovery.index_relays.len(), | ||
| 48 | "starting grasp-mirror" | ||
| 49 | ); | ||
| 50 | |||
| 51 | let db = db::MirrorDb::open(&config.storage.database).await?; | ||
| 52 | |||
| 53 | match cli.command.unwrap_or(Command::Daemon) { | ||
| 54 | Command::Daemon => run_daemon(config, db).await, | ||
| 55 | Command::Status => run_status(db).await, | ||
| 56 | Command::Verify => run_verify(config).await, | ||
| 57 | Command::MirrorOnce => run_mirror_once(config, db).await, | ||
| 58 | } | ||
| 59 | } | ||
| 60 | |||
| 61 | fn build_nostr_client(relay_urls: &[String]) -> nostr_sdk::Client { | ||
| 62 | let client = nostr_sdk::Client::default(); | ||
| 63 | for url in relay_urls { | ||
| 64 | let _ = client.add_relay(url); | ||
| 65 | } | ||
| 66 | client | ||
| 67 | } | ||
| 68 | |||
| 69 | async fn run_daemon(config: config::ResolvedConfig, db: db::MirrorDb) -> Result<()> { | ||
| 70 | let db = Arc::new(db); | ||
| 71 | let config = Arc::new(config); | ||
| 72 | |||
| 73 | let servers = health::verify_all_servers(&config.servers.known).await; | ||
| 74 | let healthy: Vec<_> = servers | ||
| 75 | .values() | ||
| 76 | .filter(|s| s.is_grasp_server()) | ||
| 77 | .cloned() | ||
| 78 | .collect(); | ||
| 79 | |||
| 80 | tracing::info!( | ||
| 81 | total = servers.len(), | ||
| 82 | healthy = healthy.len(), | ||
| 83 | "server verification complete" | ||
| 84 | ); | ||
| 85 | |||
| 86 | let healthy = Arc::new(healthy); | ||
| 87 | |||
| 88 | let relay_urls = config.relay_urls(); | ||
| 89 | let nostr_client = build_nostr_client(&relay_urls); | ||
| 90 | nostr_client.connect().await; | ||
| 91 | |||
| 92 | let mirror = git_mirror::GitMirror::new(&config.storage.mirror_dir); | ||
| 93 | let nostr_mirror = nostr_mirror::NostrMirror::new(nostr_client.clone()); | ||
| 94 | |||
| 95 | let mut interval = tokio::time::interval(std::time::Duration::from_secs( | ||
| 96 | config.discovery.poll_interval_secs, | ||
| 97 | )); | ||
| 98 | |||
| 99 | tracing::info!( | ||
| 100 | "daemon started, polling every {}s", | ||
| 101 | config.discovery.poll_interval_secs | ||
| 102 | ); | ||
| 103 | |||
| 104 | loop { | ||
| 105 | tokio::select! { | ||
| 106 | _ = interval.tick() => { | ||
| 107 | if let Err(e) = mirror_cycle( | ||
| 108 | &config, | ||
| 109 | &db, | ||
| 110 | &nostr_client, | ||
| 111 | &mirror, | ||
| 112 | &nostr_mirror, | ||
| 113 | &healthy, | ||
| 114 | ).await { | ||
| 115 | tracing::error!(error = %e, "mirror cycle failed"); | ||
| 116 | } | ||
| 117 | } | ||
| 118 | _ = tokio::signal::ctrl_c() => { | ||
| 119 | tracing::info!("shutting down"); | ||
| 120 | break; | ||
| 121 | } | ||
| 122 | } | ||
| 123 | } | ||
| 124 | |||
| 125 | Ok(()) | ||
| 126 | } | ||
| 127 | |||
| 128 | async fn mirror_cycle( | ||
| 129 | config: &Arc<config::ResolvedConfig>, | ||
| 130 | db: &Arc<db::MirrorDb>, | ||
| 131 | nostr_client: &nostr_sdk::Client, | ||
| 132 | mirror: &git_mirror::GitMirror, | ||
| 133 | nostr_mirror: &nostr_mirror::NostrMirror, | ||
| 134 | servers: &Arc<Vec<health::GraspServer>>, | ||
| 135 | ) -> Result<()> { | ||
| 136 | tracing::info!("starting mirror cycle"); | ||
| 137 | |||
| 138 | let relay_urls = config.relay_urls(); | ||
| 139 | let repos = discovery::discover_repos_from_relays(nostr_client, &config.npubs, &relay_urls) | ||
| 140 | .await?; | ||
| 141 | |||
| 142 | tracing::info!(count = repos.len(), "discovered repos"); | ||
| 143 | |||
| 144 | discovery::persist_discovered_repos(db, &repos).await?; | ||
| 145 | |||
| 146 | let server_map: std::collections::HashMap<String, health::GraspServer> = servers | ||
| 147 | .iter() | ||
| 148 | .map(|s| (s.domain.clone(), s.clone())) | ||
| 149 | .collect(); | ||
| 150 | |||
| 151 | for repo in &repos { | ||
| 152 | let missing = discovery::identify_missing_servers(repo, &server_map); | ||
| 153 | |||
| 154 | if missing.is_empty() { | ||
| 155 | tracing::debug!(identifier = %repo.identifier, "repo already on all servers"); | ||
| 156 | continue; | ||
| 157 | } | ||
| 158 | |||
| 159 | tracing::info!( | ||
| 160 | identifier = %repo.identifier, | ||
| 161 | missing = missing.iter().map(|s| s.domain.as_str()).collect::<Vec<_>>().join(", "), | ||
| 162 | "mirroring to missing servers" | ||
| 163 | ); | ||
| 164 | |||
| 165 | mirror.mirror_repo_to_servers(db, repo, &missing).await?; | ||
| 166 | nostr_mirror.forward_repo_events(db, repo, servers).await?; | ||
| 167 | } | ||
| 168 | |||
| 169 | nostr_mirror | ||
| 170 | .sync_all_events(db, &config.npubs, servers) | ||
| 171 | .await?; | ||
| 172 | |||
| 173 | tracing::info!("mirror cycle complete"); | ||
| 174 | Ok(()) | ||
| 175 | } | ||
| 176 | |||
| 177 | async fn run_status(db: db::MirrorDb) -> Result<()> { | ||
| 178 | let repos = db.get_all_repos().await?; | ||
| 179 | let syncs = db.get_sync_summary().await?; | ||
| 180 | |||
| 181 | println!("=== GRASP Mirror Status ===\n"); | ||
| 182 | println!("Repos tracked: {}\n", repos.len()); | ||
| 183 | |||
| 184 | for repo in &repos { | ||
| 185 | println!(" {} ({})", repo.identifier, &repo.pubkey[..12]); | ||
| 186 | } | ||
| 187 | |||
| 188 | println!("\nSync records: {}\n", syncs.len()); | ||
| 189 | for sync in &syncs { | ||
| 190 | let status = if sync.git_synced && sync.nostr_synced { | ||
| 191 | "OK" | ||
| 192 | } else if sync.error.is_some() { | ||
| 193 | "ERR" | ||
| 194 | } else { | ||
| 195 | "PENDING" | ||
| 196 | }; | ||
| 197 | println!( | ||
| 198 | " repo:{} server:{} git:{} nostr:{} [{}]", | ||
| 199 | sync.repo_id, | ||
| 200 | sync.server_domain, | ||
| 201 | if sync.git_synced { "Y" } else { "N" }, | ||
| 202 | if sync.nostr_synced { "Y" } else { "N" }, | ||
| 203 | status, | ||
| 204 | ); | ||
| 205 | if let Some(err) = &sync.error { | ||
| 206 | println!(" error: {}", err); | ||
| 207 | } | ||
| 208 | } | ||
| 209 | |||
| 210 | Ok(()) | ||
| 211 | } | ||
| 212 | |||
| 213 | async fn run_verify(config: config::ResolvedConfig) -> Result<()> { | ||
| 214 | let servers = health::verify_all_servers(&config.servers.known).await; | ||
| 215 | |||
| 216 | println!("=== GRASP Server Verification ===\n"); | ||
| 217 | |||
| 218 | for (domain, server) in &servers { | ||
| 219 | let grasp = server.is_grasp_server(); | ||
| 220 | let version = server | ||
| 221 | .nip11 | ||
| 222 | .as_ref() | ||
| 223 | .and_then(|i| i.version.clone()) | ||
| 224 | .unwrap_or_else(|| "unknown".to_string()); | ||
| 225 | let grasps = server | ||
| 226 | .nip11 | ||
| 227 | .as_ref() | ||
| 228 | .and_then(|i| i.supported_grasps.clone()) | ||
| 229 | .map(|g| g.join(", ")) | ||
| 230 | .unwrap_or_else(|| "NONE".to_string()); | ||
| 231 | |||
| 232 | println!( | ||
| 233 | " {} [{}] v={} GRASP={}", | ||
| 234 | domain, | ||
| 235 | if grasp { "OK" } else { "SKIP" }, | ||
| 236 | version, | ||
| 237 | grasps, | ||
| 238 | ); | ||
| 239 | } | ||
| 240 | |||
| 241 | let healthy = servers.values().filter(|s| s.is_grasp_server()).count(); | ||
| 242 | println!( | ||
| 243 | "\n{}/{} verified as GRASP servers", | ||
| 244 | healthy, | ||
| 245 | servers.len() | ||
| 246 | ); | ||
| 247 | |||
| 248 | Ok(()) | ||
| 249 | } | ||
| 250 | |||
| 251 | async fn run_mirror_once(config: config::ResolvedConfig, db: db::MirrorDb) -> Result<()> { | ||
| 252 | let db = Arc::new(db); | ||
| 253 | let config = Arc::new(config); | ||
| 254 | |||
| 255 | let servers = health::verify_all_servers(&config.servers.known).await; | ||
| 256 | let healthy: Vec<_> = servers | ||
| 257 | .values() | ||
| 258 | .filter(|s| s.is_grasp_server()) | ||
| 259 | .cloned() | ||
| 260 | .collect(); | ||
| 261 | |||
| 262 | let relay_urls = config.relay_urls(); | ||
| 263 | let nostr_client = build_nostr_client(&relay_urls); | ||
| 264 | nostr_client.connect().await; | ||
| 265 | |||
| 266 | let mirror = git_mirror::GitMirror::new(&config.storage.mirror_dir); | ||
| 267 | let nostr_mirror = nostr_mirror::NostrMirror::new(nostr_client.clone()); | ||
| 268 | let healthy = Arc::new(healthy); | ||
| 269 | |||
| 270 | mirror_cycle( | ||
| 271 | &config, | ||
| 272 | &db, | ||
| 273 | &nostr_client, | ||
| 274 | &mirror, | ||
| 275 | &nostr_mirror, | ||
| 276 | &healthy, | ||
| 277 | ) | ||
| 278 | .await | ||
| 279 | } | ||