diff --git a/.env.example b/.env.example index 05b72c7..9f7254d 100644 --- a/.env.example +++ b/.env.example @@ -5,3 +5,12 @@ PLM_RPC_PASSWORD=change-me #CACHE_MB=1200 #REPORT_SERVICES=tcp://electrum.example.com:50001 #ELECTRUMX_TCP_PORT=50001 + +# SSL (port 50002): on by default, self-signed certificate generated once and kept in the electrumx-ssl volume. +#SSL_ENABLED=true +# name (or IP) of the server, written in the generated certificate +#SSL_HOSTNAME=electrum.example.com +#ELECTRUMX_SSL_PORT=50002 +# your own certificate (paths inside the container; mount the files, see DEPLOY.md): +#SSL_CERTFILE=/certs/fullchain.pem +#SSL_KEYFILE=/certs/privkey.pem diff --git a/CLAUDE.md b/CLAUDE.md index a7a7ab3..f203214 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -22,7 +22,7 @@ DB_ENGINE=rocksdb DB_DIRECTORY=... DAEMON_URL=http://user:pass@host:port/ electr electrumx_rpc getinfo # talk to a running server via local RPC ``` -Docker: `docker compose up -d --build` (root `Dockerfile`, `docker-compose.yml`, `.env` from `.env.example`) runs the server on the external `palladium` network of the Palladium node container, daemon at `palladiumd:2332`. +Docker: `docker compose up -d --build` (root `Dockerfile`, `docker-compose.yml`, `.env` from `.env.example`) runs the server on the external `palladium` network of the Palladium node container, daemon at `palladiumd:2332`. `docker-entrypoint.sh` sets up SSL: it uses `SSL_CERTFILE`/`SSL_KEYFILE` if given, else the self-signed certificate in the `electrumx-ssl` volume, generating it only if missing (never overwrite it: clients pin it), and sets `SERVICES`. Configuration is entirely via environment variables, parsed in `src/electrumx/server/env.py` (documented in `docs/environment.rst`). `DB_ENGINE` is mandatory in 2.0 (`leveldb` or `rocksdb`). Python >=3.10; free-threaded 3.14t is experimental. diff --git a/DEPLOY.md b/DEPLOY.md index 333790e..d80f9ce 100644 --- a/DEPLOY.md +++ b/DEPLOY.md @@ -47,12 +47,12 @@ Keep `.env` private (`chmod 600 .env`); it is ignored by git. | Port | What | Open to | Notes | | --- | --- | --- | --- | | **50001/tcp** | Electrum protocol, plain | clients (internet, if the server is public) | published by `docker-compose.yml` | -| **50002/tcp** | Electrum protocol over SSL | clients | optional, needs a certificate (see below); not published by default | +| **50002/tcp** | Electrum protocol over SSL | clients | on by default, certificate generated automatically (see "SSL"); published by `docker-compose.yml` | | **2333/tcp** | Palladium P2P (the node) | the internet, inbound | published by the node's compose file | | 2332/tcp | node RPC | **only the `palladium` network** | never publish or forward it: it can spend wallet funds | | 28332, 28334, 28335/tcp | node ZMQ | only the `palladium` network | never publish or forward them | -Changing the published port: `ELECTRUMX_TCP_PORT` in `.env` (host side only; the container always listens on 50001). +Changing the published ports: `ELECTRUMX_TCP_PORT` / `ELECTRUMX_SSL_PORT` in `.env` (host side only; the container always listens on 50001). A server only for yourself (or for other containers) needs no open port: connect to `127.0.0.1:50001`, or join the `palladium` network and use `electrumx:50001`. @@ -79,31 +79,85 @@ Never forward 2332 or 28332/28334/28335. A server on a VPS with a public IP open ## SSL (port 50002) -Most Electrum wallets prefer SSL. You need a certificate and key for the host name of the server. +SSL is **on by default** and needs nothing to be set up: Electrum wallets prefer it, and plain TCP (50001) sends +addresses and balances unencrypted. -1. Get a certificate, for example with Let's Encrypt (`certbot certonly --standalone -d electrum.example.com`), - or a self-signed one for tests: - ```bash - openssl req -x509 -newkey rsa:4096 -nodes -days 3650 -subj "/CN=electrum.example.com" \ - -keyout privkey.pem -out fullchain.pem - ``` -2. Create `docker-compose.override.yml` next to `docker-compose.yml` (compose reads it automatically): +### What happens at start + +`docker-entrypoint.sh` (the entrypoint of the image) does this at every start: + +1. If you gave your own certificate (`SSL_CERTFILE` and `SSL_KEYFILE`, see below), it uses that. +2. Otherwise it looks for `/var/lib/electrumx-ssl/server.crt` and `server.key`, in the `electrumx-ssl` volume. + - **They exist: they are used as they are, never overwritten.** + - **They do not exist (first start): a self-signed certificate valid 10 years is generated** for `SSL_HOSTNAME` + and saved there. If only one of the two files exists, the container stops with an error instead of overwriting it. +3. It prints the certificate subject, expiry date and SHA-256 fingerprint in the log + (`docker compose logs electrumx | grep entrypoint`), and warns if it expires in less than 30 days. +4. If `SERVICES` is not set, it sets it to `tcp://:50001,ssl://:50002`. + +The compose file publishes 50002 (`ELECTRUMX_SSL_PORT`). Settings, all in `.env`: + +| Variable | Default | Meaning | +| --- | --- | --- | +| `SSL_ENABLED` | `true` | `false`: no SSL, only `tcp://:50001` | +| `SSL_HOSTNAME` | `electrumx` | name (or IP) written in a generated certificate; set it to the name of the server (it matters only when the certificate is generated) | +| `SSL_CERTFILE`, `SSL_KEYFILE` | unset | your own certificate and key (paths inside the container); both or neither | +| `ELECTRUMX_SSL_PORT` | `50002` | host port published for SSL | + +### Self-signed certificate: what clients see + +A generated certificate is signed by nobody. Electrum wallets handle it by *trust on first use*: at the first connection +the wallet saves the certificate, and from then on it refuses a different one (as SSH does with host keys), so a +man-in-the-middle cannot take over later. The fields of the certificate (name, country, ...) are not checked by the +client, only the expiry date. Consequences: + +- **Keep the certificate.** It is in the `electrumx-ssl` volume, separate from the database on purpose: the database + can be deleted and rebuilt at any time (`docker volume rm _electrumx-db`), the certificate should not be. + `docker compose down -v` deletes **both** volumes, and after that a new certificate is generated and every client + that saved the old one refuses to connect. +- **Back it up** while the container runs: + ```bash + docker compose cp electrumx:/var/lib/electrumx-ssl ./ssl-backup # contains the private key: keep it private + ``` + To restore it, put the two files somewhere on the host and use them as "your own certificate" below. +- If it expires or is lost, clients that saved it must be told to forget it, or the server must get a new name. + Generated certificates last 10 years, so expiry is rarely the problem. +- Whether a wallet accepts a self-signed certificate depends on the wallet. Electrum does (trust on first use); + check the wallet you intend to use. + +### Your own certificate (for example Let's Encrypt) + +With a certificate signed by a CA the client verifies it by itself, with no trust on first use, but it needs a domain +and must be renewed (about every 90 days). + +1. Get the certificate and key (`certbot certonly --standalone -d electrum.example.com`). + Copy the files out of `/etc/letsencrypt/live/...` (they are symlinks into `archive/`) to a folder, and copy them + again after each renewal. The container runs as uid 1000, so the key must be readable by it + (`chown 1000 privkey.pem && chmod 600 privkey.pem`). +2. Mount the folder with a `docker-compose.override.yml` next to `docker-compose.yml` (compose reads it automatically): ```yaml services: electrumx: - environment: - SERVICES: tcp://:50001,ssl://:50002 - SSL_CERTFILE: /certs/fullchain.pem - SSL_KEYFILE: /certs/privkey.pem - ports: - - "50002:50002" volumes: - /path/to/certs:/certs:ro ``` - The container runs as uid 1000: the key must be readable by it (`chown 1000 privkey.pem; chmod 600 privkey.pem`). - With Let's Encrypt, copy the files out of `/etc/letsencrypt/live/...` (they are symlinks into `archive/`), and - copy them again after each renewal. -3. `docker compose up -d`, and open 50002 as in "Ports". +3. In `.env`: + ```ini + SSL_CERTFILE=/certs/fullchain.pem + SSL_KEYFILE=/certs/privkey.pem + ``` +4. `docker compose up -d`. After a renewal, `docker compose restart electrumx` loads the new files. + +If the files cannot be read, the container stops with a message that says so (see `docker compose logs electrumx`). + +### Check it + +```bash +echo | openssl s_client -connect 127.0.0.1:50002 2>/dev/null | openssl x509 -noout -subject -enddate -fingerprint -sha256 +printf '{"id":1,"method":"server.version","params":["test","1.4"]}\n' | openssl s_client -quiet -connect 127.0.0.1:50002 +``` + +The fingerprint must be the one printed in the log. Open 50002 as in "Ports" for a public server. ## Announcing a public server @@ -160,3 +214,5 @@ From another machine, `nc -zv 50001` must succeed if the server is m | `blockchain.outpoint.subscribe` or package broadcast return a daemon error | expected: Palladium Core is based on Bitcoin Core 0.19 and does not have these RPCs | | the server stays at an old height | the node is not synced; ElectrumX only follows the node | | clients cannot connect from outside | port not published/forwarded, firewall, or `SERVICES` does not list that protocol | +| container restarts at once, log says `ERROR: ... SSL_CERTFILE` | your own certificate is not mounted or not readable by uid 1000, only one of `SSL_CERTFILE`/`SSL_KEYFILE` is set, or only one of the generated files exists in the `electrumx-ssl` volume | +| a wallet refuses the server after it worked | the certificate changed (for example after `docker compose down -v`); the wallet saved the old one | diff --git a/Dockerfile b/Dockerfile index 6ca3860..e465c19 100644 --- a/Dockerfile +++ b/Dockerfile @@ -24,14 +24,14 @@ RUN python -m venv venv \ FROM python:3.14-slim-trixie -# the libs needed by rocksdb (no development headers or statics) -RUN apt-get update && apt-get -y --no-install-recommends install librocksdb9.10 && rm -rf /var/lib/apt/lists/* +# the libs needed by rocksdb (no development headers or statics), and openssl to generate the SSL certificate +RUN apt-get update && apt-get -y --no-install-recommends install librocksdb9.10 openssl && rm -rf /var/lib/apt/lists/* ENV COIN=Palladium ENV NET=mainnet ENV DB_ENGINE=rocksdb ENV DB_DIRECTORY=/var/lib/electrumx -ENV SERVICES="tcp://:50001" +# SERVICES is set by docker-entrypoint.sh (tcp://:50001 and, with SSL, ssl://:50002), see there for the SSL settings. # set at run time, see docker-compose.yml: #ENV DAEMON_URL="http://user:password@palladiumd:2332/" #ENV CACHE_MB=2000 @@ -39,14 +39,18 @@ ENV SERVICES="tcp://:50001" WORKDIR /usr/src/app COPY --from=builder /usr/src/app/venv ./venv +COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh -# the data directory is created here so that an empty volume is owned by the electrumx user +# the data directories are created here so that empty volumes are owned by the electrumx user RUN useradd --create-home --uid 1000 electrumx \ - && mkdir -p "$DB_DIRECTORY" && chown electrumx:electrumx "$DB_DIRECTORY" + && mkdir -p "$DB_DIRECTORY" /var/lib/electrumx-ssl \ + && chown electrumx:electrumx "$DB_DIRECTORY" /var/lib/electrumx-ssl USER electrumx VOLUME /var/lib/electrumx +VOLUME /var/lib/electrumx-ssl -# 50001: Electrum protocol over TCP. -EXPOSE 50001 +# 50001: Electrum protocol over TCP, 50002: over SSL. +EXPOSE 50001 50002 +ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"] CMD ["/usr/src/app/venv/bin/electrumx_server"] diff --git a/README.md b/README.md index 6ac371e..5fc37e9 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,9 @@ as `palladiumd:2332`. RPC and ZMQ of the node are not published on the host, so The first start builds the database from the node from genesis, which takes a while; follow it with `docker compose logs -f electrumx`. The database is the `electrumx-db` volume. Electrum clients connect to -TCP port 50001 (`ELECTRUMX_TCP_PORT` in `.env`). To announce a public server to the network, set +TCP port 50001 (`ELECTRUMX_TCP_PORT` in `.env`) and SSL port 50002 (`ELECTRUMX_SSL_PORT`). SSL is on by default: +a self-signed certificate is generated on the first start, kept in the `electrumx-ssl` volume and reused on every +later start (or use your own, see [DEPLOY.md](DEPLOY.md)). To announce a public server to the network, set `REPORT_SERVICES` in `.env`. Settings are in `.env` and `docker-compose.yml`; the image defaults are in the `Dockerfile`. Ports, firewall, SSL, RPC credentials and troubleshooting are in [DEPLOY.md](DEPLOY.md). diff --git a/docker-compose.yml b/docker-compose.yml index 5b76564..5a05935 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -11,15 +11,24 @@ services: CACHE_MB: ${CACHE_MB:-1200} # address announced to other servers and clients, if this server is public (tcp://host:50001) REPORT_SERVICES: ${REPORT_SERVICES:-} + # SSL: on by default, with a self-signed certificate generated on the first start (docker-entrypoint.sh) + SSL_ENABLED: ${SSL_ENABLED:-true} + SSL_HOSTNAME: ${SSL_HOSTNAME:-electrumx} + # your own certificate instead (paths inside the container, mount the files with an override file) + SSL_CERTFILE: ${SSL_CERTFILE:-} + SSL_KEYFILE: ${SSL_KEYFILE:-} ports: - "${ELECTRUMX_TCP_PORT:-50001}:50001" + - "${ELECTRUMX_SSL_PORT:-50002}:50002" volumes: - electrumx-db:/var/lib/electrumx + - electrumx-ssl:/var/lib/electrumx-ssl # the certificate: keep it, clients pin it stop_grace_period: 2m # lets the server flush the db on shutdown networks: [palladium] volumes: electrumx-db: + electrumx-ssl: networks: palladium: diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh new file mode 100755 index 0000000..9006e82 --- /dev/null +++ b/docker-entrypoint.sh @@ -0,0 +1,64 @@ +#!/bin/sh +# Entrypoint of the plm-electrumx image: sets up SSL, then runs the server (the command given to the container). +# +# SSL_ENABLED true (default) / false. If true, the server also listens on ssl://:50002. +# SSL_HOSTNAME name (or IP) written in a generated certificate. Default: electrumx. +# SSL_CERTFILE, SSL_KEYFILE +# paths of your own certificate and key (mounted in the container). If unset, the files +# /var/lib/electrumx-ssl/server.crt and server.key are used; they are generated on the first +# start (self-signed, 10 years) and reused on every later start, never overwritten. +# SERVICES if unset: tcp://:50001, plus ssl://:50002 when SSL is enabled. If set, it is left as it is. +set -eu + +SSL_DIR=/var/lib/electrumx-ssl +SSL_ENABLED="${SSL_ENABLED:-true}" +SSL_HOSTNAME="${SSL_HOSTNAME:-electrumx}" + +log() { echo "entrypoint: $*"; } + +case "$SSL_ENABLED" in + true|TRUE|True|1|yes|on) + if [ -n "${SSL_CERTFILE:-}" ] || [ -n "${SSL_KEYFILE:-}" ]; then + if [ -z "${SSL_CERTFILE:-}" ] || [ -z "${SSL_KEYFILE:-}" ]; then + log "ERROR: set both SSL_CERTFILE and SSL_KEYFILE, or neither" >&2; exit 1 + fi + if [ ! -r "$SSL_CERTFILE" ] || [ ! -r "$SSL_KEYFILE" ]; then + log "ERROR: cannot read $SSL_CERTFILE / $SSL_KEYFILE (is it mounted, and readable by uid $(id -u)?)" >&2; exit 1 + fi + log "using the SSL certificate $SSL_CERTFILE" + else + export SSL_CERTFILE="$SSL_DIR/server.crt" + export SSL_KEYFILE="$SSL_DIR/server.key" + if [ -e "$SSL_CERTFILE" ] && [ -e "$SSL_KEYFILE" ]; then + log "using the existing SSL certificate $SSL_CERTFILE" + elif [ -e "$SSL_CERTFILE" ] || [ -e "$SSL_KEYFILE" ]; then + log "ERROR: only one of $SSL_CERTFILE and $SSL_KEYFILE exists; not overwriting it. Fix or remove it." >&2; exit 1 + else + case "$SSL_HOSTNAME" in + *[!0-9.]*) san="DNS:$SSL_HOSTNAME" ;; + *) san="IP:$SSL_HOSTNAME" ;; + esac + log "generating a self-signed SSL certificate for $SSL_HOSTNAME (valid 10 years) in $SSL_DIR" + openssl req -x509 -newkey rsa:2048 -nodes -days 3650 \ + -subj "/CN=$SSL_HOSTNAME" -addext "subjectAltName=$san" \ + -keyout "$SSL_KEYFILE" -out "$SSL_CERTFILE" 2>/dev/null + chmod 600 "$SSL_KEYFILE" + fi + fi + log "certificate: $(openssl x509 -in "$SSL_CERTFILE" -noout -subject -enddate | tr '\n' ' ')" + log "$(openssl x509 -in "$SSL_CERTFILE" -noout -fingerprint -sha256)" + if ! openssl x509 -in "$SSL_CERTFILE" -noout -checkend 2592000 >/dev/null; then + log "WARNING: the SSL certificate expires in less than 30 days or is already expired" + fi + export SERVICES="${SERVICES:-tcp://:50001,ssl://:50002}" + ;; + *) + # empty values would be read by the server as "set" + unset SSL_CERTFILE SSL_KEYFILE 2>/dev/null || true + export SERVICES="${SERVICES:-tcp://:50001}" + log "SSL disabled" + ;; +esac + +log "SERVICES=$SERVICES" +exec "$@"