What changed, and why it matters
This commit adds support for Unix domain sockets to the electrs Bitcoin Electrum server. The main security benefit is that administrators can use filesystem permissions to control which local users or processes can connect, instead of relying solely on TCP network rules. It also lets operators advertise a different public address than the one the server actually binds to, which is useful when tunneling through SSH or NAT. The changes are mostly a feature addition and refactoring; there is no direct evidence in the commit of a fixed vulnerability or exploit.
Treat this as a routine feature addition rather than a security patch. Operators who want the security benefits should run electrs with Unix domain sockets and restrict directory/socket permissions to trusted users. Review the new abstract_socket dependency for supply-chain risk and ensure socket cleanup behavior does not remove files outside the intended path. No urgent upgrade is required for security reasons based on this commit alone.
Security signals we found
Adds Unix domain socket support, enabling filesystem-permission-based access control for RPC and metrics endpoints
Introduces public_addr option to decouple bound listening address from advertised Electrum server address (relevant for NAT/SSH tunnels)
Adds cleanup of Unix socket path on accept-loop termination
Adds warning logging when the socket file is removed by another process
No direct bug fix, memory-safety issue, or cryptographic change visible in the diff
Evidence from the diff
The patch replaces std::net::SocketAddr/TcpListener/TcpStream usage with a new abstract_socket crate that can represent either TCP or Unix domain socket addresses. It updates the Electrum RPC server, Prometheus metrics endpoint, and Bitcoin P2P connection paths to accept the abstract address type. A new public_addr configuration option is introduced so the server.features Electrum RPC response can report a host:port:t|s string even when the bound address is a Unix socket. The accept loop now logs if the incoming iterator ends unexpectedly and attempts to clean up the Unix socket file on shutdown.
Changed components
Electrum RPC server (src/server.rs, src/electrum.rs)Configuration parsing (src/config.rs, internal/config_specification.toml)Metrics HTTP endpoint (src/metrics.rs)Bitcoin P2P connection (src/p2p.rs)Daemon connection setup (src/daemon.rs)Documentation (doc/config.md)Inspect captured patch +184 / −29
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<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 {:?}",
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<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,
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<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 {})
}
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<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")
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<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 })?;
Why this scored 29/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.