5th ISO attempt died in rustables's build.rs (which uses bindgen to
wrap libnftnl) with "couldn't find any valid shared libraries
matching: libclang". bindgen requires libclang.so at build time
to parse C headers. rustables also needs libnftnl-dev + libmnl-dev
for the actual wrappers.
Added to the fips-builder stage apt install line.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Third time's the charm. The upstream fips Cargo.toml puts fips-gateway
behind features.gateway = ["dep:rustables"], so the previous two
attempts (--bins, --workspace --bins) never produced the binary —
only the default feature set was compiled. cargo deb --no-build then
panics looking for the missing binary.
Inspected /tmp/fips-investigate (fresh clone of upstream main on
2026-04-19) to confirm — the feature flag is the gate, not a
workspace layout issue.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Plain `cargo build --release --bins` only built the root crate's
binary targets. fips-gateway is a workspace member, so we need
--workspace to pull every member's bins. Without it cargo deb
--no-build panics looking for target/release/fips-gateway.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Consumes the new authenticated_peer_count + anchor_connected fields
from fips.status. Shows a cyan dot + "connected" when fips.v0l.io is
in the identity cache (DHT routing to unknown npubs will work), or
an orange "not reached" with a one-line explainer that federation
and messaging will fall back to Tor until the anchor reconnects.
Peer count appears on the same row so users see "3 peers" when the
fleet-pair script has been run, or "0 peers" on a fresh install
still waiting for the anchor handshake.
Block only renders when service_active — pre-onboarding the FIPS
package is masked so there's nothing meaningful to report.
Covers the UI half of task #20. Multi-anchor defaulting is still
open (need real anchor addresses beyond fips.v0l.io).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two new fields on the /rpc fips.status payload:
- authenticated_peer_count: how many FIPS peers the daemon has an
authenticated session to right now. 0 means isolated / not on
the mesh; >0 means traffic to any known npub can DHT-route.
- anchor_connected: true when the public anchor (fips.v0l.io,
npub1zv58cn7…) is present in the daemon's identity cache. The
anchor bootstraps DHT routing for general-case deployments, so
this is the best single-value indicator the UI can show for
"will federation traffic over FIPS work between previously-
unknown peers?"
Implementation: fips::service::peer_connectivity_summary shells
out to `sudo -n fipsctl show peers` + `... show identity-cache`
(archipelago user already has NOPASSWD:ALL per the ISO sudoers
and live fleet nodes, confirmed). Failure returns (0, false) so
the UI degrades to "unknown" state without crashing.
Only queried when service_active — pre-onboarding / daemon-down
nodes skip the fipsctl call entirely.
UI side (FipsNetworkCard) consumes the full status JSON, so the
two new fields are available via existing prop plumbing; visual
treatment can come later.
Also fixes ISO build (commit 3e04456c wasn't sufficient): the
Dockerfile needs `cargo build --release --bins` — upstream FIPS
added a `fips-gateway` binary target, and plain `cargo build
--release` only builds the default bin list, which caused
`cargo deb --no-build` to fail hunting for the missing binary.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Until now federation.sync-state only fired on (a) user clicking Sync
in the UI or (b) server-name push. That meant own_fips_npub,
last_transport, peer state updates — all the things v1.5 added for
auto-upgrade from Tor to FIPS — didn't propagate until the user
poked the button.
Fix: spawn a background task in server.rs that runs
federation::sync_with_peer for every Trusted peer every 30 minutes.
First run is 60s after boot (let onboarding settle) and peers are
staggered 5s apart to not hammer Tor's SOCKS proxy with concurrent
connects.
The sync path already prefers FIPS (via PeerRequest), so once peers
have learned each other's fips_npub (now automatic thanks to the
own_fips_npub broadcast in state snapshots), subsequent periodic
syncs route over FIPS — transport badge cycles from 'tor' to 'fips'
on its own without user action.
Covers task #30.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
rust:1-slim-bookworm doesn't include dbus/ssl dev headers, and
jmcorgan/fips upstream started linking against libdbus-sys + openssl
at some recent commit. Observed during the 2026-04-19 v1.5.0-alpha
rebuild: libdbus-sys's build.rs panics when pkg-config can't find
dbus-1.pc, which kills the whole cargo build → the whole ISO build
→ ships an ISO without FIPS installed.
Also mask nostr-vpn.service + archipelago-wg*.service in the rootfs
Dockerfile: these have WantedBy=multi-user.target so systemd pulls
them into the default boot target, but their EnvironmentFile + an
ExecStartPre guard cause them to [FAILED] in the boot MOTD on every
fresh install until onboarding writes their env files. Masking
keeps the startup clean; the onboarding / install RPC handlers
unmask + start them when prerequisites exist (same model as
archipelago-fips).
Bonus discovery from same diag: the default build was silently
reusing a stale rootfs cache from Apr 12 — before the FIPS
integration landed. So the v1.5.0-alpha ISO I shipped had no FIPS
package at all. Rebuild pass with --rebuild forces fresh rootfs.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The original v1.5.0-alpha cut (commit 77206a89) captured the binary
at that exact point in main. Since then ~12 commits landed that are
now live on every fleet node — own_fips_npub in state sync,
bidirectional peer flow, lazy-bind peer listener, self-peer guard,
multi-relay nostr publish + UI, avatar/banner upload, bidirectional
network requests. Rather than bump to v1.5.1-alpha for an in-flight
alpha iteration, refresh the artifacts in place so the manifest +
binary + frontend all agree.
- archipelago: sha 77206a89→e3df7a68, 40,020,488→40,078,936 bytes
- archipelago-frontend-1.5.0-alpha.tar.gz: sha 83fbacf3→2bf973ee,
53,359,792→53,358,751 bytes
- changelog extended from 7 to 15 lines to cover every landed item.
check-release-manifest.sh passes — safe to publish.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
ISO itself stays out of git (2.4GB), but this pointer file records
the hash + retrieval path so anyone flashing to a fresh .253-style
install knows exactly what they're getting. Summary of code landed
in this ISO listed at the bottom so the changelog isn't scattered
across commit messages.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Previously the profile editor only accepted external URLs for
picture/banner — typing in a URL works, but anyone without their
own image host couldn't use an avatar at all. Now there's an
"Upload" button next to each field that pushes the selected file
to /api/blob and pastes the returned capability-signed local URL
(`/blob/<cid>?cap=…&exp=…&peer=…`) straight into the form field.
- Two new refs: avatarUploading / bannerUploading so each button
shows "Uploading…" independently.
- uploadAsset(ev, 'picture' | 'banner') wraps the POST, validates
HTTP 200 + presence of self_test_url, surfaces failures in the
existing profileError banner.
- File input is re-cleared on completion so the user can pick the
same file again without refreshing.
- Live preview in the <img> at the top of the editor updates
immediately because profileForm[field] is reactive.
Image persists through Save & Publish via the existing
identity.update-profile + identity.publish-profile (both now
multi-relay). The image URL is still local-only — external nostr
clients won't resolve it until we integrate a public image host
(noted in task #29).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Pairs with the backend change that broadcasts kind:0 to every
enabled relay. Web5Identities.vue's publishProfile() now reads the
richer response ({accepted, rejected, relays_attempted, published})
and shows one of three states:
- all relays accepted → "Published to all N relays (event_id…)"
- partial → "Published to X/N relays" plus a warning with the first
relay's rejection reason
- zero → "Published to 0/N relays — check Manage Relays" (error)
User can now tell at a glance whether their profile actually made
it to the wider nostr network or only the local relay.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Previously handle_identity_publish_profile defaulted to a single
hard-coded relay (ws://localhost:18081) so the user's kind:0 profile
event only ever landed on the local relay — hence "Manage Relays
shows N connected, but profile edits don't propagate" from testing.
Fix — two-layer change:
- identity_manager::publish_profile now takes `&[String]` relays
instead of one URL. Adds each relay to the nostr-sdk client,
gives 15s for handshakes, publishes, then surfaces per-relay
accept/reject in a new ProfilePublishOutcome struct so the UI
can show WHICH relays accepted vs. rejected and WHY.
- RPC handle_identity_publish_profile no longer defaults to the
local relay: pulls the ENABLED list from nostr_relays::list_relays
(the same table that powers Manage Relays) and publishes to every
entry. Accepts an optional `relays: [...]` override for tests.
- At-least-one-accept guarantee: if every relay rejects, the call
errors instead of silently reporting published=true. User gets a
real error message listing the failures.
- Response shape: `{event_id, accepted: [urls], rejected: [[url,
reason]], relays_attempted: N, published: bool}` so the UI can
show a useful status block after clicking Publish.
relay_url_matches is tolerant of trailing-slash / case differences
since nostr-sdk canonicalises URLs internally.
Covers the publishing half of task #29; avatar/banner upload UI is
still open.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Before: Alice sent /network.send-request to Bob, Bob accepted via
/network.accept-request and gained Alice in his peers list, but Alice
was never notified — her pending row sat there and she had to
manually add Bob separately. User complaint: "it's strange you have
to do it both ways."
Fix — the accept now fires a best-effort connection_accepted message
back to the requester:
- handle_network_accept_request: after writing the local peer record,
assembles a `{type: "connection_accepted", request_id, from_did,
from_onion, from_pubkey}` JSON, signs + encrypts + POSTs it to the
requester via node_message::send_to_peer. Uses PeerRequest internally
so it prefers FIPS and falls back to Tor.
- handle_node_message: parses incoming plaintext as JSON; on a match
for type=connection_accepted, auto-adds the sender to peers.json
(the existing self-pubkey guard in add_peer still applies) and
short-circuits the normal store_received path so the acceptance
doesn't also land as a chat message in Alice's inbox.
Offline handling: if Alice is offline when Bob accepts, the notify
warns and the local accept still succeeds. Alice will receive any
subsequent message from Bob normally; future iteration could
retry on reconnect.
Federation-invite flow (federation.accept-invite → notify_join) was
already bidirectional; this closes the gap for the peer flow.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
need an archipelago restart
Previously the server checked `fips0` once at startup; if the
interface wasn't up (pre-onboarding, or post-onboarding before the
user clicked Activate FIPS), the peer listener never bound and stayed
unreachable until the next archipelago restart.
Replaced with a `peer_late_bind_loop` background task: polls every
30s for an fd00::/8 address on `fips0` and binds the listener the
moment one appears. First tick fires immediately so the hot path —
fips0 already up at startup — is still zero-cost. Cancellation
cascades through the same `tokio::sync::watch` channel the main
listener uses.
Side effects:
- main.rs no longer computes peer_addr eagerly; dropped the unused
param from serve_with_shutdown.
- FipsTransport::is_available already caches the service probe so
the 30s poll doesn't thrash systemctl.
Covers task #21. Unblocks the first-boot + onboarding flow for
fresh ISO installs on .253.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Pre-v1.4 federation pairs (who exchanged invites before fips_npub
was part of the invite code) had no path to learn each other's FIPS
npub — they'd stay Tor-only forever even after upgrading. Fix:
every state snapshot now carries the sender's own_fips_npub, and
update_node_state refreshes the stored fips_npub on the receiver
side whenever it differs.
- NodeStateSnapshot.own_fips_npub (serde default for back-compat).
- build_local_state takes own_fips_npub alongside the other
single-value fields.
- handle_federation_get_state populates own_fips_npub from
identity::fips_npub, with a fallback to the upstream daemon's
/etc/fips/fips.pub for legacy nodes that never materialised a
seed-derived key.
- storage::update_node_state now writes fips_npub into the
FederatedNode when a new value arrives and trims whitespace
before comparing, so key rotations also flow through.
- Test fixtures (storage + transport/delta + sync) updated for the
new field; existing tests pass.
Net effect: on the next sync, .116 and .228 learn each other's
fips_npub (currently null from the old invite) and subsequent
federation calls route FIPS-first automatically.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Observed on .228: /var/lib/archipelago/peers.json contained an entry
matching the node's own node_key.pub pubkey. It had been added
2026-03-02 and stuck around forever since add_peer() only dedupes by
pubkey — nothing stops a pubkey that happens to be ours.
How it probably got there: somewhere in the auto-add paths
(node-message receive, mesh federation bridge, invite back-and-forth)
a message we'd sent was fed back and the receiver-side add used the
echoed from_pubkey without realising it was us. Doesn't matter which
path — the guard belongs in storage.
add_peer now short-circuits when the candidate pubkey matches
data_dir/identity/node_key.pub. Helper is_own_pubkey best-effort:
unreadable identity → returns false so normal peers aren't blocked.
Also manually purged the one stray entry on .228 (1 removed, 2 real
peers remain). Future deploys include this guard so the phantom can't
come back.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Real 413 root cause on .116 and .228 turned out not to be the body-size
limit — their /etc/nginx/sites-enabled/archipelago was a stale regular
FILE, not a symlink to sites-available, so every nginx update since
someone froze the active config had been invisible to running nginx.
The /api/blob location, added at some point after that freeze, didn't
exist in sites-enabled, so every attachment upload hit nginx's default
1m client_max_body_size and returned 413 regardless of attachment
size.
Deploy now re-creates the symlink on every run: if sites-enabled is a
regular file or missing, we replace it with a symlink to
sites-available. Idempotent if it's already correct.
Also applied the fix live on all 4 fleet nodes — /api/blob now
responds 401 (session-auth required, as designed) instead of 413 on
2MB+ test uploads.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Was seeing "upload failed: 413" on mesh attachment sends between
federated nodes — a ~7MB image becomes ~10MB base64 in the
typed_envelope wire and hit the 10m client_max_body_size on
/archipelago/, /content/, and /dwn/. Bumped those six locations
(two per server block, regular + HTTPS) to 256m so modern
attachments/blobs don't trip the proxy. /rpc/ stays at 1m —
internal JSON-RPC calls are small and don't need the headroom.
Applied to all 4 fleet nodes live; ISO source config updated so
fresh installs get the same limits.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Versioning was drifting on three axes — fixed all of them:
1. Cargo.toml → 1.5.0-alpha (was 1.5.0). User wants `-alpha` suffix
on every pre-stable release; this is the current state of main.
2. neode-ui/package.json was still 1.3.5 — brought in line.
3. /opt/archipelago/build-info.txt was stale on .198 (1.3.4) and
.253 (1.3.5), absent on .116/.228. That file OVERRIDES the
binary's CARGO_PKG_VERSION for the UI sidebar, which is why
.198/.253 kept showing old versions even with fresh binaries.
scripts/deploy-to-target.sh now writes build-info.txt on every
deploy, reading the version straight from Cargo.toml — so the
sidebar can never drift from the binary again.
Release artifacts + manifest:
- releases/v1.5.0-alpha/archipelago (40M, sha in manifest)
- releases/v1.5.0-alpha/archipelago-frontend-1.5.0-alpha.tar.gz (51M)
- releases/manifest.json bumped with full 7-line changelog covering
FIPS-first routing, Settings toggle, transitive federation, cancel
button, transport badges, peer listener, and the build-info fix.
- scripts/check-release-manifest.sh — new pre-publish guard. Refuses
to pass if: Cargo.toml ≠ manifest version, changelog is empty
(release notes are mandatory), or any component's sha256/size
doesn't match the file on disk. Run locally or from CI.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Every federated node card now shows a colored badge indicating how
archipelago actually reached the peer on the most recent successful
call — FIPS / TOR / LAN / MESH — not a prediction based on available
addresses. The badge is hidden when we've never reached the peer.
Backend:
- Cargo.toml: 1.4.0 → 1.5.0 (visible in the sidebar health endpoint).
- FederatedNode gains last_transport + last_transport_at (serde
default for back-compat with v1.4 nodes.json files).
- federation::storage::record_peer_transport(did, onion, transport)
— writes both fields plus last_seen after each successful peer
call. Matches by DID first, falls back to onion.
- federation::sync::sync_with_peer now calls record_peer_transport
immediately after a successful PeerRequest return, so the badge
on the sync'ing peer's card reflects the transport the call
actually rode (fips vs tor).
Frontend:
- types.ts FederatedNode gains last_transport / last_transport_at
(union-typed to the four known kinds).
- NodeList.vue: new transportBadge(node) returns {label, cls, title}
tuned per transport. Hidden when last_transport is absent so we
never lie. Tooltip shows "Last reached via <x> · <time ago>" so
stale data is self-evident. Removed the predictive icon from the
transport store — badge is now 100% ground-truth.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Previously the Pending Peer Requests panel only had Approve/Reject for
inbound rows; outbound rows in the 'sent' state had no action and
would sit there until the target explicitly approved or rejected. Now
you can Cancel an outbound request — the local row is dropped and a
PeerCancel nostr DM is sent so the target's inbound row also
disappears.
Backend:
- HandshakeMessage::PeerCancel {reason: Option<String>} variant.
- nostr_handshake::send_peer_cancel() mirrors send_peer_reject.
- handshake.poll handler dispatches inbound PeerCancel: finds the
matching inbound pending row (same from_nostr_pubkey, state=Pending)
and deletes it. Reply shape gains `cancelled_inbound: [id]`.
- federation::pending::delete() — hard-remove (set_state only
transitions; we don't want 'Cancelled' ghosts in the audit trail).
- federation.cancel-request RPC: outbound+Sent only, default
notify=true (cancelling silently is a footgun), best-effort DM
(relay failure doesn't block local deletion). Wired in dispatcher.
Frontend:
- PendingRequestsPanel.vue: Cancel button appears only on
outbound+sent rows. Emits 'cancel' event with request id.
- Federation.vue: cancelPending(id) handler calls
rpcClient.federationCancelRequest and reloads the list.
- rpcClient.federationCancelRequest(id, reason?, notify=true).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- TransportPrefsCard.vue: import from '@/api/rpc-client' (not
'@/api/rpc') so vue-tsc resolves the module during build.
- scripts/fleet-fips-unpair.sh: companion to the fleet-pair script —
rewrites each node's fips.yaml to anchor-only (fips.v0l.io) so we
can prove the general-case deployment works without the LAN
fast-path. Prints per-node peer counts + DHT AAAA resolution for
every cross-node pair after the change.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
When Alice syncs state with a Trusted peer Bob, she now learns about
Bob's other Trusted peers and auto-adds them as Observers on her side
— so Carol's fips_npub is known locally and subsequent federation
traffic to Carol can route directly over FIPS without a separate
invite round-trip.
- NodeStateSnapshot gains a `federated_peers: Vec<FederationPeerHint>`
field (serde default for backward compat with v1.4 snapshots).
- FederationPeerHint is a minimal projection: did, pubkey, onion,
name, fips_npub — excludes per-receiver fields (trust_level,
added_at, last_seen, last_state).
- build_local_state takes the local federation list and includes only
Trusted peers. Observer/Untrusted peers are NOT re-exported — a
node shouldn't launder other people's federation through its own
authority.
- sync_with_peer merges the received hints via merge_transitive_peers
when the source is Trusted: existing entries get fips_npub
refreshed if missing; unknown DIDs are added at Observer trust
(never auto-promoted to Trusted).
- Bounded to 1 hop: merged Observer entries do NOT get re-exported in
the local node's own snapshots. So Bob → Alice learns Carol, but
Alice's snapshots to Dave do not include Carol.
- Tests: round-trip + filter-non-trusted-from-snapshot coverage.
- Storage + delta test fixtures updated for the new field.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
scripts/fleet-fips-pair.sh writes a deterministic /etc/fips/fips.yaml
on each of our 4 dev fleet nodes (.116/.198/.228/.253), listing the
other three as static FIPS peers over their LAN IPs (UDP 2121 / TCP
8443). Also flips `node.identity.persistent: true` so the npub stays
stable across restarts — without this the daemon rolls a new keypair
on every restart and federation invites that carried the previous
npub go stale.
The script is NOT the general deployment mechanism:
- Every archipelago install already ships fips.v0l.io as an anchor
peer, so any node can DHT-route to any npub that has ever announced
on the public mesh.
- Federation invites (v1.4+) carry the peer's fips_npub, so accepting
an invite is enough for crate::fips::dial::peer_base_url(npub) to
reach the peer through the anchor network.
- This script is a LAN fast-path optimization so intra-fleet traffic
stays on the wire instead of bouncing through fips.v0l.io.
Usage:
scripts/fleet-fips-pair.sh # apply to all nodes
scripts/fleet-fips-pair.sh --verify # print current peer state
Verified: all 4 fleet nodes now report 3 authenticated peers each
(their 3 fleet siblings), plus fips.v0l.io in the identity cache.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds a user-configurable toggle for how each peer-to-peer service
reaches federated peers. Three options per service:
- Auto (default) — FIPS preferred, Tor fallback (current behavior).
- FIPS only — fail rather than fall through to Tor.
- Tor only — explicit opt-in to onion anonymity for that service.
Services covered (matching the UI rows):
- Federation — state sync, invites, peer notifications
- Peers — address/DID rotation broadcasts
- Peer Files — content catalog download/browse/preview
- Messaging — archipelago channel + mesh bridge
- Mesh File Sharing — content_ref blob fetches
Implementation:
- settings::transport — persisted struct + process-wide OnceLock handle
(so deep call sites don't need data_dir threaded through signatures).
On-disk file: <data_dir>/settings/transport_preferences.json; missing
or corrupt → defaults (Auto everywhere).
- settings::transport::init() called from main.rs after config load.
- fips::dial::PeerRequest gains a .service(kind) builder; send_* checks
the preference before choosing a transport. FIPS-only fails loudly
when FIPS is unavailable (so users who pick it know when something
falls back).
- Every FIPS-first migration site tags its PeerRequest with the
matching PeerService so the toggle actually applies.
- transport.preferences + transport.set-preference RPCs added; wired
into the dispatcher.
- neode-ui/src/views/settings/TransportPrefsCard.vue — standalone card
with a 5-row Auto/FIPS/Tor tri-state. Not wired into Settings.vue —
the user places components themselves (see feedback_ui_entry_points).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Migrates the remaining Tor-direct peer call sites to PeerRequest so
FIPS is the default when the peer is federated and running the daemon:
- node_message::send_to_peer / check_peer_reachable: gain a
fips_npub parameter. Error messages updated to reference both
transports.
- Callers (api/rpc/network.rs, api/rpc/peers.rs, server health
loop): look up fips_npub from federation storage by onion and
pass it.
- mesh::send_typed_wire_via_federation: the spawned background POST
for the /archipelago/mesh-typed endpoint now uses PeerRequest with
federation-resolved fips_npub. Signature domain unchanged.
- api/rpc/mesh/typed_messages.rs fetch_blob_from_peer: blob URL
rebuilt as (base_url, path_with_query) so PeerRequest can append
the query string after swapping the host. Cap/exp/peer
parameters are still signed over the content ref itself, so
transport choice is invisible to the signature.
- network/dwn_sync.rs sync_with_peers: per-peer fips_npub lookup
before sync_single_peer; health/pull/push each dial through
PeerRequest, so any DWN peer known to federation gets FIPS.
Left Tor-only on purpose:
- api/rpc/identity/handlers.rs handle_identity_resolve_peer_onion —
resolving TO a DID, no anchor yet.
- content.browse / preview calls to non-federated peers fall
through to Tor naturally inside PeerRequest (no fips_npub → skip
FIPS branch).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
All four content-over-peer handlers prefer FIPS when the peer is in
our federation and has advertised a FIPS npub; fall back to Tor
otherwise (unknown peers, FIPS daemon down, transient failure).
- content.handle_content_download_peer / _paid: DID-authenticated
fetch, payment token header threaded through both transports.
- content.handle_content_browse_peer / _preview: no DID header by
design (anonymous browse) — still benefits from FIPS when the
peer happens to be federated.
- federation::fips_npub_for_onion: storage helper that looks up a
peer's FIPS npub from the federation nodes file given their onion
address. Suffix-tolerant (`abc` matches `abc.onion`).
Preserves the Tor-only path for truly unknown peers: PeerRequest
returns Err from the Tor branch instead of silently succeeding,
matching the previous behavior when the peer was unreachable.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Every federation peer-to-peer call now prefers FIPS (direct ULA dial
over `fips0`, ~LAN latency) and falls back to Tor only on network
failure. Per-method ed25519 signatures are preserved on both
transports so authenticity doesn't change.
- fips::dial::PeerRequest — fluent builder that owns transport
selection. Returns the Response plus the TransportKind that carried
it, so handlers can log or expose which path was used.
- fips::dial::is_service_active — free-standing async probe used by
migration sites (the transport::fips::is_available cache is keyed
to a `&self`, not usable from static contexts).
- federation/sync.rs: sync_with_peer + deploy_to_peer drop the
hand-rolled reqwest::Proxy dance, call PeerRequest instead.
- federation/invites.rs: notify_join takes the remote's fips_npub
(already parsed out of the invite code since v1.4) and dials over
FIPS when available. The "peer-joined" signature domain is
unchanged.
- api/rpc/federation/handlers.rs: DID rotation broadcast loops over
federated peers through PeerRequest; the per-peer result payload
gains a `transport` field so the UI can surface mesh vs. onion.
- api/rpc/tor/mod.rs: onion-address-change propagation is now the
most useful FIPS-first call — fips_npub is stable across onion
rotation, so peers get the new address even when the old onion
is already dead.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Wires the FIPS transport end-to-end so peer-to-peer calls can reach
other nodes over the mesh without going through Tor:
- fips::dial — raw RFC 1035 DNS client (zero new deps) that queries the
FIPS daemon's local resolver at 127.0.0.1:5354 for `<npub>.fips` AAAA
records. Exposes peer_base_url(npub) → "http://[fd9d:…]:5679" plus a
reqwest client factory for call-site migrations.
- fips::iface — parses /proc/net/if_inet6 to find the ULA address on
`fips0`. Runs under the archipelago service user without extra caps.
- FipsTransport::is_available() — live probe of archipelago-fips and
upstream fips.service via `systemctl is-active`, cached 10s so the
send hot path doesn't thrash DBus.
- FipsTransport::send() — resolve npub, POST TransportMessage JSON to
the peer's /transport/inbox. Today /transport/inbox isn't wired on
the receive side, so call-site migrations use dial::peer_base_url
directly against the already-signed endpoints (/rpc/v1,
/archipelago/node-message, /content/*). The inbox handler lands as
part of the Settings/transport work.
- server::serve_with_shutdown — takes an optional peer_addr and spawns
a second listener bound specifically to the fips0 ULA on port 5679.
The peer listener applies is_peer_allowed_path() — a whitelist of
endpoints that already do per-request signature auth — and returns
404 for everything else. Shutdown cascades to both listeners via a
watch channel; 5s drain window preserved.
- main.rs — if fips0 has a ULA at startup, pass the peer SocketAddr to
serve_with_shutdown; otherwise run the main listener only.
Security: the peer listener is bound to the fips0 ULA directly, not
wildcard, so it's unreachable from WAN IPv6. The path whitelist limits
exposure to endpoints whose handlers verify ed25519 signatures or
federation DID headers server-side.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Nodes without a seed-derived FIPS key (legacy deploys, fresh pre-onboarding
installs) were reporting "Awaiting seed" in the dashboard even when the
upstream fips.service was running — status.npub was None unless
/data/identity/fips_key.pub existed.
- fips/service.rs: new read_upstream_npub() reads /etc/fips/fips.pub
(bech32 text or raw 32 bytes) from the debian package.
- fips/mod.rs: FipsStatus::current() prefers the seed-derived npub,
falls back to the upstream key. service_active is now TRUE if either
archipelago-fips.service OR upstream fips.service is active; adds
upstream_service_state to the status payload.
- fips/update.rs: resolve the upstream default branch from the GitHub
repo API (jmcorgan/fips is on `master`, not `main`) instead of
hardcoding — future repo rename just works.
- network/router.rs + api/rpc/router.rs: diagnostics gain wifi_ssid from
`nmcli -t device` so the Network card can show the connected SSID.
- UI: Home.vue adds a FIPS row to the Local Network card; Server.vue
mounts the new FipsNetworkCard and shows SSID + FIPS Mesh rows;
HomeNetworkCard.vue removed (superseded by the inline rows).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Commits the musl-static backend binary and frontend tarball under
releases/v1.4.0/ so the raw-content URLs referenced by manifest.json
actually resolve. Without this commit update.check returns "available"
but update.download 404s, which is why self-updates have been silently
broken since 1.3.5.
Size budget note: this is ~110 MiB committed per release (binary + PWA
assets). Established pattern in the existing manifest; once CI publishes
reliably we can move to a GitHub release or FileBrowser share instead.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Bakes the FIPS (Free Internetworking Peering System) mesh daemon into
the node stack, supervised by archipelago alongside Tor. Runs as a
system service, identity derives from the same BIP-39 master seed, and
user-triggered updates track upstream main.
Identity
seed.rs: new HKDF label archipelago/fips/secp256k1/v1 → dedicated
secp256k1 key, distinct from the Nostr-node key for crypto isolation
but still seed-recoverable
identity.rs: writes fips_key[.pub] to /data/identity on onboarding,
chmod 0600; fips_key_exists / load_fips_keys / fips_npub accessors
Transport
TransportKind::Fips=3 inserted between LAN and Tor (Tor bumps to 4)
→ router prefers FIPS over Tor for all peer traffic
PeerRecord gains fips_npub + last_fips fields (serde(default) for
backward-compat with older nodes)
transport/fips.rs: NodeTransport stub, reports unavailable until the
daemon is live so router falls through to Tor cleanly
Federation invites
FederatedNode and FederationInvite carry optional fips_npub
create_invite / accept_invite / peer-joined callback thread it end
to end; signature domain deliberately unchanged — FIPS Noise does
its own session auth, so the unsigned hint only affects path
selection
crate::fips
config.rs: renders /etc/fips/fips.yaml and sudo-installs key material
service.rs: systemctl status/activate/restart/mask wrappers
update.rs: GitHub API check against upstream main; apply stubbed
until per-commit .deb artefact source is decided
RPC + dashboard
fips.status / fips.check-update / fips.apply-update / fips.install /
fips.restart registered in dispatcher
HomeNetworkCard.vue shipped standalone (unmounted — place in Home.vue
when ready); shows state pill, version, FIPS npub, update button,
activate button when key is present but service is down
ISO + systemd
archipelago-fips.service: conditional on key presence, masked by
default — backend unmasks after onboarding writes the key
build-auto-installer-iso.sh: multi-stage Dockerfile builds the FIPS
.deb from jmcorgan/fips main (fail-loud), COPYs it into rootfs, apt
installs it so trixie resolves deps; unit copied + masked
Version bump: 1.3.5 → 1.4.0
Tests: 33 new/updated passing (seed, identity, transport, federation,
fips module, transport::fips).
Known gaps: fips.apply-update returns a clear stub error until
upstream publishes per-commit .deb artefacts; HomeNetworkCard is not
mounted in Home.vue by default.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Pure formatter output — no semantic changes. Sweeping these into their
own commit so the FIPS integration diff that follows stays scoped to
the actual feature.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The CI workflow calls `test-iso-qemu.sh "$ISO" 120`. The old arg parser
had a `case *) ISO=...` fallthrough that silently let the second
positional `120` overwrite ISO, so QEMU went looking for a file literally
named "120". That's the "failed step" the user was seeing on recent ISO
runs — the rest of the job succeeded because the QEMU step has
`continue-on-error: true`.
Changes:
- Treat `--timeout=N` or a bare numeric first-match as a CI timeout in
seconds; the original ISO path still wins the positional.
- When a timeout is set, force `--nographic` (CI has no DISPLAY anyway)
and wrap the QEMU invocation in coreutils' `timeout` so the script
always returns instead of hanging.
- After termination (or timeout), grep the serial log for well-known
systemd/live-boot markers. Pass if the kernel reached userspace, fail
if no marker appeared within the window — useful signal rather than
the previous "did the VM shut itself off" proxy.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The .github/workflows/ci.yml Rust job runs cargo fmt --check, clippy
with -D warnings, and tests. All three were failing. This commit:
- Applies rustfmt across the tree (the bulk of the diff — untouched
since the last toolchain bump, so a wide sweep was unavoidable).
- Fixes the correctness-level clippy errors:
container/bitcoin_simulator.rs wildcard-in-or-pattern
container/manifest.rs from_str rename to parse (reserved name)
container/podman_client.rs .get(0) -> .first()
container/runtime.rs manual += collapse
archipelago/src/constants.rs doc-comment → module-doc
api/rpc/package/install.rs stray /// comment above a non-item
container/docker_packages.rs redundant field init
streaming/advertisement.rs missing Metric import in tests
tests/orchestration_tests.rs `vec!` in non-Vec contexts
mesh/listener/dispatch.rs unused store_plain_message import
api/rpc/tor/mod.rs and mesh/steganography.rs: push-after-new → vec!
- Quiets wide legacy surfaces with crate-level allows in main.rs for
stylistic lints (too_many_arguments, type_complexity, doc indent,
enum variant prefix, wildcard-in-or, assertions-on-constants,
drop_non_drop, unused_io_amount, ptr_arg) — these fired in dozens
of places with no correctness payoff and have been churning every
toolchain bump.
- Tags intentional-dead-code helpers: wallet/ and streaming/ modules
are WIP, mesh::send_chunked_payload and DM_V1_MARKER are kept for
rollback compatibility, vpn::get_nostr_vpn_status is surface-area
for a not-yet-landed RPC.
cargo fmt --check, cargo clippy --all-targets --all-features
-- -D warnings, and cargo test --all-features now all pass locally.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Root cause of the "every bubble shows twice" complaint after the prior
dedup fix: the frontend was firing mesh.send twice per user action. A
held/repeating Enter key on the input fires a keydown per repeat, and
handleSendMessage didn't guard on mesh.sending, so both calls queued
through the store's sendQueue and both executed against the same
contact_id (backend logs show two mesh.send RPCs 13ms apart, same text).
That's why sender and receiver both saw doubles — the envelope actually
was transmitted twice.
Mesh.vue: handleSendMessage now early-returns if mesh.sending or
sendingArch is already set. Send button replaces the `...` placeholder
with a proper spinning ring (`.mesh-send-spinner`) so the held-Enter case
stops looking like the app is ignoring the user.
mesh/mod.rs: send_typed_wire_via_federation no longer blocks on the Tor
POST. Sent MeshMessage is recorded synchronously (UI bubble appears
instantly); the HTTP goes in tokio::spawn. Tor circuit setup was the
1–5s lag the user was seeing on every send to a federation peer. Delivery
failure still shows as `delivered: false` via the read-receipt path.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two mesh fixes bundled so the deploy lands them together:
Doubled messages (radio + federation): dedup at store_message now runs
a third cross-transport check keyed on (sender_seq, plaintext, 120s).
The existing (sender_pubkey, sender_seq) match missed the common case
where the same envelope arrives via LoRa radio (sender_pubkey looked
up from the firmware key) and again via Tor federation (sender_pubkey
= archipelago ed25519), because the two lookups disagree. The new
cross-transport match closes that gap without loosening legacy paths.
Stale contacts after clear-all: meshcore's on-device contact table is
persistent and reads back into peers on the next refresh_contacts, so
the previous "nuclear" clear wiped app state for a few seconds before
the old rows reappeared. New persistent `radio_contact_blocklist`
(mesh-ignored-radio-contacts.json) captures the pubkeys present at
clear-time; `refresh_contacts` filters them on read and the filter
survives restart. Federation-synthetic peers are excluded from the
snapshot so the list rebuilds normally on the next gossip.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Clear All now deletes messages.json, mesh-contacts.json, sessions.json,
mesh-outbox.json and clears shared secrets for a truly clean slate.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Mesh adverts now use the node's configured server name (e.g. "ThinkPad",
"Arch Dev") instead of DID key fragments ("Archy-z6MkmkSB")
- Added mesh.clear-all RPC to reset peers, messages, contacts, and history
- Added "Clear All" button in Mesh UI peers panel
- Both glibc and musl builds verified
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Add deploy_secondary() function for deploying to multiple LAN nodes
- --both now deploys to .198 and .253 (previously .198 only)
- Fleet deploy updated for 3 LAN nodes
- Mesh DM fixes: protocol frame format, DM-via-channel routing
- Federation pending requests, discover modal
- VPN status UI improvements
- Image versions and container specs updates
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Bundles the Phase 2b/3/4/5 work that accumulated across prior sessions
and the new attachment chunking router from this session. Everything
ships in one shot so the full mesh surface stays coherent on-wire.
Telegram primitives (variants 13–18, 20–22):
- Reply / Reaction / ReadReceipt / Forward / Edit / Delete
- Presence heartbeat + last-seen tracking
- ChannelInvite + ContactCard payload types
- MessageKey (sender_pubkey, sender_seq) as cross-transport identity
- Action menu, reply banner, edit banner, tombstones, (edited) marker
- Debounced auto-read-receipts on scroll + message arrival
Activated prototypes (Phase 4):
- PsbtHash send RPC
- Contacts CRUD (in-memory alias/notes/pinned/blocked)
- Outbox 📤 badge, rotate-prekeys button
- Chunked send fallback (MCIIXXTT framing) as auto-failover inside
send_typed_wire when a typed wire exceeds the LoRa per-frame budget
Unified inbox (Phase 1):
- conversations.list + conversations.messages RPCs (UI collapse deferred)
Attachment transport router (new this session):
- ContentInline variant 23 + ContentInlinePayload carrying file bytes
directly in the envelope for small files with no Tor path
- mesh.send-content-inline RPC — mirrors to local BlobStore, rides
send_typed_wire which auto-chunks over MCIIXXTT framing (~2.3 KB cap)
- mesh.transport-advice RPC as single source of truth for tier
decisions: auto-mesh / choose / tor-only / impossible
- Receive arm writes inline bytes to local BlobStore so the existing
content_ref card renderer handles both transports uniformly
- MeshState.blob_store field + order-independent propagation from
RpcHandler::set_blob_store / set_mesh_service
- Frontend handleAttachFile calls advice first, branches into silent
auto-send, transport-chooser modal, Tor-only path, or red error
- Transport modal with 📡 mesh / 🧅 Tor options + ETA + disabled
state when peer has no Tor reachability
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
MeshCore firmware frame for cmd 0x03 is
`[cmd][txt_type][channel][timestamp_le32][text]`, not `[cmd][channel][text]`.
Missing txt_type + timestamp caused every channel broadcast to come
back with ERR_UNSUPPORTED, which broke the DM-via-channel path
entirely (nothing was reaching the radio). Bring the frame into
spec — verified against meshcore-dev/MeshCore docs/companion_protocol.md.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Firmware rejected send_channel_text(1, ...) with "Unsupported command"
because channel 1 isn't configured on the device. Revert to channel 0
for the DM wrapper — the 0xD1 marker + dest_prefix header still
disambiguates DMs from plain public-channel text. Also revert
Mesh.vue publicChannel back to index 0 so user-typed broadcasts
target the same (only) working channel.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Meshcore direct unicast silently drops between our two Archy nodes
(firmware reports flood sends with resp_code=6 but nothing arrives).
Wrap DMs as channel-1 broadcasts with a [0xD1][dest_prefix(6)][inner]
header; receivers filter by prefix and dispatch the inner payload
through the existing typed/base64/chunk ladder. Shrink chunk body to
125B so the wrapper still fits the 160B LoRa budget. Auto-heal
routing: CMD_RESET_PATH (0x0D) any type-1 contact with path_len=0 on
refresh so floods take over. send_text now returns the firmware's
flood/direct mode flag for diagnostics.
Disable the 120s presence heartbeat broadcaster — its CBOR payload
was being re-echoed as plaintext by the shared repeater, spamming
every visible node with garbled "Archy-…: av�…fstatusfonline…"
messages on channel 0. mesh.broadcast-presence RPC stays registered
but no longer transmits. Re-enable only once presence moves off the
shared broadcast path.
Also: MeshState.cmd_tx behind RwLock so stop()→start() cycles don't
fail with "command channel already consumed"; MeshService.send_cmd
helper; drop_message_by_id for control envelopes that shouldn't
appear as Sent bubbles; self_advert_name reflected into MeshStatus
after set; path_len/flags parsed out of RESP_CONTACT.
Frontend: unified inbox merges mesh peers with federation nodes by
DID/pubkey/name; hide presence/read_receipt/edit/channel_invite/
contact_card from chat stream; publicChannel index → 1 to match the
new DM-via-channel routing.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* Replaces click-anywhere-on-bubble with a tiny ⋯ trigger in the meta row
that fades in on hover (always visible on touch devices). Outside-click
closes the menu, bubble gets a `menu-open` class so the trigger stays lit.
* Action menu gains Forward (any message) + Edit + Delete (own messages
only, delete is red). Reaction spinner + reply preview upgraded to handle
typed targets (attachment/invoice/location/alert) via summarizeForPreview.
* Pending-edit banner with ✎ icon mirrors the reply banner; Send flushes as
mesh.edit-message when pendingEdit is set.
* Forwarded bubbles render "↪ Forwarded from {orig_name}" header; tombstone
+ (edited) markers; pending-reply close button upsized (28px, red hover).
* Scroll + message-arrival watcher fires a debounced 400ms read receipt
with per-peer seq dedup so we never double-ack.
* Chat header: ⟲ rotate-prekeys button next to the shield badge; 📤 outbox
count when mesh.outbox reports queued messages. Blob-store test widget
removed and chat list now sorts by most-recent message timestamp.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Adds every remaining wire variant and RPC needed to finish the Telegram-quality
mesh plan in a single pass:
* Variants 15 ReadReceipt, 16 Forward, 17 Edit, 18 Delete, 20 Presence,
21 ChannelInvite; plus MeshMessageType::ContactCard(22) cleanup (was
enum-only, now wired through from_u8/label/from_label).
* MessageType::from_label() as the inverse of label() — used by the Forward
path to re-encode a stored typed body back through its original variant.
* RPCs: mesh.send-psbt (variant 3 was previously enum-only),
mesh.send-read-receipt, mesh.forward-message, mesh.edit-message,
mesh.delete-message, mesh.broadcast-presence, mesh.presence-list,
mesh.contacts-list, mesh.contacts-save, mesh.contacts-block,
mesh.send-channel-invite, conversations.list, conversations.messages.
* MeshState gains presence (pubkey → status+timestamps) and contacts
(pubkey → ContactEntry{alias,notes,pinned,blocked}) in-memory stores.
* MeshService gains find_message_by_id (Forward lookup), apply_local_edit /
apply_local_delete (optimistic local echo), and send_chunked_payload — an
MC-framed base64 splitter that fires as a fallback inside send_typed_wire
when wire > MAX_MESSAGE_LEN and no federation path is known. Reuses the
existing receive-side reassembly in listener/decode.rs.
* Receive dispatch arms for PsbtHash, Presence, ChannelInvite, ReadReceipt
(rolls forward `delivered` flag on own-Sent ≤ seq for that peer), Forward,
Edit, Delete. Edit/Delete guard against cross-peer tampering by matching
the target MessageKey pubkey against the sender's advertised pubkey_hex.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Mesh peer pubkeys (LoRa advert ed25519) differ from federation node
pubkeys (archipelago identity), so matching on pubkey always missed
and attachments >160B had no transport. Match on master DID instead;
also accept an explicit peer_onion override from the frontend, which
resolves the peer by display name against federation.list-nodes.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>