fix: harden fips startup and app port relays
This commit is contained in:
parent
0aee010f9c
commit
709922c293
@ -44,6 +44,7 @@ Start with:
|
|||||||
- [Developer Guide](docs/developer-guide.md)
|
- [Developer Guide](docs/developer-guide.md)
|
||||||
- [App Developer Guide](docs/app-developer-guide.md)
|
- [App Developer Guide](docs/app-developer-guide.md)
|
||||||
- [App Manifest Spec](docs/app-manifest-spec.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)
|
- [Operations Runbook](docs/operations-runbook.md)
|
||||||
- [Troubleshooting](docs/troubleshooting.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 |
|
| [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 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 |
|
| [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 |
|
| [Apps README](apps/README.md) | Packaged app catalog overview |
|
||||||
| [Image Recipe](image-recipe/README.md) | Bootable image build flow |
|
| [Image Recipe](image-recipe/README.md) | Bootable image build flow |
|
||||||
| [Operations Runbook](docs/operations-runbook.md) | Production operations and recovery |
|
| [Operations Runbook](docs/operations-runbook.md) | Production operations and recovery |
|
||||||
|
|||||||
@ -83,7 +83,8 @@ impl RpcHandler {
|
|||||||
pub(super) async fn handle_fips_install(&self) -> Result<serde_json::Value> {
|
pub(super) async fn handle_fips_install(&self) -> Result<serde_json::Value> {
|
||||||
let identity_dir = fips::identity_dir_from(&self.config.data_dir);
|
let identity_dir = fips::identity_dir_from(&self.config.data_dir);
|
||||||
fips::config::install(&identity_dir).await?;
|
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;
|
let status = fips::FipsStatus::query(&self.config.data_dir).await;
|
||||||
Ok(serde_json::to_value(status)?)
|
Ok(serde_json::to_value(status)?)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -54,7 +54,8 @@ pub async fn ensure_activated(data_dir: &std::path::Path) {
|
|||||||
tracing::warn!("FIPS auto-activate: config install failed: {:#}", e);
|
tracing::warn!("FIPS auto-activate: config install failed: {:#}", e);
|
||||||
return;
|
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);
|
tracing::warn!("FIPS auto-activate: service activate failed: {:#}", e);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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.
|
/// Whether the `fips` debian package is installed on the host.
|
||||||
pub async fn package_installed() -> bool {
|
pub async fn package_installed() -> bool {
|
||||||
// dpkg-query -W -f='${Status}' fips → "install ok installed" when present.
|
// dpkg-query -W -f='${Status}' fips → "install ok installed" when present.
|
||||||
@ -131,17 +141,28 @@ done
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Resolve which systemd unit is actually supervising the fips daemon
|
/// Resolve which systemd unit should be started when FIPS is inactive.
|
||||||
/// on this host. Nodes installed from the archipelago ISO run
|
/// Newer Archipelago images may ship `archipelago-fips.service`; nodes with
|
||||||
/// `archipelago-fips.service`; nodes that were apt-installed (or had
|
/// the upstream Debian package may only have `fips.service`. Activation must
|
||||||
/// fips running before archipelago took over) may only have the
|
/// choose a unit systemd can actually load, otherwise the dashboard repeatedly
|
||||||
/// upstream `fips.service`. Restart/Reconnect must operate on whichever
|
/// offers an "Activate" action that can never succeed.
|
||||||
/// one is running, otherwise the UI button is a silent no-op.
|
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,
|
/// Returns the archipelago-managed unit name if it's active,
|
||||||
/// else the upstream unit name if that's active,
|
/// else the upstream unit name if that's active,
|
||||||
/// else the archipelago-managed name as a default (so activate() can
|
/// else a startable activation unit.
|
||||||
/// bring it up).
|
|
||||||
pub async fn active_unit() -> &'static str {
|
pub async fn active_unit() -> &'static str {
|
||||||
if unit_state(super::SERVICE_UNIT).await == "active" {
|
if unit_state(super::SERVICE_UNIT).await == "active" {
|
||||||
return super::SERVICE_UNIT;
|
return super::SERVICE_UNIT;
|
||||||
@ -149,7 +170,7 @@ pub async fn active_unit() -> &'static str {
|
|||||||
if unit_state(super::UPSTREAM_SERVICE_UNIT).await == "active" {
|
if unit_state(super::UPSTREAM_SERVICE_UNIT).await == "active" {
|
||||||
return super::UPSTREAM_SERVICE_UNIT;
|
return super::UPSTREAM_SERVICE_UNIT;
|
||||||
}
|
}
|
||||||
super::SERVICE_UNIT
|
activation_unit().await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn mask(unit: &str) -> Result<()> {
|
pub async fn mask(unit: &str) -> Result<()> {
|
||||||
@ -248,4 +269,10 @@ mod tests {
|
|||||||
// Must not panic regardless of host state.
|
// Must not panic regardless of host state.
|
||||||
let _ = package_installed().await;
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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!(
|
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
|
e
|
||||||
);
|
);
|
||||||
return;
|
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,
|
_ => None,
|
||||||
};
|
};
|
||||||
let v6_task = if let Some(port) = v4_any_port {
|
let v6_task = if let Some(port) = v4_any_port {
|
||||||
let v6_addr = SocketAddr::new(
|
let v6_addr =
|
||||||
std::net::IpAddr::V6(std::net::Ipv6Addr::UNSPECIFIED),
|
SocketAddr::new(std::net::IpAddr::V6(std::net::Ipv6Addr::UNSPECIFIED), port);
|
||||||
port,
|
|
||||||
);
|
|
||||||
match bind_v6_only(v6_addr) {
|
match bind_v6_only(v6_addr) {
|
||||||
Ok(listener) => {
|
Ok(listener) => {
|
||||||
info!("IPv6 web listener bound {} (mesh ULA reachable)", v6_addr);
|
info!("IPv6 web listener bound {} (mesh ULA reachable)", v6_addr);
|
||||||
@ -1017,7 +1017,10 @@ impl Server {
|
|||||||
)))
|
)))
|
||||||
}
|
}
|
||||||
Err(e) => {
|
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
|
None
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -1031,9 +1034,9 @@ impl Server {
|
|||||||
// IPv4 only for most apps, so a phone dialing [ULA]:8123 got
|
// IPv4 only for most apps, so a phone dialing [ULA]:8123 got
|
||||||
// nothing even with the firewall open (HA/FileBrowser/Gitea/
|
// nothing even with the firewall open (HA/FileBrowser/Gitea/
|
||||||
// Portainer/Pine all v4-only on 2026-07-26; a few bind [::]
|
// Portainer/Pine all v4-only on 2026-07-26; a few bind [::]
|
||||||
// themselves). Bridge each catalog launch port to its v4 loopback
|
// themselves). Bridge each catalog launch port on the fips0 ULA
|
||||||
// listener with a V6ONLY relay — self-selecting: where the app
|
// only. Binding wildcard [::]:port reserves the same host ports
|
||||||
// already answers on v6 the bind fails and the app wins.
|
// Podman needs and can restart-loop apps that publish those ports.
|
||||||
let relay_task = tokio::spawn(app_port_v6_relay_loop(tx.subscribe()));
|
let relay_task = tokio::spawn(app_port_v6_relay_loop(tx.subscribe()));
|
||||||
|
|
||||||
let peer_task = tokio::spawn(peer_late_bind_loop(
|
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> {
|
|||||||
tokio::net::TcpListener::from_std(socket.into())
|
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
|
/// 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
|
/// why). Rescans every 60s so ports of freshly installed apps get bridged
|
||||||
/// without a daemon restart. Each relay is a V6ONLY listener forwarding
|
/// without a daemon restart. Each relay binds to the fips0 ULA only and
|
||||||
/// raw TCP to the same port on IPv4 loopback.
|
/// forwards raw TCP to the same port on IPv4 loopback.
|
||||||
async fn app_port_v6_relay_loop(mut shutdown_rx: tokio::sync::watch::Receiver<bool>) {
|
async fn app_port_v6_relay_loop(mut shutdown_rx: tokio::sync::watch::Receiver<bool>) {
|
||||||
use std::collections::HashSet;
|
use std::collections::HashSet;
|
||||||
let mut bridged: HashSet<u16> = HashSet::new();
|
let mut bridged: HashSet<u16> = HashSet::new();
|
||||||
@ -1099,6 +1106,7 @@ async fn app_port_v6_relay_loop(mut shutdown_rx: tokio::sync::watch::Receiver<bo
|
|||||||
loop {
|
loop {
|
||||||
tokio::select! {
|
tokio::select! {
|
||||||
_ = interval.tick() => {
|
_ = interval.tick() => {
|
||||||
|
let Some(fips_ip) = crate::fips::iface::fips0_ula() else { continue };
|
||||||
for &port in crate::fips::app_ports::APP_LAUNCH_PORTS {
|
for &port in crate::fips::app_ports::APP_LAUNCH_PORTS {
|
||||||
if bridged.contains(&port) {
|
if bridged.contains(&port) {
|
||||||
continue;
|
continue;
|
||||||
@ -1123,15 +1131,12 @@ async fn app_port_v6_relay_loop(mut shutdown_rx: tokio::sync::watch::Receiver<bo
|
|||||||
if !v4_up {
|
if !v4_up {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let addr = SocketAddr::new(
|
let addr = fips_app_relay_addr(fips_ip, port);
|
||||||
std::net::IpAddr::V6(std::net::Ipv6Addr::UNSPECIFIED),
|
// EADDRINUSE = fipsd or another process already answers
|
||||||
port,
|
// on this mesh address/port, so stay out of the way.
|
||||||
);
|
|
||||||
// EADDRINUSE = the app itself already answers on v6 —
|
|
||||||
// exactly when we must stay out of the way.
|
|
||||||
let Ok(listener) = bind_v6_only(addr) else { continue };
|
let Ok(listener) = bind_v6_only(addr) else { continue };
|
||||||
bridged.insert(port);
|
bridged.insert(port);
|
||||||
debug!("v6 relay bridging [::]:{port} -> 127.0.0.1:{port}");
|
debug!("v6 relay bridging [{fips_ip}]:{port} -> 127.0.0.1:{port}");
|
||||||
let mut rx = shutdown_rx.clone();
|
let mut rx = shutdown_rx.clone();
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
loop {
|
loop {
|
||||||
@ -1945,6 +1950,14 @@ mod merge_tests {
|
|||||||
assert!(!is_peer_allowed_path("/rpc/v2"));
|
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]
|
#[test]
|
||||||
fn preserves_transitional_state_on_merge() {
|
fn preserves_transitional_state_on_merge() {
|
||||||
// existing: user initiated a stop, spawn_transitional set Stopping.
|
// existing: user initiated a stop, spawn_transitional set Stopping.
|
||||||
|
|||||||
@ -45,12 +45,44 @@ Completed locally in this pass:
|
|||||||
schema can be audited without a Python `PyYAML` dependency.
|
schema can be audited without a Python `PyYAML` dependency.
|
||||||
- Updated root/community docs, CI, PR template, app developer notes, and
|
- Updated root/community docs, CI, PR template, app developer notes, and
|
||||||
container/deployment docs toward public contributor expectations.
|
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:
|
Verified locally:
|
||||||
|
|
||||||
- `./scripts/audit-secrets.sh` passes.
|
- `./scripts/audit-secrets.sh` passes.
|
||||||
- Full `apps/*/manifest.yml` repository audit passes with warnings only.
|
- Full `apps/*/manifest.yml` repository audit passes with warnings only.
|
||||||
- `bash -n` passes for the edited shell scripts.
|
- `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:
|
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.
|
- Finish Phase 1 password/node/token sanitization beyond the two API keys.
|
||||||
- Publish from fresh history after the sanitized tree is final.
|
- Publish from fresh history after the sanitized tree is final.
|
||||||
- Run full Rust, frontend, Android, and lifecycle gate verification.
|
- 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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
252
docs/nostr-git-source-hosting.md
Normal file
252
docs/nostr-git-source-hosting.md
Normal file
@ -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/<npub>/<short-topic>` or `pr/<event-id>`.
|
||||||
|
- 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://<maintainer-npub>/<relay-hint>/archy`
|
||||||
|
- `https://<grasp-host>/<maintainer-npub>/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?
|
||||||
@ -113,7 +113,7 @@
|
|||||||
<div v-if="statusMessage" class="mb-3 p-3 rounded-lg text-xs" :class="statusIsError ? 'bg-red-400/10 text-red-300' : 'bg-green-400/10 text-green-300'">{{ statusMessage }}</div>
|
<div v-if="statusMessage" class="mb-3 p-3 rounded-lg text-xs" :class="statusIsError ? 'bg-red-400/10 text-red-300' : 'bg-green-400/10 text-green-300'">{{ statusMessage }}</div>
|
||||||
|
|
||||||
<div v-if="status.key_present && !status.service_active" class="flex gap-2 mt-auto pt-3 shrink-0">
|
<div v-if="status.key_present && !status.service_active" class="flex gap-2 mt-auto pt-3 shrink-0">
|
||||||
<button class="flex-1 min-h-[44px] px-4 py-2 glass-button rounded-lg text-sm font-medium transition-colors" :disabled="installing" @click="installAndActivate">{{ installing ? 'Installing…' : 'Activate' }}</button>
|
<button class="flex-1 min-h-[44px] px-4 py-2 glass-button rounded-lg text-sm font-medium transition-colors" :disabled="installing" @click="installAndActivate">{{ installing ? 'Starting…' : 'Start' }}</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@ -211,10 +211,10 @@ async function installAndActivate() {
|
|||||||
installing.value = true
|
installing.value = true
|
||||||
try {
|
try {
|
||||||
status.value = await rpcClient.call<FipsStatus>({ method: 'fips.install' })
|
status.value = await rpcClient.call<FipsStatus>({ method: 'fips.install' })
|
||||||
flash('FIPS installed and activated')
|
flash('FIPS started')
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
const msg = e instanceof Error ? e.message : String(e)
|
const msg = e instanceof Error ? e.message : String(e)
|
||||||
flash(`Install failed: ${msg}`, true)
|
flash(`Start failed: ${msg}`, true)
|
||||||
} finally {
|
} finally {
|
||||||
installing.value = false
|
installing.value = false
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user