Files
57_Wolve 7faa9098de feat: unified launcher, multi-OS hardening, login alerts & auto-updates
Restructure around a single entry point (automations.sh) with a Gum wizard and
a self-extracting bundle for repo-less installs. Add scripts/oslib.sh so the
provisioning scripts (setup-host, harden-ssh, harden-jumphost, sshuser) run on
Alpine/Debian/Alma; seed root keys from globals/.

- ntfy SSH-login alerts (user, source IP, key, region, jump target) via pam_exec
- daily auto-updates with AUTO_REBOOT=idle (reboots only when no SSH active) and
  opt-in Alpine stable-branch upgrades
- generic + per-deployment cloud-init; Gitea release workflow on tag
- README/LICENSE/.gitignore/.gitattributes (force LF); repo URLs -> Gitea
2026-06-12 14:56:02 -05:00

542 lines
16 KiB
Bash

#!/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 <<JSON
{
"description": "SimpleX relay base policy: deny-all on internet zone",
"variable": { "wan_if": "${WAN_IFACE}" },
"zone": { "internet": { "iface": "\$wan_if" } },
"policy": [
{ "in": "internet", "action": "drop" },
{ "action": "accept" }
]
}
JSON
cat > /etc/awall/optional/ssh.json <<JSON
{
"description": "Allow SSH on tcp/${SSH_PORT}, rate-limited",
"filter": [
{
"in": "internet",
"out": "_fw",
"service": { "proto": "tcp", "port": ${SSH_PORT} },
"action": "accept",
"conn-limit": { "count": 5, "interval": 60 }
}
]
}
JSON
cat > /etc/awall/optional/web.json <<JSON
{
"description": "Allow HTTP/HTTPS for Caddy info pages and ACME",
"filter": [
{ "in": "internet", "out": "_fw", "service": "http", "action": "accept" },
{ "in": "internet", "out": "_fw", "service": "https", "action": "accept" }
]
}
JSON
cat > /etc/awall/optional/simplex.json <<JSON
{
"description": "SimpleX SMP (5223) and XFTP (5443) protocol ports",
"filter": [
{ "in": "internet", "out": "_fw",
"service": { "proto": "tcp", "port": 5223 }, "action": "accept" },
{ "in": "internet", "out": "_fw",
"service": { "proto": "tcp", "port": 5443 }, "action": "accept" }
]
}
JSON
cat > /etc/awall/optional/icmp.json <<JSON
{
"description": "Allow rate-limited ICMP echo (ping)",
"filter": [
{
"in": "internet",
"service": "ping",
"action": "accept",
"flow-limit": { "count": 10, "interval": 6 }
}
]
}
JSON
awall enable base ssh web simplex icmp
awall translate --output /etc/iptables 2>/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 <<EOF
DOMAIN=${DOMAIN}
ACME_EMAIL=${ACME_EMAIL}
XFTP_QUOTA=${XFTP_QUOTA}
KEY_TYPE=${KEY_TYPE}
SMP_PASS=${SMP_PASS}
XFTP_PASS=${XFTP_PASS}
CERT_PATH=${CERT_PATH}
EOF
chmod 600 .env
cat > 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 <<EOF
{
email ${ACME_EMAIL}
}
http://smp.${DOMAIN} {
redir https://smp.${DOMAIN}{uri} permanent
}
smp.${DOMAIN}:8443 {
tls { key_type ${KEY_TYPE} }
reverse_proxy smp-server:8000
}
http://xftp.${DOMAIN} {
redir https://xftp.${DOMAIN}{uri} permanent
}
xftp.${DOMAIN}:8443 {
tls { key_type ${KEY_TYPE} }
reverse_proxy xftp-server:8000
}
EOF
fi
'
environment:
DOMAIN: ${DOMAIN:?}
ACME_EMAIL: ${ACME_EMAIL:?}
KEY_TYPE: ${KEY_TYPE:-rsa4096}
volumes:
- ./caddy_conf:/etc/caddy
restart: "no"
caddy:
image: caddy:2-alpine
depends_on:
caddy-init:
condition: service_completed_successfully
cap_add:
- NET_ADMIN
ports:
- "80:80"
- "443:8443"
volumes:
- ./caddy_conf:/etc/caddy
- caddy_data:/data
- caddy_config:/config
restart: unless-stopped
healthcheck:
test: >
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:-<not yet available>}"
echo " clearnet: smp.${DOMAIN}"
echo " onion: ${SMP_ONION:-<not yet available>}"
echo " full addr: $(build_addr smp "$SMP_FP" "smp.${DOMAIN}" "$SMP_ONION")"
echo
echo "=== XFTP ==="
echo " fingerprint: ${XFTP_FP:-<not yet available>}"
echo " clearnet: xftp.${DOMAIN}"
echo " onion: ${XFTP_ONION:-<not yet available>}"
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 <<EOF
NEXT STEPS (do these now, not later):
1. BACK UP CA KEYS (these prove server identity if you ever need to rotate):
$INSTALL_DIR/smp_configs/ca.key
$INSTALL_DIR/xftp_configs/ca.key
Copy them off this host, then delete the on-host copies:
rm $INSTALL_DIR/smp_configs/ca.key
rm $INSTALL_DIR/xftp_configs/ca.key
2. BACK UP TOR HIDDEN SERVICE KEYS (preserve the .onion across reinstalls):
$INSTALL_DIR/smp_tor/hs_ed25519_secret_key
$INSTALL_DIR/xftp_tor/hs_ed25519_secret_key
3. Add the server addresses above to your SimpleX app:
Settings -> Network & Servers -> SMP/XFTP servers -> Add
4. Verify the firewall is doing its job:
awall list
iptables -L -n
5. Watch the stack:
cd $INSTALL_DIR && docker compose logs -f
EOF