d73a791c86
`deploy/lxc-install.sh` had never been executed -- there was no Proxmox host to run it on, and PLAN.md said so rather than letting it read as tested. It was reviewed and `bash -n` checked, which is not the same claim. Running it for the first time found two Arch-isms in `install.sh`, the script it wraps, and only a Debian machine could have found either. `python -m venv` is the one that mattered. On Arch `python` is Python 3, so the bare name had worked on the only machine this had ever run on. Debian has no `python` at all unless somebody installed `python-is-python3`, and the LXC bootstrap installs `python3` -- so the install aborted at the virtualenv step, with the service user, the bind mount and the clone already in place. It is `python3` now, which is right on both. `--shell /usr/bin/nologin` is the one that did not. That is where Arch keeps nologin and not where Debian does, but nothing ever invoked it: `sudo -u` execs the command directly and systemd's `User=` never reads a shell. The account worked while pointing at a file that was not there. `/usr/sbin/nologin` is correct on Debian and resolves on Arch too, whose `/usr/sbin` is a symlink to `bin`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
284 lines
13 KiB
Bash
Executable File
284 lines
13 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# Install LLeMbas as a system service behind nginx with a self-signed cert.
|
|
#
|
|
# Creates a dedicated service user, a virtualenv, a systemd unit and an nginx
|
|
# vhost. Idempotent: safe to re-run. To deploy new code afterwards use
|
|
# update.sh, which is what a `git push` should be followed by.
|
|
#
|
|
# Everything is configurable from the environment:
|
|
#
|
|
# SITE_HOST=chat.example ./deploy/install.sh # vhost name
|
|
# APP_PORT=8080 # loopback port
|
|
# PREFIX=/srv/lembas # install root
|
|
# HOME_DIR=/home/lembas # service user's home
|
|
# REPO_URL=... # defaults to this checkout's origin
|
|
#
|
|
# PREFIX defaults to a bind mount of HOME_DIR rather than living directly under
|
|
# /srv, because on many machines the root filesystem is small and the venv plus
|
|
# database belong on the larger /home volume. Set PREFIX=HOME_DIR to skip that.
|
|
set -euo pipefail
|
|
|
|
HERE="$(dirname "$(readlink -f "$0")")"
|
|
|
|
SITE_HOST="${SITE_HOST:-lembas.local}"
|
|
APP_PORT="${APP_PORT:-8080}"
|
|
SERVICE_USER="${SERVICE_USER:-lembas}"
|
|
HOME_DIR="${HOME_DIR:-/home/lembas}"
|
|
PREFIX="${PREFIX:-/srv/lembas}"
|
|
BRANCH="${LEMBAS_BRANCH:-main}"
|
|
# Which channel this host follows: `stable` (the newest release tag) or `edge`
|
|
# (the branch tip). Stable by default, because a branch tip is not a release --
|
|
# following one means deploying whatever was pushed five minutes ago, which is
|
|
# right for whoever builds this and wrong for whoever runs it.
|
|
# On a **re-run**, default to what this host already follows rather than to
|
|
# `stable`. The channel lives in two places -- `lembas.env`, which the page
|
|
# reads, and the systemd unit, which the button obeys -- and a re-run keeps the
|
|
# env file ("keeping it, and its secret key") while rewriting the unit. So a
|
|
# re-run to fix something unrelated silently moved one half and not the other,
|
|
# and left the host with a page naming one channel and a button deploying
|
|
# another. That mismatch has an alert of its own; an installer that *causes* it
|
|
# is the wrong end to be detecting it from.
|
|
#
|
|
# Parsed, not sourced -- `lembas.env` holds the secret key, and there is no
|
|
# reason for this to have it in a variable.
|
|
_installed_channel=""
|
|
if [[ -f "$PREFIX/lembas.env" ]]; then
|
|
_installed_channel=$(sed -n 's/^LEMBAS_UPDATE_CHANNEL=\([a-z]\{1,16\}\)$/\1/p' \
|
|
"$PREFIX/lembas.env" | tail -1)
|
|
fi
|
|
CHANNEL="${LEMBAS_CHANNEL:-${_installed_channel:-stable}}"
|
|
# Whether to install the units that let the web interface update this host.
|
|
# Off, and off on a re-run that does not ask for it: it grants anybody who can
|
|
# administer the web UI the ability to deploy the branch, as root. See the
|
|
# "Updating from the web interface" section of deploy/README.md.
|
|
INSTALL_UPDATE_HELPER="${INSTALL_UPDATE_HELPER:-0}"
|
|
# Default to wherever this checkout came from, so a fork deploys itself.
|
|
REPO_URL="${REPO_URL:-$(git -C "$HERE" remote get-url origin 2>/dev/null || true)}"
|
|
|
|
APP="$PREFIX/app"
|
|
VENV="$PREFIX/venv"
|
|
ENV_FILE="$PREFIX/lembas.env"
|
|
|
|
if [[ -z "$REPO_URL" ]]; then
|
|
echo "Could not determine REPO_URL. Set it explicitly." >&2
|
|
exit 1
|
|
fi
|
|
|
|
# The deployment clones as the service user, which has no SSH key and should not
|
|
# have one: a credential that can push to the repository, sitting on a box, to
|
|
# do a read-only job. Whoever runs this usually has an ssh:// origin because
|
|
# *they* push over SSH, so the default inherited from their checkout is the one
|
|
# thing that cannot work here.
|
|
#
|
|
# The clone would fail loudly anyway. Saying so first turns "Permission denied
|
|
# (publickey)" from the service user into a sentence that names the fix.
|
|
if [[ "$REPO_URL" == ssh://* || "$REPO_URL" == git@* ]]; then
|
|
echo "== repository ==" >&2
|
|
echo " $REPO_URL is an SSH URL, and $SERVICE_USER has no key." >&2
|
|
echo " Set an https URL, which is what a deployment should fetch over:" >&2
|
|
echo " REPO_URL=https://host/owner/repo.git $0" >&2
|
|
echo " (Or give $SERVICE_USER a read-only deploy key and re-run.)" >&2
|
|
exit 1
|
|
fi
|
|
|
|
echo "== plan =="
|
|
echo " host : https://$SITE_HOST -> 127.0.0.1:$APP_PORT"
|
|
echo " user : $SERVICE_USER ($HOME_DIR)"
|
|
echo " prefix : $PREFIX"
|
|
echo " repo : $REPO_URL ($BRANCH, $CHANNEL channel)"
|
|
if [[ "$INSTALL_UPDATE_HELPER" == "1" ]]; then
|
|
echo " updates : web interface may deploy $BRANCH as root (helper units)"
|
|
else
|
|
echo " updates : by hand only ($PREFIX/app/deploy/update.sh)"
|
|
fi
|
|
|
|
echo "== service user =="
|
|
# --system: no ageing, no mail spool. Home under /home, not /var/lib, so the
|
|
# venv and database sit on the larger volume.
|
|
#
|
|
# `/usr/sbin/nologin` is Debian's path and works on both: Arch keeps `nologin`
|
|
# in /usr/bin, but its /usr/sbin is a symlink to bin, so the Debian spelling
|
|
# resolves there while the Arch one does not resolve on Debian at all.
|
|
if ! getent passwd "$SERVICE_USER" >/dev/null; then
|
|
sudo useradd --system --create-home --home-dir "$HOME_DIR" \
|
|
--shell /usr/sbin/nologin --comment "LLeMbas" "$SERVICE_USER"
|
|
else
|
|
echo " user $SERVICE_USER already exists"
|
|
fi
|
|
sudo chmod 755 "$HOME_DIR"
|
|
|
|
if [[ "$PREFIX" != "$HOME_DIR" ]]; then
|
|
echo "== $PREFIX bind-mount onto $HOME_DIR =="
|
|
sudo mkdir -p "$PREFIX"
|
|
grep -q "^$HOME_DIR[[:space:]]" /etc/fstab \
|
|
|| echo "$HOME_DIR $PREFIX none bind 0 0" | sudo tee -a /etc/fstab >/dev/null
|
|
sudo systemctl daemon-reload
|
|
mountpoint -q "$PREFIX" || sudo mount "$PREFIX"
|
|
fi
|
|
|
|
echo "== checkout =="
|
|
if [[ ! -d "$APP/.git" ]]; then
|
|
sudo -u "$SERVICE_USER" git clone --branch "$BRANCH" "$REPO_URL" "$APP"
|
|
else
|
|
echo " already cloned; use update.sh to pull"
|
|
fi
|
|
|
|
echo "== virtualenv =="
|
|
# `python3`, not `python`. On Arch -- the machine this was written on and the
|
|
# only one it had ever run on -- `python` is Python 3 and the bare name worked.
|
|
# On Debian it does not exist unless somebody installed `python-is-python3`, so
|
|
# the LXC bootstrap aborted here, after the service user, the bind mount and the
|
|
# clone were already in place. `python3` is correct on both.
|
|
if [[ ! -x "$VENV/bin/python" ]]; then
|
|
sudo -u "$SERVICE_USER" python3 -m venv "$VENV"
|
|
fi
|
|
sudo -u "$SERVICE_USER" "$VENV/bin/pip" install --quiet --upgrade pip
|
|
# The extras a deployment gets. `search` because DuckDuckGo is the default web
|
|
# search provider and is meant to need no setup; `ssh` because agent chats reach
|
|
# their machine over it and a deployment without it offers the feature with an
|
|
# install hint instead. Listed here AND in update.sh -- an extra added to only
|
|
# one of them means existing deployments silently miss it.
|
|
LEMBAS_EXTRAS="${LEMBAS_EXTRAS:-search,ssh}"
|
|
sudo -u "$SERVICE_USER" "$VENV/bin/pip" install --quiet -e "$APP[$LEMBAS_EXTRAS]"
|
|
|
|
echo "== environment =="
|
|
# Generated once and never regenerated: rotating LEMBAS_SECRET_KEY signs every
|
|
# user out AND makes the stored upstream API keys unreadable.
|
|
if [[ ! -f "$ENV_FILE" ]]; then
|
|
KEY=$("$VENV/bin/python" -c "import secrets; print(secrets.token_urlsafe(48))")
|
|
sudo tee "$ENV_FILE" >/dev/null <<EOF
|
|
# LLeMbas service environment. Generated by deploy/install.sh.
|
|
# LEMBAS_SECRET_KEY signs sessions and encrypts stored API keys.
|
|
# Changing it signs everyone out and makes stored API keys unreadable.
|
|
LEMBAS_SECRET_KEY=$KEY
|
|
LEMBAS_DATA_DIR=$PREFIX/data
|
|
# Loopback only: reachable through the nginx vhost, never directly.
|
|
LEMBAS_HOST=127.0.0.1
|
|
LEMBAS_PORT=$APP_PORT
|
|
LEMBAS_LOG_LEVEL=info
|
|
LEMBAS_ALLOW_SIGNUP=true
|
|
LEMBAS_DEFAULT_THEME=moria
|
|
# Which branch /admin/updates compares against. Deployment configuration, not
|
|
# an instance setting: it decides what code runs here, and a value a web
|
|
# administrator could edit would turn "you may deploy the branch" into "you may
|
|
# deploy anything".
|
|
LEMBAS_UPDATE_BRANCH=$BRANCH
|
|
# stable follows the newest release tag; edge follows the branch tip.
|
|
LEMBAS_UPDATE_CHANNEL=$CHANNEL
|
|
EOF
|
|
sudo chown "$SERVICE_USER:$SERVICE_USER" "$ENV_FILE"
|
|
sudo chmod 600 "$ENV_FILE"
|
|
echo " generated $ENV_FILE"
|
|
else
|
|
echo " $ENV_FILE exists, keeping it (and its secret key)"
|
|
fi
|
|
|
|
sudo install -d -o "$SERVICE_USER" -g "$SERVICE_USER" -m 750 "$PREFIX/data"
|
|
|
|
echo "== systemd unit =="
|
|
sed -e "s|__PREFIX__|$PREFIX|g" -e "s|__SERVICE_USER__|$SERVICE_USER|g" \
|
|
"$HERE/lembas.service" | sudo tee /etc/systemd/system/lembas.service >/dev/null
|
|
# Which version of the template this host is running. update.sh compares
|
|
# against it and says so when the template moves on, because the installed
|
|
# unit usually grows host-specific lines and cannot simply be overwritten.
|
|
sha256sum "$HERE/lembas.service" | cut -d' ' -f1 | sudo tee "$PREFIX/.unit-applied" >/dev/null
|
|
sudo systemctl daemon-reload
|
|
|
|
echo "== update helper =="
|
|
# Two units and a marker. The marker is what the web interface reads to decide
|
|
# whether to offer the button at all -- a file rather than `systemctl
|
|
# is-enabled`, because that would be a subprocess on every page render to answer
|
|
# a question that changes once.
|
|
UPDATE_MARKER="$PREFIX/data/.update-helper"
|
|
# Where root's copy of the update script lives, and why it is a copy.
|
|
#
|
|
# The unit runs as root. Pointing its ExecStart at `$PREFIX/app/deploy/update.sh`
|
|
# meant root executing a file owned by the **unprivileged service account** --
|
|
# so anything able to write as that account could rewrite the script, create the
|
|
# request file it also owns, and be root. That is the whole privilege boundary
|
|
# the helper exists to keep, defeated by a `chown`.
|
|
#
|
|
# The second path is worse because it needs no compromise at all: an update
|
|
# pulls new code *as the service user*, and root then runs whatever
|
|
# `deploy/update.sh` that pull contained. Control of the branch would have been
|
|
# control of root.
|
|
#
|
|
# So root runs a copy it owns, installed here, by an administrator, deliberately.
|
|
# The cost is that improving `update.sh` needs `install.sh` re-run -- which is
|
|
# the correct trade: root should not execute a script that arrived over the
|
|
# network a moment ago.
|
|
UPDATE_HELPER_DIR="/usr/local/lib/lembas"
|
|
UPDATE_HELPER="$UPDATE_HELPER_DIR/update.sh"
|
|
if [[ "$INSTALL_UPDATE_HELPER" == "1" ]]; then
|
|
sudo mkdir -p "$UPDATE_HELPER_DIR"
|
|
sudo install -o root -g root -m 755 "$HERE/update.sh" "$UPDATE_HELPER"
|
|
for unit in lembas-update.path lembas-update.service; do
|
|
sed -e "s|__PREFIX__|$PREFIX|g" \
|
|
-e "s|__SERVICE_USER__|$SERVICE_USER|g" \
|
|
-e "s|__UPDATE_BRANCH__|$BRANCH|g" \
|
|
-e "s|__UPDATE_CHANNEL__|$CHANNEL|g" \
|
|
-e "s|__UPDATE_HELPER__|$UPDATE_HELPER|g" \
|
|
"$HERE/$unit" | sudo tee "/etc/systemd/system/$unit" >/dev/null
|
|
done
|
|
sudo systemctl daemon-reload
|
|
sudo systemctl enable --now lembas-update.path
|
|
# The channel goes *into* the marker, not just its existence. It is declared
|
|
# in two places -- the unit above and lembas.env -- and this is what lets the
|
|
# Updates page notice when somebody has edited one and not the other.
|
|
echo "$CHANNEL" | sudo tee "$UPDATE_MARKER" >/dev/null
|
|
sudo chown "$SERVICE_USER:$SERVICE_USER" "$UPDATE_MARKER"
|
|
echo " installed. The web interface can now deploy the $CHANNEL channel and restart."
|
|
else
|
|
# Removed rather than left, so turning it off is re-running without the flag
|
|
# rather than remembering three commands. The button then says so and prints
|
|
# the manual one, which is the honest degradation.
|
|
sudo systemctl disable --now lembas-update.path 2>/dev/null || true
|
|
sudo rm -f /etc/systemd/system/lembas-update.path \
|
|
/etc/systemd/system/lembas-update.service "$UPDATE_MARKER" \
|
|
"$UPDATE_HELPER"
|
|
sudo systemctl daemon-reload
|
|
echo " not installed (INSTALL_UPDATE_HELPER=1 to allow updating from the web UI)"
|
|
fi
|
|
|
|
echo "== self-signed cert for $SITE_HOST =="
|
|
sudo mkdir -p /etc/nginx/ssl
|
|
if [[ ! -f "/etc/nginx/ssl/$SITE_HOST.crt" ]]; then
|
|
sudo openssl req -x509 -newkey rsa:2048 -nodes \
|
|
-keyout "/etc/nginx/ssl/$SITE_HOST.key" -out "/etc/nginx/ssl/$SITE_HOST.crt" \
|
|
-days 3650 -subj "/CN=$SITE_HOST" -addext "subjectAltName=DNS:$SITE_HOST"
|
|
sudo chmod 600 "/etc/nginx/ssl/$SITE_HOST.key"
|
|
sudo chmod 644 "/etc/nginx/ssl/$SITE_HOST.crt"
|
|
fi
|
|
|
|
echo "== nginx vhost =="
|
|
sed -e "s|__SITE_HOST__|$SITE_HOST|g" -e "s|__APP_PORT__|$APP_PORT|g" \
|
|
"$HERE/nginx-vhost.conf" | sudo tee "/etc/nginx/conf.d/$SITE_HOST.conf" >/dev/null
|
|
sudo nginx -t
|
|
sudo systemctl reload nginx
|
|
|
|
# What this host was installed with, so update.sh can name the vhost it should
|
|
# be comparing against and print a command that actually runs. Without it the
|
|
# drift check below could only say "something changed somewhere".
|
|
printf 'SITE_HOST=%s\nAPP_PORT=%s\n' "$SITE_HOST" "$APP_PORT" \
|
|
| sudo tee "$PREFIX/.deploy-env" >/dev/null
|
|
sha256sum "$HERE/nginx-vhost.conf" | cut -d' ' -f1 \
|
|
| sudo tee "$PREFIX/.vhost-applied" >/dev/null
|
|
|
|
echo "== local name resolution =="
|
|
# Only useful when the LAN's DNS does not already answer for this name.
|
|
if ! getent hosts "$SITE_HOST" >/dev/null; then
|
|
printf '127.0.0.1\t%s\n::1\t\t%s\n' "$SITE_HOST" "$SITE_HOST" | sudo tee -a /etc/hosts >/dev/null
|
|
echo " added $SITE_HOST to /etc/hosts"
|
|
else
|
|
echo " $SITE_HOST already resolves"
|
|
fi
|
|
|
|
echo "== enable service =="
|
|
sudo systemctl enable --now lembas
|
|
sleep 2
|
|
sudo systemctl --no-pager --lines=0 status lembas || true
|
|
|
|
echo
|
|
echo "LLeMbas is up at https://$SITE_HOST (self-signed cert; accept the warning)"
|
|
echo "Create the first account -- it becomes the administrator."
|