diff --git a/.env.example b/.env.example index 2a0dcbe..229b724 100644 --- a/.env.example +++ b/.env.example @@ -20,8 +20,10 @@ LEMBAS_RELOAD=false # debug | info | warning | error LEMBAS_LOG_LEVEL=info -# Allow new accounts to register themselves. The very first account created is -# always an admin, regardless of this setting. Turn off once your users exist. +# Allow new accounts to register themselves. This is only the INITIAL value: +# once an administrator sets it under Admin -> General, the stored setting wins +# and this variable is ignored. The very first account created is always an +# admin regardless. LEMBAS_ALLOW_SIGNUP=true # Default theme for signed-out visitors: moria (dark) or shire (light). diff --git a/CLAUDE.md b/CLAUDE.md index 08b20ee..b107fb8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -132,6 +132,12 @@ as parameters for exactly this reason. two marks on one page with identical ids make the second silently reuse the first one's gradients. +**Two kinds of settings.** `lembas.config` is deployment configuration read +from the environment at startup. `services/settings_store.py` is instance +settings an admin edits at runtime, stored in the `settings` table. Environment +variables seed the latter as an *initial* value only — once stored, the database +wins, or a toggle in the UI would silently revert on the next restart. + **JSON columns need reassignment.** `user.settings_json["theme"] = x` on a plain dict is not detected. The columns use `MutableDict` (`db/types.py`), but the safe habit is `obj.field = {**obj.field, "k": v}`. @@ -165,10 +171,14 @@ inlined and themed. If the mark changes, update both. ## Deployment -`deploy/` holds the systemd unit and nginx vhost for the gamebox install at -`https://chat.lan`. See `deploy/README.md`. Service user `lembas`, home -`/home/lembas` bind-mounted to `/srv/lembas`, mirroring the llama-swap and -comfyui conventions on that machine. +`deploy/` holds a systemd unit template, an nginx vhost template, and +install/update scripts. Both templates are parameterised (`__PREFIX__`, +`__SITE_HOST__`, …) and substituted at install time, so nothing host-specific is +committed here. See `deploy/README.md`. + +This repository is **public**. Keep deployment-specific hostnames, ports and +internal infrastructure detail out of it — those belong in whatever private +notes describe the machine. ## Not built yet diff --git a/README.md b/README.md index b7d08b0..4bc8c2d 100644 --- a/README.md +++ b/README.md @@ -37,7 +37,9 @@ runtime. Clone it, `pip install -e .`, run it. - **OpenAI connections** — point at OpenAI, LM Studio, vLLM, llama.cpp, llama-swap, Ollama or OpenRouter; models are discovered and cached - **Accounts** — first account becomes the administrator, argon2 password - hashing, revocable server-side sessions + hashing, revocable server-side sessions, self-service password change +- **Admin settings** — open or close registration from the UI, stored in the + database and effective immediately - **Two themes** — *Moria* (dark) and *Shire* (light), switchable per user **Planned** @@ -80,7 +82,7 @@ All variables are prefixed `LEMBAS_` and can live in `.env`. See | `LEMBAS_SECRET_KEY` | *generated* | Signs sessions and encrypts stored API keys. **Set this.** A generated key changes every restart, signing everyone out and making stored API keys unreadable. | | `LEMBAS_DATA_DIR` | `./data` | SQLite database and uploads. | | `LEMBAS_HOST` / `LEMBAS_PORT` | `127.0.0.1` / `8080` | Bind address. | -| `LEMBAS_ALLOW_SIGNUP` | `true` | Let new users register themselves. The first account is always an admin regardless. | +| `LEMBAS_ALLOW_SIGNUP` | `true` | Whether new users may register themselves — the *initial* value only. Once set under **Admin → General** the stored setting wins. The first account is always an admin regardless. | | `LEMBAS_DEFAULT_THEME` | `moria` | `moria` (dark) or `shire` (light). | | `LEMBAS_SESSION_TTL` | `2592000` | Session lifetime in seconds. | | `LEMBAS_REQUEST_TIMEOUT` | `300` | Seconds to wait on an upstream model. | diff --git a/deploy/README.md b/deploy/README.md index 1a5a080..63f2d05 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -1,51 +1,63 @@ # Deployment -Installs LLeMbas as a **system** service behind nginx at `https://chat.lan`. +Installs LLeMbas as a **system** service behind nginx with a self-signed +certificate. Written for a systemd + nginx host; tested on Arch. -Written for `gamebox` (Arch), and follows the conventions already used there -for llama-swap and comfyui: - -| | | +| | Default | |---|---| | Service user | `lembas` (system account, `nologin`) | -| Home | `/home/lembas`, bind-mounted to `/srv/lembas` | -| Checkout | `/srv/lembas/app` (git clone of the Gitea remote) | -| Virtualenv | `/srv/lembas/venv` | -| Database | `/srv/lembas/data/lembas.db` | -| Environment | `/srv/lembas/lembas.env` (mode 600) | +| Home | `/home/lembas` | +| Install prefix | `/srv/lembas` (bind mount of the home) | +| Checkout | `$PREFIX/app` | +| Virtualenv | `$PREFIX/venv` | +| Database | `$PREFIX/data/lembas.db` | +| Environment | `$PREFIX/lembas.env` (mode 600) | | Unit | `/etc/systemd/system/lembas.service` | -| Vhost | `/etc/nginx/conf.d/chat.lan.conf`, self-signed cert | -| Listens | `127.0.0.1:8080` — reachable only through nginx | +| Vhost | `/etc/nginx/conf.d/.conf` | +| Listens on | `127.0.0.1:8080` — reachable only through nginx | -The home lives on `/home` rather than `/var/lib` because the root LV on that -box is only 50 GB; `/srv/lembas` is the same bind-mount trick as `/srv/llama` -and `/srv/comfyui`. +The prefix defaults to a bind mount of the service user's home because on many +machines the root filesystem is small while `/home` is not, and the virtualenv +plus database belong on the larger volume. Set `PREFIX=$HOME_DIR` to skip it. ## First install ```bash -./deploy/install.sh +SITE_HOST=chat.example ./deploy/install.sh ``` Idempotent — safe to re-run. It creates the user and bind mount, clones the -repo, builds the venv, generates `lembas.env` with a fresh -`LEMBAS_SECRET_KEY`, installs the unit and vhost, issues a self-signed cert, -adds a `/etc/hosts` entry, and enables the service. +repo, builds the venv, generates `lembas.env` with a fresh `LEMBAS_SECRET_KEY`, +installs the unit and vhost, issues a self-signed certificate, adds a +`/etc/hosts` entry if the name does not already resolve, and enables the +service. -Then open , accept the self-signed certificate warning, and -create the first account — it becomes the administrator. +Then open `https://`, accept the certificate warning, and create the +first account — it becomes the administrator. + +Everything is overridable from the environment: + +| Variable | Default | | +|---|---|---| +| `SITE_HOST` | `lembas.local` | nginx `server_name` and certificate CN | +| `APP_PORT` | `8080` | loopback port the service binds | +| `SERVICE_USER` | `lembas` | system account to run as | +| `HOME_DIR` | `/home/lembas` | that account's home | +| `PREFIX` | `/srv/lembas` | install root (bind mount of `HOME_DIR`) | +| `REPO_URL` | this checkout's `origin` | so a fork deploys itself | +| `LEMBAS_BRANCH` | `main` | branch to deploy | ## Deploying a change ```bash -git push # from the working copy +git push ./deploy/update.sh ``` -`update.sh` fetches, hard-resets `/srv/lembas/app` to `origin/main`, reinstalls -dependencies and restarts the service, then prints what changed. The hard reset -is deliberate: nothing is ever edited in place there, so there is no local work -to preserve and no merge conflicts to resolve. +`update.sh` fetches, hard-resets the deployment checkout to `origin/main`, +reinstalls dependencies and restarts, printing the commits it pulled. The hard +reset is deliberate: nothing is ever edited in place there, so there is no local +work to preserve and no conflicts to resolve. ## Operating it @@ -53,10 +65,9 @@ to preserve and no merge conflicts to resolve. systemctl status lembas journalctl -u lembas -f sudo -u lembas /srv/lembas/venv/bin/lembas info # paths and counts -sudo systemctl restart lembas ``` -Configuration lives in `/srv/lembas/lembas.env`. Edit it and restart. +Configuration lives in `$PREFIX/lembas.env`. Edit it and restart. ## Notes @@ -70,11 +81,10 @@ delivers it in one lump at the end, which is indistinguishable from streaming being broken. `proxy_read_timeout` is raised to an hour because a model can think for minutes before the first token. -**Name resolution.** `chat.lan` is in Pi-hole, but this box queries the router -first and the router's dnsmasq is authoritative for `.lan` without forwarding -those queries on — the same reason `comfy.lan` needs one. `install.sh` adds a -`/etc/hosts` entry, which is harmless if DNS already answers. - **Hardening is deliberately moderate.** `ProtectSystem=full`, not `strict`: the agentic features planned for later need to run commands, and a lockdown that has to be torn out again is worse than one that was never applied. + +**Use a real certificate if this is exposed beyond a trusted LAN.** The +self-signed cert exists so the install works with no external dependencies; +point `ssl_certificate` at a real one and nothing else needs to change. diff --git a/deploy/install.sh b/deploy/install.sh index 93a6019..aac818e 100755 --- a/deploy/install.sh +++ b/deploy/install.sh @@ -1,28 +1,52 @@ #!/usr/bin/env bash -# Install LLeMbas as a system service behind nginx at https://chat.lan. +# Install LLeMbas as a system service behind nginx with a self-signed cert. # -# Follows the conventions already used on this box for llama-swap and comfyui: -# a dedicated service user whose home lives on /home (the root LV is only -# 50 GB) and is bind-mounted to /srv/, a system unit so it survives -# logout, and an nginx vhost 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. # -# 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 -REPO_URL="${LEMBAS_REPO_URL:-https://git.houmeres.sk/Houmeres/LLeMbas.git}" +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}" -SERVICE_USER=lembas -HOME_DIR=/home/lembas -PREFIX=/srv/lembas +# 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" -HERE="$(dirname "$(readlink -f "$0")")" + +if [[ -z "$REPO_URL" ]]; then + echo "Could not determine REPO_URL. Set it explicitly." >&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)" echo "== service user ==" # --system: no ageing, no mail spool. Home under /home, not /var/lib, so the -# venv and database sit on the big volume. +# 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" @@ -31,12 +55,14 @@ else fi sudo chmod 755 "$HOME_DIR" -echo "== /srv/lembas bind-mount onto /home ==" -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" +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 @@ -53,8 +79,8 @@ sudo -u "$SERVICE_USER" "$VENV/bin/pip" install --quiet --upgrade pip sudo -u "$SERVICE_USER" "$VENV/bin/pip" install --quiet -e "$APP" echo "== environment ==" -# Generated once and never regenerated: rotating LEMBAS_SECRET_KEY would sign -# every user out and make the stored API keys unreadable. +# 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 sudo systemctl daemon-reload -echo "== self-signed cert for chat.lan ==" +echo "== self-signed cert for $SITE_HOST ==" sudo mkdir -p /etc/nginx/ssl -if [[ ! -f /etc/nginx/ssl/chat.lan.crt ]]; then +if [[ ! -f "/etc/nginx/ssl/$SITE_HOST.crt" ]]; then sudo openssl req -x509 -newkey rsa:2048 -nodes \ - -keyout /etc/nginx/ssl/chat.lan.key -out /etc/nginx/ssl/chat.lan.crt \ - -days 3650 -subj "/CN=chat.lan" -addext "subjectAltName=DNS:chat.lan" - sudo chmod 600 /etc/nginx/ssl/chat.lan.key - sudo chmod 644 /etc/nginx/ssl/chat.lan.crt + -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 ==" -sudo install -Dm644 "$HERE/chat.lan.nginx.conf" /etc/nginx/conf.d/chat.lan.conf +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 echo "== local name resolution ==" -# chat.lan is in Pi-hole, but this box asks the router first and the router's -# dnsmasq is authoritative for .lan without forwarding it on -- same reason -# comfy.lan needs a hosts entry. Harmless if DNS already resolves it. -grep -q 'chat\.lan' /etc/hosts \ - || printf '127.0.0.1\tchat.lan\n::1\t\tchat.lan\n' | sudo tee -a /etc/hosts >/dev/null +# 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 @@ -111,5 +141,5 @@ sleep 2 sudo systemctl --no-pager --lines=0 status lembas || true echo -echo "LLeMbas is up at https://chat.lan (self-signed cert; accept the warning)" +echo "LLeMbas is up at https://$SITE_HOST (self-signed cert; accept the warning)" echo "Create the first account -- it becomes the administrator." diff --git a/deploy/lembas.service b/deploy/lembas.service index a7c3c1b..49f0bf2 100644 --- a/deploy/lembas.service +++ b/deploy/lembas.service @@ -1,48 +1,43 @@ -# LLeMbas system service. +# LLeMbas system service template. # -# Deployed to /etc/systemd/system/lembas.service by deploy/install.sh. +# install.sh substitutes __PREFIX__ and __SERVICE_USER__ and writes the result +# to /etc/systemd/system/lembas.service. Edit this file, not the installed copy. # # A system unit, not a user unit, so it survives logout and comes up at boot -# without anyone signing in -- matching llama-swap and comfyui on this box. -# -# /srv/lembas is a bind mount of /home/lembas: the root LV is only 50 GB and -# the venv plus SQLite database belong on /home, same trick as /srv/llama. +# without anyone signing in. [Unit] Description=LLeMbas - web UI for language models -Documentation=https://git.houmeres.sk/Houmeres/LLeMbas After=network-online.target Wants=network-online.target -# The unit is useless without the bind mount: the venv and database live there. -RequiresMountsFor=/srv/lembas -# Not a hard dependency. LLeMbas starts fine with the endpoint down and shows a -# readable error in the admin UI, which is better than refusing to boot. -After=llama-swap.service +# The prefix is usually a bind mount; the venv and database live there, so +# starting before it is mounted would create an empty database in its place. +RequiresMountsFor=__PREFIX__ [Service] Type=simple -User=lembas -Group=lembas -WorkingDirectory=/srv/lembas/app -EnvironmentFile=/srv/lembas/lembas.env -ExecStart=/srv/lembas/venv/bin/lembas serve +User=__SERVICE_USER__ +Group=__SERVICE_USER__ +WorkingDirectory=__PREFIX__/app +EnvironmentFile=__PREFIX__/lembas.env +ExecStart=__PREFIX__/venv/bin/lembas serve Restart=on-failure RestartSec=5 -# Reachable only through the nginx chat.lan vhost, never directly on the LAN. -# The bind address is set by LEMBAS_HOST in the environment file. +# The bind address comes from LEMBAS_HOST in the environment file, which the +# installer sets to 127.0.0.1: reachable through nginx, never directly. # --- Hardening ------------------------------------------------------------- -# Modest rather than maximal: the agentic features planned for later will need -# to run commands, so ProtectSystem=strict would only be torn out again. +# Moderate rather than maximal. The agentic features planned for later need to +# run commands, and a lockdown that has to be torn out again is worse than one +# that was never applied. NoNewPrivileges=yes PrivateTmp=yes ProtectSystem=full ProtectKernelTunables=yes ProtectControlGroups=yes RestrictSUIDSGID=yes -# Only /srv/lembas needs to be writable; /home/lembas is the same inode. -ReadWritePaths=/srv/lembas +ReadWritePaths=__PREFIX__ LimitNOFILE=65535 [Install] diff --git a/deploy/chat.lan.nginx.conf b/deploy/nginx-vhost.conf similarity index 54% rename from deploy/chat.lan.nginx.conf rename to deploy/nginx-vhost.conf index be8d204..4d32155 100644 --- a/deploy/chat.lan.nginx.conf +++ b/deploy/nginx-vhost.conf @@ -1,12 +1,16 @@ -# chat.lan - HTTPS reverse proxy to LLeMbas (127.0.0.1:8080). -# Deployed to /etc/nginx/conf.d/chat.lan.conf. Self-signed cert (chat.lan). +# nginx vhost template for LLeMbas. # -# Mirrors the comfy.lan and llama.lan vhosts on this box. +# install.sh substitutes __SITE_HOST__ and __APP_PORT__ and writes the result to +# /etc/nginx/conf.d/.conf. Edit this file, not the installed copy. +# +# Assumes a self-signed certificate at /etc/nginx/ssl/.{crt,key}, which +# install.sh generates. To use a real certificate, point ssl_certificate at it; +# nothing else here needs to change. server { listen 80; listen [::]:80; - server_name chat.lan; + server_name __SITE_HOST__; return 301 https://$host$request_uri; } @@ -14,17 +18,17 @@ server { listen 443 ssl; listen [::]:443 ssl; http2 on; - server_name chat.lan; + server_name __SITE_HOST__; - ssl_certificate /etc/nginx/ssl/chat.lan.crt; - ssl_certificate_key /etc/nginx/ssl/chat.lan.key; + ssl_certificate /etc/nginx/ssl/__SITE_HOST__.crt; + ssl_certificate_key /etc/nginx/ssl/__SITE_HOST__.key; ssl_protocols TLSv1.2 TLSv1.3; # File uploads land here once that feature exists; 0 = no limit. client_max_body_size 0; location / { - proxy_pass http://127.0.0.1:8080; + proxy_pass http://127.0.0.1:__APP_PORT__; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; @@ -32,13 +36,14 @@ server { proxy_set_header X-Forwarded-Proto $scheme; # Streamed replies are server-sent events. Every one of these matters: - # with buffering on, nginx holds the whole reply and delivers it in one - # lump at the end, which looks exactly like streaming being broken. + # with buffering on (the default) nginx holds the whole reply and + # delivers it in one lump at the end, which is indistinguishable from + # streaming being broken. proxy_buffering off; proxy_request_buffering off; proxy_cache off; - # SSE is plain HTTP/1.1 chunked, so the connection header must not be - # the websocket upgrade dance -- it must simply stay open. + # SSE is plain HTTP/1.1 chunked, not a websocket upgrade, so the + # connection header must simply be left to keep-alive. proxy_set_header Connection ""; # A model can think for minutes before the first token. The default @@ -47,9 +52,8 @@ server { proxy_send_timeout 3600s; } - # Static assets are immutable per release and never need revalidating. location /static/ { - proxy_pass http://127.0.0.1:8080; + proxy_pass http://127.0.0.1:__APP_PORT__; proxy_set_header Host $host; expires 1h; add_header Cache-Control "public"; diff --git a/deploy/update.sh b/deploy/update.sh index db63397..781cb1e 100755 --- a/deploy/update.sh +++ b/deploy/update.sh @@ -1,36 +1,39 @@ #!/usr/bin/env bash -# Pull the latest LLeMbas and restart the service. +# Pull the latest LLeMbas into the deployment and restart the service. # -# This is what to run after pushing: it fetches, hard-resets the deployment -# checkout to the remote branch, reinstalls dependencies if they changed, and -# restarts. Nothing is ever edited in place at /srv/lembas/app, so a hard reset -# is safe and avoids merge conflicts from a dirty deployment tree. +# Run this after pushing. It fetches, hard-resets the deployment checkout to the +# remote branch, reinstalls dependencies if they changed, and restarts. Nothing +# is ever edited in place under the deployment prefix, so a hard reset is safe +# and avoids merge conflicts from a dirty tree. set -euo pipefail -SERVICE_USER=lembas -PREFIX=/srv/lembas +SERVICE_USER="${SERVICE_USER:-lembas}" +PREFIX="${PREFIX:-/srv/lembas}" +BRANCH="${LEMBAS_BRANCH:-main}" + APP="$PREFIX/app" VENV="$PREFIX/venv" -BRANCH="${LEMBAS_BRANCH:-main}" if [[ ! -d "$APP/.git" ]]; then echo "No deployment at $APP. Run deploy/install.sh first." >&2 exit 1 fi -before=$(sudo -u "$SERVICE_USER" git -C "$APP" rev-parse HEAD) +git_as() { sudo -u "$SERVICE_USER" git -C "$APP" "$@"; } + +before=$(git_as rev-parse HEAD) echo "== fetching ==" -sudo -u "$SERVICE_USER" git -C "$APP" fetch --quiet origin "$BRANCH" -sudo -u "$SERVICE_USER" git -C "$APP" reset --hard --quiet "origin/$BRANCH" +git_as fetch --quiet origin "$BRANCH" +git_as reset --hard --quiet "origin/$BRANCH" -after=$(sudo -u "$SERVICE_USER" git -C "$APP" rev-parse HEAD) +after=$(git_as rev-parse HEAD) if [[ "$before" == "$after" ]]; then - echo " already at $(git -C "$APP" rev-parse --short HEAD), nothing to pull" + echo " already at ${after:0:7}, nothing to pull" else - echo " $(echo "$before" | cut -c1-7) -> $(echo "$after" | cut -c1-7)" - sudo -u "$SERVICE_USER" git -C "$APP" --no-pager log --oneline "$before..$after" | sed 's/^/ /' + echo " ${before:0:7} -> ${after:0:7}" + git_as --no-pager log --oneline "$before..$after" | sed 's/^/ /' fi # Cheap and idempotent; catches a dependency added since the last deploy. @@ -42,7 +45,7 @@ sudo systemctl restart lembas sleep 2 if systemctl is-active --quiet lembas; then - echo " lembas is running at https://chat.lan" + echo " lembas is running" else echo " lembas FAILED to start:" >&2 sudo journalctl -u lembas -n 30 --no-pager >&2 diff --git a/src/lembas/api/admin.py b/src/lembas/api/admin.py index 214c68a..764526a 100644 --- a/src/lembas/api/admin.py +++ b/src/lembas/api/admin.py @@ -11,7 +11,8 @@ from sqlalchemy import func, select from sqlalchemy.orm import Session as DBSession from lembas.api.deps import AdminUser, Db -from lembas.db.models import Connection, Model +from lembas.db.models import Connection, Model, User +from lembas.services import settings_store from lembas.services.crypto import decrypt, encrypt, mask from lembas.services.llm.openai_client import Endpoint, LLMError, list_models from lembas.web.templating import render @@ -39,7 +40,43 @@ def _connections(db: DBSession) -> list[Connection]: @router.get("") async def admin_home(user: AdminUser): - return RedirectResponse("/admin/connections", status_code=status.HTTP_303_SEE_OTHER) + return RedirectResponse("/admin/general", status_code=status.HTTP_303_SEE_OTHER) + + +@router.get("/general") +async def general_page(request: Request, db: Db, user: AdminUser, saved: bool = False): + return render( + request, + "admin/general.html", + { + "values": settings_store.get_group(db), + "saved": saved, + "user_count": db.scalar(select(func.count()).select_from(User)), + }, + ) + + +@router.post("/general") +async def save_general( + db: Db, + user: AdminUser, + instance_name: str = Form("LLeMbas"), + allow_signup: bool = Form(False), +) -> Response: + """Save instance settings. + + Unchecked checkboxes are simply absent from a form post, which is why + allow_signup defaults to False here -- that absence *is* the "off" signal. + """ + settings_store.update( + db, + { + "instance_name": instance_name.strip()[:120] or "LLeMbas", + "allow_signup": allow_signup, + }, + ) + log.info("registration %s by %s", "opened" if allow_signup else "closed", user.email) + return RedirectResponse("/admin/general?saved=1", status_code=status.HTTP_303_SEE_OTHER) @router.get("/connections") diff --git a/src/lembas/api/auth.py b/src/lembas/api/auth.py index 65d3677..3a836ff 100644 --- a/src/lembas/api/auth.py +++ b/src/lembas/api/auth.py @@ -13,6 +13,7 @@ from lembas.config import settings from lembas.db.models import ROLE_ADMIN, ROLE_USER, User from lembas.security.passwords import hash_password, validate_password, verify_password from lembas.security.sessions import COOKIE_NAME, create_session, revoke_session +from lembas.services import settings_store from lembas.web.templating import render log = logging.getLogger(__name__) @@ -48,6 +49,19 @@ def _safe_next(raw: str | None) -> str: return raw +def _login_page(request: Request, db: Db, *, status_code: int = 200, **context): + """Render the sign-in page. + + Always goes through here so `allow_signup` reflects the *stored* setting + rather than the environment default baked in by render(). Otherwise the + "Create one" link would keep appearing after an administrator closed + registration, offering a link that only leads to a refusal. + """ + context.setdefault("next", "/") + context["allow_signup"] = settings_store.signup_allowed(db) + return render(request, "auth/login.html", context, status_code=status_code) + + @router.get("/login") async def login_form(request: Request, db: Db, user: CurrentUser, next: str = "/"): if user is not None: @@ -57,7 +71,7 @@ async def login_form(request: Request, db: Db, user: CurrentUser, next: str = "/ # cannot possibly satisfy. if _no_users_yet(db): return RedirectResponse("/auth/register", status_code=status.HTTP_303_SEE_OTHER) - return render(request, "auth/login.html", {"next": _safe_next(next)}) + return _login_page(request, db, next=_safe_next(next)) @router.post("/login") @@ -75,21 +89,23 @@ async def login( # cannot be used to discover which addresses are registered. if user is None or not verify_password(password, user.password_hash): log.info("failed sign-in for %s", email) - return render( + return _login_page( request, - "auth/login.html", - {"error": "That email and password do not match.", "email": email, - "next": _safe_next(next)}, + db, status_code=status.HTTP_401_UNAUTHORIZED, + error="That email and password do not match.", + email=email, + next=_safe_next(next), ) if not user.active: - return render( + return _login_page( request, - "auth/login.html", - {"error": "This account has been deactivated. Ask an administrator.", - "email": email, "next": _safe_next(next)}, + db, status_code=status.HTTP_403_FORBIDDEN, + error="This account has been deactivated. Ask an administrator.", + email=email, + next=_safe_next(next), ) token = create_session( @@ -108,12 +124,12 @@ async def register_form(request: Request, db: Db, user: CurrentUser): if user is not None: return RedirectResponse("/", status_code=status.HTTP_303_SEE_OTHER) first_run = _no_users_yet(db) - if not first_run and not settings.allow_signup: - return render( + if not first_run and not settings_store.signup_allowed(db): + return _login_page( request, - "auth/login.html", - {"error": "Registration is closed. Ask an administrator for an account."}, + db, status_code=status.HTTP_403_FORBIDDEN, + error="Registration is closed. Ask an administrator for an account.", ) return render(request, "auth/register.html", {"first_run": first_run}) @@ -127,12 +143,12 @@ async def register( password: str = Form(...), ): first_run = _no_users_yet(db) - if not first_run and not settings.allow_signup: - return render( + if not first_run and not settings_store.signup_allowed(db): + return _login_page( request, - "auth/login.html", - {"error": "Registration is closed. Ask an administrator for an account."}, + db, status_code=status.HTTP_403_FORBIDDEN, + error="Registration is closed. Ask an administrator for an account.", ) name = name.strip() diff --git a/src/lembas/api/pages.py b/src/lembas/api/pages.py index e9f9675..21138c2 100644 --- a/src/lembas/api/pages.py +++ b/src/lembas/api/pages.py @@ -97,13 +97,23 @@ async def chat_detail(request: Request, db: Db, user: RequiredUser, chat_id: str @router.get("/settings") -async def settings_page(request: Request, db: Db, user: RequiredUser): +async def settings_page( + request: Request, + db: Db, + user: RequiredUser, + error: str = "", + saved: str = "", +): + # error/saved arrive as query parameters because the password form redirects + # back here: a POST that re-rendered in place would re-submit on refresh. return render( request, "settings.html", { "chat": None, "models": chat_service.available_models(db), + "error": error, + "saved": saved, **_sidebar_context(db, user), }, ) diff --git a/src/lembas/api/preferences.py b/src/lembas/api/preferences.py index 8f08639..c493b8b 100644 --- a/src/lembas/api/preferences.py +++ b/src/lembas/api/preferences.py @@ -2,9 +2,17 @@ from __future__ import annotations -from fastapi import APIRouter, Body +import logging + +from fastapi import APIRouter, Body, Form, Request, status +from fastapi.responses import RedirectResponse, Response from lembas.api.deps import Db, RequiredUser +from lembas.config import settings +from lembas.security.passwords import hash_password, validate_password, verify_password +from lembas.security.sessions import COOKIE_NAME, create_session, revoke_all_for_user + +log = logging.getLogger(__name__) router = APIRouter(prefix="/api/preferences", tags=["preferences"]) @@ -27,3 +35,68 @@ async def set_theme(db: Db, user: RequiredUser, theme: str = Body(..., embed=Tru user.settings_json = {**(user.settings_json or {}), "theme": theme} db.commit() return {"ok": True, "theme": theme} + + +@router.post("/password") +async def change_password( + request: Request, + db: Db, + user: RequiredUser, + current_password: str = Form(...), + new_password: str = Form(...), + confirm_password: str = Form(...), +) -> Response: + """Change your own password. + + Every other session is revoked on success. If the reason for changing a + password is that someone else knows it, leaving their session alive would + defeat the point. + """ + + def back(message: str, ok: bool = False) -> Response: + from urllib.parse import quote + + field = "saved" if ok else "error" + return RedirectResponse( + f"/settings?{field}={quote(message)}", status_code=status.HTTP_303_SEE_OTHER + ) + + if not verify_password(current_password, user.password_hash): + log.info("failed password change for %s: current password wrong", user.email) + return back("Your current password is not correct.") + + if new_password != confirm_password: + return back("The new passwords do not match.") + + if (problem := validate_password(new_password)) is not None: + return back(problem) + + if verify_password(new_password, user.password_hash): + return back("That is already your password.") + + user.password_hash = hash_password(new_password) + db.commit() + + revoke_all_for_user(db, user) + token = create_session( + db, + user, + user_agent=request.headers.get("user-agent", ""), + ip_address=request.client.host if request.client else "", + ) + log.info("password changed for %s; other sessions revoked", user.email) + + # revoke_all_for_user killed this session too, so hand back a fresh cookie + # -- otherwise changing your password would sign you out of the tab you are + # standing in. + response = back("Password changed. Any other sessions have been signed out.", ok=True) + response.set_cookie( + COOKIE_NAME, + token, + max_age=settings.session_ttl, + httponly=True, + samesite="lax", + secure=False, + path="/", + ) + return response diff --git a/src/lembas/services/settings_store.py b/src/lembas/services/settings_store.py new file mode 100644 index 0000000..63823a2 --- /dev/null +++ b/src/lembas/services/settings_store.py @@ -0,0 +1,63 @@ +"""Instance-wide settings that administrators can change at runtime. + +Distinct from ``lembas.config``, which holds deployment configuration read from +the environment at startup. Anything here is editable from the admin UI and +lives in the ``settings`` table. + +Environment variables act as the *initial* value only. Once an administrator +sets something in the UI, the stored value wins -- otherwise a toggle in the +interface would silently revert on the next restart, which is worse than not +offering the toggle at all. +""" + +from __future__ import annotations + +from typing import Any + +from sqlalchemy.orm import Session as DBSession + +from lembas.config import settings as env_settings +from lembas.db.models import Setting + +GENERAL = "general" + + +def _defaults() -> dict[str, Any]: + return { + "allow_signup": env_settings.allow_signup, + # When on, new accounts land in the `pending` role and cannot sign in + # until an administrator approves them. Reserved for the users pass. + "require_approval": False, + "instance_name": "LLeMbas", + } + + +def get_group(db: DBSession, key: str = GENERAL) -> dict[str, Any]: + """Stored settings for a group, with defaults filled in for absent keys.""" + values = _defaults() if key == GENERAL else {} + row = db.get(Setting, key) + if row is not None and isinstance(row.value, dict): + values.update(row.value) + return values + + +def get(db: DBSession, name: str, *, key: str = GENERAL) -> Any: + return get_group(db, key).get(name) + + +def update(db: DBSession, changes: dict[str, Any], *, key: str = GENERAL) -> dict[str, Any]: + """Merge changes into a settings group and persist them.""" + row = db.get(Setting, key) + if row is None: + row = Setting(key=key, value={}) + db.add(row) + + # Reassigned rather than mutated: SQLAlchemy only reliably detects a change + # to a JSON column when the whole value is replaced. + row.value = {**(row.value or {}), **changes} + db.commit() + return get_group(db, key) + + +def signup_allowed(db: DBSession) -> bool: + return bool(get(db, "allow_signup")) diff --git a/src/lembas/web/templates/admin/_layout.html b/src/lembas/web/templates/admin/_layout.html index 05afef6..21bd4c5 100644 --- a/src/lembas/web/templates/admin/_layout.html +++ b/src/lembas/web/templates/admin/_layout.html @@ -22,6 +22,10 @@