diff --git a/README.md b/README.md index cba8f411..f0e24993 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,7 @@ Start with: - [Developer Guide](docs/developer-guide.md) - [App Developer Guide](docs/app-developer-guide.md) - [App Manifest Spec](docs/app-manifest-spec.md) +- [Nostr Git Source Hosting Plan](docs/nostr-git-source-hosting.md) - [Operations Runbook](docs/operations-runbook.md) - [Troubleshooting](docs/troubleshooting.md) @@ -90,6 +91,7 @@ python3 scripts/check-app-catalog-drift.py --release --strict | [API Reference](docs/api-reference.md) | JSON-RPC API overview | | [App Developer Guide](docs/app-developer-guide.md) | How to package and test apps | | [App Manifest Spec](docs/app-manifest-spec.md) | Manifest schema and validation rules | +| [Nostr Git Source Hosting Plan](docs/nostr-git-source-hosting.md) | ngit/NIP-34 contribution workflow and maintainer model | | [Apps README](apps/README.md) | Packaged app catalog overview | | [Image Recipe](image-recipe/README.md) | Bootable image build flow | | [Operations Runbook](docs/operations-runbook.md) | Production operations and recovery | diff --git a/core/archipelago/src/api/rpc/fips.rs b/core/archipelago/src/api/rpc/fips.rs index 1b4b42be..e7f0574b 100644 --- a/core/archipelago/src/api/rpc/fips.rs +++ b/core/archipelago/src/api/rpc/fips.rs @@ -83,7 +83,8 @@ impl RpcHandler { pub(super) async fn handle_fips_install(&self) -> Result { let identity_dir = fips::identity_dir_from(&self.config.data_dir); fips::config::install(&identity_dir).await?; - fips::service::activate(fips::SERVICE_UNIT).await?; + let unit = fips::service::activation_unit().await; + fips::service::activate(unit).await?; let status = fips::FipsStatus::query(&self.config.data_dir).await; Ok(serde_json::to_value(status)?) } diff --git a/core/archipelago/src/fips/mod.rs b/core/archipelago/src/fips/mod.rs index e8a26511..d7f4c8ef 100644 --- a/core/archipelago/src/fips/mod.rs +++ b/core/archipelago/src/fips/mod.rs @@ -54,7 +54,8 @@ pub async fn ensure_activated(data_dir: &std::path::Path) { tracing::warn!("FIPS auto-activate: config install failed: {:#}", e); return; } - if let Err(e) = service::activate(SERVICE_UNIT).await { + let unit = service::activation_unit().await; + if let Err(e) = service::activate(unit).await { tracing::warn!("FIPS auto-activate: service activate failed: {:#}", e); return; } diff --git a/core/archipelago/src/fips/service.rs b/core/archipelago/src/fips/service.rs index 1d4106c4..53ca36d2 100644 --- a/core/archipelago/src/fips/service.rs +++ b/core/archipelago/src/fips/service.rs @@ -32,6 +32,16 @@ pub async fn unit_state(unit: &str) -> String { } } +/// Whether systemd knows about `unit`. +pub async fn unit_exists(unit: &str) -> bool { + Command::new("systemctl") + .args(["cat", unit]) + .output() + .await + .map(|out| out.status.success()) + .unwrap_or(false) +} + /// Whether the `fips` debian package is installed on the host. pub async fn package_installed() -> bool { // dpkg-query -W -f='${Status}' fips → "install ok installed" when present. @@ -131,17 +141,28 @@ done Ok(()) } -/// Resolve which systemd unit is actually supervising the fips daemon -/// on this host. Nodes installed from the archipelago ISO run -/// `archipelago-fips.service`; nodes that were apt-installed (or had -/// fips running before archipelago took over) may only have the -/// upstream `fips.service`. Restart/Reconnect must operate on whichever -/// one is running, otherwise the UI button is a silent no-op. +/// Resolve which systemd unit should be started when FIPS is inactive. +/// Newer Archipelago images may ship `archipelago-fips.service`; nodes with +/// the upstream Debian package may only have `fips.service`. Activation must +/// choose a unit systemd can actually load, otherwise the dashboard repeatedly +/// offers an "Activate" action that can never succeed. +pub async fn activation_unit() -> &'static str { + if unit_exists(super::SERVICE_UNIT).await { + return super::SERVICE_UNIT; + } + if unit_exists(super::UPSTREAM_SERVICE_UNIT).await { + return super::UPSTREAM_SERVICE_UNIT; + } + super::SERVICE_UNIT +} + +/// Resolve which systemd unit is actually supervising the fips daemon on this +/// host. Restart/Reconnect must operate on whichever one is running, otherwise +/// the UI button is a silent no-op. /// /// Returns the archipelago-managed unit name if it's active, /// else the upstream unit name if that's active, -/// else the archipelago-managed name as a default (so activate() can -/// bring it up). +/// else a startable activation unit. pub async fn active_unit() -> &'static str { if unit_state(super::SERVICE_UNIT).await == "active" { return super::SERVICE_UNIT; @@ -149,7 +170,7 @@ pub async fn active_unit() -> &'static str { if unit_state(super::UPSTREAM_SERVICE_UNIT).await == "active" { return super::UPSTREAM_SERVICE_UNIT; } - super::SERVICE_UNIT + activation_unit().await } pub async fn mask(unit: &str) -> Result<()> { @@ -248,4 +269,10 @@ mod tests { // Must not panic regardless of host state. let _ = package_installed().await; } + + #[tokio::test] + async fn test_unit_exists_is_bool() { + // Must not panic regardless of host state. + let _ = unit_exists("archipelago-bogus-test.service").await; + } } diff --git a/core/archipelago/src/server.rs b/core/archipelago/src/server.rs index 1d733fe1..904495ec 100644 --- a/core/archipelago/src/server.rs +++ b/core/archipelago/src/server.rs @@ -940,14 +940,16 @@ impl Server { ); } } - if let Err(e) = crate::fips::service::activate(crate::fips::SERVICE_UNIT).await { + let unit = crate::fips::service::activation_unit().await; + if let Err(e) = crate::fips::service::activate(unit).await { tracing::warn!( - "archipelago-fips activate failed on startup: {} — user can retry via fips.install RPC", + "FIPS activate failed on startup via {}: {} — user can retry via fips.install RPC", + unit, e ); return; } - tracing::info!("archipelago-fips auto-activated on startup"); + tracing::info!("FIPS auto-activated on startup via {}", unit); }); } @@ -1000,10 +1002,8 @@ impl Server { _ => None, }; let v6_task = if let Some(port) = v4_any_port { - let v6_addr = SocketAddr::new( - std::net::IpAddr::V6(std::net::Ipv6Addr::UNSPECIFIED), - port, - ); + let v6_addr = + SocketAddr::new(std::net::IpAddr::V6(std::net::Ipv6Addr::UNSPECIFIED), port); match bind_v6_only(v6_addr) { Ok(listener) => { info!("IPv6 web listener bound {} (mesh ULA reachable)", v6_addr); @@ -1017,7 +1017,10 @@ impl Server { ))) } Err(e) => { - warn!("IPv6 web listener bind {} failed: {} — UI stays v4-only", v6_addr, e); + warn!( + "IPv6 web listener bind {} failed: {} — UI stays v4-only", + v6_addr, e + ); None } } @@ -1031,9 +1034,9 @@ impl Server { // IPv4 only for most apps, so a phone dialing [ULA]:8123 got // nothing even with the firewall open (HA/FileBrowser/Gitea/ // Portainer/Pine all v4-only on 2026-07-26; a few bind [::] - // themselves). Bridge each catalog launch port to its v4 loopback - // listener with a V6ONLY relay — self-selecting: where the app - // already answers on v6 the bind fails and the app wins. + // themselves). Bridge each catalog launch port on the fips0 ULA + // only. Binding wildcard [::]:port reserves the same host ports + // Podman needs and can restart-loop apps that publish those ports. let relay_task = tokio::spawn(app_port_v6_relay_loop(tx.subscribe())); let peer_task = tokio::spawn(peer_late_bind_loop( @@ -1087,10 +1090,14 @@ fn bind_v6_only(addr: SocketAddr) -> std::io::Result { tokio::net::TcpListener::from_std(socket.into()) } +fn fips_app_relay_addr(ip: std::net::Ipv6Addr, port: u16) -> SocketAddr { + SocketAddr::new(std::net::IpAddr::V6(ip), port) +} + /// IPv6→IPv4 relay for catalog app launch ports (see the spawn site for /// why). Rescans every 60s so ports of freshly installed apps get bridged -/// without a daemon restart. Each relay is a V6ONLY listener forwarding -/// raw TCP to the same port on IPv4 loopback. +/// without a daemon restart. Each relay binds to the fips0 ULA only and +/// forwards raw TCP to the same port on IPv4 loopback. async fn app_port_v6_relay_loop(mut shutdown_rx: tokio::sync::watch::Receiver) { use std::collections::HashSet; let mut bridged: HashSet = HashSet::new(); @@ -1099,6 +1106,7 @@ async fn app_port_v6_relay_loop(mut shutdown_rx: tokio::sync::watch::Receiver { + let Some(fips_ip) = crate::fips::iface::fips0_ula() else { continue }; for &port in crate::fips::app_ports::APP_LAUNCH_PORTS { if bridged.contains(&port) { continue; @@ -1123,15 +1131,12 @@ async fn app_port_v6_relay_loop(mut shutdown_rx: tokio::sync::watch::Receiver 127.0.0.1:{port}"); + debug!("v6 relay bridging [{fips_ip}]:{port} -> 127.0.0.1:{port}"); let mut rx = shutdown_rx.clone(); tokio::spawn(async move { loop { @@ -1945,6 +1950,14 @@ mod merge_tests { assert!(!is_peer_allowed_path("/rpc/v2")); } + #[test] + fn app_relay_binds_to_fips_ula_not_wildcard() { + let ula = "fd12:3456:789a::1".parse().unwrap(); + let addr = fips_app_relay_addr(ula, 8083); + assert_eq!(addr.ip(), std::net::IpAddr::V6(ula)); + assert_eq!(addr.port(), 8083); + } + #[test] fn preserves_transitional_state_on_merge() { // existing: user initiated a stop, spawn_transitional set Stopping. diff --git a/docs/OPEN-SOURCE-READINESS-PLAN.md b/docs/OPEN-SOURCE-READINESS-PLAN.md index 29db56e7..153717e6 100644 --- a/docs/OPEN-SOURCE-READINESS-PLAN.md +++ b/docs/OPEN-SOURCE-READINESS-PLAN.md @@ -45,12 +45,44 @@ Completed locally in this pass: schema can be audited without a Python `PyYAML` dependency. - Updated root/community docs, CI, PR template, app developer notes, and container/deployment docs toward public contributor expectations. +- Fixed native FIPS activation fallback: nodes that have the packaged + `fips.service` but not `archipelago-fips.service` now start the available + unit instead of repeatedly failing activation against a missing unit. The UI + now labels the action as `Start` instead of making native FIPS look like an + installable app. +- Fixed the FIPS app-port relay design so it binds relays to the node's FIPS + ULA instead of wildcard `[::]`, avoiding collisions with Podman-published app + ports such as FileBrowser `8083` and Botfights `9100`. +- Added `docs/nostr-git-source-hosting.md`, a NIP-34/ngit/GRASP source hosting + plan using a Bitcoin Core-style maintainer model: public review and easy + forks, with canonical merge rights held by a small signed maintainer set. Verified locally: - `./scripts/audit-secrets.sh` passes. - Full `apps/*/manifest.yml` repository audit passes with warnings only. - `bash -n` passes for the edited shell scripts. +- Targeted FIPS dashboard vitest passes. +- Targeted Rust tests for FIPS service unit detection and FIPS app relay + address selection pass. + +Verified on a Linux Archipelago verification node: + +- Native FIPS was restored by starting the already-installed packaged + `fips.service`; the daemon became active and joined the FIPS tree. +- Correct local lifecycle API endpoint is HTTP, not HTTPS + (`ARCHY_HOST=127.0.0.1 ARCHY_SCHEME=http`). +- Read-only lifecycle run progressed past login and confirmed required + containers, Bitcoin RPC, ElectrumX TCP, and manifest port-drift checks, but + did not complete cleanly: `botfights` and `filebrowser` remained in + `restarting` longer than the matrix window, and the LND `lncli getinfo` + probe hung. Do not run the destructive gate until those live-node issues are + understood. +- After the node updated to `1.7.116-alpha`, `botfights`, `filebrowser`, and + `lnd` were active/running and ports `8083`/`9100` were held by Podman's + `rootlessport` as expected. The packaged `fips.service` remained installed + and enabled but inactive, so the native FIPS service fallback should still + ship before the public launch. Still required before public publish: @@ -58,6 +90,10 @@ Still required before public publish: - Finish Phase 1 password/node/token sanitization beyond the two API keys. - Publish from fresh history after the sanitized tree is final. - Run full Rust, frontend, Android, and lifecycle gate verification. +- Resolve the live-node lifecycle blockers above, then rerun the read-only + suite followed by the destructive gate only on an approved verification node. +- Decide the canonical Archipelago maintainer npub and merge-maintainer npub + list before publishing the Nostr Git source-hosting workflow. --- diff --git a/docs/nostr-git-source-hosting.md b/docs/nostr-git-source-hosting.md new file mode 100644 index 00000000..908ad300 --- /dev/null +++ b/docs/nostr-git-source-hosting.md @@ -0,0 +1,252 @@ +# Nostr Git Source Hosting Plan + +This plan describes how Archipelago can publish and accept contributions to its +source code through `ngit`, NIP-34, and GRASP while keeping the developer +experience inside Archipelago. + +## Goals + +- Publish Archipelago source from a sanitized, fresh-history repository. +- Make the in-app registry the primary onboarding path for contributors. +- Let contributors clone, branch, push PR branches, open PRs, and discuss issues + with a Nostr identity from their Archipelago node. +- Follow the Bitcoin Core development model: broad public review and easy forks, + with canonical merge authority held by a small maintainer set. +- Give contributors full read, fork, and proposal rights, but no direct merge + rights on the canonical repository. +- Keep the official maintainer identity and merge authority separate from user + node identities. + +## Current Building Blocks + +Archipelago already has most of the primitives needed for this: + +- App manifests and the app registry already install developer tooling as + rootless Podman apps. +- The `gitea` app provides a conventional fallback Git UI and package registry. +- The app launcher already exposes a consent-gated NIP-07 bridge for launched + apps using `getPublicKey`, `signEvent`, NIP-04, and NIP-44 requests. +- The backend exposes node and identity Nostr signing RPC methods. +- FIPS gives nodes a stable mesh identity and private transport path, but repo + announcements and PRs should remain NIP-34 compatible on normal Nostr relays. +- DWN protocol registration exists and can be used later for local contribution + metadata/cache, but should not be required for the first public workflow. + +## Protocol Basis + +Use existing Nostr Git conventions rather than inventing an Archipelago-only +protocol: + +- NIP-34 repository announcement events identify repositories with kind `30617`. +- NIP-34 repository state events publish branch/tag refs with kind `30618`. +- NIP-34 patches, pull requests, PR updates, issues, and status events use kinds + `1617`, `1618`, `1619`, `1621`, and `1630`-`1633`. +- `ngit` provides the `git-remote-nostr` helper for `nostr://` clone URLs and PR + branches. +- GRASP servers provide Git Smart HTTP storage while Nostr events remain the + authority for repository identity, refs, PRs, issues, and maintainer state. + +Primary references: + +- https://nips.nostr.com/34 +- https://docs.rs/crate/ngit/latest/source/README.md +- https://ngit.dev/grasp/ + +## Recommended Architecture + +### Apps + +Create two first-party apps: + +- `ngit`: CLI/runtime package containing `ngit` and `git-remote-nostr`. +- `archipelago-source`: web UI for cloning Archipelago source, viewing NIP-34 + issues/PRs, opening branches, and submitting PR events. + +The `archipelago-source` app should depend on `ngit`. It can also recommend +Gitea for users who want a conventional local web Git UI, but Gitea should not +be the source of truth for public contribution permissions. + +### Contributor Onboarding + +When the user installs `archipelago-source` from the registry: + +1. Show a modal before first launch: "Contribute to Archipelago". +2. Explain that the app will use their Archipelago Nostr identity to clone and + sign contribution events. +3. Display the maintainer repository announcement, clone URL, maintainer npub, + and relay/GRASP endpoints. +4. Ask for consent to: + - fetch repository metadata from configured relays, + - clone source through `nostr://`, + - create local branches, + - sign NIP-34 issue/PR/comment events, + - push PR branches to approved GRASP servers. +5. Store approval per app origin, identity id, repository id, and relay set. + +This should build on the existing NIP-07 app-launcher bridge, but use a more +specific permission scope than the generic sign-event approval. + +### Identity And Permissions + +Use four identity classes: + +- `archipelago-maintainer`: an offline or tightly controlled Nostr key that + signs the canonical kind `30617` repo announcement and status/merge events. +- `archipelago-merge-maintainer`: one of the small set of maintainer npubs + allowed to advance canonical refs and publish valid merged/applied status. +- `archipelago-build`: release automation key for signed release artifacts and + CI status events. It must not have merge authority. +- `contributor`: user node or app-specific identity used for PRs, issues, and + comments. + +Contributor rights: + +- Clone the repository. +- Open issues. +- Push proposal branches using `pr//` or `pr/`. +- Publish NIP-34 PR/update/comment events. +- Rebase and update their own PR branch. +- Run local validation and attach status evidence. + +Contributor restrictions: + +- Cannot update `refs/heads/main` or release branches in canonical state. +- Cannot publish maintainer-valid merge/applied status. +- Cannot alter the canonical repository announcement. +- Cannot publish release catalog signatures. + +Maintainer rights: + +- Publish/update the canonical repo announcement. +- Publish canonical `refs/heads/main` state. +- Mark PRs merged/closed/draft via NIP-34 status events. +- Sign release tags and catalog updates. + +Fork rights: + +- Any contributor can create their own NIP-34 kind `30617` repository + announcement for a fork. +- Fork announcements should use the NIP-34 `u` tag to point back to the + canonical `archy` repository. +- The source app should make forking a first-class path: "Fork on Nostr", clone + the fork locally, push branches to the contributor's GRASP list, and open PRs + back to canonical Archipelago when they want review. +- Forks can have their own maintainer npubs, relays, policies, and release + cadence, but the app should clearly label them as forks unless signed by the + canonical maintainer set. + +The GRASP server policy should enforce this by accepting pushes to maintainer +refs only when backed by signed maintainer state, while allowing contributor PR +refs from their own npubs. + +## Repository Layout + +Canonical repo announcement: + +- repo id: `archy` +- display name: `Archipelago` +- clone URLs: + - `nostr:////archy` + - `https:////archy.git` +- relays: + - Archipelago-operated relay + - at least two public Nostr relays that support the event load +- GRASP servers: + - Archipelago-operated GRASP instance + - one public GRASP-compatible mirror + +Keep the existing HTTP Git remote as a mirror during launch. The docs can +present `nostr://` as the preferred contribution path once the workflow is +proven. + +## UI Requirements + +The source app should provide: + +- A first-run contribution modal with a real Archipelago source graphic, not a + generic text-only dialog. +- Current clone status and local path. +- Branch list, changed files, commit form, and push/open-PR flow. +- PR inbox, issue list, maintainer status, and relay health. +- Explicit identity indicator showing which npub will sign events. +- A merge rights indicator that clearly says contributors can propose changes + but cannot merge them. +- A fork flow that creates a user-owned NIP-34 repo announcement and remote, + then offers "Open PR to Archipelago" from any fork branch. +- Maintainer badges based only on pinned canonical maintainer npubs, not relay + metadata or server-side account names. +- Links to container docs, deployment docs, manifest spec, and open-source + readiness tasks. + +## Backend Work + +Add an RPC module for source contribution workflow: + +- `source.repo-info`: returns canonical announcement, clone URL, relay set, + maintainer npubs, and local clone state. +- `source.ensure-ngit`: verifies the `ngit` app/runtime is installed. +- `source.clone`: clones or updates the local source checkout. +- `source.status`: returns branch, dirty files, ahead/behind, and PR state. +- `source.commit`: creates a local commit from selected files. +- `source.fork`: creates a contributor-owned NIP-34 fork announcement and local + remote. +- `source.open-pr`: pushes a PR branch and publishes a kind `1618` event. +- `source.update-pr`: updates the branch and publishes kind `1619`. +- `source.issue`: publishes a kind `1621` event. + +Backend must shell out through a narrow command wrapper, never arbitrary user +commands. The wrapper should set an isolated working tree under +`/var/lib/archipelago/source/archy`, run as the Archipelago service user, and +deny operations outside that path. + +## Security Model + +- Never expose maintainer private keys to an Archipelago node. +- Prefer app-specific contributor identities over the node's default identity. +- Require per-action consent for first PR push, issue creation, and signing any + event that tags the canonical repository. +- Pin the canonical maintainer npub in the app manifest and backend config. +- Keep the canonical merge-maintainer allow list signed by the + `archipelago-maintainer` key; never infer merge rights from GRASP server + accounts. +- Verify the canonical kind `30617` event signature before displaying clone + instructions. +- Treat GRASP servers as untrusted storage; verify Git refs against signed + Nostr state. +- Do not use destructive git operations from the UI without an explicit modal. +- Store local clones and generated patches outside app container writable roots + unless the user exports them. + +## MVP + +1. Package `ngit` as a first-party app. +2. Stand up one Archipelago-operated GRASP server and one Nostr relay. +3. Publish sanitized fresh-history `archy` through `ngit init`. +4. Add a simple `archipelago-source` app that clones source and links out to the + preferred Nostr Git browser. +5. Add app-launcher consent scopes for repository-specific NIP-34 signing. +6. Allow issues and PR branch submission from contributor npubs. +7. Add a one-click fork flow that publishes a contributor-owned fork + announcement referencing canonical Archipelago. +8. Keep maintainer merge/status publication manual. + +## Later + +- Native PR review UI with file diffs and inline comments. +- CI status events signed by the build identity. +- FIPS-first source sync between trusted Archipelago nodes. +- Private prerelease repositories using NIP-42 allow lists and/or protected + events if the ecosystem support is mature enough. +- Multi-maintainer policy with threshold signatures or explicit maintainer-list + rotation events. + +## Open Questions + +- Which maintainer npub should become canonical for `archy`? +- Should contributor identities be node-default or app-specific by default? +- Which GRASP implementation should be deployed first: `ngit-grasp` or another + NIP-34/GRASP-compatible relay? +- Should the source app include a full web Git UI in v1, or launch Gitea/ngit + browser links for review while keeping signing/submission native? +- What exact license and contribution certificate should contributors accept + before submitting PR events? diff --git a/neode-ui/src/views/server/FipsNetworkCard.vue b/neode-ui/src/views/server/FipsNetworkCard.vue index fece9ab3..03ca8556 100644 --- a/neode-ui/src/views/server/FipsNetworkCard.vue +++ b/neode-ui/src/views/server/FipsNetworkCard.vue @@ -113,7 +113,7 @@
{{ statusMessage }}
- +
@@ -211,10 +211,10 @@ async function installAndActivate() { installing.value = true try { status.value = await rpcClient.call({ method: 'fips.install' }) - flash('FIPS installed and activated') + flash('FIPS started') } catch (e: unknown) { const msg = e instanceof Error ? e.message : String(e) - flash(`Install failed: ${msg}`, true) + flash(`Start failed: ${msg}`, true) } finally { installing.value = false }