diff --git a/README.md b/README.md index 9ef09b5..222e2b9 100644 --- a/README.md +++ b/README.md @@ -79,6 +79,97 @@ The binary is at `target/release/wolfe`. ./target/release/wolfe --config my-config.toml start ``` +### Stop + +The node shuts down gracefully via any of these methods: + +```bash +# From the terminal that started it +Ctrl+C + +# Via JSON-RPC +curl -s http://127.0.0.1:8332/ \ + -H "Content-Type: application/json" \ + -d '{"jsonrpc":"2.0","id":1,"method":"stop"}' + +# Via signal (SIGINT or SIGTERM) +kill -INT $(pgrep -f "wolfe start") # or: kill $(pgrep -f "wolfe start") +``` + +You can also stop from the **Settings** page in the web dashboard. + +All methods trigger the same graceful shutdown: Lightning channel state is persisted, the consensus engine is interrupted cleanly, and peer connections are closed. Both `SIGINT` (Ctrl+C) and `SIGTERM` (default for `kill`, `systemctl stop`, and `launchctl unload`) are handled the same way. + +### Auto-Start on macOS (launchd) + +To have BitcoinWolfe start automatically on login and restart after crashes: + +```bash +# 1. Copy the template plist +cp config/com.bitcoinwolfe.node.plist ~/Library/LaunchAgents/ + +# 2. Edit it — replace the placeholder paths with your actual paths: +# __WOLFE_BINARY__ → full path to the wolfe binary (e.g. /Users/you/BitcoinWolfe/target/release/wolfe) +# __WOLFE_DIR__ → full path to the project directory (e.g. /Users/you/BitcoinWolfe) +nano ~/Library/LaunchAgents/com.bitcoinwolfe.node.plist + +# 3. Load the service +launchctl load ~/Library/LaunchAgents/com.bitcoinwolfe.node.plist + +# 4. Verify it's running +launchctl list | grep bitcoinwolfe +curl -s http://127.0.0.1:8332/api/peers | head -c 100 +``` + +To manage the service: + +```bash +# Stop the service (and prevent auto-restart) +launchctl unload ~/Library/LaunchAgents/com.bitcoinwolfe.node.plist + +# Reload after config changes +launchctl unload ~/Library/LaunchAgents/com.bitcoinwolfe.node.plist +launchctl load ~/Library/LaunchAgents/com.bitcoinwolfe.node.plist +``` + +> **Note:** The plist uses `KeepAlive` with `SuccessfulExit = false`, so launchd will restart the node if it crashes but not if you stop it intentionally via RPC `stop`, `Ctrl+C`, or the dashboard. +> +> `~/Library/LaunchAgents/` runs at user **login**, not at boot. For boot-time startup install the plist into `/Library/LaunchDaemons/` (requires `sudo`) and add a `UserName` key — note that `LaunchDaemons` run as root unless `UserName` is set. + +### Auto-Start on Linux (systemd) + +```bash +# Create a service file +sudo tee /etc/systemd/system/bitcoinwolfe.service << 'EOF' +[Unit] +Description=BitcoinWolfe Node +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +User=YOUR_USER +WorkingDirectory=/path/to/BitcoinWolfe +ExecStart=/path/to/BitcoinWolfe/target/release/wolfe start +Restart=on-failure +RestartSec=10 +# systemctl stop sends SIGTERM, which the node handles gracefully. +# Give Lightning state and consensus enough time to persist before SIGKILL. +TimeoutStopSec=30 + +[Install] +WantedBy=multi-user.target +EOF + +# Enable and start +sudo systemctl daemon-reload +sudo systemctl enable bitcoinwolfe +sudo systemctl start bitcoinwolfe + +# Check status +sudo systemctl status bitcoinwolfe +``` + --- ## CLI Usage @@ -170,6 +261,11 @@ accept_inbound_channels = true min_channel_size_sat = 20000 max_channel_size_sat = 16777215 # rapid_gossip_sync_url = "https://rapidsync.lightningdevkit.org/snapshot" + +# Lightning peers that the node should keep connected. The reconnector +# checks every 60 seconds and dials any listed peer that's currently +# offline. Hostnames are re-resolved on each tick. +# persistent_peers = ["02abc...@host.example.com:9735"] ``` ### Nostr diff --git a/config/com.bitcoinwolfe.node.plist b/config/com.bitcoinwolfe.node.plist new file mode 100644 index 0000000..83c69d5 --- /dev/null +++ b/config/com.bitcoinwolfe.node.plist @@ -0,0 +1,44 @@ + + + + + + Label + com.bitcoinwolfe.node + + + ProgramArguments + + __WOLFE_BINARY__ + start + + + + WorkingDirectory + __WOLFE_DIR__ + + + RunAtLoad + + + + KeepAlive + + SuccessfulExit + + + + + ThrottleInterval + 10 + + + StandardOutPath + __WOLFE_DIR__/wolfe-launchd.log + StandardErrorPath + __WOLFE_DIR__/wolfe-launchd.log + + diff --git a/crates/wolfe-node/src/main.rs b/crates/wolfe-node/src/main.rs index 48cfed8..699ec32 100644 --- a/crates/wolfe-node/src/main.rs +++ b/crates/wolfe-node/src/main.rs @@ -425,6 +425,79 @@ async fn main() -> Result<()> { } }); info!("Lightning background processor started"); + + // Periodically (re)connect to persistent peers from config. + // Pubkeys are validated once here, then any persistent peer not + // currently connected is dialed every 60 seconds. Hostnames are + // re-resolved on each tick so DHCP/DNS changes on the LAN don't + // permanently break reconnection. + if !config.lightning.persistent_peers.is_empty() { + let mut valid_peers: Vec<(bitcoin::secp256k1::PublicKey, String, String)> = Vec::new(); + for peer_str in &config.lightning.persistent_peers { + match split_ln_peer(peer_str) { + Ok((pubkey, addr_str)) => { + valid_peers.push((pubkey, addr_str, peer_str.clone())); + } + Err(e) => { + warn!(%peer_str, %e, "invalid persistent_peers entry, skipping"); + } + } + } + + if !valid_peers.is_empty() { + let valid_count = valid_peers.len(); + let ln_reconnect = ln.clone(); + let ln_reconnect_shutdown = shutdown.clone(); + tokio::spawn(async move { + // Brief delay to let the listener bind first + tokio::time::sleep(std::time::Duration::from_secs(2)).await; + let mut interval = tokio::time::interval(std::time::Duration::from_secs(60)); + loop { + interval.tick().await; + if ln_reconnect_shutdown.load(Ordering::Relaxed) { + break; + } + + let connected: std::collections::HashSet = + ln_reconnect + .peer_manager() + .list_peers() + .into_iter() + .map(|p| p.counterparty_node_id) + .collect(); + + for (pubkey, addr_str, peer_str) in &valid_peers { + if connected.contains(pubkey) { + continue; + } + let addr = match resolve_addr(addr_str).await { + Ok(a) => a, + Err(e) => { + debug!(%peer_str, ?e, "persistent peer DNS resolve failed, will retry"); + continue; + } + }; + match ln_reconnect.connect_peer(*pubkey, addr).await { + Ok(()) => info!( + %pubkey, + %addr, + "persistent Lightning peer connected" + ), + Err(e) => debug!( + %peer_str, + ?e, + "persistent peer not reachable, will retry" + ), + } + } + } + }); + info!( + valid = valid_count, + "persistent Lightning peer reconnector started" + ); + } + } } // ── Initialize RPC server ─────────────────────────────────────────── @@ -484,6 +557,7 @@ async fn main() -> Result<()> { config.nostr.fee_oracle_interval_secs, config.nostr.name.clone(), config.nostr.about.clone(), + config.nostr.picture.clone(), ) .await { @@ -899,9 +973,8 @@ async fn main() -> Result<()> { } } - // Shutdown on Ctrl+C - _ = tokio::signal::ctrl_c() => { - info!("shutdown signal received"); + // Shutdown on SIGINT (Ctrl+C) or SIGTERM (kill, systemctl stop, launchctl unload) + _ = shutdown_signal() => { shutdown.store(true, Ordering::Relaxed); break; } @@ -1027,3 +1100,64 @@ fn prune_block_files(blocks_dir: &std::path::Path, target_bytes: u64) -> Result< Ok(()) } + +/// Parse a "pubkey@host:port" string. Validates the pubkey synchronously and +/// returns the address portion as a string for later async resolution. Splitting +/// pubkey parsing from DNS lookup lets us validate the static parts once at +/// startup while still re-resolving hostnames each reconnect cycle. +fn split_ln_peer(s: &str) -> Result<(bitcoin::secp256k1::PublicKey, String)> { + let (pk_str, addr_str) = s + .split_once('@') + .ok_or_else(|| anyhow::anyhow!("expected pubkey@host:port"))?; + let pubkey: bitcoin::secp256k1::PublicKey = pk_str.parse()?; + Ok((pubkey, addr_str.to_string())) +} + +/// Resolve a "host:port" string to a SocketAddr without blocking the runtime. +async fn resolve_addr(addr_str: &str) -> Result { + tokio::net::lookup_host(addr_str) + .await? + .next() + .ok_or_else(|| anyhow::anyhow!("could not resolve {}", addr_str)) +} + +/// Wait for any termination signal. On Unix this is SIGINT or SIGTERM, so +/// `Ctrl+C`, `kill`, `systemctl stop`, and `launchctl unload` all trigger the +/// same graceful shutdown path. +async fn shutdown_signal() { + #[cfg(unix)] + { + use tokio::signal::unix::{signal, SignalKind}; + let mut sigterm = match signal(SignalKind::terminate()) { + Ok(s) => s, + Err(e) => { + error!( + ?e, + "failed to install SIGTERM handler, falling back to Ctrl+C only" + ); + let _ = tokio::signal::ctrl_c().await; + return; + } + }; + let mut sigint = match signal(SignalKind::interrupt()) { + Ok(s) => s, + Err(e) => { + error!( + ?e, + "failed to install SIGINT handler, falling back to Ctrl+C only" + ); + let _ = tokio::signal::ctrl_c().await; + return; + } + }; + tokio::select! { + _ = sigterm.recv() => info!("SIGTERM received"), + _ = sigint.recv() => info!("SIGINT received"), + } + } + #[cfg(not(unix))] + { + let _ = tokio::signal::ctrl_c().await; + info!("Ctrl+C received"); + } +} diff --git a/crates/wolfe-nostr/src/lib.rs b/crates/wolfe-nostr/src/lib.rs index 138a716..d56265c 100644 --- a/crates/wolfe-nostr/src/lib.rs +++ b/crates/wolfe-nostr/src/lib.rs @@ -47,6 +47,7 @@ pub struct NostrBridge { keys: Keys, profile_name: Option, profile_about: Option, + profile_picture: Option, } /// Handle for sending events to the Nostr bridge from the main loop. @@ -72,6 +73,7 @@ impl NostrBridge { /// /// Returns `(bridge, sender, client)` where `client` is a shared handle /// that can be used by RPC handlers to publish events or query relays. + #[allow(clippy::too_many_arguments)] pub async fn new( secret_key: Option<&str>, relays: &[String], @@ -80,6 +82,7 @@ impl NostrBridge { fee_oracle_interval_secs: u64, profile_name: Option, profile_about: Option, + profile_picture: Option, ) -> Result<(Self, NostrSender, Arc), NostrError> { let keys = match secret_key { Some(sk) => Keys::parse(sk).map_err(|e| NostrError::InvalidKey(e.to_string()))?, @@ -115,6 +118,7 @@ impl NostrBridge { keys, profile_name, profile_about, + profile_picture, }, NostrSender { tx }, shared_client, @@ -141,7 +145,10 @@ impl NostrBridge { ); // Publish profile metadata (NIP-01 kind 0) if configured - if self.profile_name.is_some() || self.profile_about.is_some() { + if self.profile_name.is_some() + || self.profile_about.is_some() + || self.profile_picture.is_some() + { let mut metadata = Metadata::new(); if let Some(ref name) = self.profile_name { metadata = metadata.name(name); @@ -149,6 +156,12 @@ impl NostrBridge { if let Some(ref about) = self.profile_about { metadata = metadata.about(about); } + if let Some(ref picture) = self.profile_picture { + match Url::parse(picture) { + Ok(url) => metadata = metadata.picture(url), + Err(e) => warn!(picture, ?e, "invalid nostr.picture URL, skipping"), + } + } match self.client.set_metadata(&metadata).await { Ok(output) => { info!( diff --git a/crates/wolfe-types/src/config.rs b/crates/wolfe-types/src/config.rs index 8d26302..798e2e2 100644 --- a/crates/wolfe-types/src/config.rs +++ b/crates/wolfe-types/src/config.rs @@ -286,6 +286,8 @@ pub struct NostrConfig { pub name: Option, /// Profile about/bio text. pub about: Option, + /// Profile picture URL (NIP-01 kind 0 metadata). + pub picture: Option, /// Relay URLs to publish events to. pub relays: Vec, /// Publish new block announcements to relays. @@ -322,6 +324,8 @@ pub struct LightningConfig { pub max_channel_size_sat: u64, /// URL for Rapid Gossip Sync server (optional, speeds up initial gossip). pub rapid_gossip_sync_url: Option, + /// Peers to keep connected to, periodically reconnecting as needed (pubkey@host:port). + pub persistent_peers: Vec, } impl Default for LightningConfig { @@ -336,6 +340,7 @@ impl Default for LightningConfig { min_channel_size_sat: 20_000, max_channel_size_sat: 16_777_215, rapid_gossip_sync_url: None, + persistent_peers: vec![], } } } @@ -374,6 +379,7 @@ impl Default for NostrConfig { secret_key: None, name: None, about: None, + picture: None, relays: vec![ "wss://relay.damus.io".to_string(), "wss://nos.lol".to_string(),