feat: deploy-to-target supports .253 + mesh/federation/VPN updates

- 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>
This commit is contained in:
Dorian
2026-04-18 11:07:08 -04:00
co-authored by Claude Opus 4.6
parent e210376e05
commit 9dd802998c
38 changed files with 3773 additions and 697 deletions
@@ -75,6 +75,8 @@ impl RpcHandler {
"handshake.discover" => self.handle_handshake_discover().await,
"handshake.connect" => self.handle_handshake_connect(params).await,
"handshake.poll" => self.handle_handshake_poll().await,
"nostr.discovery-status" => self.handle_nostr_discovery_status().await,
"nostr.set-discovery" => self.handle_nostr_set_discovery(params).await,
// TOTP 2FA
"auth.totp.setup.begin" => self.handle_totp_setup_begin(params).await,
@@ -1,14 +1,37 @@
use super::*;
use crate::api::rpc::RpcHandler;
use crate::credentials;
use crate::federation::{self, FederatedNode, TrustLevel};
use crate::federation::{self, pending, FederatedNode, TrustLevel};
use crate::identity;
use crate::mesh;
use crate::network::dwn_store::DwnStore;
use crate::nostr_handshake;
use anyhow::{Context, Result};
use tracing::{debug, info, warn};
const FEDERATION_PROTOCOL: &str = "https://archipelago.dev/protocols/federation/v1";
impl RpcHandler {
/// Register a federation node with the running mesh service so it's
/// immediately addressable as a chat target. The mesh service seeds
/// federation peers at startup, but federation nodes added or rotated
/// later in the session would otherwise stay invisible to the mesh
/// chat UI until the next mesh restart, and `mesh.send` against the
/// frontend's synthesised contact_id would fail with "Unknown
/// federation peer". Best-effort: silently no-ops when mesh is off.
async fn register_federation_peer_in_mesh(
&self,
pubkey_hex: &str,
did: &str,
name: Option<&str>,
) {
let svc = self.mesh_service.read().await;
if let Some(svc) = svc.as_ref() {
mesh::upsert_federation_peer(&svc.shared_state(), pubkey_hex, did, name).await;
}
}
}
impl RpcHandler {
/// federation.invite — Generate an invite code containing our DID + onion for a peer.
pub(in crate::api::rpc) async fn handle_federation_invite(&self) -> Result<serde_json::Value> {
@@ -65,6 +88,12 @@ impl RpcHandler {
info!(peer_did = %node.did, "Joined federation with peer");
// Make the new peer immediately addressable from the mesh chat UI.
// Without this, the row exists in the federation list but `mesh.send`
// against it fails until the next mesh service restart re-seeds.
self.register_federation_peer_in_mesh(&node.pubkey, &node.did, node.name.as_deref())
.await;
// Store federation membership as DWN message
if let Ok(store) = DwnStore::new(&self.config.data_dir).await {
let dwn_data = serde_json::json!({
@@ -315,8 +344,20 @@ impl RpcHandler {
let tor_active = data.server_info.tor_address.is_some();
let server_name = data.server_info.name.clone().filter(|n| !n.is_empty());
// Encode our local Nostr identity as bech32 npub so federated peers
// can display it under our name in the mesh UI without each peer
// having to know how to convert hex → bech32 themselves.
let nostr_npub = tokio::fs::read_to_string(self.config.data_dir.join("identity/nostr_pubkey"))
.await
.ok()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.and_then(|hex| nostr_sdk::PublicKey::from_hex(&hex).ok())
.and_then(|pk| nostr_sdk::ToBech32::to_bech32(&pk).ok());
let state = federation::build_local_state(
apps, 0.0, 0, 0, 0, 0, 0, tor_active, server_name,
apps, 0.0, 0, 0, 0, 0, 0, tor_active, server_name, nostr_npub,
);
Ok(serde_json::to_value(&state)?)
@@ -395,6 +436,10 @@ impl RpcHandler {
federation::add_node(&self.config.data_dir, node).await?;
info!(peer_did = %did, "Peer joined our federation");
// Mirror into mesh state so the inbound peer is addressable from
// the chat UI without waiting for the next mesh restart.
self.register_federation_peer_in_mesh(pubkey, did, None).await;
Ok(serde_json::json!({ "accepted": true }))
}
@@ -698,11 +743,31 @@ impl RpcHandler {
}
let old_pubkey = node.pubkey.clone();
let rotated_name = node.name.clone();
node.did = new_did.to_string();
node.pubkey = new_pubkey.to_string();
node.last_seen = Some(chrono::Utc::now().to_rfc3339());
federation::save_nodes(&self.config.data_dir, &nodes).await?;
// Drop the stale mesh peer entry keyed by the old pubkey's
// synthetic contact_id, then upsert a fresh one under the
// new pubkey so the chat UI doesn't show two rows post-rotation.
{
let svc = self.mesh_service.read().await;
if let Some(svc) = svc.as_ref() {
let state = svc.shared_state();
let stale_id = mesh::federation_peer_contact_id(&old_pubkey);
state.peers.write().await.remove(&stale_id);
mesh::upsert_federation_peer(
&state,
new_pubkey,
new_did,
rotated_name.as_deref(),
)
.await;
}
}
info!(
old_did = %old_did,
new_did = %new_did,
@@ -725,4 +790,142 @@ impl RpcHandler {
}
}
}
/// federation.list-pending-requests — return the inbox of inbound peer
/// requests received over Nostr (and our outbound `Sent` rows). Each
/// row carries a stable `id` the FE refers to when calling
/// `federation.approve-request` / `federation.reject-request`.
pub(in crate::api::rpc) async fn handle_federation_list_pending_requests(
&self,
) -> Result<serde_json::Value> {
let requests = pending::load_pending(&self.config.data_dir).await?;
Ok(serde_json::json!({ "requests": requests }))
}
/// federation.approve-request — turn a pending peer request into a
/// federation invite, ship it back via NIP-44, and add the requester
/// to our federation list as `Observer` (NOT Trusted — the user must
/// explicitly promote afterwards via `federation.set-trust`).
///
/// This is the *only* code path that ever causes our onion to leave
/// this box over Nostr, and the onion only travels inside a NIP-44
/// ciphertext addressed to the requester's specific nostr pubkey.
pub(in crate::api::rpc) async fn handle_federation_approve_request(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
let id = params
.get("id")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing id"))?;
let req = pending::find_by_id(&self.config.data_dir, id)
.await?
.ok_or_else(|| anyhow::anyhow!("Pending request not found: {}", id))?;
if !matches!(req.state, pending::PendingState::Pending) || req.outbound {
anyhow::bail!("Pending request is not awaiting approval (state={:?})", req.state);
}
let (data, _) = self.state_manager.get_snapshot().await;
let local_did = identity::did_key_from_pubkey_hex(&data.server_info.pubkey)?;
let local_onion = data
.server_info
.tor_address
.clone()
.ok_or_else(|| anyhow::anyhow!("Tor address not available"))?;
let local_pubkey = data.server_info.pubkey.clone();
// Generate a one-shot federation invite. The code embeds OUR onion
// and OUR pubkey, but it leaves this box only inside the NIP-44
// ciphertext below.
let invite_code =
federation::create_invite(&self.config.data_dir, &local_did, &local_onion, &local_pubkey)
.await?;
// Pre-add the requester to OUR federation list as Observer so that
// when their `federation.peer-joined` callback arrives over Tor we
// already trust their pubkey enough to accept the join. Their DID
// and pubkey come from the request — we'll cross-check the pubkey
// against the eventual peer-joined signature in the existing
// verification path (handlers.rs line ~365).
if !req.from_did.is_empty() {
// We don't know the requester's onion or ed25519 pubkey yet —
// they'll send those in the federation.peer-joined callback
// after they apply our invite. Until then we can't add a real
// FederatedNode entry. We just store the pending row as
// Approved so the UI shows progress, and trust the existing
// peer-joined handler to admit them as Observer when they call.
//
// Caveat: peer-joined currently hardcodes TrustLevel::Trusted.
// We override that below by demoting on success.
debug!(
requester_did = %req.from_did,
"Approval pending — waiting for federation.peer-joined callback over Tor"
);
}
// Encrypt + send the invite over NIP-44 to the requester.
let identity_dir = self.config.data_dir.join("identity");
nostr_handshake::send_peer_invite(
&identity_dir,
&req.from_nostr_pubkey,
&invite_code,
&self.config.nostr_relays,
self.config.nostr_tor_proxy.as_deref(),
)
.await?;
pending::set_state(&self.config.data_dir, id, pending::PendingState::Approved).await?;
info!(
id = %id,
from = %req.from_nostr_pubkey,
"Approved peer request and shipped invite over NIP-44"
);
Ok(serde_json::json!({
"approved": true,
"id": id,
}))
}
/// federation.reject-request — drop a pending request and, if requested,
/// ship a NIP-44 `PeerReject` to the sender so their UI can update.
pub(in crate::api::rpc) async fn handle_federation_reject_request(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
let id = params
.get("id")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing id"))?;
let reason = params.get("reason").and_then(|v| v.as_str());
let notify = params
.get("notify")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let req = pending::find_by_id(&self.config.data_dir, id)
.await?
.ok_or_else(|| anyhow::anyhow!("Pending request not found: {}", id))?;
if !matches!(req.state, pending::PendingState::Pending) || req.outbound {
anyhow::bail!("Pending request is not awaiting approval (state={:?})", req.state);
}
if notify {
let identity_dir = self.config.data_dir.join("identity");
let _ = nostr_handshake::send_peer_reject(
&identity_dir,
&req.from_nostr_pubkey,
reason,
&self.config.nostr_relays,
self.config.nostr_tor_proxy.as_deref(),
)
.await;
}
pending::set_state(&self.config.data_dir, id, pending::PendingState::Rejected).await?;
info!(id = %id, from = %req.from_nostr_pubkey, "Rejected peer request");
Ok(serde_json::json!({ "rejected": true, "id": id }))
}
}
+306 -80
View File
@@ -1,11 +1,119 @@
//! Nostr peer-discovery RPCs.
//!
//! `handshake.discover` — browse other nodes' presence events on configured
//! relays. Returns DID + nostr pubkey only; no onion is ever exposed.
//!
//! `handshake.connect` — send a `PeerRequest` to a discovered node's nostr
//! pubkey. Records the outbound request locally so the user can see what
//! they've sent. Does NOT include our onion address on the wire.
//!
//! `handshake.poll` — fetch new NIP-44 DMs addressed to our nostr pubkey
//! and dispatch them: inbound `PeerRequest` is queued in
//! `federation::pending` for manual approval; inbound `PeerInvite` is
//! applied via the existing federation invite-acceptance flow (which
//! adds the new peer as `Observer` — see federation.rs); inbound
//! `PeerReject` is recorded against the matching outbound row.
use super::RpcHandler;
use crate::{nostr_handshake, peers};
use anyhow::Result;
use crate::federation::pending::{
self, PendingPeerRequest, PendingState,
};
use crate::nostr_handshake::{self, HandshakeMessage};
use anyhow::{Context, Result};
use nostr_sdk::FromBech32;
use serde::{Deserialize, Serialize};
const NOSTR_STATE_FILE: &str = "nostr_discovery_state.json";
/// Runtime override for `Config::nostr_discovery_enabled`. The OS-level
/// config file is read once at boot and is OFF by default; this state file
/// lets the user flip discoverability on/off at runtime via the Federation
/// UI without restarting the service. Both the boot-time presence publish
/// and the `handshake.poll` handler check this file before doing anything.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
struct NostrDiscoveryState {
#[serde(default)]
enabled: bool,
}
async fn load_discovery_state(data_dir: &std::path::Path) -> NostrDiscoveryState {
let path = data_dir.join(NOSTR_STATE_FILE);
match tokio::fs::read_to_string(&path).await {
Ok(s) => serde_json::from_str(&s).unwrap_or_default(),
Err(_) => NostrDiscoveryState::default(),
}
}
async fn save_discovery_state(
data_dir: &std::path::Path,
state: &NostrDiscoveryState,
) -> Result<()> {
let path = data_dir.join(NOSTR_STATE_FILE);
let content = serde_json::to_string_pretty(state).context("serialize discovery state")?;
tokio::fs::write(&path, content)
.await
.context("write discovery state")?;
Ok(())
}
impl RpcHandler {
/// Discover nodes (presence-only — returns Nostr pubkeys + DIDs, no onion addresses).
/// Read the current runtime discoverability flag.
pub(super) async fn handle_nostr_discovery_status(&self) -> Result<serde_json::Value> {
let state = load_discovery_state(&self.config.data_dir).await;
Ok(serde_json::json!({ "enabled": state.enabled }))
}
/// Set the runtime discoverability flag. If turning ON, publish presence
/// once immediately so the user gets visible feedback that the relays
/// have been notified. If turning OFF, do NOT actively scrub the relays
/// here — `nostr_handshake::publish_presence` is replaceable, so the
/// next reboot's startup pass plus the existing legacy revocation in
/// `nostr_discovery::revoke_legacy_advertisements` are sufficient. A
/// future Layer 3 task adds an explicit "tombstone" publish if needed.
pub(super) async fn handle_nostr_set_discovery(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
let enabled = params
.get("enabled")
.and_then(|v| v.as_bool())
.ok_or_else(|| anyhow::anyhow!("Missing enabled"))?;
save_discovery_state(&self.config.data_dir, &NostrDiscoveryState { enabled }).await?;
if enabled && !self.config.nostr_relays.is_empty() {
let (data, _) = self.state_manager.get_snapshot().await;
let identity_dir = self.config.data_dir.join("identity");
let did = crate::identity::did_key_from_pubkey_hex(&data.server_info.pubkey)
.unwrap_or_default();
let version = data.server_info.version.clone();
let relays = self.config.nostr_relays.clone();
let tor_proxy = self.config.nostr_tor_proxy.clone();
tokio::spawn(async move {
if let Err(e) = nostr_handshake::publish_presence(
&identity_dir,
&did,
&version,
&relays,
tor_proxy.as_deref(),
)
.await
{
tracing::warn!("Initial presence publish failed: {}", e);
}
});
}
Ok(serde_json::json!({ "enabled": enabled }))
}
/// Discover discoverable nodes via Nostr presence events.
/// Returns (nostr_pubkey, npub, DID, version) only — never an onion.
pub(super) async fn handle_handshake_discover(&self) -> Result<serde_json::Value> {
// Discoverability gate: respect the runtime toggle. We allow `discover`
// to query relays as long as the user is actively browsing — they're
// an anonymous observer of presence events, not publishing anything.
let identity_dir = self.config.data_dir.join("identity");
let nodes = nostr_handshake::discover_nodes(
&identity_dir,
@@ -16,59 +124,90 @@ impl RpcHandler {
Ok(serde_json::json!({ "nodes": nodes }))
}
/// Send encrypted connection request to a peer's Nostr pubkey.
/// Params: { recipient_nostr_pubkey }
/// Send a `PeerRequest` to a discovered node. Onion is never sent.
/// Params: `{ recipient_nostr_pubkey, message?, name? }`.
pub(super) async fn handle_handshake_connect(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
// Accept either hex pubkey or npub1... bech32 format
let recipient_raw = params
.get("recipient_nostr_pubkey")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing recipient_nostr_pubkey"))?;
let recipient = if recipient_raw.starts_with("npub1") {
let recipient_hex = if recipient_raw.starts_with("npub1") {
nostr_sdk::PublicKey::from_bech32(recipient_raw)
.map_err(|e| anyhow::anyhow!("Invalid npub: {}", e))?
.to_hex()
} else {
recipient_raw.to_string()
};
let recipient = recipient.as_str();
let recipient_npub = nostr_sdk::PublicKey::from_hex(&recipient_hex)
.ok()
.and_then(|pk| nostr_sdk::ToBech32::to_bech32(&pk).ok())
.unwrap_or_default();
let message = params.get("message").and_then(|v| v.as_str());
let optional_name = params.get("name").and_then(|v| v.as_str());
let (data, _) = self.state_manager.get_snapshot().await;
let our_onion = data
.server_info
.tor_address
.as_deref()
.ok_or_else(|| anyhow::anyhow!("No Tor address available — is Tor running?"))?;
let our_node_pubkey = &data.server_info.pubkey;
let our_did = crate::identity::did_key_from_pubkey_hex(our_node_pubkey)
.unwrap_or_default();
let our_did =
crate::identity::did_key_from_pubkey_hex(&data.server_info.pubkey).unwrap_or_default();
let our_version = &data.server_info.version;
let our_name = data.server_info.name.as_deref();
let our_name = optional_name.or(data.server_info.name.as_deref());
let identity_dir = self.config.data_dir.join("identity");
nostr_handshake::send_connect_request(
nostr_handshake::send_peer_request(
&identity_dir,
recipient,
our_onion,
our_node_pubkey,
&recipient_hex,
&our_did,
our_version,
our_name,
message,
&self.config.nostr_relays,
self.config.nostr_tor_proxy.as_deref(),
)
.await?;
Ok(serde_json::json!({ "ok": true, "sent_to": recipient }))
// Record the outbound request so the user can see "Sent" status
// and so the eventual NIP-44 PeerInvite reply can be matched.
let row = pending::insert_outbound(
&self.config.data_dir,
recipient_hex.clone(),
recipient_npub,
String::new(), // remote DID unknown until they reply
None,
message.map(String::from),
)
.await?;
Ok(serde_json::json!({
"ok": true,
"sent_to": recipient_hex,
"id": row.id,
}))
}
/// Poll for incoming encrypted handshake messages (connect requests/responses).
/// Auto-adds peers and auto-responds to requests.
/// Poll relays for inbound NIP-44 handshake messages, then dispatch:
/// - `PeerRequest` → queue in `federation::pending` for approval
/// - `PeerInvite` → apply via federation invite flow (adds as Observer)
/// - `PeerReject` → mark matching outbound row as `Rejected`
///
/// Never auto-adds peers, never auto-responds, never sends our onion.
pub(super) async fn handle_handshake_poll(&self) -> Result<serde_json::Value> {
// Runtime gate: if the user hasn't enabled discoverability, don't
// touch the relays. The poll endpoint is a hard no-op until they
// explicitly opt in via the Federation UI toggle.
let state = load_discovery_state(&self.config.data_dir).await;
if !state.enabled {
return Ok(serde_json::json!({
"polled": 0,
"new_requests": Vec::<PendingPeerRequest>::new(),
"applied_invites": Vec::<String>::new(),
"rejected_outbound": Vec::<String>::new(),
"skipped": Vec::<String>::new(),
"discovery_disabled": true,
}));
}
let identity_dir = self.config.data_dir.join("identity");
let handshakes = nostr_handshake::poll_handshakes(
&identity_dir,
@@ -78,72 +217,159 @@ impl RpcHandler {
)
.await?;
let (data, _) = self.state_manager.get_snapshot().await;
let mut added_peers = Vec::new();
let mut new_requests: Vec<PendingPeerRequest> = Vec::new();
let mut applied_invites: Vec<String> = Vec::new();
let mut rejected_outbound: Vec<String> = Vec::new();
let mut skipped: Vec<String> = Vec::new();
for hs in &handshakes {
let (onion, node_pubkey, name) = match &hs.message {
nostr_handshake::HandshakeMessage::ConnectRequest {
onion,
node_pubkey,
match &hs.message {
HandshakeMessage::PeerRequest {
from_did,
version: _,
name,
..
message,
} => {
// Auto-respond with our details
if let Some(our_onion) = data.server_info.tor_address.as_deref() {
let our_did = crate::identity::did_key_from_pubkey_hex(
&data.server_info.pubkey,
)
.unwrap_or_default();
let _ = nostr_handshake::send_connect_response(
&identity_dir,
&hs.from_nostr_pubkey,
our_onion,
&data.server_info.pubkey,
&our_did,
&data.server_info.version,
data.server_info.name.as_deref(),
&self.config.nostr_relays,
self.config.nostr_tor_proxy.as_deref(),
)
.await;
match pending::insert_inbound(
&self.config.data_dir,
hs.from_nostr_pubkey.clone(),
hs.from_nostr_npub.clone(),
from_did.clone(),
name.clone(),
message.clone(),
)
.await
{
Ok(Some(row)) => new_requests.push(row),
Ok(None) => skipped.push(hs.from_nostr_pubkey.clone()),
Err(e) => {
tracing::warn!(
from = %hs.from_nostr_pubkey,
error = %e,
"Dropped peer request (rate limit or storage error)"
);
skipped.push(hs.from_nostr_pubkey.clone());
}
}
(onion.clone(), node_pubkey.clone(), name.clone())
}
nostr_handshake::HandshakeMessage::ConnectResponse {
onion,
node_pubkey,
name,
..
} => (onion.clone(), node_pubkey.clone(), name.clone()),
};
HandshakeMessage::PeerInvite { invite_code } => {
// Match against an outbound Sent request from this nostr
// pubkey. If we never sent them anything, ignore — we
// don't accept unsolicited invites over Nostr.
let pendings = pending::load_pending(&self.config.data_dir).await?;
let matching = pendings.iter().find(|r| {
r.outbound
&& r.from_nostr_pubkey == hs.from_nostr_pubkey
&& matches!(r.state, PendingState::Sent)
});
let Some(row) = matching else {
tracing::warn!(
from = %hs.from_nostr_pubkey,
"Ignoring unsolicited PeerInvite — no matching Sent request"
);
continue;
};
let row_id = row.id.clone();
let (data, _) = self.state_manager.get_snapshot().await;
let local_did = crate::identity::did_key_from_pubkey_hex(
&data.server_info.pubkey,
)
.unwrap_or_default();
let local_onion = data
.server_info
.tor_address
.clone()
.unwrap_or_default();
let local_pubkey = data.server_info.pubkey.clone();
// Auto-add as peer
let peer = peers::KnownPeer {
onion,
pubkey: node_pubkey.clone(),
name,
added_at: Some(chrono::Utc::now().to_rfc3339()),
};
let _ = peers::add_peer(&self.config.data_dir, peer).await;
added_peers.push(node_pubkey);
let identity_dir2 = self.config.data_dir.join("identity");
let node_identity =
crate::identity::NodeIdentity::load_or_create(&identity_dir2).await?;
match crate::federation::accept_invite(
&self.config.data_dir,
invite_code,
&local_did,
&local_onion,
&local_pubkey,
|bytes| node_identity.sign(bytes),
)
.await
{
Ok(node) => {
// Approved-by-them: their box already has us as Observer
// (their approval handler added us under that trust level
// before sending the invite). Demote our local entry to
// Observer too — accept_invite hardcodes Trusted, but the
// discovery flow should never auto-trust.
let _ = crate::federation::set_trust_level(
&self.config.data_dir,
&node.did,
crate::federation::TrustLevel::Observer,
)
.await;
// Mirror into the mesh peer table immediately so the
// chat UI can address the new peer without waiting
// for the next mesh restart.
let svc = self.mesh_service.read().await;
if let Some(svc) = svc.as_ref() {
crate::mesh::upsert_federation_peer(
&svc.shared_state(),
&node.pubkey,
&node.did,
node.name.as_deref(),
)
.await;
}
pending::set_state(
&self.config.data_dir,
&row_id,
PendingState::Approved,
)
.await?;
applied_invites.push(node.did);
}
Err(e) => {
tracing::warn!(
from = %hs.from_nostr_pubkey,
error = %e,
"Failed to apply PeerInvite"
);
}
}
}
HandshakeMessage::PeerReject { reason } => {
let pendings = pending::load_pending(&self.config.data_dir).await?;
if let Some(row) = pendings.iter().find(|r| {
r.outbound
&& r.from_nostr_pubkey == hs.from_nostr_pubkey
&& matches!(r.state, PendingState::Sent)
}) {
let row_id = row.id.clone();
pending::set_state(
&self.config.data_dir,
&row_id,
PendingState::Rejected,
)
.await?;
rejected_outbound.push(row_id);
tracing::info!(
from = %hs.from_nostr_pubkey,
reason = ?reason,
"Outbound peer request rejected"
);
}
}
}
}
let serialized: Vec<serde_json::Value> = handshakes
.iter()
.map(|hs| {
serde_json::json!({
"from_nostr_pubkey": hs.from_nostr_pubkey,
"from_nostr_npub": hs.from_nostr_npub,
"message": hs.message,
"timestamp": hs.timestamp,
})
})
.collect();
Ok(serde_json::json!({
"handshakes": serialized,
"added_peers": added_peers,
"polled": handshakes.len(),
"new_requests": new_requests,
"applied_invites": applied_invites,
"rejected_outbound": rejected_outbound,
"skipped": skipped,
}))
}
}
+1
View File
@@ -123,6 +123,7 @@ impl RpcHandler {
&req_msg.to_string(),
None,
None,
None,
).await?;
// Also add them as a pending peer locally
@@ -910,6 +910,7 @@ pub(super) async fn get_app_config(
"GITEA__packages__ENABLED=true".to_string(),
"GITEA__repository__ENABLE_PUSH_CREATE_USER=true".to_string(),
"GITEA__repository__ENABLE_PUSH_CREATE_ORG=true".to_string(),
"GITEA__security__X_FRAME_OPTIONS=".to_string(),
],
None,
None,
@@ -261,6 +261,7 @@ impl RpcHandler {
if !is_tailscale {
run_args.push("--cap-drop=ALL");
run_args.push("--security-opt=no-new-privileges:true");
run_args.push("--pids-limit=4096");
for cap in &security_caps {
run_args.push(cap);
}
@@ -600,11 +601,11 @@ impl RpcHandler {
.spawn()
.context("Failed to start image pull")?;
// Wrap the entire pull (stderr progress + wait) in a 60s timeout.
// If the registry is unreachable, the pull hangs on DNS/TCP and the
// stderr reader never returns — so the timeout must cover everything.
// Wrap the entire pull (stderr progress + wait) in a 10-minute timeout.
// Large image layers (Minio, Postgres, ffmpeg) can take several minutes
// to pull. 60s was too short and caused premature retries on slow registries.
let pull_result = tokio::time::timeout(
std::time::Duration::from_secs(60),
std::time::Duration::from_secs(600),
async {
if let Some(stderr) = child.stderr.take() {
let reader = BufReader::new(stderr);
@@ -1097,15 +1098,21 @@ server {
}
}
// Set ROOT_URL in Gitea config
// Set ROOT_URL in Gitea config — port 3000 is the nginx iframe proxy,
// which is the public-facing port users and the UI iframe access.
let host_ip = &self.config.host_ip;
let root_url = format!("GITEA__server__ROOT_URL=http://{}:3001/", host_ip);
let _ = tokio::process::Command::new("podman")
.args(["exec", "gitea", "sh", "-c",
&format!("grep -q ROOT_URL /data/gitea/conf/app.ini && sed -i 's|ROOT_URL.*|ROOT_URL = http://{}:3001/|' /data/gitea/conf/app.ini || true", host_ip)])
&format!("grep -q ROOT_URL /data/gitea/conf/app.ini && sed -i 's|ROOT_URL.*|ROOT_URL = http://{}:3000/|' /data/gitea/conf/app.ini || true", host_ip)])
.output()
.await;
info!("Gitea: ROOT_URL set to http://{}:3001/", host_ip);
// Also ensure X_FRAME_OPTIONS is empty so Gitea doesn't send the header
let _ = tokio::process::Command::new("podman")
.args(["exec", "gitea", "sh", "-c",
"grep -q X_FRAME_OPTIONS /data/gitea/conf/app.ini && sed -i 's|X_FRAME_OPTIONS.*|X_FRAME_OPTIONS =|' /data/gitea/conf/app.ini || sed -i '/^\\[security\\]/a X_FRAME_OPTIONS =' /data/gitea/conf/app.ini"])
.output()
.await;
info!("Gitea: ROOT_URL set to http://{}:3000/, X_FRAME_OPTIONS cleared", host_ip);
}
if package_id == "nextcloud" {
+114 -11
View File
@@ -138,6 +138,8 @@ impl RpcHandler {
.output()
.await;
let db_pass = super::config::read_or_generate_secret("immich-db-password").await;
let _ = tokio::process::Command::new("podman")
.args([
"run",
@@ -148,10 +150,24 @@ impl RpcHandler {
"unless-stopped",
"--network",
"immich-net",
"--network-alias",
"immich_postgres",
"--cap-drop=ALL",
"--cap-add=CHOWN",
"--cap-add=DAC_OVERRIDE",
"--cap-add=FOWNER",
"--cap-add=SETGID",
"--cap-add=SETUID",
"--security-opt=no-new-privileges:true",
"--memory=512m",
"--pids-limit=4096",
"--health-cmd=pg_isready -U postgres || exit 1",
"--health-interval=30s",
"--health-retries=3",
"-v",
"/var/lib/archipelago/immich-db:/var/lib/postgresql/data",
"-e",
"POSTGRES_PASSWORD=immichpass",
&format!("POSTGRES_PASSWORD={}", db_pass),
"-e",
"POSTGRES_USER=postgres",
"-e",
@@ -172,6 +188,15 @@ impl RpcHandler {
"unless-stopped",
"--network",
"immich-net",
"--network-alias",
"immich_redis",
"--cap-drop=ALL",
"--security-opt=no-new-privileges:true",
"--memory=128m",
"--pids-limit=2048",
"--health-cmd=valkey-cli ping || exit 1",
"--health-interval=30s",
"--health-retries=3",
"git.tx1138.com/lfg2025/valkey:7-alpine",
])
.output()
@@ -188,6 +213,12 @@ impl RpcHandler {
"unless-stopped",
"--network",
"immich-net",
"--network-alias",
"immich_server",
"--cap-drop=ALL",
"--security-opt=no-new-privileges:true",
"--memory=2g",
"--pids-limit=4096",
"-p",
"2283:2283",
"-v",
@@ -197,7 +228,7 @@ impl RpcHandler {
"-e",
"DB_USERNAME=postgres",
"-e",
"DB_PASSWORD=immichpass",
&format!("DB_PASSWORD={}", db_pass),
"-e",
"DB_DATABASE_NAME=immich",
"-e",
@@ -276,6 +307,20 @@ impl RpcHandler {
"unless-stopped",
"--network",
"penpot-net",
"--network-alias",
"penpot-postgres",
"--cap-drop=ALL",
"--cap-add=CHOWN",
"--cap-add=DAC_OVERRIDE",
"--cap-add=FOWNER",
"--cap-add=SETGID",
"--cap-add=SETUID",
"--security-opt=no-new-privileges:true",
"--memory=512m",
"--pids-limit=4096",
"--health-cmd=pg_isready -U penpot || exit 1",
"--health-interval=30s",
"--health-retries=3",
"-v",
"/var/lib/archipelago/penpot-postgres:/var/lib/postgresql/data",
"-e",
@@ -300,6 +345,15 @@ impl RpcHandler {
"unless-stopped",
"--network",
"penpot-net",
"--network-alias",
"penpot-valkey",
"--cap-drop=ALL",
"--security-opt=no-new-privileges:true",
"--memory=192m",
"--pids-limit=2048",
"--health-cmd=valkey-cli ping || exit 1",
"--health-interval=30s",
"--health-retries=3",
"-e",
"VALKEY_EXTRA_FLAGS=--maxmemory 128mb --maxmemory-policy volatile-lfu",
"git.tx1138.com/lfg2025/valkey:8.1",
@@ -318,6 +372,12 @@ impl RpcHandler {
"unless-stopped",
"--network",
"penpot-net",
"--network-alias",
"penpot-backend",
"--cap-drop=ALL",
"--security-opt=no-new-privileges:true",
"--memory=1g",
"--pids-limit=4096",
"-v",
"/var/lib/archipelago/penpot-assets:/opt/data/assets",
"-e",
@@ -354,6 +414,12 @@ impl RpcHandler {
"unless-stopped",
"--network",
"penpot-net",
"--network-alias",
"penpot-exporter",
"--cap-drop=ALL",
"--security-opt=no-new-privileges:true",
"--memory=512m",
"--pids-limit=2048",
"-e",
&format!("PENPOT_SECRET_KEY={}", secret),
"-e",
@@ -376,6 +442,12 @@ impl RpcHandler {
"unless-stopped",
"--network",
"penpot-net",
"--network-alias",
"penpot-frontend",
"--cap-drop=ALL",
"--security-opt=no-new-privileges:true",
"--memory=512m",
"--pids-limit=2048",
"-p",
"9001:8080",
"-v",
@@ -473,7 +545,18 @@ impl RpcHandler {
"--restart", "unless-stopped",
"--network", "archy-net",
"--network-alias", "archy-btcpay-db",
"--cap-drop=ALL",
"--cap-add=CHOWN",
"--cap-add=DAC_OVERRIDE",
"--cap-add=FOWNER",
"--cap-add=SETGID",
"--cap-add=SETUID",
"--security-opt=no-new-privileges:true",
"--memory=512m",
"--pids-limit=4096",
"--health-cmd=pg_isready -U btcpay || exit 1",
"--health-interval=30s",
"--health-retries=3",
"-v", "/var/lib/archipelago/postgres-btcpay:/var/lib/postgresql/data",
"-e", "POSTGRES_DB=btcpay",
"-e", "POSTGRES_USER=btcpay",
@@ -501,7 +584,10 @@ impl RpcHandler {
"--restart", "unless-stopped",
"--network", "archy-net",
"--network-alias", "archy-nbxplorer",
"--cap-drop=ALL",
"--security-opt=no-new-privileges:true",
"--memory=512m",
"--pids-limit=4096",
"-p", "32838:32838",
"-v", "/var/lib/archipelago/nbxplorer:/data",
"-e", "NBXPLORER_DATADIR=/data",
@@ -531,7 +617,10 @@ impl RpcHandler {
"--restart", "unless-stopped",
"--network", "archy-net",
"--network-alias", "btcpay-server",
"--cap-drop=ALL",
"--security-opt=no-new-privileges:true",
"--memory=1g",
"--pids-limit=4096",
"-p", "23000:49392",
"-v", "/var/lib/archipelago/btcpay:/datadir",
"-e", "ASPNETCORE_URLS=http://0.0.0.0:49392",
@@ -632,7 +721,18 @@ impl RpcHandler {
"--restart", "unless-stopped",
"--network", "archy-net",
"--network-alias", "archy-mempool-db",
"--cap-drop=ALL",
"--cap-add=CHOWN",
"--cap-add=DAC_OVERRIDE",
"--cap-add=FOWNER",
"--cap-add=SETGID",
"--cap-add=SETUID",
"--security-opt=no-new-privileges:true",
"--memory=512m",
"--pids-limit=4096",
"--health-cmd=mariadb-admin ping -u root --password=$MYSQL_ROOT_PASSWORD || exit 1",
"--health-interval=30s",
"--health-retries=3",
"-v", "/var/lib/archipelago/mysql-mempool:/var/lib/mysql",
"-e", "MYSQL_DATABASE=mempool",
"-e", "MYSQL_USER=mempool",
@@ -652,7 +752,10 @@ impl RpcHandler {
"--restart", "unless-stopped",
"--network", "archy-net",
"--network-alias", "mempool-api",
"--cap-drop=ALL",
"--security-opt=no-new-privileges:true",
"--memory=512m",
"--pids-limit=4096",
"-p", "8999:8999",
"-v", "/var/lib/archipelago/mempool:/data",
"-e", "MEMPOOL_BACKEND=electrum",
@@ -682,7 +785,10 @@ impl RpcHandler {
"--restart", "unless-stopped",
"--network", "archy-net",
"--network-alias", "mempool",
"--cap-drop=ALL",
"--security-opt=no-new-privileges:true",
"--memory=256m",
"--pids-limit=2048",
"-p", "4080:8080",
"-e", "FRONTEND_HTTP_PORT=8080",
"-e", "BACKEND_MAINNET_HTTP_HOST=mempool-api",
@@ -718,7 +824,7 @@ impl RpcHandler {
.into_iter()
.find(|r| r.enabled)
.map(|r| r.url)
.unwrap_or_else(|| "23.182.128.160:3000/lfg2025".to_string());
.unwrap_or_else(|| "git.tx1138.com/lfg2025".to_string());
let user_tmp = format!(
"{}/.local/share/containers/tmp",
@@ -740,16 +846,13 @@ impl RpcHandler {
format!("{}/indeedhub:1.0.0", registry),
];
// Pull all images with retry; fail the install if any image can't be pulled.
// Previously this just logged a warning and continued, leaving the stack
// broken and the user seeing "failed" with no recovery path.
for img in &images {
info!("Pulling {}", img);
let status = tokio::process::Command::new("podman")
.args(["pull", img, "--tls-verify=false"])
.env("TMPDIR", &user_tmp)
.status()
.await;
if !status.map(|s| s.success()).unwrap_or(false) {
tracing::warn!("Failed to pull {}", img);
}
pull_image_with_retry(img).await
.with_context(|| format!("Failed to pull IndeedHub image: {}", img))?;
}
// Create indeedhub-net
+9
View File
@@ -90,6 +90,15 @@ impl RpcHandler {
let (data, _) = self.state_manager.get_snapshot().await;
let pubkey = data.server_info.pubkey.clone();
// Skip sending to ourselves (prevents duplicate messages in group chat)
if let Some(ref our_onion) = data.server_info.tor_address {
let our = our_onion.trim_end_matches(".onion");
let their = onion.trim_end_matches(".onion");
if our == their {
return Ok(serde_json::json!({ "ok": true, "sent_to": onion, "skipped": "self" }));
}
}
// Load signing key for E2E encryption
let identity_dir = self.config.data_dir.join("identity");
let node_id = crate::identity::NodeIdentity::load_or_create(&identity_dir).await?;
+1 -36
View File
@@ -587,42 +587,7 @@ impl RpcHandler {
}
}
// NostrVPN mesh participants (from nvpn config)
let our_npub = vpn::read_nvpn_config_value("nostr", "public_key").await;
for path in vpn::NVPN_CONFIG_PATHS {
if let Ok(content) = tokio::fs::read_to_string(path).await {
if let Ok(table) = content.parse::<toml::Table>() {
if let Some(networks) = table.get("networks").and_then(|v| v.as_array()) {
for net in networks {
if let Some(participants) = net.get("participants").and_then(|v| v.as_array()) {
for p in participants {
if let Some(npub) = p.as_str() {
// Skip our own npub
if our_npub.as_deref() == Some(npub) { continue; }
// Check peer_aliases for a friendly name
let alias = table.get("peer_aliases")
.and_then(|a| a.get(npub))
.and_then(|v| v.as_str())
.unwrap_or("");
let short = if npub.len() > 20 {
format!("{}...{}", &npub[..12], &npub[npub.len()-6..])
} else { npub.to_string() };
peers.push(serde_json::json!({
"name": if alias.is_empty() { short } else { alias.to_string() },
"ip": "mesh",
"npub": npub,
"type": "nostrvpn",
}));
}
}
}
}
}
}
break; // Use first config found
}
}
// NostrVPN peer loading removed — standalone WireGuard only
Ok(serde_json::json!({ "peers": peers }))
}