Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
96 changes: 96 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```
Comment thread
refined-element marked this conversation as resolved.

---

## CLI Usage
Expand Down Expand Up @@ -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
Expand Down
44 changes: 44 additions & 0 deletions config/com.bitcoinwolfe.node.plist
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<!-- Service identity -->
<key>Label</key>
<string>com.bitcoinwolfe.node</string>

<!-- Binary and arguments -->
<key>ProgramArguments</key>
<array>
<string>__WOLFE_BINARY__</string>
<string>start</string>
</array>

<!-- Working directory (where wolfe.toml lives) -->
<key>WorkingDirectory</key>
<string>__WOLFE_DIR__</string>

<!-- Start on boot / login -->
<key>RunAtLoad</key>
<true/>

<!-- Restart if the process exits unexpectedly -->
<key>KeepAlive</key>
<dict>
<key>SuccessfulExit</key>
<false/>
</dict>

<!-- Wait 10 seconds before restarting after a crash -->
<key>ThrottleInterval</key>
<integer>10</integer>

<!-- Logging. Path lives at the project root rather than under data/
because launchd opens these files BEFORE the program runs and will
not create missing parent directories. -->
<key>StandardOutPath</key>
<string>__WOLFE_DIR__/wolfe-launchd.log</string>
<key>StandardErrorPath</key>
<string>__WOLFE_DIR__/wolfe-launchd.log</string>
</dict>
</plist>
140 changes: 137 additions & 3 deletions crates/wolfe-node/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<bitcoin::secp256k1::PublicKey> =
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 ───────────────────────────────────────────
Expand Down Expand Up @@ -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
{
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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<std::net::SocketAddr> {
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");
}
}
15 changes: 14 additions & 1 deletion crates/wolfe-nostr/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ pub struct NostrBridge {
keys: Keys,
profile_name: Option<String>,
profile_about: Option<String>,
profile_picture: Option<String>,
}

/// Handle for sending events to the Nostr bridge from the main loop.
Expand All @@ -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],
Expand All @@ -80,6 +82,7 @@ impl NostrBridge {
fee_oracle_interval_secs: u64,
profile_name: Option<String>,
profile_about: Option<String>,
profile_picture: Option<String>,
) -> Result<(Self, NostrSender, Arc<Client>), NostrError> {
let keys = match secret_key {
Some(sk) => Keys::parse(sk).map_err(|e| NostrError::InvalidKey(e.to_string()))?,
Expand Down Expand Up @@ -115,6 +118,7 @@ impl NostrBridge {
keys,
profile_name,
profile_about,
profile_picture,
},
NostrSender { tx },
shared_client,
Expand All @@ -141,14 +145,23 @@ 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);
}
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!(
Expand Down
Loading
Loading