2026-04-18 22:57:51 -04:00
//! RPC handlers for the FIPS mesh transport subsystem.
//!
//! Surface is deliberately thin: a read-only `fips.status`, a user-gated
//! `fips.check-update`, a stubbed `fips.apply-update`, and a
//! `fips.install` that (re-)materialises the daemon config + key and
//! activates the service. All writes go through `sudo` helpers in
//! `crate::fips`.
use super ::RpcHandler ;
use crate ::fips ;
use anyhow ::Result ;
impl RpcHandler {
pub ( super ) async fn handle_fips_status ( & self ) -> Result < serde_json ::Value > {
2026-04-21 07:08:26 -04:00
let status = fips ::FipsStatus ::query ( & self . config . data_dir ). await ;
2026-04-18 22:57:51 -04:00
Ok ( serde_json ::to_value ( status ) ? )
}
pub ( super ) async fn handle_fips_check_update ( & self ) -> Result < serde_json ::Value > {
let check = fips ::update ::check (). await ? ;
Ok ( serde_json ::to_value ( check ) ? )
}
pub ( super ) async fn handle_fips_apply_update ( & self ) -> Result < serde_json ::Value > {
fips ::update ::apply (). await ? ;
Ok ( serde_json ::json! ({ "applied" : true }))
}
/// Install config + key into /etc/fips and activate the service.
/// Intended to be called:
/// - once by the seed-onboarding flow, right after the FIPS key
/// is written to /data/identity/fips_key, and
/// - on user demand from the dashboard if something drifted.
pub ( super ) async fn handle_fips_install ( & self ) -> Result < serde_json ::Value > {
let identity_dir = fips ::identity_dir_from ( & self . config . data_dir );
fips ::config ::install ( & identity_dir ). await ? ;
fips ::service ::activate ( fips ::SERVICE_UNIT ). await ? ;
2026-04-21 07:08:26 -04:00
let status = fips ::FipsStatus ::query ( & self . config . data_dir ). await ;
2026-04-18 22:57:51 -04:00
Ok ( serde_json ::to_value ( status ) ? )
}
2026-04-21 07:08:26 -04:00
/// Restart whichever fips unit is supervising the daemon on this host.
/// Nodes installed from the archipelago ISO use `archipelago-fips.service`;
/// nodes that had the upstream debian package set up first may only have
/// `fips.service`. We resolve the active one via `service::active_unit()`
/// so the UI button is never a no-op.
2026-04-18 22:57:51 -04:00
pub ( super ) async fn handle_fips_restart ( & self ) -> Result < serde_json ::Value > {
2026-04-21 07:08:26 -04:00
let unit = fips ::service ::active_unit (). await ;
fips ::service ::restart ( unit ). await ? ;
Ok ( serde_json ::json! ({ "restarted" : true , "unit" : unit }))
2026-04-18 22:57:51 -04:00
}
2026-04-20 16:40:25 -04:00
/// Full reconnect: stop the daemon, bring it back, wait for the DHT
/// bootstrap window, poll the identity-cache + peer list, and
/// classify what recovered (or didn't) so the UI can explain it to
/// the user instead of showing a generic failure.
///
/// Runtime: ~20s. Needs an RPC timeout ≥ 45s on the client.
pub ( super ) async fn handle_fips_reconnect ( & self ) -> Result < serde_json ::Value > {
let identity_dir = fips ::identity_dir_from ( & self . config . data_dir );
2026-04-21 07:08:26 -04:00
let before = fips ::FipsStatus ::query ( & self . config . data_dir ). await ;
2026-04-20 16:40:25 -04:00
// Heal the pre-fix bech32-text fips_key.pub → 32-raw-bytes
// mismatch. The daemon silently authenticates with a garbage
// pubkey when the .pub file is 63-char text, which looks like
// "anchor unreachable" to the user even though the real fault
// was an identity malformed on the node itself. Re-install the
// config + keys so /etc/fips gets the healed .pub.
let key_src = identity_dir . join ( "fips_key" );
let pub_src = identity_dir . join ( "fips_key.pub" );
if key_src . exists () {
let _ = fips ::config ::normalize_pub_file ( & key_src , & pub_src ). await ;
// Re-install refreshes /etc/fips/fips.pub from the healed
// source. No-op if nothing changed.
let _ = fips ::config ::install ( & identity_dir ). await ;
}
2026-04-21 07:08:26 -04:00
// Operate on whichever fips unit is actually up — nodes that
// have the upstream `fips.service` rather than the
// archipelago-managed `archipelago-fips.service` used to see
// Reconnect silently fail because we stopped a unit that
// didn't exist. Clean stop+start rather than `restart` so a
// daemon that fails to come back up surfaces as
// service_active=false instead of quietly sticking with the
// old process.
let unit = fips ::service ::active_unit (). await ;
let _ = fips ::service ::stop ( unit ). await ;
2026-04-20 16:40:25 -04:00
tokio ::time ::sleep ( std ::time ::Duration ::from_millis ( 800 )). await ;
2026-04-21 07:08:26 -04:00
fips ::service ::activate ( unit ). await ? ;
// Re-push seed anchors after restart so freshly-bound daemons
// don't have to wait 5 min for the periodic apply loop.
if let Ok ( list ) = fips ::anchors ::load ( & self . config . data_dir ). await {
if ! list . is_empty () {
let _ = fips ::anchors ::apply ( & list ). await ;
}
}
2026-04-20 16:40:25 -04:00
// Anchor bootstrap window: poll the status every ~3s for up to
// 20s. Bail as soon as the anchor is connected.
let mut last_status : Option < fips ::FipsStatus > = None ;
let deadline = std ::time ::Instant ::now () + std ::time ::Duration ::from_secs ( 20 );
loop {
tokio ::time ::sleep ( std ::time ::Duration ::from_secs ( 3 )). await ;
2026-04-21 07:08:26 -04:00
let s = fips ::FipsStatus ::query ( & self . config . data_dir ). await ;
2026-04-20 16:40:25 -04:00
if s . anchor_connected {
last_status = Some ( s );
break ;
}
last_status = Some ( s );
if std ::time ::Instant ::now () >= deadline {
break ;
}
}
let after = last_status . unwrap_or_else ( || before . clone ());
let recovered = after . anchor_connected && ! before . anchor_connected ;
let likely_cause = if after . anchor_connected {
"connected"
} else if ! after . service_active {
"daemon_down"
} else if ! after . key_present {
"no_seed_key"
} else if after . authenticated_peer_count == 0 {
// Daemon is up with a key but hasn't authenticated any
// peers — almost always outbound UDP/8668 dropped by the
// local firewall/router, or the anchor itself being down.
"no_outbound_udp_or_anchor_down"
} else {
"peers_but_no_anchor"
};
let hint = match likely_cause {
2026-04-21 07:08:26 -04:00
"connected" => "An anchor is reachable." ,
"daemon_down" => "The FIPS daemon didn't come back up — check the FIPS service on this host." ,
2026-04-20 16:40:25 -04:00
"no_seed_key" => "No seed-derived FIPS key on disk. Re-run the onboarding unlock step." ,
"no_outbound_udp_or_anchor_down" =>
2026-04-21 07:08:26 -04:00
"Daemon is running but no peers handshook. Your router / ISP might be blocking outbound UDP 8668, or every configured anchor could be down. Add a reachable peer in Seed Anchors." ,
2026-04-20 16:40:25 -04:00
"peers_but_no_anchor" =>
2026-04-21 07:08:26 -04:00
"Mesh has peers but none of them are anchors we recognise. Add your cluster's anchor in Seed Anchors." ,
2026-04-20 16:40:25 -04:00
_ => "" ,
};
Ok ( serde_json ::json! ({
"recovered" : recovered ,
"likely_cause" : likely_cause ,
"hint" : hint ,
"before" : before ,
"after" : after ,
}))
}
2026-04-21 06:21:37 -04:00
/// List the seed-anchor entries configured on this node.
pub ( super ) async fn handle_fips_list_seed_anchors ( & self ) -> Result < serde_json ::Value > {
let list = fips ::anchors ::load ( & self . config . data_dir ). await ? ;
Ok ( serde_json ::json! ({ "seed_anchors" : list }))
}
/// Add (or update) a seed anchor and immediately push it into the
/// running daemon. Params: `{ npub, address, transport?, label? }`.
pub ( super ) async fn handle_fips_add_seed_anchor (
& self ,
params : & serde_json ::Value ,
) -> Result < serde_json ::Value > {
let anchor : fips ::anchors ::SeedAnchor = serde_json ::from_value ( params . clone ())
. map_err ( | e | anyhow ::anyhow! ( "bad seed anchor payload: {}" , e )) ? ;
if ! anchor . npub . starts_with ( "npub1" ) {
anyhow ::bail! ( "npub must be bech32 (npub1...)" );
}
if ! anchor . address . contains ( ':' ) {
anyhow ::bail! ( "address must be host:port (e.g. 192.168.1.116:8668)" );
}
let list =
fips ::anchors ::add ( & self . config . data_dir , anchor . clone ()). await ? ;
// Push just the newly-added anchor into the running daemon so
// the user sees effect without waiting for the periodic apply.
let results = fips ::anchors ::apply ( & [ anchor ]). await ;
Ok ( serde_json ::json! ({
"seed_anchors" : list ,
"apply" : results . iter (). map ( | r | {
serde_json ::json! ({ "npub" : r . npub , "ok" : r . ok , "message" : r . message })
}). collect ::< Vec < _ >> (),
}))
}
/// Remove a seed anchor by npub. Params: `{ npub }`. Does NOT tear
/// down an already-authenticated peer connection — it only stops
/// us from re-dialing the anchor on the next apply cycle.
pub ( super ) async fn handle_fips_remove_seed_anchor (
& self ,
params : & serde_json ::Value ,
) -> Result < serde_json ::Value > {
let npub = params
. get ( "npub" )
. and_then ( | v | v . as_str ())
. ok_or_else ( || anyhow ::anyhow! ( "missing npub" )) ? ;
let list = fips ::anchors ::remove ( & self . config . data_dir , npub ). await ? ;
Ok ( serde_json ::json! ({ "seed_anchors" : list }))
}
/// Re-apply all seed anchors to the running daemon. Useful after a
/// FIPS restart or when the user wants to force a reconnection
/// attempt without waiting for the periodic apply loop.
pub ( super ) async fn handle_fips_apply_seed_anchors ( & self ) -> Result < serde_json ::Value > {
let list = fips ::anchors ::load ( & self . config . data_dir ). await ? ;
let results = fips ::anchors ::apply ( & list ). await ;
Ok ( serde_json ::json! ({
"applied" : results . len (),
"results" : results . iter (). map ( | r | {
serde_json ::json! ({ "npub" : r . npub , "ok" : r . ok , "message" : r . message })
}). collect ::< Vec < _ >> (),
}))
}
2026-04-18 22:57:51 -04:00
}