Wren Linux

wren-linux / data / onion-site-provision.sh

#!/bin/bash
# Runs as root INSIDE the onion site's VM, once, right after the unattended
# Debian install (mockvps-kvm --provision), while the VM still has a network.
# extras/onion-site.sh fills in @APP@ (any or python) and @VM_USER@ first.
#
# Afterwards the VM runs with no network at all: visitors reach nginx only
# through Tor on the host, and apt reaches Debian only through the host's Tor,
# during "onion-site update". So nothing the site does can give away the
# host's address, and nothing here may expect the internet later.
#
#   nginx (port 80)  serves /srv/site/static, and passes everything else to the
#                    app on 127.0.0.1:8000; no version banner, no access log
#   any              you run your own app server on 127.0.0.1:8000
#   python           gunicorn runs the Flask app in /srv/site/app (app.py, app)
#                    as a locked-down service, site-app
set -euo pipefail
export DEBIAN_FRONTEND=noninteractive

APP='@APP@'
SITE_USER='@VM_USER@'
SITE=/srv/site

log() { printf '\n[onion-site guest] %s\n' "$*"; }
die() { printf '[onion-site guest] ERROR: %s\n' "$*" >&2; exit 1; }
case $APP in any | python) ;; *) die "unknown app kind '$APP'" ;; esac
getent passwd "$SITE_USER" >/dev/null || die "user $SITE_USER not found"

log "The time zone is UTC (a local one would tell visitors where the host is)"
ln -sf /usr/share/zoneinfo/UTC /etc/localtime
echo UTC >/etc/timezone

pkgs=(nginx)
[ "$APP" != python ] || pkgs+=(gunicorn python3-flask)
log "Installing ${pkgs[*]}"
apt-get update
apt-get install -y --no-install-recommends "${pkgs[@]}"

log "The site's folders (owned by $SITE_USER; the web server only reads)"
install -d -o "$SITE_USER" -g "$SITE_USER" -m 0755 "$SITE" "$SITE/static" "$SITE/app"
install -d -o "$SITE_USER" -g "$SITE_USER" -m 0700 "$SITE/data"
if [ ! -e "$SITE/static/index.html" ]; then
    printf '%s\n' '<!doctype html><title>It works</title>' \
        '<p>This onion site works. Put your own files here with: onion-site upload static FOLDER</p>' \
        >"$SITE/static/index.html"
    chown "$SITE_USER:$SITE_USER" "$SITE/static/index.html"
fi
install -d -m 0755 /usr/share/wren-site
printf '%s\n' '<!doctype html><title>Not running</title>' \
    '<p>The site'"'"'s app isn'"'"'t answering yet (nothing on 127.0.0.1:8000 in the VM).</p>' \
    >/usr/share/wren-site/no-app.html

log "nginx"
cat >/etc/nginx/sites-available/onion-site <<'EOF'
# Written by Wren Linux extras/onion-site.sh. Every visitor comes through Tor, so
# the logs would only hold times, and those are left out.
server {
    listen 80 default_server;
    listen [::]:80 default_server;
    server_name _;
    server_tokens off;
    access_log off;
    client_max_body_size 10m;
    root /srv/site/static;
    add_header X-Content-Type-Options nosniff always;
    add_header Referrer-Policy no-referrer always;
    add_header X-Frame-Options SAMEORIGIN always;
    # Hidden files are never served (an uploaded .git names its author); .well-known is for visitors
    location ~ /\.(?!well-known(/|$)) { return 404; }

    # A file in /srv/site/static if there is one, else the app
    location / {
        try_files $uri $uri/index.html @app;
    }
    location @app {
        proxy_pass http://127.0.0.1:8000;
        proxy_set_header Host $host;
        proxy_set_header X-Forwarded-Proto http;
        proxy_hide_header X-Powered-By;
        proxy_hide_header Server;
    }
    error_page 502 503 504 /no-app.html;
    location = /no-app.html {
        internal;
        root /usr/share/wren-site;
    }
}
EOF
rm -f /etc/nginx/sites-enabled/default
ln -sf /etc/nginx/sites-available/onion-site /etc/nginx/sites-enabled/onion-site
nginx -t
systemctl enable nginx >/dev/null
systemctl restart nginx

if [ "$APP" = python ]; then
    log "The Python app: gunicorn, as $SITE_USER, locked down"
    if [ ! -e "$SITE/app/app.py" ]; then
        cat >"$SITE/app/app.py" <<'EOF'
# A starting point (Wren Linux extras/onion-site.sh). Replace it with your own
# Flask app, keeping the name: the service runs app:app from this folder.
# Write anything you keep to /srv/site/data; the rest is read-only to the app.
from flask import Flask

app = Flask(__name__)


@app.route("/hello")
def hello():
    return "Hello from the onion site's Python app.\n"
EOF
        chown "$SITE_USER:$SITE_USER" "$SITE/app/app.py"
    fi
    cat >/etc/systemd/system/site-app.service <<EOF
# Written by Wren Linux extras/onion-site.sh
[Unit]
Description=The onion site's Python app (gunicorn)
After=network.target

[Service]
User=$SITE_USER
Group=$SITE_USER
WorkingDirectory=$SITE/app
ExecStart=/usr/bin/gunicorn --bind 127.0.0.1:8000 --workers 2 app:app
Restart=on-failure
NoNewPrivileges=yes
PrivateTmp=yes
ProtectSystem=strict
ProtectHome=yes
ReadWritePaths=$SITE/data
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectControlGroups=yes
RestrictSUIDSGID=yes
LockPersonality=yes

[Install]
WantedBy=multi-user.target
EOF
    systemctl daemon-reload
    systemctl enable --now site-app.service
fi

log "$SITE_USER may restart the site's services without a password"
printf '%s\n' "# Written by Wren Linux extras/onion-site.sh" \
    "$SITE_USER ALL=(root) NOPASSWD: /usr/bin/systemctl restart site-app.service, /usr/bin/systemctl reload nginx.service" \
    >/etc/sudoers.d/onion-site
chmod 0440 /etc/sudoers.d/onion-site
visudo -cf /etc/sudoers.d/onion-site >/dev/null || { rm -f /etc/sudoers.d/onion-site; die "the sudoers rule didn't pass visudo"; }

log "From now on apt reaches Debian only through the host's Tor (during onion-site update)"
printf '%s\n' '// Written by Wren Linux extras/onion-site.sh: this VM has no network; during' \
    '// "onion-site update" 10.0.2.100:9050 leads to the host'"'"'s Tor.' \
    'Acquire::http::Proxy "socks5h://10.0.2.100:9050";' \
    'Acquire::https::Proxy "socks5h://10.0.2.100:9050";' >/etc/apt/apt.conf.d/90wren-tor
# The daily apt timers would only fail without a network
systemctl disable --now apt-daily.timer apt-daily-upgrade.timer >/dev/null 2>&1 || true

log "Provisioning finished."