diff --git a/Cargo.lock b/Cargo.lock index 3eb8b5f..f052edf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,15 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "abstract_socket" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a070310b2037a33c2e4cbf89718ef7169fb0a031757b7b3c46f9c15321517dd9" +dependencies = [ + "libc", +] + [[package]] name = "aho-corasick" version = "1.1.3" @@ -408,6 +417,7 @@ checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" name = "electrs" version = "0.11.1" dependencies = [ + "abstract_socket", "anyhow", "bitcoin", "bitcoin-test-data", diff --git a/Cargo.toml b/Cargo.toml index 1090c4d..f14a8c9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,6 +22,7 @@ metrics_process = ["prometheus/process"] spec = "internal/config_specification.toml" [dependencies] +abstract_socket = "0.1" anyhow = "1.0" bitcoin = { version = "0.32.8", features = ["serde", "rand-std"] } bitcoin_slices = { version = "0.11.0", features = ["bitcoin", "sha2"] } diff --git a/doc/config.md b/doc/config.md index 3784b20..39ec18f 100644 --- a/doc/config.md +++ b/doc/config.md @@ -3,6 +3,8 @@ This applies only if you do **not** use some other automated systems such as Debian packages. If you use automated systems, refer to their documentation first! +Note that all instances of socket addresses support Unix domain sockets, read about them below. + ### Bitcoind configuration Pruning must be turned **off** for `electrs` to work. @@ -82,6 +84,20 @@ You would need to either use a webserver to provide SSL (see _SSL connection_ be Electrs will listen by default on `127.0.0.1:50001`, which means it will only serve clients in the local machine. This is configured via the `electrum_rpc_addr` setting and if you wish to connect from another machine, you need to change it to `0.0.0.0:50001`. This is less secure though, and the recommended way to access Electrs remotely is to keep listening on `127.0.0.1` and tunnel to your server. +## Unix domain sockets + +Electrs supports binding and connecting to Unix domain sockets which can provide improved speed and security. However not all other services support this now. Still, using it is recommended if you can. + +Aside from avoiding the overhead of OS having to handle TCP packets, Unix sockets can provide improved access control and authentication using filesystem permissions. For instance, if you're running electrs as `electrs` user, an electrum client as `electrum` user and an untrusted software as `untrusted` user then with TCP the `untrusted` software, should it become malicious, can attack the connection between the Electrum client and electrs by binding the address before electrs has the chance to bind it. A Unix socket bound in a directory only writable by the `electrs` user makes this impossible. Further, if you're worried about DoS attacks, you can restrict the socket permissions to trusted clients only. + +This feature is currently mainly implemented for use with SSH-tunnelled clients/servers but it should start working right away when other software adds support. Here's a list of known software that might start supporting Unix sockets: + +* `bitcoind` has several issues and PRs concerning Unix sockets +* `prometheus` has an issue requesting Unix sockets open +* Briefly discussed in an Electrum issue with positive attitude + +There is also [a library that can translate between the sockets using the `LD_PRELOAD` hack](https://github.com/kohlschutter/unsock), if you want to try forcing software to use the Unix socket before it's supported natively. + ## Extra configuration suggestions ### SSL connection diff --git a/internal/config_specification.toml b/internal/config_specification.toml index f4ae65e..7b7dc7e 100644 --- a/internal/config_specification.toml +++ b/internal/config_specification.toml @@ -142,6 +142,11 @@ type = "String" doc = "The banner to be shown in the Electrum console" default = "concat!(\"Welcome to electrs \", env!(\"CARGO_PKG_VERSION\"), \" (Electrum Rust Server)!\").to_owned()" +[[param]] +name = "public_addr" +type = "crate::config::ElectrumAddr" +doc = "Sets the publicly advertised value of the server address. By default the electrum_rpc_addr is used; this setting can be used to correct it if the port is remapped by NAT or if the socket is tunnelled to a machine with a different address. This may be also used when Electrum RPC is bound to Unix domain socket. The format is the standard Electrum connection string host:port:connection where connection is either t for TCP or s for TLS." + [[param]] name = "log_filters" type = "String" diff --git a/src/config.rs b/src/config.rs index b844dda..e7b1277 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,3 +1,4 @@ +use abstract_socket::{SocketAddr, ToSocketAddrs}; use bitcoin::p2p::Magic; use bitcoin::Network; use bitcoincore_rpc::Auth; @@ -5,8 +6,6 @@ use dirs_next::home_dir; use std::ffi::{OsStr, OsString}; use std::fmt; -use std::net::SocketAddr; -use std::net::ToSocketAddrs; use std::path::PathBuf; use std::str::FromStr; @@ -88,6 +87,77 @@ impl ResolvAddr { } } +/// Electrum address +/// +/// This is parsed the same as Electrum connection string but used in public advert instead. +#[derive(Debug, Clone, Deserialize)] +#[serde(try_from = "String")] +pub struct ElectrumAddr { + pub host: String, + pub port: u16, + pub is_tls: bool, +} + +impl ElectrumAddr { + fn from_tcp(addr: &SocketAddr) -> Option { + match addr { + SocketAddr::Net(addr) => { + let host = addr.ip().to_string(); + let addr = ElectrumAddr { + host, + port: addr.port(), + is_tls: false, + }; + Some(addr) + } + _ => None, + } + } +} + +impl TryFrom for ElectrumAddr { + type Error = anyhow::Error; + + fn try_from(string: String) -> Result { + string.parse() + } +} + +impl std::str::FromStr for ElectrumAddr { + type Err = anyhow::Error; + + fn from_str(s: &str) -> Result { + use anyhow::Context; + + let (host_port, conn_type) = s + .rsplit_once(':') + .ok_or_else(|| anyhow::anyhow!("missing colons in the Electrum address"))?; + let is_tls = match conn_type { + "t" => false, + "s" => true, + invalid => anyhow::bail!("invalid connection type {}", invalid), + }; + let (host, port) = host_port + .rsplit_once(':') + .ok_or_else(|| anyhow::anyhow!("the Electrum address contains only one colon"))?; + let port = port + .parse() + .context("cannot parse the port of Electrum address")?; + + Ok(ElectrumAddr { + host: host.to_owned(), + port, + is_tls, + }) + } +} + +impl ::configure_me::parse_arg::ParseArgFromStr for ElectrumAddr { + fn describe_type(mut writer: W) -> fmt::Result { + write!(writer, "an Electrum network address in the form host:port:connection_type where connection_type is either t for TCP or s for TLS") + } +} + /// This newtype implements `ParseArg` for `Network`. #[derive(Deserialize)] pub struct BitcoinNetwork(Network); @@ -146,6 +216,7 @@ pub struct Config { pub disable_electrum_rpc: bool, pub server_banner: String, pub magic: Magic, + pub public_addr: Option, } pub struct SensitiveAuth(pub Auth); @@ -337,6 +408,10 @@ impl Config { std::process::exit(0); } + let public_addr = config + .public_addr + .or(ElectrumAddr::from_tcp(&electrum_rpc_addr)); + let config = Config { network: config.network, db_path: config.db_dir, @@ -359,6 +434,7 @@ impl Config { disable_electrum_rpc: config.disable_electrum_rpc, server_banner: config.server_banner, magic, + public_addr, }; eprintln!( "Starting electrs {} on {} {} with {:?}", diff --git a/src/daemon.rs b/src/daemon.rs index 40df169..20eb959 100644 --- a/src/daemon.rs +++ b/src/daemon.rs @@ -140,7 +140,7 @@ impl Daemon { } let p2p = Mutex::new(Connection::connect( - config.daemon_p2p_addr, + config.daemon_p2p_addr.clone(), metrics, config.magic, )?); diff --git a/src/electrum.rs b/src/electrum.rs index e6ef9c6..de25e88 100644 --- a/src/electrum.rs +++ b/src/electrum.rs @@ -13,7 +13,6 @@ use serde_json::{self, json, Value}; use std::collections::{hash_map::Entry, HashMap}; use std::fmt; use std::iter::FromIterator; -use std::net::SocketAddr; use std::str::FromStr; use crate::{ @@ -151,7 +150,7 @@ pub struct Rpc { daemon: Daemon, signal: Signal, banner: String, - addr: SocketAddr, + addr: Option, } impl Rpc { @@ -175,7 +174,7 @@ impl Rpc { daemon, signal, banner: config.server_banner.clone(), - addr: config.electrum_rpc_addr, + addr: config.public_addr.clone(), }) } @@ -506,13 +505,23 @@ impl Rpc { } fn features(&self) -> Result { + let hosts = if let Some(addr) = &self.addr { + if addr.is_tls { + json!({ &addr.host: { + "ssl_port": addr.port + }}) + } else { + json!({ &addr.host: { + "tcp_port": addr.port + }}) + } + } else { + json!({}) + }; + Ok(json!({ "genesis_hash": self.tracker.chain().get_block_hash(0), - "hosts": { - self.addr.ip().to_string(): { - "tcp_port": self.addr.port() - } - }, + "hosts": hosts, "protocol_max": PROTOCOL_VERSION, "protocol_min": PROTOCOL_VERSION, "pruning": null, diff --git a/src/metrics.rs b/src/metrics.rs index 9d6ef8b..c9916f7 100644 --- a/src/metrics.rs +++ b/src/metrics.rs @@ -1,5 +1,7 @@ #[cfg(feature = "metrics")] mod metrics_impl { + use abstract_socket::SocketAddr; + use anyhow::{Context, Result}; #[cfg(feature = "metrics_process")] @@ -8,8 +10,6 @@ mod metrics_impl { use prometheus::{self, Encoder, HistogramOpts, HistogramVec, Registry, TEXT_FORMAT}; use tiny_http::{Header as HttpHeader, Response, Server}; - use std::net::SocketAddr; - use crate::thread::spawn; pub struct Metrics { @@ -17,7 +17,11 @@ mod metrics_impl { } impl Metrics { - pub fn new(addr: SocketAddr) -> Result { + pub fn new(addr: &SocketAddr) -> Result { + use std::net::TcpListener; + #[cfg(target_family = "unix")] + use std::os::unix::net::UnixListener; + let reg = Registry::new(); #[cfg(feature = "metrics_process")] @@ -27,7 +31,14 @@ mod metrics_impl { let result = Self { reg }; let reg = result.reg.clone(); - let server = match Server::http(addr) { + let listener: tiny_http::Listener = match addr { + SocketAddr::Net(addr) => TcpListener::bind(addr).map(Into::into), + #[cfg(target_family = "unix")] + SocketAddr::Uds(ref addr) => UnixListener::bind_addr(addr).map(Into::into), + } + .with_context(|| format!("failed to bind address {}", addr))?; + + let server = match Server::from_listener(listener, None) { Ok(server) => server, Err(err) => bail!("failed to start HTTP server on {}: {}", addr, err), }; @@ -117,12 +128,12 @@ pub use metrics_impl::{Gauge, Histogram, Metrics}; mod metrics_fake { use anyhow::Result; - use std::net::SocketAddr; + use abstract_socket::SocketAddr; pub struct Metrics {} impl Metrics { - pub fn new(_addr: SocketAddr) -> Result { + pub fn new(_addr: &SocketAddr) -> Result { debug!("metrics collection is disabled"); Ok(Self {}) } diff --git a/src/p2p.rs b/src/p2p.rs index 0787f36..3786f52 100644 --- a/src/p2p.rs +++ b/src/p2p.rs @@ -1,3 +1,4 @@ +use abstract_socket::{SocketAddr, Stream}; use anyhow::{Context, Result}; use bitcoin::blockdata::block::Header as BlockHeader; use bitcoin::consensus::Encodable; @@ -21,7 +22,7 @@ use bitcoin_slices::{bsl, Parse}; use crossbeam_channel::{bounded, select, Receiver, Sender}; use std::io::Write; -use std::net::{IpAddr, Ipv4Addr, SocketAddr, TcpStream}; +use std::net::{IpAddr, Ipv4Addr}; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; use crate::types::SerBlock; @@ -136,7 +137,7 @@ impl Connection { } pub(crate) fn connect(address: SocketAddr, metrics: &Metrics, magic: Magic) -> Result { - let recv_conn = TcpStream::connect(address) + let recv_conn = Stream::connect(&address) .with_context(|| format!("p2p failed to connect: {:?}", address))?; let mut send_conn = recv_conn .try_clone() @@ -316,7 +317,7 @@ impl Connection { } fn build_version_message() -> NetworkMessage { - let addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(0, 0, 0, 0)), 0); + let addr = (IpAddr::V4(Ipv4Addr::new(0, 0, 0, 0)), 0).into(); let timestamp = SystemTime::now() .duration_since(UNIX_EPOCH) .expect("Time error") diff --git a/src/server.rs b/src/server.rs index f50662f..e2eb27f 100644 --- a/src/server.rs +++ b/src/server.rs @@ -1,3 +1,4 @@ +use abstract_socket::{Listener, Stream}; use anyhow::{Context, Result}; use crossbeam_channel::{select, unbounded, Sender}; use rayon::prelude::*; @@ -6,7 +7,7 @@ use std::{ collections::hash_map::HashMap, io::{BufRead, BufReader, Write}, iter::once, - net::{Shutdown, TcpListener, TcpStream}, + net::Shutdown, }; use crate::{ @@ -20,11 +21,11 @@ use crate::{ struct Peer { id: usize, client: Client, - stream: TcpStream, + stream: Stream, } impl Peer { - fn new(id: usize, stream: TcpStream) -> Self { + fn new(id: usize, stream: Stream) -> Self { let client = Client::default(); Self { id, client, stream } } @@ -62,13 +63,38 @@ pub fn run() -> Result<()> { fn serve() -> Result<()> { let config = Config::from_args(); - let metrics = Metrics::new(config.monitoring_addr)?; + let metrics = Metrics::new(&config.monitoring_addr)?; let (server_tx, server_rx) = unbounded(); if !config.disable_electrum_rpc { - let listener = TcpListener::bind(config.electrum_rpc_addr)?; + let listener = Listener::bind(&config.electrum_rpc_addr)?; info!("serving Electrum RPC on {}", listener.local_addr()?); - spawn("accept_loop", || accept_loop(listener, server_tx)); // detach accepting thread + let electrum_rpc_addr = config.electrum_rpc_addr.clone(); + spawn("accept_loop", move || { + let result = accept_loop(listener, server_tx); + // The loop is actually supposed to be infinite so `Ok` is not OK. + if result.is_ok() { + error!("The `Incoming` iterator ended unexpectedly"); + } + #[cfg(unix)] + if let abstract_socket::SocketAddr::Uds(uds_addr) = electrum_rpc_addr { + if let Some(path) = uds_addr.as_pathname() { + match std::fs::remove_file(path) { + // Not found means something else removed the socket already, so it is not + // an error because we got the desired outcome but it's still concerning + // that something is messing with our socket. + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + warn!("The socket file was removed by something else, this may indicate broken setup"); + } + Ok(()) => (), + Err(error) => { + error!("Failed to remove the socket: {}", error); + } + } + } + } + result + }); // detach accepting thread }; let server_batch_size = metrics.histogram_vec( @@ -158,7 +184,7 @@ struct Event { } enum Message { - New(TcpStream), + New(Stream), Request(String), Done, } @@ -209,7 +235,7 @@ fn handle_peer_events( } } -fn accept_loop(listener: TcpListener, server_tx: Sender) -> Result<()> { +fn accept_loop(listener: Listener, server_tx: Sender) -> Result<()> { for (peer_id, conn) in listener.incoming().enumerate() { let stream = conn.context("failed to accept")?; let tx = server_tx.clone(); @@ -224,7 +250,7 @@ fn accept_loop(listener: TcpListener, server_tx: Sender) -> Result<()> { Ok(()) } -fn recv_loop(peer_id: usize, stream: &TcpStream, server_tx: Sender) -> Result<()> { +fn recv_loop(peer_id: usize, stream: &Stream, server_tx: Sender) -> Result<()> { let msg = Message::New(stream.try_clone()?); server_tx.send(Event { peer_id, msg })?;