diff options
| author | Your Name <you@example.com> | 2026-05-27 15:32:38 +0530 |
|---|---|---|
| committer | Your Name <you@example.com> | 2026-05-27 15:32:38 +0530 |
| commit | d889b890f9e63815c178853ed98a1e31f6cec7f8 (patch) | |
| tree | 3eda17e8edaf61cc6dba640ae12a24e90c9d8b8c | |
| parent | d60fa03de6edae0667a93ac36be4206e76255a2c (diff) | |
Fix compile issues for NIP-46 build
- nip46.rs: match on owned RelayPoolNotification (not .as_ref())
- git_mirror.rs: accept shared nostr_sdk::Client for state event publishing
- main.rs: pass nostr_client to mirror_repo_to_servers
- NIP46-PLAN.md: full implementation checklist and roadmap
- PLAN.md: updated
| -rw-r--r-- | NIP46-PLAN.md | 111 | ||||
| -rw-r--r-- | PLAN.md | 86 | ||||
| -rw-r--r-- | src/git_mirror.rs | 12 | ||||
| -rw-r--r-- | src/main.rs | 2 | ||||
| -rw-r--r-- | src/nip46.rs | 4 |
5 files changed, 176 insertions, 39 deletions
diff --git a/NIP46-PLAN.md b/NIP46-PLAN.md new file mode 100644 index 0000000..8d8d56b --- /dev/null +++ b/NIP46-PLAN.md | |||
| @@ -0,0 +1,111 @@ | |||
| 1 | # GRASP Mirror NIP-46 Remote Signing — Implementation Plan | ||
| 2 | |||
| 3 | ## Goal | ||
| 4 | |||
| 5 | Build and deploy the grasp-mirror daemon with NIP-46 remote signing so it can publish authenticated `kind:30618` state events and push git data to GRASP servers, without storing nsec keys on the VPS. | ||
| 6 | |||
| 7 | ## Architecture | ||
| 8 | |||
| 9 | ``` | ||
| 10 | ┌─────────────┐ | ||
| 11 | │ Amber │ | ||
| 12 | │ (phone) │ | ||
| 13 | └──────┬──────┘ | ||
| 14 | │ NIP-46 (kind:24133) | ||
| 15 | │ NIP-04 encrypted | ||
| 16 | ┌──────┴──────┐ | ||
| 17 | │ Nostr Relay │ | ||
| 18 | │ (relay/ngit)│ | ||
| 19 | └──────┬──────┘ | ||
| 20 | │ | ||
| 21 | ┌──────┴──────┐ | ||
| 22 | │ grasp-mirror│ | ||
| 23 | │ daemon │ | ||
| 24 | │ (VPS) │ | ||
| 25 | └──────┬──────┘ | ||
| 26 | │ git push + kind:30618 | ||
| 27 | ┌──────┴──────┐ | ||
| 28 | │ GRASP │ | ||
| 29 | │ servers │ | ||
| 30 | └─────────────┘ | ||
| 31 | ``` | ||
| 32 | |||
| 33 | - Daemon generates client keypairs per npub, persists in SQLite | ||
| 34 | - Produces `nostrconnect://` pairing URIs shown in health endpoint | ||
| 35 | - User opens URI in Amber → NIP-46 session established | ||
| 36 | - Before git push, daemon builds unsigned `kind:30618` from repo refs | ||
| 37 | - Sends `sign_event` request to signer via NIP-46 relay protocol | ||
| 38 | - Amber signs on phone → daemon receives signed event → publishes + pushes | ||
| 39 | |||
| 40 | ## Checklist | ||
| 41 | |||
| 42 | ### Phase 1: Fix compile errors locally | ||
| 43 | |||
| 44 | - [ ] Fix `nip46.rs` — `notification.as_ref()` → match on owned `RelayPoolNotification` | ||
| 45 | - [ ] Fix `nip46.rs` — ensure `message.id()` borrow is cloned before consuming message | ||
| 46 | - [ ] Fix `git_mirror.rs` — accept shared `nostr_sdk::Client` instead of creating throwaway per server | ||
| 47 | - [ ] Fix `main.rs` — pass `nostr_client` to `git_mirror.mirror_repo_to_servers` | ||
| 48 | - [ ] Verify `db.rs` — `bool` FromRow works with sqlx 0.8 sqlite INTEGER | ||
| 49 | - [ ] Pin Rust 1.95.0 in Ansible role `tasks/main.yml` | ||
| 50 | - [ ] Commit and push to GRASP origin | ||
| 51 | |||
| 52 | ### Phase 2: Build on VPS | ||
| 53 | |||
| 54 | - [ ] GRASP server running on target VPS (dependency: migration session) | ||
| 55 | - [ ] Push SSH key to VPS for Ansible key-based auth | ||
| 56 | - [ ] Run `ansible-playbook playbooks/30-grasp-mirror.yml -v` | ||
| 57 | - [ ] Fix any remaining compile errors iteratively | ||
| 58 | - [ ] Binary installed at `/usr/local/bin/grasp-mirror` | ||
| 59 | - [ ] Systemd service `grasp-mirror` running | ||
| 60 | |||
| 61 | ### Phase 3: Verify and pair | ||
| 62 | |||
| 63 | - [ ] Health endpoint responds: `curl http://localhost:7335/health` | ||
| 64 | - [ ] Response includes `nip46` array with session statuses | ||
| 65 | - [ ] Each unpaired npub shows `pairing_uri` | ||
| 66 | - [ ] Pair npub 1 (`npub12m5...`) via Amber | ||
| 67 | - [ ] Pair npub 2 (`npub19jx...`) via Amber | ||
| 68 | - [ ] Pair npub 3 (`npub1c03...`) via Amber | ||
| 69 | - [ ] Health endpoint shows all 3 sessions `connected: true` | ||
| 70 | - [ ] Test signing: daemon builds and signs a `kind:30618` event | ||
| 71 | - [ ] Test push: git data pushes to a target GRASP server | ||
| 72 | |||
| 73 | ### Phase 4: Document roadmap | ||
| 74 | |||
| 75 | - [ ] Add QEMU/OpenWRT orchestration roadmap to PLAN.md or PROGRESS.md | ||
| 76 | |||
| 77 | ## Key Files | ||
| 78 | |||
| 79 | | File | Role | | ||
| 80 | |---|---| | ||
| 81 | | `src/nip46.rs` | NIP-46 client: sessions, NIP-04 encrypt/decrypt, relay listener, `sign_event()` | | ||
| 82 | | `src/db.rs` | `nip46_sessions` table + CRUD methods | | ||
| 83 | | `src/config.rs` | `Nip46Config` with relays + signing_timeout_secs | | ||
| 84 | | `src/git_mirror.rs` | Builds unsigned `kind:30618`, signs via NIP-46, publishes before push | | ||
| 85 | | `src/http_health.rs` | NIP-46 session status in `/health` JSON | | ||
| 86 | | `src/main.rs` | Wires NIP-46 init + listener into daemon startup | | ||
| 87 | | `ansible/roles/grasp_mirror/tasks/main.yml` | Ansible deploy tasks (build + install) | | ||
| 88 | | `ansible/roles/grasp_mirror/defaults/main.yml` | NIP-46 relay + timeout vars | | ||
| 89 | | `ansible/roles/grasp_mirror/templates/config.toml.j2` | Config with `[nip46]` section | | ||
| 90 | |||
| 91 | ## Constraints | ||
| 92 | |||
| 93 | - No `any` type in TypeScript (N/A — Rust project) | ||
| 94 | - No comments in code unless requested | ||
| 95 | - Use `pnpm` for JS, `cargo` for Rust | ||
| 96 | - Rust 1.95.0 required (nostr-sdk 0.39 doesn't compile on 1.85) | ||
| 97 | - NIP-04 encryption for NIP-46 messages (not NIP-44 — simpler, Amber supports it) | ||
| 98 | - Signing timeout: 7 days (604800 seconds) — queue-and-wait model | ||
| 99 | - GRASP push auth requires signed `kind:30618` by maintainer, not HTTP auth | ||
| 100 | |||
| 101 | ## Future Roadmap: QEMU/OpenWRT Test Orchestration | ||
| 102 | |||
| 103 | After migration, repurpose old VPS (2 vCPU, 8GB RAM, 99GB disk) as dedicated OpenWRT test runner: | ||
| 104 | |||
| 105 | 1. **Deploy loom-worker** on old VPS — decentralized compute marketplace via Nostr | ||
| 106 | 2. **Write QEMU execution adapter** for loom — Unix domain socket that spins up OpenWRT instances, runs tests, streams results | ||
| 107 | 3. **Use existing act-runner** custom pipeline to submit loom jobs from CI | ||
| 108 | 4. **Do NOT build custom orchestration** — loom already has standardized event kinds (5001/5100/5101), Cashu payments, Blossom result storage, encrypted communication | ||
| 109 | 5. **Evaluate loom from budabit** (`nostr://npub1hw6amg8p24ne08c9gdq8hhpqx0t0pwanpae9z25crn7m9uy7yarse465gr/relay.ngit.dev/loom-worker`) before building anything custom | ||
| 110 | |||
| 111 | Loom's pluggable adapter architecture means we write ~200 lines of Deno/TypeScript for the QEMU adapter and get the entire Nostr job queue, payment, and result streaming for free. | ||
| @@ -1,25 +1,61 @@ | |||
| 1 | # grasp-mirror Bugfix Plan | 1 | # grasp-mirror Plan |
| 2 | 2 | ||
| 3 | ## Bugs Identified from VPS Logs | 3 | ## Completed |
| 4 | 4 | ||
| 5 | ### Bug 1: `remote 'push_target' already exists` | 5 | - [x] Fix `push_mirror` in `src/git_mirror.rs` — reuse existing `push_target` remote via `remote_set_url` |
| 6 | - **Location**: `src/git_mirror.rs:137` | 6 | - [x] Fix `mirror_cycle` in `src/main.rs` — make per-repo errors non-fatal |
| 7 | - **Cause**: `repo.remote("push_target", target_url)` creates a new remote every cycle. On subsequent cycles, the bare repo already has a `push_target` remote, so `git2` returns `Exists (-4)`. | 7 | - [x] Add HTTP health endpoint (`/health`, `/api/mirror-health`) on port 7335 |
| 8 | - **Fix**: Use `repo.find_remote("push_target")` first. If it exists, call `repo.remote_set_url()` to update the URL. If not, create it. | 8 | - [x] Mirror repos from 3 npubs |
| 9 | 9 | - [x] Ansible role, playbook, systemd unit, config templates | |
| 10 | ### Bug 2: Single repo failure aborts entire cycle | 10 | - [x] Dashboard integration on `services.orangesync.tech` |
| 11 | - **Location**: `src/main.rs:mirror_cycle()` — the `mirror.mirror_repo_to_servers()` call uses `?` which propagates the error and aborts the loop. | 11 | - [x] Watchdog integration |
| 12 | - **Cause**: When one repo (e.g. `market`) fails to clone from any server, the `?` operator returns early, skipping the remaining ~87 repos. | 12 | |
| 13 | - **Fix**: Replace `?` with a `match` that logs the error and continues to the next repo. Only the `discover_repos_from_relays` and `persist_discovered_repos` calls should be fatal (if relay queries fail, nothing works). | 13 | ## Phase 3: NIP-46 Remote Signing via Amber |
| 14 | 14 | ||
| 15 | ## Checklist | 15 | ### Problem |
| 16 | 16 | GRASP servers reject unauthenticated git pushes. Authorization requires a `kind:30618` state event signed by a maintainer's key. The daemon needs to sign these events but should never hold raw nsec keys. | |
| 17 | - [ ] Fix `push_mirror` in `src/git_mirror.rs` — reuse existing `push_target` remote | 17 | |
| 18 | - [ ] Fix `mirror_cycle` in `src/main.rs` — make per-repo errors non-fatal | 18 | ### Solution |
| 19 | - [ ] Rebuild release binary (`cargo build --release`) | 19 | NIP-46 (Nostr Connect) remote signing via Amber on the user's phone. The daemon acts as a NIP-46 client, sends `sign_event:30618` requests to Amber, and waits for approval before pushing. |
| 20 | - [ ] Push updated source to all 9 GRASP servers (`git push origin master`) | 20 | |
| 21 | - [ ] Redeploy via Ansible (`ansible-playbook playbooks/30-grasp-mirror.yml`) | 21 | ### Architecture |
| 22 | - [ ] Verify: `grasp-mirror status` shows repos tracked and sync records | 22 | ``` |
| 23 | - [ ] Verify: `journalctl -u grasp-mirror` shows successful mirror cycles without `already exists` errors | 23 | VPS: grasp-mirror ←→ relays ←→ Amber (phone) |
| 24 | - [ ] Verify: spot-check `git ls-remote` against a few GRASP servers for repos from all 3 npubs | 24 | - discovers repos - NIP-46 kind:24133 |
| 25 | - [ ] Verify: `https://git.orangesync.tech/api/mirror-health` returns `"status": "ok"` | 25 | - builds state events - sign_event:30618 |
| 26 | - queues signing - user approves | ||
| 27 | - publishes event - returns signature | ||
| 28 | - git push | ||
| 29 | |||
| 30 | services.orangesync.tech | ||
| 31 | - pairing UI + QR codes | ||
| 32 | - status display | ||
| 33 | ``` | ||
| 34 | |||
| 35 | ### Config | ||
| 36 | ```toml | ||
| 37 | [nip46] | ||
| 38 | relays = ["wss://relay.orangesync.tech", "wss://ngit.orangesync.tech"] | ||
| 39 | signing_timeout_secs = 604800 # 7 days | ||
| 40 | ``` | ||
| 41 | |||
| 42 | ### Checklist | ||
| 43 | |||
| 44 | - [ ] Add `nip46_sessions` and `signing_queue` tables to `src/db.rs` | ||
| 45 | - [ ] Build `src/nip46.rs` — NIP-46 client with session management | ||
| 46 | - Generate client keypair per npub, persist to SQLite | ||
| 47 | - Build `nostrconnect://` URIs with `perms=sign_event:30618` | ||
| 48 | - Listen for `kind:24133` events on configured relays | ||
| 49 | - Handle connect/get_public_key/sign_event handshake | ||
| 50 | - `sign_state_event(npub, state_event) -> Result<SignedEvent>` method | ||
| 51 | - Auto-reconnect on session drop | ||
| 52 | - [ ] Wire NIP-46 into `src/main.rs` daemon startup — connect sessions for all 3 npubs | ||
| 53 | - [ ] Modify `src/git_mirror.rs` — before push, build `kind:30618` state event, request signature via NIP-46 | ||
| 54 | - [ ] Add pairing URI + NIP-46 status to health endpoint (`/api/mirror-health`) | ||
| 55 | - [ ] Update dashboard HTML (`static/services/index.html`) with pairing UI + QR codes | ||
| 56 | - [ ] Update Ansible config template with `[nip46]` section | ||
| 57 | - [ ] Add `health_port` to `[storage]` in config template (already done) | ||
| 58 | - [ ] Rebuild release binary | ||
| 59 | - [ ] Push to all 9 GRASP servers | ||
| 60 | - [ ] Redeploy via Ansible | ||
| 61 | - [ ] End-to-end test: pair npub via Amber, verify signing flow works | ||
diff --git a/src/git_mirror.rs b/src/git_mirror.rs index c486887..cdf853e 100644 --- a/src/git_mirror.rs +++ b/src/git_mirror.rs | |||
| @@ -31,6 +31,7 @@ impl GitMirror { | |||
| 31 | repo: &DiscoveredRepo, | 31 | repo: &DiscoveredRepo, |
| 32 | target_servers: &[GraspServer], | 32 | target_servers: &[GraspServer], |
| 33 | nip46_client: Option<&Arc<Nip46Client>>, | 33 | nip46_client: Option<&Arc<Nip46Client>>, |
| 34 | nostr_client: &nostr_sdk::Client, | ||
| 34 | ) -> Result<()> { | 35 | ) -> Result<()> { |
| 35 | if target_servers.is_empty() { | 36 | if target_servers.is_empty() { |
| 36 | tracing::debug!( | 37 | tracing::debug!( |
| @@ -80,17 +81,6 @@ impl GitMirror { | |||
| 80 | let relay_url = server.relay_url(); | 81 | let relay_url = server.relay_url(); |
| 81 | if let Ok(url) = RelayUrl::parse(&relay_url) { | 82 | if let Ok(url) = RelayUrl::parse(&relay_url) { |
| 82 | let urls = vec![url]; | 83 | 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 { | 84 | if let Err(e) = nostr_client.send_event_to(urls, event.clone()).await { |
| 95 | tracing::warn!( | 85 | tracing::warn!( |
| 96 | server = %server.domain, | 86 | server = %server.domain, |
diff --git a/src/main.rs b/src/main.rs index 8e1383e..974898f 100644 --- a/src/main.rs +++ b/src/main.rs | |||
| @@ -231,7 +231,7 @@ async fn mirror_cycle( | |||
| 231 | "mirroring to missing servers" | 231 | "mirroring to missing servers" |
| 232 | ); | 232 | ); |
| 233 | 233 | ||
| 234 | if let Err(e) = mirror.mirror_repo_to_servers(db, repo, &missing, nip46_client).await { | 234 | if let Err(e) = mirror.mirror_repo_to_servers(db, repo, &missing, nip46_client, nostr_client).await { |
| 235 | 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"); |
| 236 | } | 236 | } |
| 237 | 237 | ||
diff --git a/src/nip46.rs b/src/nip46.rs index bedc814..c3b1e86 100644 --- a/src/nip46.rs +++ b/src/nip46.rs | |||
| @@ -156,10 +156,10 @@ impl Nip46Client { | |||
| 156 | let db = db.clone(); | 156 | let db = db.clone(); |
| 157 | 157 | ||
| 158 | async move { | 158 | async move { |
| 159 | if let RelayPoolNotification::Event { event, .. } = notification.as_ref() { | 159 | if let RelayPoolNotification::Event { event, .. } = notification { |
| 160 | if event.kind == Kind::Custom(24133) { | 160 | if event.kind == Kind::Custom(24133) { |
| 161 | let _ = | 161 | let _ = |
| 162 | Self::handle_response(&sessions, &pending, &db, event).await; | 162 | Self::handle_response(&sessions, &pending, &db, &event).await; |
| 163 | } | 163 | } |
| 164 | } | 164 | } |
| 165 | Ok(false) | 165 | Ok(false) |