Support Unix sockets

Unix sockets provide better security and performance, so it is natural
for electrs to want to support them. Because of how similar they are to
TCP sockets the required changes are theoretically small. However, there
is no crate I know of that provided the abstract API, so I made one.

I originally only wanted to support electrum RPC but it turns out not
having to deal with two sets of `SocketAddr` is easier and it wasn't too
difficult. Unix socket was already supported by `tiny_http` via their
own abstraction and we have no other dependencies requiring TCP.

Because this also required modifying the logic around publishing the
electrum address I added an option to set it explicitly regardless of
whether the user is dealing with Unix or TCP socket because having this
is useful for TCP too - the user might want to publish a different port
number if the port was mapped via NAT or a different address when
tunnelled. In theory, this is still not perfect because the same server
could be tunnelled multiple times but we don't bother with such edge
case until someone requires it.

Another side improvement is reporting if the accept thread ends because
of incoming stream ending rather than error. This should never happen
but is useful for debugging if it ever does.
This commit is contained in:
Martin Habovstiak 2026-01-21 20:07:08 +01:00
parent fb1013af30
commit 898e57dcdd
10 changed files with 184 additions and 29 deletions

10
Cargo.lock generated
View file

@ -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",

View file

@ -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"] }

View file

@ -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

View file

@ -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"

View file

@ -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<Self> {
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<String> for ElectrumAddr {
type Error = anyhow::Error;
fn try_from(string: String) -> Result<Self, Self::Error> {
string.parse()
}
}
impl std::str::FromStr for ElectrumAddr {
type Err = anyhow::Error;
fn from_str(s: &str) -> Result<Self, Self::Err> {
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<W: fmt::Write>(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<ElectrumAddr>,
}
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 {:?}",

View file

@ -140,7 +140,7 @@ impl Daemon {
}
let p2p = Mutex::new(Connection::connect(
config.daemon_p2p_addr,
config.daemon_p2p_addr.clone(),
metrics,
config.magic,
)?);

View file

@ -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<crate::config::ElectrumAddr>,
}
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<Value> {
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,

View file

@ -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<Self> {
pub fn new(addr: &SocketAddr) -> Result<Self> {
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<Self> {
pub fn new(_addr: &SocketAddr) -> Result<Self> {
debug!("metrics collection is disabled");
Ok(Self {})
}

View file

@ -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<Self> {
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")

View file

@ -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<Event>) -> Result<()> {
fn accept_loop(listener: Listener, server_tx: Sender<Event>) -> 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<Event>) -> Result<()> {
Ok(())
}
fn recv_loop(peer_id: usize, stream: &TcpStream, server_tx: Sender<Event>) -> Result<()> {
fn recv_loop(peer_id: usize, stream: &Stream, server_tx: Sender<Event>) -> Result<()> {
let msg = Message::New(stream.try_clone()?);
server_tx.send(Event { peer_id, msg })?;