#!/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. CHANNEL="${LEMBAS_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. if ! getent passwd "$SERVICE_USER" >/dev/null; then sudo useradd --system --create-home --home-dir "$HOME_DIR" \ --shell /usr/bin/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 ==" if [[ ! -x "$VENV/bin/python" ]]; then sudo -u "$SERVICE_USER" python -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 </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" if [[ "$INSTALL_UPDATE_HELPER" == "1" ]]; then 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" \ "$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" 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."