TESM-Lizenzserver 2.0.0 -- Neubau
Vollstaendiger Neubau der Anwendung. Der vorherige Stand bleibt unveraendert im Zweig SONNET5 erhalten. Aufbau: apps/tesm-license (Anwendung), packages/tesm-core (gemeinsamer Kern), packages/tesm-licensing (Lizenzprotokoll), deploy (Installation, systemd, privilegierter Helfer), docs, tests. Die verwaltete Anwendung liegt in ihrem eigenen Repository; beide Repositorien bringen die gemeinsamen Pakete mit, damit sich jedes allein installieren laesst. Die wichtigsten Unterschiede zum Vorgaenger: * Keine doppelte licensing.py -- ein Paket, das beide Anwendungen installieren, statt zweier Dateien, die byte-identisch bleiben sollen. * Der Webprozess laeuft unprivilegiert; alles, was Root braucht, geht ueber einen einzigen Helfer mit Positivlisten fuer jedes Argument. * CSRF-Schutz ueberhaupt -- der Vorgaenger hatte keinen. * Rechte werden serverseitig geprueft, nicht nur im Template ausgeblendet. * Keine Lizenz ohne master_endpoint: eine Ausstellung ohne Endpunkt wird abgelehnt statt eine Lizenz zu erzeugen, die sich nie aktivieren kann. * Offline-Aktivierung in beide Richtungen; die Lizenz bleibt als "Aktivierung offen" markiert, bis sie zurueckkommt. * Getrennte Signaturkontexte je Nachrichtenart, Nonce gegen Wiedereinspielung, seq gegen das Zurueckrollen auf eine aeltere Lizenz. * Kein Hostname im Maschinen-Fingerabdruck. * Verschachtelte Datenbankverbindungen sind ein Fehler, kein Deadlock. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,351 @@
|
||||
#!/bin/bash
|
||||
# =============================================================================
|
||||
# tesm-helper -- die einzige Stelle, an der TESM Root-Rechte bekommt.
|
||||
#
|
||||
# Der Webprozess laeuft unprivilegiert. Alles, was Root braucht, geht durch
|
||||
# dieses Skript: es gehoert root, ist per sudoers mit NOPASSWD nur fuer genau
|
||||
# diesen Pfad freigegeben und prueft jedes Argument gegen eine Positivliste.
|
||||
#
|
||||
# Damit ist die Sicherheitsgrenze klein, an einer Stelle und pruefbar -- statt
|
||||
# ueber tausende Zeilen Anwendungscode verteilt. Der Vorgaenger liess die
|
||||
# komplette Flask-Anwendung als root laufen; eine einzige Luecke in irgendeinem
|
||||
# Pfad haette den Host bedeutet.
|
||||
#
|
||||
# Regeln fuer Aenderungen an dieser Datei:
|
||||
# * Jedes neue Verb braucht eine Argumentpruefung. Ohne Pruefung kein Verb.
|
||||
# * Nie "$@" ungeprueft an eine Shell weiterreichen.
|
||||
# * Nie einen Pfad akzeptieren, der nicht gegen eine feste Liste geprueft ist.
|
||||
# =============================================================================
|
||||
set -euo pipefail
|
||||
umask 022
|
||||
|
||||
APP_KEYS_ALLOWED=("tesm" "tesm-license")
|
||||
SERVICES_ALLOWED=(
|
||||
"tesm.service" "tesm-monitor.service"
|
||||
"tesm-license.service"
|
||||
"kea-dhcp4-server" "kea-dhcp4-server.service"
|
||||
"nginx" "nginx.service"
|
||||
)
|
||||
PACKAGES_ALLOWED=("kea-dhcp4-server" "kea-common" "cifs-utils" "certbot" "expect"
|
||||
"samba-common-bin")
|
||||
|
||||
fail() { echo "tesm-helper: $*" >&2; exit 2; }
|
||||
|
||||
require_root() {
|
||||
[[ "$(id -u)" -eq 0 ]] || fail "muss als root laufen (ueber sudo aufrufen)"
|
||||
}
|
||||
|
||||
# -- Argumentpruefungen ------------------------------------------------------
|
||||
|
||||
check_in_list() {
|
||||
local needle="$1"; shift
|
||||
local item
|
||||
for item in "$@"; do
|
||||
[[ "$needle" == "$item" ]] && return 0
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
# Name einer Installation auf diesem Host: entweder die Hauptinstallation
|
||||
# ("tesm") oder eine benannte Instanz daneben ("tesm-opus"). Mehr als diese
|
||||
# beiden Formen gibt es nicht -- so kann keine erfundene Zeichenkette einen
|
||||
# Pfad ausserhalb des vorgesehenen Namensraums erzeugen.
|
||||
check_app_key() {
|
||||
local key="$1" app
|
||||
for app in "${APP_KEYS_ALLOWED[@]}"; do
|
||||
[[ "$key" == "$app" ]] && return 0
|
||||
[[ "$key" == "$app-"* && "${key#"$app"-}" =~ ^[a-z0-9][a-z0-9-]{0,15}$ ]] && return 0
|
||||
done
|
||||
fail "unbekannter App-Schluessel: $key"
|
||||
}
|
||||
|
||||
check_service() {
|
||||
check_in_list "$1" "${SERVICES_ALLOWED[@]}" || fail "Dienst nicht erlaubt: $1"
|
||||
}
|
||||
|
||||
check_hostname() {
|
||||
[[ "$1" =~ ^[A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?$ ]] || fail "ungueltiger Hostname"
|
||||
}
|
||||
|
||||
check_timezone() {
|
||||
[[ "$1" =~ ^[A-Za-z]+(/[A-Za-z0-9_+-]+){0,2}$ ]] || fail "ungueltige Zeitzone"
|
||||
[[ -f "/usr/share/zoneinfo/$1" ]] || fail "unbekannte Zeitzone"
|
||||
}
|
||||
|
||||
check_domain() {
|
||||
[[ "$1" =~ ^[A-Za-z0-9]([A-Za-z0-9.-]{0,251}[A-Za-z0-9])?$ ]] || fail "ungueltige Domain"
|
||||
}
|
||||
|
||||
check_email() {
|
||||
[[ "$1" =~ ^[^@[:space:]]+@[^@[:space:]]+\.[^@[:space:]]+$ ]] || fail "ungueltige E-Mail"
|
||||
}
|
||||
|
||||
check_unc() {
|
||||
[[ "$1" =~ ^//[A-Za-z0-9._-]+/[^[:space:]]+$ || "$1" =~ ^\\\\[A-Za-z0-9._-]+\\[^[:space:]]+$ ]] \
|
||||
|| fail "ungueltiger Freigabepfad"
|
||||
}
|
||||
|
||||
check_mount_target() {
|
||||
# Nur unterhalb der bekannten Einbindungswurzeln, kein ".." im Pfad.
|
||||
[[ "$1" == /mnt/tesm-shares/* ]] || fail "Einbindungsziel liegt ausserhalb von /mnt/tesm-shares"
|
||||
[[ "$1" != *".."* ]] || fail "unzulaessiger Pfad"
|
||||
}
|
||||
|
||||
check_readable_path() {
|
||||
# Positivliste der Dateien, die die Anwendung lesen darf, obwohl sie ihr
|
||||
# nicht gehoeren. Ohne die ".."-Sperre koennte ein Muster wie
|
||||
# /etc/nginx/sites-available/../../../root/tesm die Liste unterlaufen: der
|
||||
# Basisname waere "tesm" und damit gueltig, gelesen wuerde aber /root/tesm.
|
||||
[[ "$1" != *".."* ]] || fail "unzulaessiger Pfad"
|
||||
case "$1" in
|
||||
/var/log/kea/kea-dhcp4.log) ;;
|
||||
/var/lib/kea/kea-leases4.csv) ;;
|
||||
/etc/netplan/90-*.yaml)
|
||||
check_app_key "$(basename "$1" .yaml | sed 's/^90-//')" ;;
|
||||
/etc/nginx/sites-available/*)
|
||||
check_app_key "$(basename "$1")" ;;
|
||||
*) fail "Datei nicht in der Leseliste: $1" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
read_stdin_limited() {
|
||||
# Maximal 1 MiB aus stdin -- verhindert, dass ein Fehler die Platte fuellt.
|
||||
head -c 1048576
|
||||
}
|
||||
|
||||
write_atomic() {
|
||||
local target="$1" mode="$2" owner="$3"
|
||||
local tmp
|
||||
tmp="$(mktemp "${target}.XXXXXX")"
|
||||
read_stdin_limited > "$tmp"
|
||||
chmod "$mode" "$tmp"
|
||||
[[ -n "$owner" ]] && chown "$owner" "$tmp"
|
||||
mv -f "$tmp" "$target"
|
||||
}
|
||||
|
||||
# -- Verben ------------------------------------------------------------------
|
||||
|
||||
verb="${1:-}"; shift || true
|
||||
require_root
|
||||
|
||||
case "$verb" in
|
||||
|
||||
service-status)
|
||||
check_service "${1:?Dienst fehlt}"
|
||||
systemctl is-active "$1" || true
|
||||
systemctl is-enabled "$1" || true
|
||||
;;
|
||||
|
||||
service-start|service-stop|service-restart|service-enable|service-disable)
|
||||
check_service "${1:?Dienst fehlt}"
|
||||
systemctl "${verb#service-}" "$1"
|
||||
;;
|
||||
|
||||
nginx-test)
|
||||
nginx -t
|
||||
;;
|
||||
|
||||
nginx-reload)
|
||||
nginx -t && systemctl reload nginx
|
||||
;;
|
||||
|
||||
nginx-write)
|
||||
check_app_key "${1:?App-Schluessel fehlt}"
|
||||
target="/etc/nginx/sites-available/$1"
|
||||
write_atomic "$target" 0644 "root:root"
|
||||
ln -sfn "$target" "/etc/nginx/sites-enabled/$1"
|
||||
rm -f /etc/nginx/sites-enabled/default
|
||||
;;
|
||||
|
||||
nginx-apply)
|
||||
# Schreiben, pruefen, neu laden -- als *eine* Operation, mit Ruecknahme.
|
||||
#
|
||||
# Getrennte Verben waren hier ein echter Fehler: "nginx-write" verlinkt die
|
||||
# Datei sofort nach sites-enabled. Schlug das anschliessende "nginx -t"
|
||||
# fehl, blieb eine kaputte Konfiguration aktiviert liegen. Der laufende
|
||||
# nginx merkte davon nichts -- aber der naechste Reload oder Neustart durch
|
||||
# irgendwen (auch fuer eine voellig andere Site) schlug fehl. Aus einem
|
||||
# abgelehnten Formular wurde so ein Ausfall des ganzen Webservers.
|
||||
check_app_key "${1:?App-Schluessel fehlt}"
|
||||
target="/etc/nginx/sites-available/$1"
|
||||
link="/etc/nginx/sites-enabled/$1"
|
||||
backup="$(mktemp /run/tesm-nginx.XXXXXX)"
|
||||
had_file=0; had_link=0
|
||||
[[ -f "$target" ]] && { cp -a "$target" "$backup"; had_file=1; }
|
||||
[[ -L "$link" || -e "$link" ]] && had_link=1
|
||||
|
||||
write_atomic "$target" 0644 "root:root"
|
||||
ln -sfn "$target" "$link"
|
||||
|
||||
if nginx -t 2>&1; then
|
||||
rm -f /etc/nginx/sites-enabled/default
|
||||
systemctl reload nginx
|
||||
rm -f "$backup"
|
||||
echo "uebernommen"
|
||||
else
|
||||
# Genau in den Zustand zurueck, der vorher galt.
|
||||
if [[ $had_file -eq 1 ]]; then
|
||||
cp -a "$backup" "$target"
|
||||
else
|
||||
rm -f "$target"
|
||||
fi
|
||||
[[ $had_link -eq 1 ]] || rm -f "$link"
|
||||
rm -f "$backup"
|
||||
# Nach der Ruecknahme muss die Konfiguration wieder sauber sein. Ist sie
|
||||
# es nicht, war sie schon vorher kaputt -- das gehoert ins Protokoll.
|
||||
nginx -t >/dev/null 2>&1 || echo "Warnung: nginx-Konfiguration war schon vorher fehlerhaft." >&2
|
||||
fail "nginx hat die Konfiguration abgelehnt -- alter Stand wiederhergestellt"
|
||||
fi
|
||||
;;
|
||||
|
||||
netplan-write)
|
||||
check_app_key "${1:?App-Schluessel fehlt}"
|
||||
# Eigene Datei je Anwendung: der Vorgaenger schrieb aus beiden Apps nach
|
||||
# /etc/netplan/90-tesm.yaml und ueberschrieb sich damit gegenseitig.
|
||||
write_atomic "/etc/netplan/90-$1.yaml" 0600 "root:root"
|
||||
;;
|
||||
|
||||
netplan-apply)
|
||||
netplan apply
|
||||
;;
|
||||
|
||||
hostname-set)
|
||||
check_hostname "${1:?Hostname fehlt}"
|
||||
hostnamectl set-hostname "$1"
|
||||
;;
|
||||
|
||||
timezone-set)
|
||||
check_timezone "${1:?Zeitzone fehlt}"
|
||||
timedatectl set-timezone "$1"
|
||||
;;
|
||||
|
||||
kea-write)
|
||||
install -d -o root -g root -m 0755 /etc/kea
|
||||
# Kea laeuft als eigener, unprivilegierter Benutzer und muss seine
|
||||
# Lease-Datei und sein Protokoll schreiben koennen. Vor dem Test anlegen,
|
||||
# sonst beanstandet Kea die fehlenden Verzeichnisse.
|
||||
install -d -o _kea -g _kea -m 0750 /var/lib/kea /var/log/kea 2>/dev/null || true
|
||||
|
||||
# Erst pruefen, dann uebernehmen. Vorher wurde jede Konfiguration
|
||||
# ungeprueft abgelegt -- eine fehlerhafte fiel erst beim naechsten Start
|
||||
# auf, und dann lag der Dienst, bis jemand ins Journal sah.
|
||||
tmp="$(mktemp /etc/kea/kea-dhcp4.conf.XXXXXX)"
|
||||
chmod 0640 "$tmp"; chown root:_kea "$tmp"
|
||||
read_stdin_limited > "$tmp"
|
||||
if command -v kea-dhcp4 >/dev/null 2>&1; then
|
||||
if ! pruefung="$(kea-dhcp4 -t "$tmp" 2>&1)"; then
|
||||
rm -f "$tmp"
|
||||
# Nur die aussagekraeftige Zeile zurueckgeben, nicht das ganze Protokoll.
|
||||
fail "Konfiguration abgelehnt: $(printf '%s' "$pruefung" | grep -m1 -i "error" || printf '%s' "$pruefung" | tail -1)"
|
||||
fi
|
||||
fi
|
||||
mv -f "$tmp" /etc/kea/kea-dhcp4.conf
|
||||
;;
|
||||
|
||||
logrotate-write)
|
||||
check_app_key "${1:?App-Schluessel fehlt}"
|
||||
write_atomic "/etc/logrotate.d/$1" 0644 "root:root"
|
||||
;;
|
||||
|
||||
mount-cifs)
|
||||
check_unc "${1:?Freigabe fehlt}"
|
||||
check_mount_target "${2:?Ziel fehlt}"
|
||||
share="${1//\\//}"
|
||||
target="$2"
|
||||
creds="$(mktemp /run/tesm-cifs.XXXXXX)"
|
||||
chmod 600 "$creds"
|
||||
# Zugangsdaten kommen ueber stdin in eine 0600-Datei -- nie ueber die
|
||||
# Kommandozeile (Prozessliste) und nie ueber die Umgebung
|
||||
# (/proc/<pid>/environ).
|
||||
read_stdin_limited > "$creds"
|
||||
mkdir -p "$target"
|
||||
if mount -t cifs "$share" "$target" \
|
||||
-o "credentials=$creds,uid=$(id -u tesm 2>/dev/null || echo 0),gid=$(id -g tesm 2>/dev/null || echo 0),file_mode=0640,dir_mode=0750,vers=3.1.1,seal"
|
||||
then
|
||||
shred -u "$creds" 2>/dev/null || rm -f "$creds"
|
||||
else
|
||||
shred -u "$creds" 2>/dev/null || rm -f "$creds"
|
||||
fail "Einbindung fehlgeschlagen"
|
||||
fi
|
||||
;;
|
||||
|
||||
umount)
|
||||
check_mount_target "${1:?Ziel fehlt}"
|
||||
umount -l "$1" || true
|
||||
rmdir "$1" 2>/dev/null || true
|
||||
;;
|
||||
|
||||
apt-install)
|
||||
[[ $# -gt 0 ]] || fail "kein Paket angegeben"
|
||||
for package in "$@"; do
|
||||
check_in_list "$package" "${PACKAGES_ALLOWED[@]}" || fail "Paket nicht erlaubt: $package"
|
||||
done
|
||||
export DEBIAN_FRONTEND=noninteractive
|
||||
apt-get update -qq
|
||||
apt-get install -y -qq "$@"
|
||||
;;
|
||||
|
||||
certbot-issue)
|
||||
check_domain "${1:?Domain fehlt}"
|
||||
check_email "${2:?E-Mail fehlt}"
|
||||
webroot="${3:?Webroot fehlt}"
|
||||
[[ "$webroot" == /var/www/* && "$webroot" != *".."* ]] || fail "ungueltiger Webroot"
|
||||
mkdir -p "$webroot"
|
||||
# Bewusst --webroot statt des nginx-Plugins: das Plugin wuerde die von der
|
||||
# Anwendung erzeugte Site-Datei selbst umschreiben.
|
||||
certbot certonly --webroot -w "$webroot" -d "$1" \
|
||||
--email "$2" --agree-tos --non-interactive --keep-until-expiring
|
||||
;;
|
||||
|
||||
selfsigned-issue)
|
||||
# Fuer Installationen ohne oeffentliche Domain: ein selbstsigniertes
|
||||
# Zertifikat, damit Port 443 ueberhaupt bedient werden kann. Bewusst
|
||||
# getrennt vom certbot-Pfad -- hier wird nichts nach draussen gesprochen.
|
||||
check_app_key "${1:?App-Schluessel fehlt}"
|
||||
subject="${2:?Common Name fehlt}"
|
||||
check_domain "$subject"
|
||||
dir="/etc/tesm/certs/$1"
|
||||
install -d -m 0755 /etc/tesm/certs
|
||||
# 0755 auf dem Verzeichnis, nicht 0750: die Anwendung muss das Zertifikat
|
||||
# lesen koennen, um Aussteller und Ablauf anzuzeigen. Geheim ist nur der
|
||||
# private Schluessel -- der bleibt 0640 root:root und wird ausschliesslich
|
||||
# vom nginx-Masterprozess (root) gelesen. Dieselbe Aufteilung wie bei
|
||||
# Let's Encrypt: live/ ist begehbar, privkey.pem nicht lesbar.
|
||||
install -d -m 0755 "$dir"
|
||||
|
||||
# Alternative Namen: alles, was ab Argument 3 kommt -- jeweils geprueft und
|
||||
# nach DNS- bzw. IP-Eintrag sortiert. Kein Argument geht ungeprueft weiter.
|
||||
alt="DNS:$subject"
|
||||
shift 2
|
||||
for name in "$@"; do
|
||||
if [[ "$name" =~ ^[0-9]{1,3}(\.[0-9]{1,3}){3}$ ]]; then
|
||||
alt="$alt,IP:$name"
|
||||
elif [[ "$name" =~ ^[A-Za-z0-9]([A-Za-z0-9.-]{0,251}[A-Za-z0-9])?$ ]]; then
|
||||
[[ "$name" == "$subject" ]] || alt="$alt,DNS:$name"
|
||||
else
|
||||
fail "ungueltiger alternativer Name: $name"
|
||||
fi
|
||||
done
|
||||
|
||||
openssl req -x509 -newkey rsa:3072 -sha256 -days 825 -nodes -keyout "$dir/privkey.pem" -out "$dir/fullchain.pem" -subj "/CN=$subject" -addext "subjectAltName=$alt" -addext "basicConstraints=critical,CA:FALSE" -addext "keyUsage=critical,digitalSignature,keyEncipherment" -addext "extendedKeyUsage=serverAuth" >/dev/null 2>&1 || fail "openssl ist fehlgeschlagen"
|
||||
chown root:root "$dir/privkey.pem" "$dir/fullchain.pem"
|
||||
chmod 0640 "$dir/privkey.pem"
|
||||
# Das Zertifikat selbst ist oeffentlich -- es geht bei jedem
|
||||
# TLS-Handschlag an jeden Client.
|
||||
chmod 0644 "$dir/fullchain.pem"
|
||||
# nginx laeuft als root und liest die Datei selbst -- die Anwendung braucht
|
||||
# den privaten Schluessel nie. Nur der Pfad wird zurueckgegeben.
|
||||
echo "$dir/fullchain.pem"
|
||||
echo "$dir/privkey.pem"
|
||||
;;
|
||||
|
||||
file-read)
|
||||
check_readable_path "${1:?Pfad fehlt}"
|
||||
[[ -f "$1" ]] || exit 0
|
||||
cat "$1"
|
||||
;;
|
||||
|
||||
*)
|
||||
fail "unbekanntes Verb: ${verb:-<leer>}"
|
||||
;;
|
||||
esac
|
||||
Reference in New Issue
Block a user