#!/usr/bin/env bash # # deploy-simplex.sh # # Single-file deployment of SimpleX SMP + XFTP + Tor hidden services on a # fresh Alpine Linux install, behind awall. # # Targets Alpine 3.19+. Run as root on a machine where: # - smp.${DOMAIN} and xftp.${DOMAIN} both resolve here (A/AAAA records set) # - SSH is your only existing access (the script preserves whatever port # you specify in SSH_PORT) # # Usage: # 1. Copy this file onto the host: scp deploy-simplex.sh root@host:/root/ # 2. Edit the CONFIG block below (or pass via env vars) # 3. Run: bash deploy-simplex.sh # # What it does: # 1. Enables community repo, updates apk # 2. Installs docker, docker-cli-compose, awall, iptables, curl, openrc bits # 3. Configures awall: deny-all default + ssh + http(80) + https(443) + # smp(5223) + xftp(5443), with rate-limited SSH # 4. Writes docker-compose.yml + Caddyfile generator + Tor configs into /opt/simplex # 5. Starts the stack # 6. Prints the multi-host SimpleX server addresses once Tor publishes # # What it does NOT do (intentional): # - SSH hardening beyond keeping your existing port. Set up keys yourself # before running this. The script does not change sshd_config. # - Backups of CA keys -- it tells you to do that, you do it. # - Auto-update of containers. Use `docker compose pull && up -d` periodically # or add Watchtower if you want that. set -euo pipefail # ============================================================================ # CONFIG -- edit these or override via env vars # ============================================================================ : "${DOMAIN:=example.com}" # apex domain; uses smp.$DOMAIN and xftp.$DOMAIN : "${ACME_EMAIL:=admin@example.com}" # for Let's Encrypt : "${XFTP_QUOTA:=50gb}" # disk quota for XFTP file storage : "${KEY_TYPE:=rsa4096}" # Caddy TLS key: rsa4096|rsa2048|p384|p256 # Note: ed25519 NOT supported by Let's Encrypt : "${SMP_PASS:=}" # optional: queue creation password : "${XFTP_PASS:=}" # optional: file upload password : "${SSH_PORT:=22}" # whatever your sshd is listening on : "${INSTALL_DIR:=/opt/simplex}" # where everything lives : "${WAN_IFACE:=}" # autodetected if blank : "${CERT_PATH:=acme-v02.api.letsencrypt.org-directory}" # use acme-staging-v02.api.letsencrypt.org-directory # to test without burning rate limits # ============================================================================ log() { printf '\033[1;32m[+]\033[0m %s\n' "$*"; } warn() { printf '\033[1;33m[!]\033[0m %s\n' "$*" >&2; } die() { printf '\033[1;31m[x]\033[0m %s\n' "$*" >&2; exit 1; } [[ $EUID -eq 0 ]] || die "Run as root." [[ -f /etc/alpine-release ]] || die "This script targets Alpine Linux." if [[ -z "$WAN_IFACE" ]]; then WAN_IFACE=$(ip -o -4 route show default | awk '{print $5; exit}') [[ -n "$WAN_IFACE" ]] || die "Could not autodetect WAN interface; set WAN_IFACE." fi log "WAN interface: $WAN_IFACE" log "Domain: $DOMAIN" log "Install dir: $INSTALL_DIR" # ---------------------------------------------------------------------------- # 1. Repos + packages # ---------------------------------------------------------------------------- log "Enabling community repo and updating apk..." ALPINE_VER=$(cut -d. -f1,2 < /etc/alpine-release) if ! grep -qE "^https?://.+/v${ALPINE_VER}/community" /etc/apk/repositories; then sed -i -E "s|^#(https?://.+/v${ALPINE_VER}/community)|\1|" /etc/apk/repositories || true if ! grep -qE "^https?://.+/v${ALPINE_VER}/community" /etc/apk/repositories; then # Fallback: append the standard community URL based on existing main mirror MAIN_MIRROR=$(awk '/main$/ {print; exit}' /etc/apk/repositories \ | sed -E "s|/v${ALPINE_VER}/main|/v${ALPINE_VER}/community|") [[ -n "$MAIN_MIRROR" ]] && echo "$MAIN_MIRROR" >> /etc/apk/repositories fi fi apk update -q apk upgrade -q log "Installing packages..." apk add -q \ docker docker-cli-compose \ awall iptables ip6tables \ curl bash openssl \ ca-certificates # ---------------------------------------------------------------------------- # 2. Kernel modules + iptables/awall # ---------------------------------------------------------------------------- log "Loading iptables kernel modules..." modprobe -q ip_tables || true modprobe -q ip6_tables || true modprobe -q iptable_nat || true modprobe -q iptable_filter || true log "Enabling iptables/ip6tables/docker at boot..." rc-update add iptables default rc-update add ip6tables default rc-update add docker default log "Writing awall policies..." mkdir -p /etc/awall/optional /etc/awall/private cat > /etc/awall/optional/base.json < /etc/awall/optional/ssh.json < /etc/awall/optional/web.json < /etc/awall/optional/simplex.json < /etc/awall/optional/icmp.json </dev/null || true awall translate --output /etc/ip6tables 2>/dev/null || true awall activate --force rc-service iptables restart || rc-service iptables start rc-service ip6tables restart || rc-service ip6tables start # ---------------------------------------------------------------------------- # 3. Sysctl hardening (modest) # ---------------------------------------------------------------------------- log "Applying minimal sysctl hardening..." cat > /etc/sysctl.d/90-simplex.conf <<'EOF' # IP spoof protection net.ipv4.conf.all.rp_filter = 1 net.ipv4.conf.default.rp_filter = 1 # Don't forward unless this is a router (Docker re-enables forwarding for the # docker0 bridge specifically, which is fine) net.ipv4.ip_forward = 0 # Ignore source-routed packets, ICMP redirects, broadcasts net.ipv4.conf.all.accept_source_route = 0 net.ipv4.conf.all.accept_redirects = 0 net.ipv4.conf.all.send_redirects = 0 net.ipv4.icmp_echo_ignore_broadcasts = 1 net.ipv4.icmp_ignore_bogus_error_responses = 1 # SYN flood protection net.ipv4.tcp_syncookies = 1 EOF sysctl -p /etc/sysctl.d/90-simplex.conf >/dev/null # ---------------------------------------------------------------------------- # 4. Start docker # ---------------------------------------------------------------------------- log "Starting docker..." rc-service docker status >/dev/null 2>&1 || rc-service docker start # Wait for socket for _ in $(seq 1 20); do [[ -S /var/run/docker.sock ]] && break sleep 1 done [[ -S /var/run/docker.sock ]] || die "Docker socket didn't appear." # ---------------------------------------------------------------------------- # 5. Lay out /opt/simplex # ---------------------------------------------------------------------------- log "Writing compose stack to ${INSTALL_DIR}..." mkdir -p "$INSTALL_DIR"/tor_conf cd "$INSTALL_DIR" cat > .env < docker-compose.yml <<'YAML' name: simplex services: caddy-init: image: alpine:latest command: > sh -c ' if [ ! -f /etc/caddy/Caddyfile ]; then cat > /etc/caddy/Caddyfile < sh -c ' test -d /data/caddy/certificates/${CERT_PATH:-acme-v02.api.letsencrypt.org-directory}/smp.${DOMAIN} && test -d /data/caddy/certificates/${CERT_PATH:-acme-v02.api.letsencrypt.org-directory}/xftp.${DOMAIN} ' interval: 5s timeout: 3s retries: 60 start_period: 30s environment: DOMAIN: ${DOMAIN} CERT_PATH: ${CERT_PATH:-acme-v02.api.letsencrypt.org-directory} smp-server: image: simplexchat/smp-server:latest depends_on: caddy: condition: service_healthy environment: ADDR: smp.${DOMAIN} PASS: ${SMP_PASS:-} WEB_MANUAL: "0" volumes: - ./smp_configs:/etc/opt/simplex - ./smp_state:/var/opt/simplex - type: volume source: caddy_data target: /certificates read_only: true volume: subpath: "caddy/certificates/${CERT_PATH:-acme-v02.api.letsencrypt.org-directory}/smp.${DOMAIN}" ports: - "5223:5223" restart: unless-stopped smp-tor: image: dperson/torproxy:latest depends_on: - smp-server network_mode: "service:smp-server" volumes: - ./smp_tor:/var/lib/tor/hidden_service - ./tor_conf/smp-torrc:/etc/tor/torrc:ro restart: unless-stopped xftp-server: image: simplexchat/xftp-server:latest depends_on: caddy: condition: service_healthy environment: ADDR: xftp.${DOMAIN} QUOTA: ${XFTP_QUOTA:?} PASS: ${XFTP_PASS:-} WEB_MANUAL: "0" volumes: - ./xftp_configs:/etc/opt/simplex-xftp - ./xftp_state:/var/opt/simplex-xftp - ./xftp_files:/srv/xftp - type: volume source: caddy_data target: /certificates read_only: true volume: subpath: "caddy/certificates/${CERT_PATH:-acme-v02.api.letsencrypt.org-directory}/xftp.${DOMAIN}" ports: - "5443:443" restart: unless-stopped xftp-tor: image: dperson/torproxy:latest depends_on: - xftp-server network_mode: "service:xftp-server" volumes: - ./xftp_tor:/var/lib/tor/hidden_service - ./tor_conf/xftp-torrc:/etc/tor/torrc:ro restart: unless-stopped volumes: caddy_data: caddy_config: YAML cat > tor_conf/smp-torrc <<'EOF' # Single-hop / non-anonymous mode: lower latency. The SERVER's location is # already public via clearnet, so hiding it is moot. Clients remain fully # anonymous to us. Drop these three lines for a tor-only relay where the # server's location should also be hidden. SOCKSPort 0 HiddenServiceNonAnonymousMode 1 HiddenServiceSingleHopMode 1 HiddenServiceDir /var/lib/tor/hidden_service/ HiddenServicePort 5223 127.0.0.1:5223 HiddenServicePort 443 127.0.0.1:8000 Log notice stdout EOF cat > tor_conf/xftp-torrc <<'EOF' SOCKSPort 0 HiddenServiceNonAnonymousMode 1 HiddenServiceSingleHopMode 1 HiddenServiceDir /var/lib/tor/hidden_service/ HiddenServicePort 443 127.0.0.1:443 HiddenServicePort 8443 127.0.0.1:8000 Log notice stdout EOF # ---------------------------------------------------------------------------- # 6. Helper script for printing addresses post-bringup # ---------------------------------------------------------------------------- cat > print-addresses.sh <<'BASH' #!/usr/bin/env bash set -euo pipefail cd "$(dirname "$0")" # shellcheck disable=SC1091 source .env get_fp() { docker compose logs "$1" 2>/dev/null \ | grep -m1 'Server address:' \ | sed -E 's|.*://([^@]+)@.*|\1|' } read_or_blank() { [[ -f "$1" ]] && cat "$1" || echo ""; } SMP_FP=$(get_fp smp-server) XFTP_FP=$(get_fp xftp-server) SMP_ONION=$(read_or_blank ./smp_tor/hostname) XFTP_ONION=$(read_or_blank ./xftp_tor/hostname) build_addr() { local scheme=$1 fp=$2 clearnet=$3 onion=$4 local hosts="$clearnet" [[ -n "$onion" ]] && hosts="${hosts},${onion}" echo "${scheme}://${fp}@${hosts}" } echo echo "=== SMP ===" echo " fingerprint: ${SMP_FP:-}" echo " clearnet: smp.${DOMAIN}" echo " onion: ${SMP_ONION:-}" echo " full addr: $(build_addr smp "$SMP_FP" "smp.${DOMAIN}" "$SMP_ONION")" echo echo "=== XFTP ===" echo " fingerprint: ${XFTP_FP:-}" echo " clearnet: xftp.${DOMAIN}" echo " onion: ${XFTP_ONION:-}" echo " full addr: $(build_addr xftp "$XFTP_FP" "xftp.${DOMAIN}" "$XFTP_ONION")" echo BASH chmod +x print-addresses.sh # ---------------------------------------------------------------------------- # 7. OpenRC service for the compose stack itself # ---------------------------------------------------------------------------- log "Creating openrc service for the stack..." cat > /etc/init.d/simplex <<'EOF' #!/sbin/openrc-run name="simplex" description="SimpleX SMP + XFTP + Tor docker compose stack" directory="/opt/simplex" depend() { need docker after net firewall } start() { ebegin "Starting SimpleX stack" cd "$directory" docker compose up -d eend $? } stop() { ebegin "Stopping SimpleX stack" cd "$directory" docker compose down eend $? } status() { cd "$directory" docker compose ps } EOF chmod +x /etc/init.d/simplex rc-update add simplex default # ---------------------------------------------------------------------------- # 8. Bring it up # ---------------------------------------------------------------------------- log "Pulling images and starting the stack..." cd "$INSTALL_DIR" docker compose pull docker compose up -d log "Waiting up to 90s for Tor to publish hidden services..." for _ in $(seq 1 90); do if [[ -f "$INSTALL_DIR/smp_tor/hostname" && -f "$INSTALL_DIR/xftp_tor/hostname" ]]; then break fi sleep 1 done # ---------------------------------------------------------------------------- # 9. Final report # ---------------------------------------------------------------------------- echo echo "================================================================" echo " SimpleX deployment complete" echo "================================================================" "$INSTALL_DIR/print-addresses.sh" || true cat <