wren-linux / tools / setup-vps.sh
#!/bin/bash
# setup-vps.sh: sets up a fresh Debian 13 VPS to serve Wren Linux's website
# (or any static site), the careful way, and can be run again to repair or
# change it. Copy it to the VPS and run it there as root:
# scp tools/setup-vps.sh root@SERVER: then ssh -t root@SERVER bash setup-vps.sh
# It asks what it needs, explaining each choice, or takes it from a file of
# VAR=value lines (--config FILE; the variables are below). Afterwards it is
# installed as wren-vps (sudo wren-vps --confirm, --status, ...).
#
# - You, not root: a user of yours (VPS_ADMIN) who logs in with your SSH key
# only; sudo asks for that user's password (for the provider's web console,
# the way in when SSH can't be used). No passwords and no root over SSH.
# - A firewall (nftables) that lets in only SSH and the web, SSH slowed down
# against guessing.
# - The web: Caddy, serving plain files from /srv/wren-site, with HTTPS
# certificates fetched and renewed by itself when there is a domain
# (VPS_DOMAIN), strict security headers (no scripts, frames or outside
# resources), and no log of visitors. Caddy runs locked down by systemd.
# - Tor onion services: the site at an onion address (VPS_ONION_SITE), and
# SSH at a second one (VPS_ONION_SSH), which only a key of yours can even
# find (client authorization, VPS_ONION_SSH_CLIENT). Once that works,
# wren-vps --ssh-onion-only closes port 22 to the internet.
# - Debian's security updates every day, with a reboot at night when one
# needs it; kernel and network hardening (sysctl).
# - Never locked out: the SSH and firewall changes undo themselves after
# VPS_CONFIRM_MINUTES unless you confirm them from a second, new login
# (wren-vps --confirm), and at the next boot if the server restarts before
# that. Keep the first session open until then.
# - A read-only git mirror at /git/ (pushed by tools/publish-site.sh).
#
# Variables (asked when not set; --yes takes the defaults, and needs the key):
# VPS_ADMIN your user's name (wren)
# VPS_SSH_KEY your SSH public key: the line itself, or a file holding it
# VPS_DOMAIN the site's domain, e.g. wrenlinux.org (none: no HTTPS
# yet, the site on plain http, for a test VM)
# VPS_EMAIL for the HTTPS certificate's notices (optional)
# VPS_ONION_SITE yes or no (yes)
# VPS_ONION_SSH yes or no (yes)
# VPS_ONION_SSH_CLIENT the public key from tools/vps-client.sh --keygen (optional:
# without it, anyone who learns the onion address can reach SSH)
# VPS_CONFIRM_MINUTES how long a change waits for --confirm (10)
# VPS_REBOOT_TIME when to reboot after an update that needs it (04:00)
# Usage: bash setup-vps.sh [--config FILE] [--yes]
# wren-vps --confirm | --ssh-onion-only | --ssh-public | --status
set -euo pipefail
umask 022
say() { printf '\033[1;32m==>\033[0m %s\n' "$*"; }
note() { printf ' %s\n' "$*"; }
warn() { printf '\033[1;33m!!\033[0m %s\n' "$*" >&2; }
die() { printf '\033[1;31mxx\033[0m %s\n' "$*" >&2; exit 1; }
# (a test points these at a folder and a log: nothing is run, only written down)
ROOT=${WREN_VPS_ROOT:-}
FAKE=${WREN_VPS_FAKE:-}
run() {
if [ -n "$FAKE" ]; then echo "$*" >>"$FAKE"; return 0; fi
"$@"
}
CONF=$ROOT/etc/wren-vps.conf
STATE=$ROOT/var/lib/wren-vps
BACKUP=$STATE/rollback
SSHD_DROP=$ROOT/etc/ssh/sshd_config.d/10-wren-vps.conf
NFT=$ROOT/etc/nftables.conf
TORRC=$ROOT/etc/tor/torrc
CADDYFILE=$ROOT/etc/caddy/Caddyfile
SITE=/srv/wren-site
GITDIR=/srv/wren-git
IMAGEDIR=/srv/wren-images
SELF=/usr/local/sbin/wren-vps
ROLLBACK=/usr/local/sbin/wren-vps-rollback
BOOT_UNIT=$ROOT/etc/systemd/system/wren-vps-undo-unconfirmed.service
[ -n "$FAKE" ] || [ "$(id -u)" -eq 0 ] || die "run it as root (sudo bash $0)."
# ---- questions: from the environment, the config file, or asked ----
interactive() { [ -z "${WREN_VPS_YES:-}" ] && { : </dev/tty; } 2>/dev/null; }
# ask_value VAR DEFAULT "Question" EXPLANATION...: keeps VAR when it is set
ask_value() {
local var=$1 def=$2 q=$3 a
shift 3
if [ -n "${!var:-}" ]; then return 0; fi
if interactive; then
echo
for line in "$@"; do note "$line"; done
IFS= read -r -p " $q${def:+ [$def]}: " a </dev/tty || a=
printf -v "$var" '%s' "${a:-$def}"
else
printf -v "$var" '%s' "$def"
fi
}
yes_no() { case ${!1:-} in yes | no) ;; y | Y) printf -v "$1" yes ;; n | N) printf -v "$1" no ;; *) die "$1 is yes or no, not \"${!1:-}\"." ;; esac; }
load_config() {
local f=$1 k v
[ -f "$f" ] || die "no config file $f."
while IFS='=' read -r k v; do
case $k in
'' | \#*) continue ;;
VPS_ADMIN | VPS_SSH_KEY | VPS_DOMAIN | VPS_EMAIL | VPS_ONION_SITE | VPS_ONION_SSH | VPS_ONION_SSH_CLIENT | VPS_CONFIRM_MINUTES | VPS_REBOOT_TIME | VPS_SSH_PUBLIC)
# set in the environment: that wins
if [ -z "${!k:-}" ]; then v=${v%\"}; v=${v#\"}; printf -v "$k" '%s' "$v"; fi ;;
*) die "$f: unknown setting \"$k\"." ;;
esac
done <"$f"
}
questions() {
ask_value VPS_ADMIN wren "Your user's name on the server" \
"You log in as a user of your own, never as root: root can't log in over SSH at all." \
"sudo asks for this user's password; keep it in your password manager."
[[ $VPS_ADMIN =~ ^[a-z][a-z0-9-]{0,30}$ ]] && [ "$VPS_ADMIN" != root ] ||
die "\"$VPS_ADMIN\" can't be the user's name: small letters, digits and dashes, a letter first."
ask_value VPS_SSH_KEY "" "Your SSH public key (the line from ~/.ssh/KEY.pub, or a file holding it)" \
"Logging in takes this key and nothing else: no password can be guessed, because none is accepted." \
"Make one on your own computer: ssh-keygen -t ed25519 -f ~/.ssh/wren-vps (then paste ~/.ssh/wren-vps.pub)"
if [ -f "$VPS_SSH_KEY" ]; then VPS_SSH_KEY=$(head -n1 "$VPS_SSH_KEY"); fi
[[ $VPS_SSH_KEY =~ ^(ssh-ed25519|sk-ssh-ed25519@openssh\.com|ecdsa-sha2-nistp(256|384|521)|ssh-rsa)\ [A-Za-z0-9+/]+={0,2}(\ [[:print:]]*)?$ ]] ||
die "That isn't an SSH public key (it starts with ssh-ed25519 ...). Nothing was changed."
ask_value VPS_DOMAIN "" "The site's domain, e.g. wrenlinux.org (Enter: none yet)" \
"With a domain, the site gets HTTPS: Caddy fetches the certificate itself and renews it." \
"Give it only when it is ready: registered (not \"pending\" at the registrar), and its A" \
"record already pointing at this server's address. Before that, the certificate can't be" \
"had, and the site would answer to the domain alone. A new record can take an hour to work." \
"Not ready yet? Press Enter: the site is on plain http at this server's address meanwhile," \
"and on your computer, once it's ready: bash tools/vps-client.sh --domain example.org"
VPS_DOMAIN=${VPS_DOMAIN,,}
# the bare name: www. is sent to it by itself (the Caddyfile below)
if [[ $VPS_DOMAIN == www.* ]]; then note "(${VPS_DOMAIN#www.}, without www.: www.${VPS_DOMAIN#www.} is sent there by itself)"; VPS_DOMAIN=${VPS_DOMAIN#www.}; fi
[ -z "$VPS_DOMAIN" ] || [[ $VPS_DOMAIN =~ ^([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$ ]] ||
die "\"$VPS_DOMAIN\" isn't a domain name."
if [ -z "$VPS_DOMAIN" ] && interactive; then
note "No domain for now. When it's ready (registered, its A record at this server), add it"
note "from your computer, in your Wren Linux copy: bash tools/vps-client.sh --domain example.org"
fi
if [ -n "$VPS_DOMAIN" ]; then
ask_value VPS_EMAIL "" "An email for the certificate's notices (Enter: none)" \
"Let's Encrypt writes here only if a certificate is about to run out. It isn't shown on the site."
[ -z "${VPS_EMAIL:-}" ] || [[ $VPS_EMAIL =~ ^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$ ]] || die "\"$VPS_EMAIL\" isn't an email address."
fi
ask_value VPS_ONION_SITE yes "The site at an onion address too? (yes/no)" \
"Tor Browser visitors then reach it without leaving Tor: nobody on the way, not even" \
"an exit relay, sees what they read. It hides the reader, not this server: the" \
"server's address is public anyway, because the site is on the normal web too."
yes_no VPS_ONION_SITE
ask_value VPS_ONION_SSH yes "SSH at an onion address of its own? (yes/no)" \
"You then log in through Tor. Once that works, port 22 can close to the whole" \
"internet: nobody can scan for it or guess at it, and the server's logs never see" \
"your home address. The provider's web console stays your way in if Tor is down."
yes_no VPS_ONION_SSH
if [ "$VPS_ONION_SSH" = yes ]; then
ask_value VPS_ONION_SSH_CLIENT "" "Your onion client key (from: bash tools/vps-client.sh --keygen; Enter: none)" \
"With it, the SSH onion address is useless to anyone without your key file: they can't" \
"even tell it exists. Without it, anyone who learns the address reaches SSH (which" \
"still takes your SSH key). Strongly recommended."
[ -z "$VPS_ONION_SSH_CLIENT" ] || [[ $VPS_ONION_SSH_CLIENT =~ ^[A-Z2-7]{52}$ ]] ||
die "That isn't an onion client key (52 capital letters and digits 2-7)."
fi
VPS_CONFIRM_MINUTES=${VPS_CONFIRM_MINUTES:-10}
[[ $VPS_CONFIRM_MINUTES =~ ^[1-9][0-9]?$ ]] || die "VPS_CONFIRM_MINUTES is 1 to 99."
VPS_REBOOT_TIME=${VPS_REBOOT_TIME:-04:00}
[[ $VPS_REBOOT_TIME =~ ^([01][0-9]|2[0-3]):[0-5][0-9]$ ]] || die "VPS_REBOOT_TIME is HH:MM."
# Port 22 on the internet: open until --ssh-onion-only closes it
VPS_SSH_PUBLIC=${VPS_SSH_PUBLIC:-yes}
yes_no VPS_SSH_PUBLIC
[ "$VPS_ONION_SSH" = yes ] || VPS_SSH_PUBLIC=yes
}
save_config() {
mkdir -p "${CONF%/*}"
(umask 077 && printf '# Written by setup-vps.sh (Wren Linux); run it again after a change\nVPS_ADMIN=%s\nVPS_SSH_KEY=%s\nVPS_DOMAIN=%s\nVPS_EMAIL=%s\nVPS_ONION_SITE=%s\nVPS_ONION_SSH=%s\nVPS_ONION_SSH_CLIENT=%s\nVPS_CONFIRM_MINUTES=%s\nVPS_REBOOT_TIME=%s\nVPS_SSH_PUBLIC=%s\n' \
"$VPS_ADMIN" "$VPS_SSH_KEY" "$VPS_DOMAIN" "${VPS_EMAIL:-}" "$VPS_ONION_SITE" "$VPS_ONION_SSH" "${VPS_ONION_SSH_CLIENT:-}" \
"$VPS_CONFIRM_MINUTES" "$VPS_REBOOT_TIME" "$VPS_SSH_PUBLIC" >"$CONF")
}
# ---- the files it writes ----
write_file() { # PATH MODE: stdin into PATH, made whole first (never half written)
mkdir -p "$(dirname "$1")"
cat >"$1.wren-new"
chmod "$2" "$1.wren-new"
mv "$1.wren-new" "$1"
}
sshd_conf() {
cat <<EOF
# Written by Wren Linux setup-vps.sh. Read before the other files here, so
# what it says wins (sshd takes the first value it finds).
PermitRootLogin no
PasswordAuthentication no
KbdInteractiveAuthentication no
PubkeyAuthentication yes
AuthenticationMethods publickey
AllowUsers $VPS_ADMIN
X11Forwarding no
AllowAgentForwarding no
AllowTcpForwarding no
PermitTunnel no
MaxAuthTries 3
# a minute to log in: over Tor, with a key's passphrase to type, half of one was too little
LoginGraceTime 60
ClientAliveInterval 300
ClientAliveCountMax 2
EOF
}
nft_conf() {
local web=''
[ -z "$VPS_DOMAIN" ] || web='tcp dport { 80, 443 } accept
udp dport 443 accept'
[ -n "$VPS_DOMAIN" ] || web='tcp dport 80 accept'
cat <<EOF
#!/usr/sbin/nft -f
# Written by Wren Linux setup-vps.sh: nothing comes in but what is listed.
flush ruleset
table inet wren_vps {
chain input {
type filter hook input priority 0; policy drop;
iif "lo" accept
ct state established,related accept
ct state invalid drop
# what IPv6 needs to work at all, and ping (limited)
meta l4proto ipv6-icmp accept
icmp type { echo-request, destination-unreachable, time-exceeded, parameter-problem } limit rate 10/second accept
$(if [ "$VPS_SSH_PUBLIC" = yes ]; then printf ' # SSH, new connections slowed down against guessing\n tcp dport 22 ct state new limit rate 10/minute burst 5 packets accept\n'; else printf ' # SSH only through its onion address (port 22 closed: wren-vps --ssh-public opens it)\n'; fi)
# the web
$web
}
chain forward {
type filter hook forward priority 0; policy drop;
}
chain output {
type filter hook output priority 0; policy accept;
}
}
EOF
}
caddy_conf() {
local onion=$1
cat <<EOF
# Written by Wren Linux setup-vps.sh. No log of visitors is kept (no "log").
{
admin off
$( [ -z "${VPS_EMAIL:-}" ] || printf ' email %s\n' "$VPS_EMAIL")
}
(wren_headers) {
header {
Content-Security-Policy "default-src 'none'; style-src 'self'; img-src 'self'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'"
X-Content-Type-Options nosniff
X-Frame-Options DENY
Referrer-Policy no-referrer
Cross-Origin-Opener-Policy same-origin
Permissions-Policy "camera=(), microphone=(), geolocation=(), interest-cohort=()"
-Server
}
}
(wren_files) {
import wren_headers
handle_path /git/* {
root * $GITDIR
file_server
}
handle_path /images/* {
root * $IMAGEDIR
file_server
}
handle {
root * $SITE
file_server
}
}
EOF
if [ -n "$VPS_DOMAIN" ]; then
cat <<EOF
$VPS_DOMAIN {
import wren_files
header Strict-Transport-Security "max-age=31536000"
$( [ -z "$onion" ] || printf ' header Onion-Location "http://%s{uri}"\n' "$onion")
}
www.$VPS_DOMAIN {
redir https://$VPS_DOMAIN{uri} permanent
}
EOF
else
cat <<'EOF'
# No domain yet: plain http, for a test VM
:80 {
import wren_files
}
EOF
fi
if [ "$VPS_ONION_SITE" = yes ]; then
cat <<'EOF'
# The onion site: Tor brings visitors here (Tor's own encryption, so http). For
# any name, as they ask for the .onion one: Caddy answers a name it wasn't told
# with an empty page. bind keeps it to this computer, where only Tor comes from.
http://:8080 {
bind 127.0.0.1
import wren_files
}
EOF
fi
}
caddy_hardening() {
cat <<'EOF'
# Written by Wren Linux setup-vps.sh: Caddy may read the site, bind the web
# ports and keep its certificates, and nothing more.
[Service]
NoNewPrivileges=yes
ProtectSystem=strict
ReadWritePaths=/var/lib/caddy
ProtectHome=yes
PrivateTmp=yes
PrivateDevices=yes
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectKernelLogs=yes
ProtectControlGroups=yes
ProtectClock=yes
ProtectHostname=yes
RestrictSUIDSGID=yes
RestrictNamespaces=yes
RestrictRealtime=yes
LockPersonality=yes
SystemCallArchitectures=native
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
CapabilityBoundingSet=CAP_NET_BIND_SERVICE
AmbientCapabilities=CAP_NET_BIND_SERVICE
EOF
}
tor_block() {
echo '# >>> Wren Linux setup-vps.sh >>>'
echo '# Onion services only: this tor is no relay and no proxy for anyone'
echo 'SocksPort 0'
if [ "$VPS_ONION_SITE" = yes ]; then
echo 'HiddenServiceDir /var/lib/tor/wren-site/'
echo 'HiddenServicePort 80 127.0.0.1:8080'
fi
if [ "$VPS_ONION_SSH" = yes ]; then
echo 'HiddenServiceDir /var/lib/tor/wren-ssh/'
echo 'HiddenServicePort 22 127.0.0.1:22'
fi
echo '# <<< Wren Linux setup-vps.sh <<<'
}
sysctl_conf() {
cat <<'EOF'
# Written by Wren Linux setup-vps.sh
kernel.kptr_restrict = 2
kernel.dmesg_restrict = 1
kernel.unprivileged_bpf_disabled = 1
net.core.bpf_jit_harden = 2
kernel.yama.ptrace_scope = 1
fs.protected_symlinks = 1
fs.protected_hardlinks = 1
fs.protected_fifos = 2
fs.protected_regular = 2
net.ipv4.tcp_syncookies = 1
net.ipv4.conf.all.rp_filter = 1
net.ipv4.conf.default.rp_filter = 1
net.ipv4.conf.all.accept_redirects = 0
net.ipv4.conf.default.accept_redirects = 0
net.ipv4.conf.all.secure_redirects = 0
net.ipv4.conf.all.send_redirects = 0
net.ipv4.conf.all.accept_source_route = 0
net.ipv6.conf.all.accept_redirects = 0
net.ipv6.conf.default.accept_redirects = 0
net.ipv6.conf.all.accept_source_route = 0
net.ipv4.icmp_echo_ignore_broadcasts = 1
EOF
}
upgrades_conf() {
cat <<EOF
// Written by Wren Linux setup-vps.sh: Debian's security updates every day,
// and a reboot at $VPS_REBOOT_TIME when one needs it
APT::Periodic::Update-Package-Lists "1";
APT::Periodic::Unattended-Upgrade "1";
Unattended-Upgrade::Automatic-Reboot "true";
Unattended-Upgrade::Automatic-Reboot-Time "$VPS_REBOOT_TIME";
Unattended-Upgrade::Remove-Unused-Dependencies "true";
EOF
}
rollback_script() {
cat <<EOF
#!/bin/sh
# Written by Wren Linux setup-vps.sh: puts the SSH and firewall settings back
# as they were, when a change wasn't confirmed in time (wren-vps --confirm).
B=/var/lib/wren-vps/rollback
[ -d "\$B" ] || exit 0
for f in sshd nft; do
case \$f in sshd) dest=/etc/ssh/sshd_config.d/10-wren-vps.conf ;; nft) dest=/etc/nftables.conf ;; esac
if [ -f "\$B/\$f.absent" ]; then rm -f "\$dest"; elif [ -f "\$B/\$f" ]; then cp -p "\$B/\$f" "\$dest"; fi
done
systemctl reload ssh || systemctl restart ssh
if [ -f /etc/nftables.conf ]; then nft -f /etc/nftables.conf; else nft flush ruleset; fi
rm -rf "\$B"
logger -t wren-vps "SSH and firewall changes rolled back: they weren't confirmed in time"
EOF
}
# The timer that undoes a change doesn't survive a restart: someone locked out
# who restarts the server from the provider's console would keep the change.
# This undoes it at the next boot instead, while it is still unconfirmed.
boot_unit() {
cat <<EOF
# Written by Wren Linux setup-vps.sh: an SSH or firewall change that wasn't
# confirmed (wren-vps --confirm) before a restart is undone at the next boot.
[Unit]
Description=Wren Linux: undo an SSH or firewall change that wasn't confirmed
ConditionPathExists=/var/lib/wren-vps/rollback
After=ssh.service nftables.service
[Service]
Type=oneshot
ExecStart=$ROLLBACK
[Install]
WantedBy=multi-user.target
EOF
}
# ---- the never-locked-out switch ----
armed() { [ -d "$BACKUP" ]; }
# session: where this login came from ("CLIENT PORT SERVER PORT"), or console.
# sudo clears SSH_CONNECTION, so it is looked up in the processes above this
# one, up to the SSH session that started them (root may read their settings).
session() {
if [ -n "${SSH_CONNECTION:-}" ]; then echo "$SSH_CONNECTION"; return; fi
local pid=$$ found
while [ "${pid:-1}" -gt 1 ]; do
# (a process this can't read is passed over; the shell opens the file, so the
# braces are what keep its error quiet, and || keeps set -e from ending this)
found=$({ tr '\0' '\n' <"/proc/$pid/environ"; } 2>/dev/null | sed -n 's/^SSH_CONNECTION=//p' | head -n1) || found=
if [ -n "$found" ]; then echo "$found"; return; fi
# (a process this can't see, hidden by hidepid, ends the walk: without || pid=,
# pipefail made the failing ps end the whole script, without a word)
pid=$(ps -o ppid= -p "$pid" 2>/dev/null | tr -d ' ') || pid=
done
echo console
}
# arm: keeps the SSH and firewall files as they are now, and undoes the change
# in VPS_CONFIRM_MINUTES unless it is confirmed
arm() {
armed && die "A change is still waiting to be confirmed: wren-vps --confirm (from a new login), or wait for it to undo itself."
mkdir -p "$BACKUP"
if [ -f "$SSHD_DROP" ]; then cp -p "$SSHD_DROP" "$BACKUP/sshd"; else : >"$BACKUP/sshd.absent"; fi
if [ -f "$NFT" ]; then cp -p "$NFT" "$BACKUP/nft"; else : >"$BACKUP/nft.absent"; fi
session >"$BACKUP/session"
run systemd-run --quiet --unit=wren-vps-rollback --on-active="${VPS_CONFIRM_MINUTES}min" --timer-property=AccuracySec=5s "$ROLLBACK"
}
apply_ssh_firewall() {
sshd_conf | write_file "$SSHD_DROP" 0644
nft_conf | write_file "$NFT" 0755
if [ -z "$FAKE" ]; then
sshd -t || { "$ROLLBACK"; die "sshd refused the new settings; they were put back."; }
# what sshd will really do, other files of the image included
local eff
eff=$(sshd -T -C user="$VPS_ADMIN",host=x,addr=203.0.113.1 2>/dev/null) || eff=
grep -qx 'passwordauthentication no' <<<"$eff" && grep -qx 'permitrootlogin no' <<<"$eff" ||
{ "$ROLLBACK"; die "Another sshd setting still lets passwords or root in; nothing was changed. Look at: sshd -T"; }
nft -c -f "$NFT" || { "$ROLLBACK"; die "nft refused the firewall; it was put back."; }
fi
run systemctl reload ssh
run systemctl enable --now nftables
run nft -f /etc/nftables.conf
}
confirm_hint() {
note "These undo themselves in $VPS_CONFIRM_MINUTES minutes unless you confirm. Keep this session open, and"
note "from a second, NEW terminal log in again ($1), then run:"
note " sudo wren-vps --confirm"
note "If that login fails, do nothing: the old settings come back by themselves."
}
confirm() {
armed || { note "Nothing is waiting to be confirmed."; exit 0; }
local made_in now
made_in=$(cat "$BACKUP/session" 2>/dev/null || true)
now=$(session)
if [ "$now" = console ]; then
warn "Confirming from the console: that doesn't show SSH still lets you in. Try a new SSH login before you log out."
elif [ "$made_in" = "$now" ]; then
die "This is the session that made the change. Confirm from a NEW login: that proves you can still get in."
fi
run systemctl stop wren-vps-rollback.timer
rm -rf "$BACKUP"
say "Confirmed: the new SSH and firewall settings stay."
}
# ---- what it does ----
user_exists() { if [ -n "$FAKE" ]; then grep -q "^$1:" "$ROOT/etc/passwd" 2>/dev/null; else id "$1" >/dev/null 2>&1; fi; }
onion_of() { # NAME: the onion address tor made for it (it takes a moment)
local f=$ROOT/var/lib/tor/$1/hostname i
for i in $(seq 1 60); do
if [ -s "$f" ]; then
local h
h=$(head -n1 "$f")
[[ $h =~ ^[a-z2-7]{56}\.onion$ ]] && { echo "$h"; return 0; }
fi
[ -n "$FAKE" ] || sleep 1
done
return 1
}
setup() {
say "Wren Linux VPS setup"
if [ -z "$FAKE" ]; then
# shellcheck disable=SC1091
. /etc/os-release
[ "${VERSION_CODENAME:-}" = trixie ] || warn "This is made for Debian 13 (trixie); this is ${PRETTY_NAME:-unknown}."
fi
armed && die "A change is still waiting to be confirmed: wren-vps --confirm (from a new login), or wait ${VPS_CONFIRM_MINUTES:-10} minutes."
if [ -f "$CONF" ]; then load_config "$CONF"; note "Using what you chose before ($CONF); edit it and run this again to change it."; fi
questions
save_config
say "Packages"
run apt-get update -q
run env DEBIAN_FRONTEND=noninteractive apt-get install -y -q --no-install-recommends \
nftables tor caddy unattended-upgrades needrestart sudo openssh-server rsync git ca-certificates
say "Your user, $VPS_ADMIN"
if ! user_exists "$VPS_ADMIN"; then
run useradd --create-home --shell /bin/bash --groups sudo "$VPS_ADMIN"
else
run usermod -aG sudo "$VPS_ADMIN"
fi
local home=$ROOT/home/$VPS_ADMIN
mkdir -p "$home/.ssh"
printf '%s\n' "$VPS_SSH_KEY" | write_file "$home/.ssh/authorized_keys" 0600
chmod 0700 "$home/.ssh"
run chown -R "$VPS_ADMIN:$VPS_ADMIN" "/home/$VPS_ADMIN/.ssh"
if [ -z "$FAKE" ] && passwd -S "$VPS_ADMIN" 2>/dev/null | awk '{ exit ($2 == "P") }'; then
note "sudo, and the provider's web console, need a password for $VPS_ADMIN. SSH never takes it."
if interactive; then
passwd "$VPS_ADMIN" </dev/tty || warn "No password was set: set one with passwd $VPS_ADMIN before you log out."
else
warn "Set one before you log out: passwd $VPS_ADMIN"
fi
fi
say "Security updates, and hardening"
upgrades_conf | write_file "$ROOT/etc/apt/apt.conf.d/52wren-vps" 0644
sysctl_conf | write_file "$ROOT/etc/sysctl.d/60-wren-vps.conf" 0644
run sysctl --system --quiet
# restart services after an update by itself (no one is there to answer)
# shellcheck disable=SC2016 # needrestart's own Perl variable
printf '# Written by Wren Linux setup-vps.sh\n$nrconf{restart} = '"'"'a'"'"';\n' | write_file "$ROOT/etc/needrestart/conf.d/60-wren-vps.conf" 0644
run systemctl enable --now unattended-upgrades
say "Tor onion services"
local onion_site='' onion_ssh=''
if [ "$VPS_ONION_SITE" = yes ] || [ "$VPS_ONION_SSH" = yes ]; then
local block
block=$(tor_block)
[ -f "$TORRC" ] || : | write_file "$TORRC" 0644
{ sed '/^# >>> Wren Linux setup-vps.sh >>>$/,/^# <<< Wren Linux setup-vps.sh <<<$/d' "$TORRC"; printf '%s\n' "$block"; } | write_file "$TORRC" 0644
if [ "$VPS_ONION_SSH" = yes ]; then
local auth=$ROOT/var/lib/tor/wren-ssh/authorized_clients
mkdir -p "$auth"
rm -f "$auth"/*.auth
if [ -n "${VPS_ONION_SSH_CLIENT:-}" ]; then
printf 'descriptor:x25519:%s\n' "$VPS_ONION_SSH_CLIENT" | write_file "$auth/$VPS_ADMIN.auth" 0600
else
warn "The SSH onion address works without a client key: anyone who learns it reaches SSH (your SSH key is still needed)."
fi
run chown -R debian-tor:debian-tor /var/lib/tor/wren-ssh
run chmod 0700 /var/lib/tor/wren-ssh /var/lib/tor/wren-ssh/authorized_clients
fi
run systemctl enable tor
# Reload, don't restart, when Tor is already running: a restart drops its onion
# services, and on an onion-only server that cuts the very SSH this run came in on,
# before it can finish (e.g. adding a domain). reload-or-restart sends Tor SIGHUP to
# take up the new torrc while its connections stay up, and starts it if it was off.
run systemctl reload-or-restart tor
if [ "$VPS_ONION_SITE" = yes ]; then onion_site=$(onion_of wren-site) || warn "Tor hasn't made the site's onion address yet; run this again in a minute."; fi
if [ "$VPS_ONION_SSH" = yes ]; then onion_ssh=$(onion_of wren-ssh) || warn "Tor hasn't made the SSH onion address yet; run this again in a minute."; fi
else
[ ! -f "$TORRC" ] || sed '/^# >>> Wren Linux setup-vps.sh >>>$/,/^# <<< Wren Linux setup-vps.sh <<<$/d' "$TORRC" | write_file "$TORRC" 0644
fi
say "The web server (Caddy)"
mkdir -p "$ROOT$SITE" "$ROOT$GITDIR" "$ROOT$IMAGEDIR"
if [ ! -e "$ROOT$SITE/index.html" ]; then
printf '<!doctype html>\n<meta charset="utf-8">\n<title>Soon</title>\n<p>Soon.</p>\n' | write_file "$ROOT$SITE/index.html" 0644
fi
run chown "$VPS_ADMIN:$VPS_ADMIN" "$SITE" "$GITDIR" "$IMAGEDIR"
if [ ! -d "$ROOT$GITDIR/wren-linux.git" ]; then
run sudo -u "$VPS_ADMIN" git init -q --bare -b main "$GITDIR/wren-linux.git"
# what a plain web server needs to serve it, kept up to date at every push
run sudo -u "$VPS_ADMIN" cp "$GITDIR/wren-linux.git/hooks/post-update.sample" "$GITDIR/wren-linux.git/hooks/post-update"
fi
# A clone checks out the branch HEAD names: main (a mirror made before said master, git's
# default, so a plain git clone of it got nothing), and the file the web server gives for it
run sudo -u "$VPS_ADMIN" git -C "$GITDIR/wren-linux.git" symbolic-ref HEAD refs/heads/main
run sudo -u "$VPS_ADMIN" git -C "$GITDIR/wren-linux.git" update-server-info
caddy_conf "$onion_site" | write_file "$CADDYFILE" 0644
caddy_hardening | write_file "$ROOT/etc/systemd/system/caddy.service.d/60-wren-vps.conf" 0644
if [ -z "$FAKE" ]; then caddy validate --adapter caddyfile --config "$CADDYFILE" >/dev/null || die "Caddy refused its settings ($CADDYFILE)."; fi
run systemctl daemon-reload
run systemctl enable caddy
run systemctl restart caddy
say "SSH and the firewall"
rollback_script | write_file "$ROOT$ROLLBACK" 0755
boot_unit | write_file "$BOOT_UNIT" 0644
run systemctl daemon-reload
run systemctl enable wren-vps-undo-unconfirmed.service
# this script, as the wren-vps command
if [ -f "$0" ]; then install -D -m 0755 "$0" "$ROOT$SELF"; fi
arm
apply_ssh_firewall
note "SSH: your key only, as $VPS_ADMIN; no root, no passwords. Firewall: SSH$([ "$VPS_SSH_PUBLIC" = yes ] || echo ' (onion only)') and the web."
confirm_hint "ssh $VPS_ADMIN@THIS-SERVER"
say "Done"
[ -z "$VPS_DOMAIN" ] || note "Site: https://$VPS_DOMAIN (its DNS: A/AAAA to this server; add a CAA record for letsencrypt.org, and DNSSEC at your registrar)"
[ -n "$VPS_DOMAIN" ] || note "Site: http://THIS-SERVER (no domain yet: plain http)"
[ -z "$onion_site" ] || note "Onion site: http://$onion_site"
if [ -n "$onion_ssh" ]; then
note "Onion SSH: $onion_ssh"
note " On your computer: bash tools/vps-client.sh --ssh-config $VPS_ADMIN $onion_ssh"
note " Once logging in through it works: sudo wren-vps --ssh-onion-only (closes port 22 to the internet)"
fi
note "Publish the site: bash tools/publish-site.sh (on your computer)"
}
ssh_public() { # yes | no: port 22 open to the internet or not
[ -f "$CONF" ] || die "Run the setup first (bash setup-vps.sh)."
load_config "$CONF"
# (nothing to ask: what you chose is kept, also what you left empty)
WREN_VPS_YES=1
questions
if [ "$1" = no ]; then
[ "$VPS_ONION_SSH" = yes ] || die "SSH has no onion address here (VPS_ONION_SSH=no): closing port 22 would lock you out."
# only from a login that came through the onion: proof that it works
case $(session) in
"127.0.0.1 "* | "::1 "*) ;;
*) die "Run this from a login through the SSH onion address (it comes from 127.0.0.1), so you know that way works." ;;
esac
fi
VPS_SSH_PUBLIC=$1
save_config
arm
apply_ssh_firewall
if [ "$1" = no ]; then
say "Port 22 is closed to the internet; SSH only through its onion address."
confirm_hint "through the onion address"
else
say "Port 22 is open to the internet again (keys only, slowed down)."
confirm_hint "ssh $VPS_ADMIN@THIS-SERVER"
fi
}
status() {
[ -f "$CONF" ] && load_config "$CONF" || die "Not set up yet: bash setup-vps.sh"
echo "User: $VPS_ADMIN Domain: ${VPS_DOMAIN:-none} Port 22 on the internet: $VPS_SSH_PUBLIC"
[ ! -s "$ROOT/var/lib/tor/wren-site/hostname" ] || echo "Onion site: $(head -n1 "$ROOT/var/lib/tor/wren-site/hostname")"
[ ! -s "$ROOT/var/lib/tor/wren-ssh/hostname" ] || echo "Onion SSH: $(head -n1 "$ROOT/var/lib/tor/wren-ssh/hostname")"
if armed; then echo "A change is waiting to be confirmed (wren-vps --confirm, from a new login)."; fi
}
case ${1:-} in
--config) [ -n "${2:-}" ] || die "--config needs a file."; load_config "$2"; shift 2; [ "${1:-}" != --yes ] || WREN_VPS_YES=1; setup ;;
--yes) WREN_VPS_YES=1; [ "${2:-}" != --config ] || load_config "${3:?--config needs a file}"; setup ;;
"") setup ;;
--confirm) confirm ;;
--ssh-onion-only) ssh_public no ;;
--ssh-public) ssh_public yes ;;
--status) status ;;
-h | --help) sed -n '2,/^set -euo/{ /^set -euo/d; s/^# \{0,1\}//; p }' "$0" ;;
*) die "unknown option \"$1\" (--help)." ;;
esac