From 38b15d63afd466b4fefdf450e971ef91542d777f Mon Sep 17 00:00:00 2001 From: amo Date: Tue, 14 Jul 2026 19:20:16 +0200 Subject: [PATCH 1/6] feat: add keyless Debian package --- .dockerignore | 4 + .env.example | 16 +- .github/workflows/ci.yml | 53 ++++ .gitignore | 8 + README.md | 94 ++++--- SECURITY.md | 14 +- debian/changelog | 7 + debian/control | 35 +++ debian/copyright | 26 ++ debian/linbo-patchless.docs | 3 + debian/linbo-patchless.postinst | 66 +++++ debian/linbo-patchless.postrm | 40 +++ debian/linbo-patchless.service | 55 ++++ debian/rules | 46 ++++ debian/source/format | 1 + docker-compose.yml | 3 +- docs/REVERSE-PROXY.md | 38 ++- docs/ROADMAP.md | 7 +- install.sh | 54 ++-- packaging/README.md | 97 +++++++ packaging/configure-host | 332 ++++++++++++++++++++++++ scripts/build-deb.sh | 67 +++++ scripts/generate-third-party-notices.js | 114 ++++++++ src/index.js | 207 +++++++++++++-- src/lib/logger.js | 2 - src/middleware/auth.js | 81 ------ src/routes/drivers.js | 30 --- tests/api/drivers.api.test.js | 194 ++++++++++---- tests/helpers/app.js | 19 +- tests/helpers/setup.js | 13 +- tests/packaging/debian-package.test.js | 106 ++++++++ ui/index.html | 130 +--------- 32 files changed, 1541 insertions(+), 421 deletions(-) create mode 100644 debian/changelog create mode 100644 debian/control create mode 100644 debian/copyright create mode 100644 debian/linbo-patchless.docs create mode 100755 debian/linbo-patchless.postinst create mode 100755 debian/linbo-patchless.postrm create mode 100644 debian/linbo-patchless.service create mode 100755 debian/rules create mode 100644 debian/source/format create mode 100644 packaging/README.md create mode 100755 packaging/configure-host create mode 100755 scripts/build-deb.sh create mode 100644 scripts/generate-third-party-notices.js delete mode 100644 src/middleware/auth.js create mode 100644 tests/packaging/debian-package.test.js diff --git a/.dockerignore b/.dockerignore index 362d767..09ff3da 100644 --- a/.dockerignore +++ b/.dockerignore @@ -5,6 +5,10 @@ node_modules .claude .omc coverage +dist +debian +packaging +scripts tests *.md .env* diff --git a/.env.example b/.env.example index e9717cf..0410289 100644 --- a/.env.example +++ b/.env.example @@ -1,24 +1,26 @@ # LINBO Patchless — example runtime configuration # # Production: copy to /etc/linbo-patchless/env, keep it root-owned and mode 0640. -# install.sh creates this file, the API key, SSH key, and host fingerprint safely. +# Installation creates this file, the SSH key, and host fingerprint safely. # HTTP server PATCHLESS_HOST=127.0.0.1 -# Installer acknowledgement only; setting this true does not add TLS. +# Explicit operator acknowledgement only; setting this true does not add TLS. PATCHLESS_ALLOW_PLAINTEXT_REMOTE=false PATCHLESS_PORT=3001 NODE_ENV=production +# Host header(s) accepted for /api behind a reverse proxy. Loopback hosts are +# always accepted. A deliberate direct interface bind must list its exposed +# name or address here as well. Comma-separate names; do not use '*'. +# PATCHLESS_ALLOWED_HOSTS=linbo-drivers.example.internal + # Logging: trace, debug, info, warn, error, fatal LOG_LEVEL=info # LOG_PRETTY=false -# Authentication. Prefer the root-managed file; do not commit a raw key. -PATCHLESS_API_KEY_FILE=/etc/linbo-patchless/api-key -# PATCHLESS_API_KEY= - -# Same-origin is the secure default. Set only when a separate UI origin is used. +# Same-origin is the secure default. Set only when a separate UI origin is used; +# configured origins are also accepted as API Host names. # CORS_ORIGIN=https://linbo-admin.example.org # Configure only behind a trusted reverse proxy; use its explicit hop count. # TRUST_PROXY=1 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 538c2b2..53c5623 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -47,3 +47,56 @@ jobs: with: name: coverage path: coverage/ + + package: + needs: test + runs-on: ubuntu-latest + container: ubuntu:26.04 + timeout-minutes: 20 + steps: + - uses: actions/checkout@v4 + + - name: Install package build dependencies + run: | + apt-get update + apt-get install --yes --no-install-recommends \ + build-essential debhelper devscripts lintian nodejs npm rsync + + - name: Validate Debian source package + run: dpkg-source --build . + + - name: Build Debian package + run: ./scripts/build-deb.sh + + - name: Inspect Debian package + run: | + package="$(find dist -maxdepth 1 -type f -name '*.deb' -print -quit)" + test -n "$package" + dpkg-deb --info "$package" + dpkg-deb --contents "$package" + lintian --fail-on error "$package" + + control_dir="$(mktemp -d)" + package_root="$(mktemp -d)" + dpkg-deb --control "$package" "$control_dir" + dpkg-deb --extract "$package" "$package_root" + sh -n "$control_dir/postinst" + sh -n "$control_dir/postrm" + + test -f "$package_root/usr/lib/linbo-patchless/src/index.js" + find "$package_root/usr/share/doc/linbo-patchless" \ + -name 'THIRD-PARTY-NOTICES.txt*' -print -quit | grep -q . + if find "$package_root/usr/lib/linbo-patchless" \ + -type f -name '*.node' -print -quit | grep -q .; then + echo 'Architecture-all package contains a native Node module' >&2 + exit 1 + fi + node -e 'require(process.argv[1]); require(process.argv[2])' \ + "$package_root/usr/lib/linbo-patchless/src/index.js" \ + "$package_root/usr/lib/linbo-patchless/node_modules/ssh2" + + - uses: actions/upload-artifact@v4 + with: + name: linbo-patchless-deb + path: dist/*.deb + if-no-files-found: error diff --git a/.gitignore b/.gitignore index e567036..cce5a7e 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,14 @@ node_modules/ !.env.example *.log coverage/ +dist/ +debian/.debhelper/ +debian/.node-build/ +debian/linbo-patchless/ +debian/debhelper-build-stamp +debian/files +*.buildinfo +*.changes .DS_Store .claude/settings.local.json .omc/ diff --git a/README.md b/README.md index 242ff1a..0d1cd2a 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ INF-Treiber mit `pnputil` als `SYSTEM`. - Image-Zuweisung und automatische, atomische `.driverpostsync`-Generierung - persistenter SYSTEM-Startup-Task mit protokolliertem, wiederholbarem Installer - Bereinigung alter Client-Caches, Treiberziele und alter `RunOnce`-Einträge -- API-Key-geschützte REST-API und vollständige Weboberfläche +- localhost-gebundene REST-API und vollständige Weboberfläche - gehärteter systemd-Dienst, Readiness-/Liveness-Checks, strukturierte Logs - Unit-/API-Tests und GitHub-Actions-CI @@ -72,16 +72,28 @@ beschreibt [docs/WINDOWS-DRIVER-FLOW.md](docs/WINDOWS-DRIVER-FLOW.md). - `linuxmuster-linbo7 >= 4.3.33-0` stellt den nativen Hook bereit; serverseitig geprüft ist derzeit LINBO/linuxmuster 7.4.5 - Node.js 20 oder neuer -- Ubuntu 26.04 für den Host-Installer +- Ubuntu 26.04 für Host-Paket und Legacy-Installer - 7-Zip 25.01 oder neuer für 7z-/EXE-Pakete; ZIP wird intern verarbeitet -Ältere LINBO-Versionen gelten bis zur realen Testmatrix als experimentell. Der -Installer warnt entsprechend, statt diese Kombination still als geprüft +Ältere LINBO-Versionen gelten bis zur realen Testmatrix als experimentell. Die +Host-Konfiguration warnt entsprechend, statt diese Kombination still als geprüft auszugeben. Auch für 7.4.5 steht der vollständige End-to-End-Nachweis mit echtem Windows-Start, NTFS-ACLs und `pnputil` noch aus. Ubuntu 22.04/24.04 werden derzeit nicht angeboten, weil ihre Standardpakete keine hinreichend neue 7-Zip-Version bereitstellen. +[linuxmuster.net 7.3 basiert offiziell auf Ubuntu 24.04 und LINBO +4.3](https://docs.linuxmuster.net/de/latest/about/what-is-new.html). Der +Patchless-Hook ist grundsätzlich ab `linuxmuster-linbo7 4.3.33-0` nutzbar, das +aktuelle `.deb` ist dort aber bewusst noch nicht installierbar: Ubuntu 24.04 +liefert standardmäßig nur [Node.js +18](https://packages.ubuntu.com/noble/nodejs) und [7-Zip +23.01](https://packages.ubuntu.com/noble/7zip). Beides unterschreitet die für +diesen Dienst festgelegte Laufzeit- bzw. Sicherheitsgrenze. Eine 7.3-Freigabe +benötigt deshalb einen gepflegten Paketweg für neuere Abhängigkeiten und einen +realen End-to-End-Test; nur die Betriebssystemprüfung zu entfernen wäre keine +saubere Unterstützung. + Der reale Pfad und Hook-Ablauf wurden read-only gegen einen frischen linuxmuster-/LINBO-7.4.5-Server auf Ubuntu 26.04 geprüft. Verwendet wird: @@ -98,29 +110,33 @@ Die vollständigen Befunde stehen in ## Installation -Auf dem linuxmuster-Server: +Der unterstützte Installationsweg ist das Debian-Paket. Es wird nicht direkt +ausgeführt, sondern mit APT installiert, damit alle Systemabhängigkeiten +aufgelöst werden: ```bash -sudo ./install.sh +sudo apt install ./linbo-patchless_1.0.0_all.deb ``` -Der Installer arbeitet idempotent und: +Das Paket enthält die bereits gebauten produktiven Node-Abhängigkeiten und +benötigt während `postinst` keinen Zugriff auf npm. Es: -- installiert die benötigten Systempakete einschließlich `7zz`/`7z` und - `innoextract`, -- installiert reproduzierbar aus `package-lock.json`, -- erstellt einen eigenen API-Key und einen eigenen Ed25519-Client-Key, +- installiert die benötigten Systempakete einschließlich `7zz` und + `innoextract` über deklarierte Paketabhängigkeiten, +- erstellt einen eigenen Ed25519-Client-Key, - übernimmt dessen Public Key mit `update-linbofs` in den LINBO-Client und verifiziert das Key-Material anschließend direkt in `/srv/linbo/linbofs64`, - pinnt den gemeinsamen Ed25519-Dropbear-Hostkey der LINBO-Clients, -- richtet Rechte, ACLs und den systemd-Dienst ein, -- prüft abschließend `/health`. - -Der API-Key wird absichtlich nicht ausgegeben. Er liegt in: +- richtet Benutzer, Konfiguration, Rechte, ACLs und den systemd-Dienst ein, +- behält Konfiguration und Schlüssel bei Paketupdates bei. -```text -/etc/linbo-patchless/api-key -``` +`install.sh` bleibt vorerst als Entwicklungs- und Migrationsweg bestehen. Für +reproduzierbare Servertests und Updates soll das `.deb` verwendet werden. +Beim Wechsel entfernt die Paketkonfiguration ausschließlich die eindeutig +erkannte alte systemd-Unit aus `/etc/systemd/system`, damit diese den +Paketdienst nicht überschreibt. Eine unbekannte lokale Unit wird nicht gelöscht, +sondern führt zu einer klaren Fehlermeldung. Der alte Anwendungsbaum unter +`/opt/linbo-patchless` bleibt zunächst als Rückfallkopie erhalten. Der Dienst lauscht sicherheitsbewusst nur auf `http://127.0.0.1:3001/`. Für einen einzelnen Administrationszugriff genügt ein SSH-Tunnel: @@ -132,10 +148,12 @@ ssh -L 3001:127.0.0.1:3001 root@ Danach ist die Oberfläche lokal unter `http://127.0.0.1:3001/` erreichbar. Für den dauerhaften Mehrbenutzerbetrieb ist ein HTTPS-Reverse-Proxy vorgesehen; eine gehärtete Vorlage steht in -[docs/REVERSE-PROXY.md](docs/REVERSE-PROXY.md). Der Installer migriert den -früheren impliziten Wildcard-Bind `0.0.0.0` auf localhost. Eine erneute -Klartextfreigabe erfordert die bewusste Bestätigung -`PATCHLESS_ALLOW_PLAINTEXT_REMOTE=true` und wird nicht empfohlen. +[docs/REVERSE-PROXY.md](docs/REVERSE-PROXY.md). Da Patchless selbst keine +Benutzeranmeldung besitzt, muss dieser Proxy Administratoren authentifizieren. +Die Installation migriert den früheren impliziten Wildcard-Bind `0.0.0.0` auf +localhost. Eine erneute Klartextfreigabe erfordert die bewusste Bestätigung +`PATCHLESS_ALLOW_PLAINTEXT_REMOTE=true` sowie den Namen oder die IP-Adresse in +`PATCHLESS_ALLOWED_HOSTS` und wird nicht empfohlen. ## Bedienung @@ -187,13 +205,12 @@ Taskstatus muss deshalb beim Testrollout erneut kontrolliert werden. ### Treiber verwalten -1. API-Key in der Weboberfläche eingeben. -2. „Neues Profil“ wählen und einen vorhandenen LINBO-Inventareintrag auswählen. -3. Treiberpaket hochladen oder entpacken. -4. `match.conf` prüfen und bei Bedarf mehrere `product`-Zeilen ergänzen. -5. Das Profil einem Windows-Image zuweisen. -6. Einen Testclient synchronisieren und anschließend Windows starten. -7. Clientlog unter `/cache/linbo-driverpostsync.log`, persistentes Windows-Log +1. „Neues Profil“ wählen und einen vorhandenen LINBO-Inventareintrag auswählen. +2. Treiberpaket hochladen oder entpacken. +3. `match.conf` prüfen und bei Bedarf mehrere `product`-Zeilen ergänzen. +4. Das Profil einem Windows-Image zuweisen. +5. Einen Testclient synchronisieren und anschließend Windows starten. +6. Clientlog unter `/cache/linbo-driverpostsync.log`, persistentes Windows-Log unter `C:\ProgramData\LINBO-Patchless\driver-install.log` und den Gerätemanager prüfen. @@ -216,12 +233,13 @@ wird abgelehnt bzw. auf dem Client sicher übersprungen. ## Wichtige API-Endpunkte -Alle `/api/*`-Routen benötigen `X-API-Key: `. +Die API besitzt keine eigene Anmeldung und ist über die Standardbindung nur +lokal erreichbar. Ein Remote-Proxy muss den Zugriff authentifizieren. | Methode | Pfad | Zweck | |---|---|---| | `GET` | `/health/live` | reine Prozess-Liveness | -| `GET` | `/health` | Readiness mit Pfad-, qcow2-Verzeichnis-, Key- und SSH-Prüfung | +| `GET` | `/health` | Readiness mit Pfad-, qcow2-Verzeichnis- und SSH-Prüfung | | `GET` | `/api/v1/drivers/hwinfo/server` | vorhandene LINBO-Serverinventare | | `GET` | `/api/v1/drivers/hwinfo/server?details=true` | Inventare einschließlich PCI-/USB-Details | | `POST` | `/api/v1/drivers/create-profile-from-inventory` | Profil aus `{ "hostname": "..." }` | @@ -238,9 +256,7 @@ Alle `/api/*`-Routen benötigen `X-API-Key: `. Beispiel: ```bash -KEY="$(sudo cat /etc/linbo-patchless/api-key)" -curl -H "X-API-Key: $KEY" \ - http://127.0.0.1:3001/api/v1/drivers/hwinfo/server +curl http://127.0.0.1:3001/api/v1/drivers/hwinfo/server ``` ## Konfiguration @@ -253,7 +269,8 @@ Besonders wichtig: | Variable | Standard/Zweck | |---|---| | `PATCHLESS_HOST` | `127.0.0.1`; Zugriff per Tunnel oder HTTPS-Proxy | -| `PATCHLESS_ALLOW_PLAINTEXT_REMOTE` | ausdrückliche Installer-Bestätigung für einen unsicheren Remote-Bind | +| `PATCHLESS_ALLOW_PLAINTEXT_REMOTE` | ausdrückliche Betreiberbestätigung für einen unsicheren Remote-Bind | +| `PATCHLESS_ALLOWED_HOSTS` | explizite öffentliche Hostnamen eines lokalen Reverse-Proxys; keine Wildcards | | `DRIVERS_BASE` | `/srv/linbo/drivers` | | `LINBO_DIR` | `/srv/linbo` | | `LINBO_HWINFO_DIR` | `/var/log/linuxmuster/linbo` | @@ -276,7 +293,8 @@ auf vorhandenen Image-Verzeichnissen benannte ACLs zum atomischen Ersetzen der Hookdateien sowie eine vererbte ACL am Image-Root. Wird ein neues Verzeichnis mit Modus `0755` angelegt, kann dessen ACL-Maske das vererbte Schreibrecht jedoch auf `r-x` begrenzen. `/health` prüft deshalb jedes gültige qcow2-Verzeichnis und -wird in diesem Fall `degraded`; anschließend den Installer erneut ausführen. +wird in diesem Fall `degraded`; anschließend +`sudo linbo-patchless-configure` ausführen. Auch Restore oder Austausch von `/srv/linbo/images` kann ACL-Metadaten entfernen. Profil- und Image-Mutationen werden prozessintern serialisiert. Schlägt das @@ -310,10 +328,12 @@ npm ci npm test docker compose config docker compose up --build +./scripts/build-deb.sh ``` Details zu Sicherheitsgrenzen stehen in [SECURITY.md](SECURITY.md), priorisierte -Weiterentwicklungen in [docs/ROADMAP.md](docs/ROADMAP.md). +Weiterentwicklungen in [docs/ROADMAP.md](docs/ROADMAP.md) und Hinweise zum +Paketbau in [packaging/README.md](packaging/README.md). ## Lizenz diff --git a/SECURITY.md b/SECURITY.md index 3ccf7a8..89bb412 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -18,11 +18,13 @@ Proof-of-Concept ohne echte Zugangsdaten. ## Betriebsgrenzen -- Der API-Key erlaubt Änderungen an Treiberprofilen und Image-Hooks. Die API - gehört ausschließlich in ein vertrauenswürdiges Admin-Netz oder hinter einen - authentifizierten HTTPS-Reverse-Proxy. -- HTTP verschlüsselt weder API-Key noch Uploads. Der eingebaute Server terminiert - bewusst kein TLS. Deshalb bindet der Installer standardmäßig nur +- Die Anwendung besitzt bewusst keine eigene Benutzeranmeldung. Ihre API kann + Treiberprofile, Dateien und Image-Hooks verändern und gehört deshalb nicht + direkt in ein Client- oder öffentliches Netz. Der eingebaute Server bindet + standardmäßig nur `127.0.0.1`; Remotezugriff erfolgt per administrativem + SSH-Tunnel oder über einen **authentifizierenden** HTTPS-Reverse-Proxy. +- HTTP verschlüsselt Uploads und Verwaltungsdaten nicht. Der eingebaute Server + terminiert bewusst kein TLS. Deshalb bindet die Installation standardmäßig nur `127.0.0.1`; Remotezugriff erfolgt per SSH-Tunnel oder HTTPS-Reverse-Proxy. Alte, implizite `0.0.0.0`-Konfigurationen werden beim Update auf localhost migriert, sofern der Betreiber Klartext-Remotezugriff nicht ausdrücklich @@ -63,5 +65,5 @@ Proof-of-Concept ohne echte Zugangsdaten. - Inventardaten können Gerätekennungen enthalten. Die normale API blendet MACs aus; API- und Journalzugriff bleiben dennoch schützenswert. -Secrets, private Schlüssel, API-Keys, reale Seriennummern oder vollständige +Secrets, private Schlüssel, reale Seriennummern oder vollständige Schullogs dürfen nicht in Issues, Testfixtures oder Commits gelangen. diff --git a/debian/changelog b/debian/changelog new file mode 100644 index 0000000..eaa727e --- /dev/null +++ b/debian/changelog @@ -0,0 +1,7 @@ +linbo-patchless (1.0.0) unstable; urgency=medium + + * Initial Debian package for linuxmuster servers. + * Bundle locked production Node.js dependencies at package build time. + * Configure the LINBO client key, host fingerprint, and filesystem ACLs. + + -- amolani Tue, 14 Jul 2026 00:00:00 +0200 diff --git a/debian/control b/debian/control new file mode 100644 index 0000000..0b52006 --- /dev/null +++ b/debian/control @@ -0,0 +1,35 @@ +Source: linbo-patchless +Section: admin +Priority: optional +Maintainer: amolani +Build-Depends: + debhelper-compat (= 13), + nodejs (>= 20), + npm +Standards-Version: 4.7.2 +Rules-Requires-Root: no +Homepage: https://github.com/amolani/linbo-patchless + +Package: linbo-patchless +Architecture: all +Depends: + ${misc:Depends}, + acl, + adduser, + cpio, + curl, + dropbear-bin, + innoextract, + linuxmuster-linbo7 (>= 4.3.33-0), + nodejs (>= 20), + openssh-client, + rsync, + xz-utils, + 7zip (>= 25.01) +Description: automatic Windows driver integration for LINBO + LINBO Patchless manages hardware-specific Windows driver profiles on a + linuxmuster server. It generates native LINBO driver post-sync hooks and + installs matching INF driver packages when Windows starts. + . + The package contains the web application and its locked production Node.js + dependencies. No npm access is required while installing it on the server. diff --git a/debian/copyright b/debian/copyright new file mode 100644 index 0000000..685932c --- /dev/null +++ b/debian/copyright @@ -0,0 +1,26 @@ +Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/ +Upstream-Name: linbo-patchless +Source: https://github.com/amolani/linbo-patchless + +Files: * +Copyright: 2026 amolani and contributors +License: GPL-3.0-only +Comment: + Production builds create node_modules from the exact package-lock.json tree. + Those generated third-party files are not part of this source package and do + not become GPL-licensed. Their package metadata, original license files and a + generated consolidated THIRD-PARTY-NOTICES.txt are shipped in the binary + package under their respective upstream licenses. + +License: GPL-3.0-only + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, version 3 only. + . + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + . + On Debian systems, the complete text of the GNU General Public License + version 3 is available in /usr/share/common-licenses/GPL-3. diff --git a/debian/linbo-patchless.docs b/debian/linbo-patchless.docs new file mode 100644 index 0000000..8d86158 --- /dev/null +++ b/debian/linbo-patchless.docs @@ -0,0 +1,3 @@ +README.md +SECURITY.md +docs/*.md diff --git a/debian/linbo-patchless.postinst b/debian/linbo-patchless.postinst new file mode 100755 index 0000000..21ff0c8 --- /dev/null +++ b/debian/linbo-patchless.postinst @@ -0,0 +1,66 @@ +#!/bin/sh +set -e + +verify_service=0 +case "$1" in + configure|reconfigure) + /usr/sbin/linbo-patchless-configure + verify_service=1 + ;; +esac + +#DEBHELPER# + +# Debhelper deliberately treats service start failures as non-fatal. On a live +# system that would leave APT reporting success while Patchless is unusable, so +# verify that the process remains active after its initial restart window. Skip +# this in chroots/containers where systemd is not the active init system. +if [ "$verify_service" -eq 1 ] && [ -d /run/systemd/system ] && \ + command -v systemctl >/dev/null 2>&1; then + read_env_value() { + awk -v key="$1" ' + $0 ~ "^[[:space:]]*" key "[[:space:]]*=" { + value = $0 + sub("^[[:space:]]*" key "[[:space:]]*=[[:space:]]*", "", value) + sub(/[[:space:]]+$/, "", value) + if (value ~ /^\".*\"$/ || value ~ /^\047.*\047$/) { + value = substr(value, 2, length(value) - 2) + } + } + END { print value } + ' /etc/linbo-patchless/env + } + + health_host="$(read_env_value PATCHLESS_HOST)" + health_port="$(read_env_value PATCHLESS_PORT)" + case "$health_host" in + ""|0|0.0.0.0|::|::0|0:0:0:0:0:0:0:0|"[::]"|"[::0]"|"[0:0:0:0:0:0:0:0]") + health_host=127.0.0.1 + ;; + esac + case "$health_port" in + ''|*[!0-9]*) health_port=3001 ;; + esac + case "$health_host" in + \[*\]) health_url_host="$health_host" ;; + *:*) health_url_host="[$health_host]" ;; + *) health_url_host="$health_host" ;; + esac + + attempt=0 + while [ "$attempt" -lt 20 ]; do + if systemctl is-active --quiet linbo-patchless.service && \ + curl --fail --silent --noproxy '*' --max-time 2 \ + "http://$health_url_host:$health_port/health/live" >/dev/null; then + exit 0 + fi + attempt=$((attempt + 1)) + sleep 1 + done + + echo "linbo-patchless: service liveness check failed after package configuration" >&2 + systemctl --no-pager --full status linbo-patchless.service >&2 || true + exit 1 +fi + +exit 0 diff --git a/debian/linbo-patchless.postrm b/debian/linbo-patchless.postrm new file mode 100755 index 0000000..d3f34a6 --- /dev/null +++ b/debian/linbo-patchless.postrm @@ -0,0 +1,40 @@ +#!/bin/sh +set -e + +#DEBHELPER# + +if [ "$1" = "purge" ]; then + config_dir=/etc/linbo-patchless + state_dir=/var/lib/linbo-patchless + client_key="$config_dir/client_key" + root_public_key=/root/.ssh/id_linbo_patchless.pub + public_key_removed=0 + derived_public_key= + + # Remove the root-side public key only when it is a regular file and can be + # proven to belong to the package's retained private key. Never follow + # symlinks or remove an unrelated administrator key. + if [ -f "$client_key" ] && [ ! -L "$client_key" ] && \ + [ -f "$root_public_key" ] && [ ! -L "$root_public_key" ]; then + derived_public_key="$(mktemp /tmp/linbo-patchless-purge-key.XXXXXX)" + if ssh-keygen -y -f "$client_key" > "$derived_public_key" 2>/dev/null && \ + cmp -s "$derived_public_key" "$root_public_key"; then + rm -f -- "$root_public_key" + public_key_removed=1 + fi + rm -f -- "$derived_public_key" + fi + + rm -rf -- "$config_dir" "$state_dir" + + # Rebuild the client filesystem after withdrawing our authorized key. A + # purge must still complete if linuxmuster itself is being removed or its + # rebuild command is temporarily unavailable. + if [ "$public_key_removed" -eq 1 ] && command -v update-linbofs >/dev/null 2>&1; then + if ! update-linbofs; then + echo "linbo-patchless: warning: update-linbofs failed during purge" >&2 + fi + fi +fi + +exit 0 diff --git a/debian/linbo-patchless.service b/debian/linbo-patchless.service new file mode 100644 index 0000000..fe07db1 --- /dev/null +++ b/debian/linbo-patchless.service @@ -0,0 +1,55 @@ +[Unit] +Description=LINBO Patchless Driver Manager +Documentation=https://github.com/amolani/linbo-patchless +Wants=network-online.target +After=network-online.target + +[Service] +Type=simple +User=linbo-patchless +Group=linbo-patchless +WorkingDirectory=/usr/lib/linbo-patchless +ExecStartPre=/usr/bin/test -r /etc/linbo-patchless/client_key +ExecStartPre=/usr/bin/test -w /srv/linbo/drivers +ExecStartPre=/usr/bin/test -r /srv/linbo/images +ExecStartPre=/usr/bin/test -x /srv/linbo/images +ExecStart=/usr/bin/node /usr/lib/linbo-patchless/src/index.js +Restart=on-failure +RestartSec=5s +TimeoutStopSec=30s +UMask=0022 + +Environment=NODE_ENV=production +EnvironmentFile=/etc/linbo-patchless/env + +MemoryMax=512M +CPUQuota=50% +TasksMax=64 +LimitNOFILE=4096 + +NoNewPrivileges=yes +CapabilityBoundingSet= +AmbientCapabilities= +ProtectSystem=strict +ProtectHome=yes +PrivateTmp=yes +PrivateDevices=yes +ProtectHostname=yes +ProtectClock=yes +ProtectKernelTunables=yes +ProtectKernelModules=yes +ProtectKernelLogs=yes +ProtectControlGroups=yes +LockPersonality=yes +RestrictSUIDSGID=yes +RestrictNamespaces=yes +RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 +SystemCallArchitectures=native + +ReadOnlyPaths=/etc/linbo-patchless +ReadOnlyPaths=-/etc/linuxmuster/sophomorix/default-school/devices.csv +ReadOnlyPaths=-/var/log/linuxmuster/linbo +ReadWritePaths=/srv/linbo/drivers /srv/linbo/images /var/lib/linbo-patchless + +[Install] +WantedBy=multi-user.target diff --git a/debian/rules b/debian/rules new file mode 100755 index 0000000..02088fa --- /dev/null +++ b/debian/rules @@ -0,0 +1,46 @@ +#!/usr/bin/make -f + +export DH_VERBOSE = 1 +export NODE_ENV = production +export npm_config_audit = false +export npm_config_fund = false +export npm_config_update_notifier = false + +NODE_BUILD_DIR := $(CURDIR)/debian/.node-build +PACKAGE_DIR := $(CURDIR)/debian/linbo-patchless +APP_DIR := $(PACKAGE_DIR)/usr/lib/linbo-patchless + +%: + dh $@ + +override_dh_auto_build: + rm -rf "$(NODE_BUILD_DIR)" + install -d "$(NODE_BUILD_DIR)" + cp package.json package-lock.json "$(NODE_BUILD_DIR)/" + cd "$(NODE_BUILD_DIR)" && npm ci --omit=dev --omit=optional --no-audit --no-fund + node scripts/generate-third-party-notices.js \ + "$(NODE_BUILD_DIR)/node_modules" \ + "$(NODE_BUILD_DIR)/THIRD-PARTY-NOTICES.txt" + +# The project test suite is executed by CI with development dependencies. +# Package builds deliberately install only the locked production dependency set. +override_dh_auto_test: + @echo "Skipping upstream tests during binary packaging (run npm run test:ci in CI)." + +override_dh_auto_install: + dh_auto_install + install -d "$(APP_DIR)" + cp -a src ui windows "$(APP_DIR)/" + install -m 0644 package.json package-lock.json "$(APP_DIR)/" + cp -a "$(NODE_BUILD_DIR)/node_modules" "$(APP_DIR)/" + install -d "$(PACKAGE_DIR)/usr/share/doc/linbo-patchless" + install -m 0644 packaging/README.md \ + "$(PACKAGE_DIR)/usr/share/doc/linbo-patchless/README.Debian" + install -m 0644 "$(NODE_BUILD_DIR)/THIRD-PARTY-NOTICES.txt" \ + "$(PACKAGE_DIR)/usr/share/doc/linbo-patchless/THIRD-PARTY-NOTICES.txt" + install -d "$(PACKAGE_DIR)/usr/sbin" + install -m 0755 packaging/configure-host "$(PACKAGE_DIR)/usr/sbin/linbo-patchless-configure" + +override_dh_auto_clean: + dh_auto_clean + rm -rf "$(NODE_BUILD_DIR)" diff --git a/debian/source/format b/debian/source/format new file mode 100644 index 0000000..89ae9db --- /dev/null +++ b/debian/source/format @@ -0,0 +1 @@ +3.0 (native) diff --git a/docker-compose.yml b/docker-compose.yml index 3ca997f..0e839e7 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -6,7 +6,8 @@ services: environment: - NODE_ENV=production - PATCHLESS_HOST=0.0.0.0 - - PATCHLESS_API_KEY=dev-key-not-for-production + # Container-internal wildcard; the published host port remains loopback-only. + - PATCHLESS_ALLOW_PLAINTEXT_REMOTE=true - LOG_LEVEL=info - DRIVERS_BASE=/data/drivers - LINBO_DIR=/data/linbo diff --git a/docs/REVERSE-PROXY.md b/docs/REVERSE-PROXY.md index b0bcc55..e7847be 100644 --- a/docs/REVERSE-PROXY.md +++ b/docs/REVERSE-PROXY.md @@ -2,8 +2,9 @@ Der Anwendungsserver lauscht standardmäßig nur auf `127.0.0.1:3001` und terminiert selbst kein TLS. Für dauerhaften Remotezugriff sollte ein lokaler -Reverse-Proxy HTTPS erzwingen. Der API-Key ist weiterhin erforderlich; ein -vorgeschaltetes SSO oder eine zusätzliche Admin-Authentifizierung ist sinnvoll. +Reverse-Proxy HTTPS erzwingen. Da die Anwendung keine eigene Anmeldung besitzt, +muss der Proxy den Zugriff per linuxmuster-SSO/OIDC/LDAP, mTLS oder mindestens +HTTP Basic Authentication auf Administratoren begrenzen. Beispiel für nginx: @@ -19,6 +20,9 @@ server { client_max_body_size 2g; location / { + auth_basic "LINBO Patchless Administration"; + auth_basic_user_file /etc/nginx/linbo-patchless.htpasswd; + proxy_pass http://127.0.0.1:3001; proxy_http_version 1.1; proxy_request_buffering off; @@ -39,11 +43,33 @@ server { } ``` +Für das gezeigte Basic-Auth-Beispiel die Passwortdatei einmalig anlegen: + +```bash +sudo apt install apache2-utils +sudo htpasswd -c /etc/nginx/linbo-patchless.htpasswd +sudo chown root:www-data /etc/nginx/linbo-patchless.htpasswd +sudo chmod 0640 /etc/nginx/linbo-patchless.htpasswd +``` + +An Standorten mit zentraler Anmeldung sollte dieser Block durch die dortige +SSO-/OIDC- oder LDAP-Integration ersetzt werden. Ein ausschließliches +Weiterleiten ohne Authentifizierung ist nicht unterstützt. + Wenn nginx auf demselben Host läuft und die Anwendung weiterhin ausschließlich -an localhost gebunden ist, kann in `/etc/linbo-patchless/env` zusätzlich -`TRUST_PROXY=1` gesetzt werden. Dadurch gelten Rate-Limits pro ursprünglicher -Client-IP. Ein beliebiger oder externer Proxy darf nicht auf diese Weise -vertraut werden. +an localhost gebunden ist, bleibt `PATCHLESS_HOST=127.0.0.1` unverändert. Da +nginx den öffentlichen Hostnamen weitergibt, müssen in +`/etc/linbo-patchless/env` außerdem diese Werte gesetzt werden: + +```ini +PATCHLESS_ALLOWED_HOSTS=linbo-drivers.example.internal +TRUST_PROXY=1 +``` + +`TRUST_PROXY=1` sorgt dafür, dass Rate-Limits pro ursprünglicher Client-IP +gelten. Ein beliebiger oder externer Proxy darf nicht auf diese Weise vertraut +werden. `PATCHLESS_ALLOWED_HOSTS` akzeptiert keine Wildcards und schützt die +localhost-API gegen DNS-Rebinding. Nach Änderung der Konfiguration: diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 9babeed..9abf6a8 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -49,7 +49,8 @@ Implementierungsaufwand. 1. Prometheus-Metriken und strukturierter Audit-Log für Profiländerungen. 2. Backup-/Restore-Kommandos für Profile, Zuordnungen und generierte Hooks. -3. WebUI-Integration in linuxmuster-webui statt separater Port-/Key-Anmeldung. +3. WebUI-Integration in linuxmuster-webui mit dessen Anmeldung statt eines + separaten Patchless-Ports. 4. Benachrichtigung bei veralteten Inventaren, fehlgeschlagenen Hook-Syncs und nicht signierten bzw. unpassenden Treiberpaketen. 5. GitHub-Actions auf vollständige Commit-SHAs pinnen und automatisierte, @@ -67,7 +68,9 @@ Implementierungsaufwand. - idempotentes Golden-Image-Bootstrap für einen verifizierten, ACL-geschützten SYSTEM-Startup-Task; `!RunOnce` bleibt ausschließlich Kompatibilitätsfallback - localhost als sicherer HTTP-Standard und eine nginx-HTTPS-Vorlage +- reproduzierbares Debian-Paket mit gebündelten Node-Abhängigkeiten und + automatisch erzeugtem CI-Artefakt - vorbereitete Releases, die als vollständiger Verzeichnisbaum aktiviert und bei fehlgeschlagener Readiness zurückgerollt werden - vollständiger Profil-/Datei-/Image-Workflow in der Weboberfläche -- systemd-Härtung, API-Key, Healthchecks, Tests und CI +- systemd-Härtung, localhost-Sicherheitsgrenze, Healthchecks, Tests und CI diff --git a/install.sh b/install.sh index 1828e15..e10d3ef 100755 --- a/install.sh +++ b/install.sh @@ -22,7 +22,7 @@ INSTALL_DIR="/opt/linbo-patchless" CONFIG_DIR="/etc/linbo-patchless" ENV_FILE="$CONFIG_DIR/env" UNIT_FILE="/etc/systemd/system/linbo-patchless.service" -API_KEY_FILE="$CONFIG_DIR/api-key" +LEGACY_API_KEY_FILE="$CONFIG_DIR/api-key" CLIENT_KEY="$CONFIG_DIR/client_key" CLIENT_PUB="/root/.ssh/id_linbo_patchless.pub" CLIENT_KEY_MARKER="$CONFIG_DIR/client-key-linbofs.sha256" @@ -114,6 +114,11 @@ echo "" [[ "$EUID" -eq 0 ]] || { log_error "Must run as root"; exit 1; } +if dpkg-query -W -f='${db:Status-Abbrev}' linbo-patchless 2>/dev/null | grep -q '^ii'; then + log_error "The linbo-patchless Debian package is installed; use apt to update it instead of install.sh" + exit 1 +fi + # ============================================================================= # Distribution and packages # ============================================================================= @@ -239,22 +244,17 @@ install -d -o "$SERVICE_USER" -g "$SERVICE_GROUP" -m 0750 "$STATE_DIR" "$HWINFO_ install -d -o "$SERVICE_USER" -g "$SERVICE_GROUP" -m 0755 "$DRIVERS_BASE" # ============================================================================= -# API key and dedicated LINBO client authentication key +# Dedicated LINBO client authentication key # ============================================================================= -for sensitive_file in "$API_KEY_FILE" "$CLIENT_KEY" "$ENV_FILE"; do +for sensitive_file in "$CLIENT_KEY" "$ENV_FILE"; do [[ ! -L "$sensitive_file" ]] || { log_error "Refusing symbolic link at $sensitive_file"; exit 1; } done -if [[ ! -s "$API_KEY_FILE" ]]; then - API_KEY_TMP="$(mktemp "$CONFIG_DIR/.api-key.XXXXXX")" - openssl rand -hex 32 > "$API_KEY_TMP" - install -o root -g "$SERVICE_GROUP" -m 0640 "$API_KEY_TMP" "$API_KEY_FILE" - rm -f -- "$API_KEY_TMP" - log_ok "API key generated at $API_KEY_FILE" -else - chown root:"$SERVICE_GROUP" "$API_KEY_FILE" - chmod 0640 "$API_KEY_FILE" - log_ok "Existing API key retained at $API_KEY_FILE" +if [[ -L "$LEGACY_API_KEY_FILE" ]]; then + log_warn "Obsolete API-key symlink left untouched: $LEGACY_API_KEY_FILE" +elif [[ -f "$LEGACY_API_KEY_FILE" ]]; then + rm -f -- "$LEGACY_API_KEY_FILE" + log_ok "Obsolete API key removed" fi if [[ ! -s "$CLIENT_KEY" ]]; then @@ -369,6 +369,18 @@ ensure_env() { fi } +remove_env() { + local key="$1" tmp + tmp="$(mktemp "$CONFIG_DIR/.env.XXXXXX")" + awk -v key="$key" ' + $0 ~ "^[[:space:]]*#?[[:space:]]*" key "[[:space:]]*=" { next } + { print } + ' "$ENV_FILE" > "$tmp" + chown root:"$SERVICE_GROUP" "$tmp" + chmod 0640 "$tmp" + mv -f -- "$tmp" "$ENV_FILE" +} + read_env_value() { local key="$1" awk -v key="$key" ' @@ -387,7 +399,8 @@ read_env_value() { ensure_env PATCHLESS_HOST "127.0.0.1" ensure_env PATCHLESS_ALLOW_PLAINTEXT_REMOTE "false" ensure_env PATCHLESS_PORT "3001" -upsert_env PATCHLESS_API_KEY_FILE "$API_KEY_FILE" +remove_env PATCHLESS_API_KEY_FILE +remove_env PATCHLESS_API_KEY upsert_env DRIVERS_BASE "$DRIVERS_BASE" upsert_env LINBO_DIR "/srv/linbo" upsert_env HWINFO_DIR "$HWINFO_DIR" @@ -405,10 +418,9 @@ upsert_env SEVEN_ZIP_BIN "$SEVEN_ZIP_BIN" ensure_env HEALTH_REQUIRE_SSH "true" ensure_env HEALTH_REQUIRE_INVENTORY "true" -# Older releases wrote 0.0.0.0 as an implicit default. Migrate that legacy -# exposure unless an administrator explicitly acknowledges remote plaintext -# HTTP. Specific non-loopback addresses are retained but warned about because -# they were necessarily an operator choice. +# Migrate every non-loopback bind unless an administrator explicitly +# acknowledges remote plaintext HTTP. This includes specific addresses from +# older configurations that predate the acknowledgement flag. CONFIGURED_HOST="$(read_env_value PATCHLESS_HOST)" ALLOW_PLAINTEXT_REMOTE="$(read_env_value PATCHLESS_ALLOW_PLAINTEXT_REMOTE)" case "$CONFIGURED_HOST" in @@ -422,7 +434,9 @@ case "$CONFIGURED_HOST" in 127.0.0.1|localhost|::1|"[::1]") ;; *) if [[ "${ALLOW_PLAINTEXT_REMOTE,,}" != "true" ]]; then - log_warn "PATCHLESS_HOST=$CONFIGURED_HOST is remotely reachable over plaintext HTTP; prefer 127.0.0.1 plus HTTPS" + upsert_env PATCHLESS_HOST "127.0.0.1" + CONFIGURED_HOST="127.0.0.1" + log_warn "Migrated unconfirmed remote HTTP bind to 127.0.0.1; set PATCHLESS_ALLOW_PLAINTEXT_REMOTE=true only for a deliberate direct exposure" fi ;; esac @@ -556,7 +570,6 @@ Type=simple User=linbo-patchless Group=linbo-patchless WorkingDirectory=/opt/linbo-patchless -ExecStartPre=/usr/bin/test -r /etc/linbo-patchless/api-key ExecStartPre=/usr/bin/test -r /etc/linbo-patchless/client_key ExecStartPre=/usr/bin/test -w /srv/linbo/drivers ExecStartPre=/usr/bin/test -r /srv/linbo/images @@ -676,7 +689,6 @@ echo "" echo " UI: http://${DISPLAY_HOST}:${HEALTH_PORT}/" echo " API: http://${DISPLAY_HOST}:${HEALTH_PORT}/api/v1/drivers" echo " Health: http://${DISPLAY_HOST}:${HEALTH_PORT}/health" -echo " API key: $API_KEY_FILE (value is intentionally not printed)" echo " Config: $ENV_FILE" echo "" log_warn "Rerun this installer if /health reports a new image directory unwritable or after restoring /srv/linbo/images." diff --git a/packaging/README.md b/packaging/README.md new file mode 100644 index 0000000..8027de5 --- /dev/null +++ b/packaging/README.md @@ -0,0 +1,97 @@ +# Debian package + +The Debian package is the supported deployment format for Ubuntu 26.04 +linuxmuster servers. It installs the application in +`/usr/lib/linbo-patchless`, keeps host configuration in +`/etc/linbo-patchless`, and stores runtime state below +`/var/lib/linbo-patchless`. + +[linuxmuster.net 7.3 uses Ubuntu 24.04 and LINBO +4.3](https://docs.linuxmuster.net/de/latest/about/what-is-new.html), but this +package does not claim support for that host yet. Noble's standard repositories +provide [Node.js 18](https://packages.ubuntu.com/noble/nodejs) and [7-Zip +23.01](https://packages.ubuntu.com/noble/7zip); both are below this project's +supported runtime/security baseline. Do not bypass the operating-system check. +A Noble build needs a maintained source for the newer dependencies and its own +end-to-end test job. + +## Build + +Build on Ubuntu 26.04 (or in a matching clean build environment): + +```bash +sudo apt install build-essential debhelper devscripts nodejs npm +./scripts/build-deb.sh +``` + +The build runs `npm ci --omit=dev --omit=optional` against `package-lock.json` +in an isolated staging directory. The resulting production `node_modules` tree +is contained in the `.deb`; package installation itself never contacts npm. +The build host therefore needs npm-registry access, while the target server does +not. This is an application distribution package, not yet a Debian-archive +source package with every npm source vendored as a Debian build dependency. + +Optional native modules are omitted; `ssh2` uses its supported JavaScript +fallback. The resulting package is therefore architecture-independent. +`package.json` and `debian/changelog` must carry the same version. CI artifacts +are test snapshots; every published update must increment that version so APT +can order upgrades correctly. + +## Install or upgrade + +```bash +sudo apt install ./dist/linbo-patchless_1.0.0_all.deb +``` + +APT installs the declared runtime dependencies. The package configurator then: + +- verifies Ubuntu 26.04 and a compatible `linuxmuster-linbo7` installation; +- creates the unprivileged `linbo-patchless` system account; +- creates or retains the dedicated LINBO client SSH key; +- embeds its public key into `linbofs` only when necessary; +- writes host-specific runtime settings without overwriting operator settings; +- removes legacy API-key settings and a regular legacy API-key file; +- grants only the filesystem ACLs needed by the service; and +- lets debhelper enable and restart the hardened systemd unit. + +The configurator is idempotent and is run again on package upgrades. Driver +profiles below `/srv/linbo/drivers` are never removed by package operations. +When migrating from `install.sh`, the configurator removes its known +`/etc/systemd/system/linbo-patchless.service` file so that it cannot shadow the +package-owned unit; its matching `multi-user.target.wants` link is migrated as +well. An unknown local unit or symlink is never deleted; package configuration +stops with an actionable error instead. The old application tree +below `/opt/linbo-patchless` is retained and may be removed manually only after +the packaged service has been verified. +Administrators can reapply host integration and ACLs at any time with: + +```bash +sudo linbo-patchless-configure +``` + +The service listens on `127.0.0.1:3001` by default. A local HTTPS reverse proxy +does not require a different bind address. Keep the loopback bind and add its +public name to `/etc/linbo-patchless/env`, for example: + +```ini +PATCHLESS_ALLOWED_HOSTS=linbo-drivers.example.internal +TRUST_PROXY=1 +``` + +Only a deliberate direct interface bind requires `PATCHLESS_HOST`, +`PATCHLESS_ALLOW_PLAINTEXT_REMOTE=true`, and the exposed name or address in +`PATCHLESS_ALLOWED_HOSTS`. For example: + +```ini +PATCHLESS_HOST=10.0.0.14 +PATCHLESS_ALLOW_PLAINTEXT_REMOTE=true +PATCHLESS_ALLOWED_HOSTS=10.0.0.14 +``` + +After changing the configuration, run: + +```bash +sudo systemctl restart linbo-patchless +``` + +This package intentionally does not create or require a Patchless API key. diff --git a/packaging/configure-host b/packaging/configure-host new file mode 100755 index 0000000..92591b8 --- /dev/null +++ b/packaging/configure-host @@ -0,0 +1,332 @@ +#!/bin/bash +set -Eeuo pipefail +umask 027 +export LC_ALL=C + +SERVICE_USER="linbo-patchless" +SERVICE_GROUP="linbo-patchless" +CONFIG_DIR="/etc/linbo-patchless" +ENV_FILE="$CONFIG_DIR/env" +CLIENT_KEY="$CONFIG_DIR/client_key" +CLIENT_PUB="/root/.ssh/id_linbo_patchless.pub" +CLIENT_KEY_MARKER="$CONFIG_DIR/client-key-linbofs.sha256" +LEGACY_API_KEY="$CONFIG_DIR/api-key" +DRIVERS_BASE="/srv/linbo/drivers" +IMAGES_DIR="/srv/linbo/images" +STATE_DIR="/var/lib/linbo-patchless" +HWINFO_DIR="$STATE_DIR/hwinfo" +SERVER_HWINFO_DIR="/var/log/linuxmuster/linbo" +DEVICES_CSV="/etc/linuxmuster/sophomorix/default-school/devices.csv" +DROPBEAR_HOST_KEY="/etc/linuxmuster/linbo/dropbear_ed25519_host_key" +LINBOFS_ARCHIVE="/srv/linbo/linbofs64" +LEGACY_UNIT="/etc/systemd/system/linbo-patchless.service" +LEGACY_WANTS_LINK="/etc/systemd/system/multi-user.target.wants/linbo-patchless.service" +TEMP_FILES=() + +log() { + printf 'linbo-patchless: %s\n' "$*" +} + +warn() { + printf 'linbo-patchless: warning: %s\n' "$*" >&2 +} + +fail() { + printf 'linbo-patchless: error: %s\n' "$*" >&2 + exit 1 +} + +cleanup() { + local file + for file in "${TEMP_FILES[@]:-}"; do + [[ -n "$file" ]] && rm -f -- "$file" + done +} +trap cleanup EXIT + +new_temp() { + local template="$1" + REPLY="$(mktemp "$template")" + TEMP_FILES+=("$REPLY") +} + +assert_regular_or_missing() { + local path="$1" + [[ ! -L "$path" ]] || fail "refusing symbolic link at $path" + [[ ! -e "$path" || -f "$path" ]] || fail "expected a regular file at $path" +} + +[[ "$EUID" -eq 0 ]] || fail "run this command as root" +[[ -r /etc/os-release ]] || fail "/etc/os-release is not readable" +# shellcheck disable=SC1091 +source /etc/os-release +if [[ "${ID:-}" != "ubuntu" || "${VERSION_ID:-}" != "26.04" ]]; then + fail "Ubuntu 26.04 is required (found: ${PRETTY_NAME:-unknown})" +fi + +LINBO_PKG_VERSION="$(dpkg-query -W -f='${Version}' linuxmuster-linbo7 2>/dev/null)" || \ + fail "linuxmuster-linbo7 is not installed" +dpkg --compare-versions "$LINBO_PKG_VERSION" ge "4.3.33-0" || \ + fail "linuxmuster-linbo7 >= 4.3.33-0 is required (installed: $LINBO_PKG_VERSION)" +if dpkg --compare-versions "$LINBO_PKG_VERSION" lt "7.4.5"; then + warn "LINBO $LINBO_PKG_VERSION provides the native hook but has not been end-to-end verified; the reference baseline is 7.4.5" +fi +[[ -d /srv/linbo ]] || fail "/srv/linbo does not exist" +[[ -d "$IMAGES_DIR" ]] || fail "$IMAGES_DIR does not exist" +[[ -r "$DROPBEAR_HOST_KEY" ]] || fail "LINBO host key not found: $DROPBEAR_HOST_KEY" +command -v update-linbofs >/dev/null 2>&1 || fail "update-linbofs is not available" + +if ! id -u "$SERVICE_USER" >/dev/null 2>&1; then + adduser --system --group --home "$STATE_DIR" --no-create-home \ + --shell /usr/sbin/nologin "$SERVICE_USER" + log "created system account $SERVICE_USER" +fi +SERVICE_GROUP="$(id -gn "$SERVICE_USER")" + +install -d -o root -g "$SERVICE_GROUP" -m 0750 "$CONFIG_DIR" +install -d -o "$SERVICE_USER" -g "$SERVICE_GROUP" -m 0750 "$STATE_DIR" "$HWINFO_DIR" +install -d -o "$SERVICE_USER" -g "$SERVICE_GROUP" -m 0755 "$DRIVERS_BASE" + +assert_regular_or_missing "$ENV_FILE" +assert_regular_or_missing "$CLIENT_KEY" +assert_regular_or_missing "$CLIENT_KEY.pub" +assert_regular_or_missing "$CLIENT_KEY_MARKER" + +if [[ ! -f "$ENV_FILE" ]]; then + new_temp "$CONFIG_DIR/.env.XXXXXX" + env_tmp="$REPLY" + printf '%s\n' '# LINBO Patchless runtime configuration.' > "$env_tmp" + chown root:"$SERVICE_GROUP" "$env_tmp" + chmod 0640 "$env_tmp" + mv -f -- "$env_tmp" "$ENV_FILE" +fi + +# API authentication was removed. Strip only active legacy variables so that +# comments and unrelated operator configuration remain intact. +new_temp "$CONFIG_DIR/.env.XXXXXX" +env_tmp="$REPLY" +awk ' + /^[[:space:]]*PATCHLESS_API_KEY(_FILE)?[[:space:]]*=/ { next } + { print } +' "$ENV_FILE" > "$env_tmp" +chown root:"$SERVICE_GROUP" "$env_tmp" +chmod 0640 "$env_tmp" +mv -f -- "$env_tmp" "$ENV_FILE" + +if [[ -L "$LEGACY_API_KEY" ]]; then + warn "leaving legacy API-key symlink untouched: $LEGACY_API_KEY" +elif [[ -f "$LEGACY_API_KEY" ]]; then + rm -f -- "$LEGACY_API_KEY" + log "removed legacy API-key file" +fi + +if [[ ! -s "$CLIENT_KEY" ]]; then + rm -f -- "$CLIENT_KEY" "$CLIENT_KEY.pub" + ssh-keygen -q -t ed25519 -N '' -C linbo-patchless -f "$CLIENT_KEY" + log "generated dedicated LINBO client key" +fi +chown root:"$SERVICE_GROUP" "$CLIENT_KEY" +chmod 0640 "$CLIENT_KEY" +if [[ -f "$CLIENT_KEY.pub" ]]; then + chown root:root "$CLIENT_KEY.pub" + chmod 0644 "$CLIENT_KEY.pub" +fi + +[[ ! -L /root/.ssh ]] || fail "refusing symbolic link at /root/.ssh" +install -d -o root -g root -m 0700 /root/.ssh +assert_regular_or_missing "$CLIENT_PUB" +new_temp "$CONFIG_DIR/.client-pub.XXXXXX" +client_pub_tmp="$REPLY" +ssh-keygen -y -f "$CLIENT_KEY" > "$client_pub_tmp" +if [[ ! -f "$CLIENT_PUB" ]] || ! cmp -s "$client_pub_tmp" "$CLIENT_PUB"; then + install -o root -g root -m 0644 "$client_pub_tmp" "$CLIENT_PUB" +fi + +CLIENT_PUB_HASH="$(sha256sum "$CLIENT_PUB" | awk '{ print $1 }')" +INSTALLED_PUB_HASH="$(cat "$CLIENT_KEY_MARKER" 2>/dev/null || true)" +CLIENT_PUB_TYPE="$(awk 'NR == 1 { print $1 }' "$CLIENT_PUB")" +CLIENT_PUB_BODY="$(awk 'NR == 1 { print $2 }' "$CLIENT_PUB")" + +linbofs_contains_client_key() { + [[ -r "$LINBOFS_ARCHIVE" && -n "$CLIENT_PUB_TYPE" && -n "$CLIENT_PUB_BODY" ]] || return 1 + xz -dc -- "$LINBOFS_ARCHIVE" 2>/dev/null \ + | cpio -i --quiet --to-stdout .ssh/authorized_keys 2>/dev/null \ + | awk -v type="$CLIENT_PUB_TYPE" -v body="$CLIENT_PUB_BODY" ' + $1 == type && $2 == body { found = 1 } + END { exit(found ? 0 : 1) } + ' +} + +LINBOFS_HAS_CLIENT_KEY=0 +if linbofs_contains_client_key; then + LINBOFS_HAS_CLIENT_KEY=1 +fi + +if [[ "$CLIENT_PUB_HASH" != "$INSTALLED_PUB_HASH" || \ + ! -f "$LINBOFS_ARCHIVE" || \ + "$LINBOFS_HAS_CLIENT_KEY" -ne 1 || \ + ( -f "$LINBOFS_ARCHIVE" && "$LINBOFS_ARCHIVE" -nt "$CLIENT_KEY_MARKER" ) ]]; then + log "embedding dedicated public key into linbofs" + update-linbofs + linbofs_contains_client_key || \ + fail "update-linbofs completed, but $LINBOFS_ARCHIVE does not contain the key" + new_temp "$CONFIG_DIR/.client-key-marker.XXXXXX" + marker_tmp="$REPLY" + printf '%s\n' "$CLIENT_PUB_HASH" > "$marker_tmp" + install -o root -g root -m 0644 "$marker_tmp" "$CLIENT_KEY_MARKER" +else + log "linbofs already contains the current client key" +fi + +HOST_FINGERPRINT="$(dropbearkey -y -f "$DROPBEAR_HOST_KEY" 2>/dev/null | awk '/^Fingerprint:/ { print $2; exit }')" +HOST_FINGERPRINT="${HOST_FINGERPRINT%=}" +[[ "$HOST_FINGERPRINT" =~ ^SHA256:[A-Za-z0-9+/]{43}$ ]] || \ + fail "could not derive a valid SHA256 fingerprint from $DROPBEAR_HOST_KEY" + +SEVEN_ZIP_BIN="" +for candidate in /usr/bin/7zz /usr/bin/7z; do + [[ -x "$candidate" ]] || continue + candidate_version="$("$candidate" i 2>&1 | grep -Eo '[0-9]{2,3}\.[0-9]{2}' | sed -n '1p')" + if [[ -n "$candidate_version" ]] && dpkg --compare-versions "$candidate_version" ge "25.01"; then + SEVEN_ZIP_BIN="$candidate" + break + fi +done +[[ -n "$SEVEN_ZIP_BIN" ]] || fail "7-Zip 25.01 or newer is required" + +upsert_env() { + local key="$1" value="$2" tmp + new_temp "$CONFIG_DIR/.env.XXXXXX" + tmp="$REPLY" + awk -v key="$key" -v value="$value" ' + BEGIN { found = 0 } + $0 ~ "^[[:space:]]*#?[[:space:]]*" key "[[:space:]]*=" { + if (!found) print key "=" value + found = 1 + next + } + { print } + END { if (!found) print key "=" value } + ' "$ENV_FILE" > "$tmp" + chown root:"$SERVICE_GROUP" "$tmp" + chmod 0640 "$tmp" + mv -f -- "$tmp" "$ENV_FILE" +} + +ensure_env() { + local key="$1" value="$2" + if ! grep -Eq "^[[:space:]]*${key}[[:space:]]*=" "$ENV_FILE"; then + upsert_env "$key" "$value" + fi +} + +read_env_value() { + local key="$1" + awk -v key="$key" ' + $0 ~ "^[[:space:]]*" key "[[:space:]]*=" { + value = $0 + sub("^[[:space:]]*" key "[[:space:]]*=[[:space:]]*", "", value) + sub(/[[:space:]]+$/, "", value) + if (value ~ /^\".*\"$/ || value ~ /^\047.*\047$/) { + value = substr(value, 2, length(value) - 2) + } + } + END { print value } + ' "$ENV_FILE" +} + +ensure_env PATCHLESS_HOST "127.0.0.1" +ensure_env PATCHLESS_ALLOW_PLAINTEXT_REMOTE "false" +ensure_env PATCHLESS_PORT "3001" +upsert_env DRIVERS_BASE "$DRIVERS_BASE" +upsert_env LINBO_DIR "/srv/linbo" +upsert_env HWINFO_DIR "$HWINFO_DIR" +upsert_env LINBO_HWINFO_DIR "$SERVER_HWINFO_DIR" +upsert_env LINBO_DEVICES_CSV "$DEVICES_CSV" +ensure_env HWINFO_STALE_HOURS "168" +upsert_env LINBO_CLIENT_SSH_KEY "$CLIENT_KEY" +ensure_env LINBO_CLIENT_SSH_PORT "2222" +ensure_env SSH_TIMEOUT "10000" +ensure_env SSH_ALLOWED_CIDRS "10.0.0.0/8,172.16.0.0/12,192.168.0.0/16" +upsert_env SSH_STRICT_HOST_KEY "true" +upsert_env SSH_HOST_KEY_ALGORITHM "ssh-ed25519" +upsert_env SSH_HOST_FINGERPRINT "$HOST_FINGERPRINT" +upsert_env SEVEN_ZIP_BIN "$SEVEN_ZIP_BIN" +ensure_env HEALTH_REQUIRE_SSH "true" +ensure_env HEALTH_REQUIRE_INVENTORY "true" + +# Migrate every non-loopback bind unless the administrator explicitly accepted +# remotely reachable plaintext HTTP. This includes specific addresses because +# early source installs may have retained them without an acknowledgement flag. +CONFIGURED_HOST="$(read_env_value PATCHLESS_HOST)" +ALLOW_PLAINTEXT_REMOTE="$(read_env_value PATCHLESS_ALLOW_PLAINTEXT_REMOTE)" +case "$CONFIGURED_HOST" in + 0.0.0.0|::|::0|0:0:0:0:0:0:0:0|"[::]"|"[::0]"|"[0:0:0:0:0:0:0:0]") + if [[ "${ALLOW_PLAINTEXT_REMOTE,,}" != "true" ]]; then + upsert_env PATCHLESS_HOST "127.0.0.1" + log "migrated legacy wildcard HTTP bind to 127.0.0.1" + fi + ;; + 127.0.0.1|localhost|::1|"[::1]") + ;; + *) + if [[ "${ALLOW_PLAINTEXT_REMOTE,,}" != "true" ]]; then + upsert_env PATCHLESS_HOST "127.0.0.1" + log "migrated unconfirmed remote HTTP bind to 127.0.0.1" + fi + ;; +esac + +chown root:"$SERVICE_GROUP" "$ENV_FILE" +chmod 0640 "$ENV_FILE" +chown -R "$SERVICE_USER":"$SERVICE_GROUP" "$DRIVERS_BASE" "$STATE_DIR" +find "$DRIVERS_BASE" -type d -exec chmod 0755 {} + +find "$DRIVERS_BASE" -type f -exec chmod 0644 {} + + +if [[ -d "$SERVER_HWINFO_DIR" ]]; then + setfacl -R -m "u:${SERVICE_USER}:rX" "$SERVER_HWINFO_DIR" + find "$SERVER_HWINFO_DIR" -type d -exec setfacl -m "d:u:${SERVICE_USER}:rX" {} + +fi +if [[ -f "$DEVICES_CSV" ]]; then + setfacl -m "u:${SERVICE_USER}:r" "$DEVICES_CSV" +fi + +if ! setfacl -m "u:${SERVICE_USER}:rX,d:u:${SERVICE_USER}:rwx,d:m::rwx" "$IMAGES_DIR"; then + fail "could not apply ACLs to $IMAGES_DIR" +fi +if ! find "$IMAGES_DIR" -mindepth 1 -maxdepth 1 -type d \ + -exec setfacl -m "u:${SERVICE_USER}:rwx,m::rwx" {} +; then + fail "could not apply ACLs to current image directories" +fi + +# A unit created by the former source installer lives in /etc and therefore +# shadows the package-owned unit in /lib or /usr/lib. Do this migration only +# after every other host check/configuration succeeded. Remove only the exact +# legacy layout; never delete an operator-defined override. +if [[ -L "$LEGACY_UNIT" ]]; then + fail "$LEGACY_UNIT is a local symlink that overrides the packaged service; review and remove it before retrying" +elif [[ -f "$LEGACY_UNIT" ]]; then + if grep -Fqx 'WorkingDirectory=/opt/linbo-patchless' "$LEGACY_UNIT" && \ + grep -Fqx 'ExecStart=/usr/bin/node /opt/linbo-patchless/src/index.js' "$LEGACY_UNIT"; then + wants_target="" + if [[ -L "$LEGACY_WANTS_LINK" ]]; then + wants_target="$(readlink "$LEGACY_WANTS_LINK")" + case "$wants_target" in + "$LEGACY_UNIT"|../linbo-patchless.service) + ;; + *) + fail "$LEGACY_WANTS_LINK has an unexpected target: $wants_target" + ;; + esac + elif [[ -e "$LEGACY_WANTS_LINK" ]]; then + fail "$LEGACY_WANTS_LINK is not a symbolic link; review it before retrying" + fi + rm -f -- "$LEGACY_UNIT" + [[ -z "$wants_target" ]] || rm -f -- "$LEGACY_WANTS_LINK" + log "removed the legacy source-installer systemd unit" + else + fail "$LEGACY_UNIT is a custom local unit that overrides the packaged service; review and remove it before retrying" + fi +fi + +log "host configuration is ready" diff --git a/scripts/build-deb.sh b/scripts/build-deb.sh new file mode 100755 index 0000000..f171d1b --- /dev/null +++ b/scripts/build-deb.sh @@ -0,0 +1,67 @@ +#!/bin/bash +set -Eeuo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" +DIST_DIR="$PROJECT_DIR/dist" +BUILD_ROOT="" + +cleanup() { + [[ -z "$BUILD_ROOT" ]] || rm -rf -- "$BUILD_ROOT" +} +trap cleanup EXIT + +for command_name in dpkg-buildpackage dpkg-parsechangelog dh npm node rsync; do + if ! command -v "$command_name" >/dev/null 2>&1; then + echo "Missing build command: $command_name" >&2 + exit 1 + fi +done + +NODE_MAJOR="$(node -p 'Number(process.versions.node.split(".")[0])')" +if [[ ! "$NODE_MAJOR" =~ ^[0-9]+$ ]] || (( NODE_MAJOR < 20 )); then + echo "Node.js 20 or newer is required for package builds" >&2 + exit 1 +fi + +[[ -f "$PROJECT_DIR/package-lock.json" ]] || { + echo "package-lock.json is required" >&2 + exit 1 +} + +APP_VERSION="$(node -p "require('$PROJECT_DIR/package.json').version")" +DEBIAN_VERSION="$(dpkg-parsechangelog -l"$PROJECT_DIR/debian/changelog" -SVersion)" +if [[ "$APP_VERSION" != "$DEBIAN_VERSION" ]]; then + echo "package.json version $APP_VERSION does not match Debian version $DEBIAN_VERSION" >&2 + exit 1 +fi + +mkdir -p "$DIST_DIR" +BUILD_ROOT="$(mktemp -d /tmp/linbo-patchless-build.XXXXXX)" +BUILD_SOURCE="$BUILD_ROOT/source" +mkdir -p "$BUILD_SOURCE" + +rsync -a \ + --exclude='.git/' \ + --exclude='coverage/' \ + --exclude='dist/' \ + --exclude='node_modules/' \ + --exclude='debian/.debhelper/' \ + --exclude='debian/.node-build/' \ + --exclude='debian/linbo-patchless/' \ + "$PROJECT_DIR/" "$BUILD_SOURCE/" + +cd "$BUILD_SOURCE" + +dpkg-buildpackage --build=binary --no-sign + +shopt -s nullglob +artifacts=("$BUILD_ROOT"/linbo-patchless_*.deb "$BUILD_ROOT"/linbo-patchless_*.buildinfo "$BUILD_ROOT"/linbo-patchless_*.changes) +if (( ${#artifacts[@]} == 0 )); then + echo "Package build completed but no artifacts were found" >&2 + exit 1 +fi +install -m 0644 -- "${artifacts[@]}" "$DIST_DIR/" + +echo "Debian package artifacts:" +printf ' %s\n' "$DIST_DIR"/* diff --git a/scripts/generate-third-party-notices.js b/scripts/generate-third-party-notices.js new file mode 100644 index 0000000..f2c58b0 --- /dev/null +++ b/scripts/generate-third-party-notices.js @@ -0,0 +1,114 @@ +#!/usr/bin/env node + +'use strict'; + +const fs = require('fs'); +const path = require('path'); + +const [modulesDirArg, outputFileArg] = process.argv.slice(2); +if (!modulesDirArg || !outputFileArg) { + console.error('Usage: generate-third-party-notices.js '); + process.exit(2); +} + +const modulesDir = path.resolve(modulesDirArg); +const outputFile = path.resolve(outputFileArg); +const licensePattern = /^(?:licen[cs]e|copying|notice)(?:[._-].*)?$/i; +const packages = new Map(); + +function normalizeLicense(value) { + if (typeof value === 'string' && value.trim()) return value.trim(); + if (Array.isArray(value)) { + const values = value + .map(entry => (typeof entry === 'string' ? entry : entry?.type)) + .filter(Boolean); + if (values.length) return values.join(' OR '); + } + return 'not declared in package.json'; +} + +function repositoryUrl(value) { + if (typeof value === 'string') return value; + if (value && typeof value.url === 'string') return value.url; + return ''; +} + +function visitNodeModules(directory) { + if (!fs.existsSync(directory)) return; + + for (const entry of fs.readdirSync(directory, { withFileTypes: true })) { + if (!entry.isDirectory() || entry.name.startsWith('.')) continue; + const entryPath = path.join(directory, entry.name); + + if (entry.name.startsWith('@')) { + visitNodeModules(entryPath); + continue; + } + + const metadataPath = path.join(entryPath, 'package.json'); + if (!fs.existsSync(metadataPath)) continue; + + const metadata = JSON.parse(fs.readFileSync(metadataPath, 'utf8')); + if (!metadata.name || !metadata.version) { + throw new Error(`Invalid package metadata: ${metadataPath}`); + } + + const key = `${metadata.name}@${metadata.version}`; + if (!packages.has(key)) { + const licenseFiles = fs.readdirSync(entryPath, { withFileTypes: true }) + .filter(file => file.isFile() && licensePattern.test(file.name)) + .map(file => ({ + name: file.name, + contents: fs.readFileSync(path.join(entryPath, file.name), 'utf8').trim(), + })) + .filter(file => file.contents); + + packages.set(key, { + name: metadata.name, + version: metadata.version, + license: normalizeLicense(metadata.license || metadata.licenses), + repository: repositoryUrl(metadata.repository), + licenseFiles, + }); + } + + visitNodeModules(path.join(entryPath, 'node_modules')); + } +} + +visitNodeModules(modulesDir); +if (packages.size === 0) { + throw new Error(`No packages found below ${modulesDir}`); +} + +const lines = [ + 'LINBO Patchless - bundled third-party notices', + '', + 'Generated from the exact production node_modules tree during package build.', + 'The declared identifier is informational; the included upstream license text controls.', + '', +]; + +for (const pkg of [...packages.values()].sort((left, right) => ( + left.name.localeCompare(right.name) || left.version.localeCompare(right.version) +))) { + lines.push('='.repeat(78)); + lines.push(`${pkg.name} ${pkg.version}`); + lines.push(`Declared license: ${pkg.license}`); + if (pkg.repository) lines.push(`Repository: ${pkg.repository}`); + + if (pkg.licenseFiles.length === 0) { + lines.push('License file: none included by upstream; consult package metadata.'); + } else { + for (const licenseFile of pkg.licenseFiles) { + lines.push(''); + lines.push(`--- ${licenseFile.name} ---`); + lines.push(licenseFile.contents); + } + } + lines.push(''); +} + +fs.mkdirSync(path.dirname(outputFile), { recursive: true }); +fs.writeFileSync(outputFile, `${lines.join('\n')}\n`, { mode: 0o644 }); +console.log(`Recorded licenses for ${packages.size} production packages in ${outputFile}`); diff --git a/src/index.js b/src/index.js index af1cca8..f689b9d 100644 --- a/src/index.js +++ b/src/index.js @@ -15,12 +15,12 @@ const express = require('express'); const path = require('path'); const fs = require('fs'); const crypto = require('crypto'); +const net = require('net'); const helmet = require('helmet'); const cors = require('cors'); const pinoHttp = require('pino-http'); const logger = require('./lib/logger'); const rateLimiter = require('./middleware/rate-limit'); -const auth = require('./middleware/auth'); // Startup reconciliation runs only for the real server process. Imported test // apps remain ready by default and can exercise the routine explicitly. @@ -101,6 +101,153 @@ function canAccess(target, mode) { const VALID_IMAGE_BASENAME = /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/; +function normalizeIpAddress(address) { + if (net.isIP(address) !== 6) return address; + try { + return new URL(`http://[${address}]/`).hostname.replace(/^\[|\]$/g, '').toLowerCase(); + } catch { + return address.toLowerCase(); + } +} + +function normalizeBindHost(host) { + if (typeof host !== 'string') return null; + let normalized = host.trim(); + if (!normalized) return null; + if (normalized.startsWith('[') && normalized.endsWith(']')) { + normalized = normalized.slice(1, -1); + } + if (/\s|[/\\?#@]/.test(normalized)) return null; + if (net.isIP(normalized)) return normalizeIpAddress(normalized); + if (!/^[A-Za-z0-9](?:[A-Za-z0-9.-]*[A-Za-z0-9])?$/.test(normalized)) return null; + return normalized.replace(/\.$/, '').toLowerCase(); +} + +function isLoopbackHost(host) { + const normalized = normalizeBindHost(host); + if (!normalized) return false; + if (normalized === 'localhost' || normalized === '::1') return true; + return net.isIP(normalized) === 4 && normalized.startsWith('127.'); +} + +function validateBindHost(host, allowPlaintextRemote = process.env.PATCHLESS_ALLOW_PLAINTEXT_REMOTE) { + const normalized = normalizeBindHost(host); + if (!normalized) throw new Error(`Invalid PATCHLESS_HOST value: ${String(host)}`); + if (!isLoopbackHost(normalized) + && String(allowPlaintextRemote || '').trim().toLowerCase() !== 'true') { + throw new Error( + `Refusing non-loopback PATCHLESS_HOST=${normalized} without PATCHLESS_ALLOW_PLAINTEXT_REMOTE=true` + ); + } + return normalized; +} + +function parseAuthority(authority) { + if (typeof authority !== 'string') return null; + const value = authority.trim(); + if (!value || /\s|[/\\?#@]/.test(value)) return null; + + let hostname; + let port = ''; + if (value.startsWith('[')) { + const match = value.match(/^\[([^\]]+)\](?::([0-9]{1,5}))?$/); + if (!match || net.isIP(match[1]) !== 6) return null; + hostname = normalizeIpAddress(match[1]); + port = match[2] || ''; + } else { + const parts = value.split(':'); + if (parts.length > 2) return null; + hostname = normalizeBindHost(parts[0]); + port = parts[1] || ''; + if (parts.length === 2 && !/^[0-9]{1,5}$/.test(parts[1])) return null; + } + + if (!hostname || (port && (Number(port) < 1 || Number(port) > 65535))) return null; + return { hostname, port }; +} + +function parseOrigin(origin) { + if (typeof origin !== 'string' || !origin.trim() || origin.includes(',')) return null; + try { + const parsed = new URL(origin.trim()); + if (!['http:', 'https:'].includes(parsed.protocol) + || parsed.username || parsed.password + || parsed.pathname !== '/' || parsed.search || parsed.hash) { + return null; + } + const authority = parseAuthority(parsed.host); + if (!authority) return null; + return { origin: parsed.origin, ...authority }; + } catch { + return null; + } +} + +function splitConfigList(value) { + return typeof value === 'string' + ? value.split(',').map(entry => entry.trim()).filter(Boolean) + : []; +} + +function getApiBoundaryConfig(env = process.env) { + const origins = new Map(); + const allowedHostnames = new Set(); + + for (const entry of splitConfigList(env.CORS_ORIGIN)) { + const parsed = parseOrigin(entry); + if (!parsed) continue; + origins.set(parsed.origin, parsed); + allowedHostnames.add(parsed.hostname); + } + + for (const entry of splitConfigList(env.PATCHLESS_ALLOWED_HOSTS)) { + const parsedOrigin = parseOrigin(entry); + const parsedAuthority = parsedOrigin || parseAuthority(entry); + if (parsedAuthority) allowedHostnames.add(parsedAuthority.hostname); + } + + return { origins, allowedHostnames }; +} + +function sameAuthority(left, right) { + if (!left || !right || left.hostname !== right.hostname) return false; + if (left.port && right.port) return left.port === right.port; + return !left.port && !right.port; +} + +function createApiBoundary(config = getApiBoundaryConfig()) { + return function apiBoundary(req, res, next) { + const requestAuthority = parseAuthority(req.headers.host); + if (!requestAuthority + || (!isLoopbackHost(requestAuthority.hostname) + && !config.allowedHostnames.has(requestAuthority.hostname))) { + return res.status(421).json({ + error: { code: 'HOST_NOT_ALLOWED', message: 'Request host is not allowed' }, + }); + } + + const rawOrigin = req.headers.origin; + const origin = rawOrigin === undefined ? null : parseOrigin(rawOrigin); + const explicitlyAllowed = Boolean(origin && config.origins.has(origin.origin)); + const sameOrigin = Boolean(origin && sameAuthority(origin, requestAuthority)); + + if (rawOrigin !== undefined && (!origin || (!sameOrigin && !explicitlyAllowed))) { + return res.status(403).json({ + error: { code: 'CROSS_ORIGIN_REQUEST', message: 'Cross-origin API request rejected' }, + }); + } + + const fetchSite = String(req.headers['sec-fetch-site'] || '').toLowerCase(); + if (['cross-site', 'same-site'].includes(fetchSite) && !sameOrigin && !explicitlyAllowed) { + return res.status(403).json({ + error: { code: 'CROSS_SITE_REQUEST', message: 'Cross-site API request rejected' }, + }); + } + + return next(); + }; +} + /** * Inspect only native, independently usable qcow2 image directories. * Symlinked directories/files and unrelated folders are deliberately ignored. @@ -153,16 +300,6 @@ function inspectImageDirectoryAccess(imagesDir, access = canAccess) { return result; } -function apiKeyConfigured() { - if (process.env.PATCHLESS_API_KEY?.trim()) return true; - const keyFile = process.env.PATCHLESS_API_KEY_FILE || '/etc/linbo-patchless/api-key'; - try { - return fs.readFileSync(keyFile, 'utf8').trim().length > 0; - } catch { - return false; - } -} - function getInlineCspHashes(htmlFile, tagName) { try { const html = fs.readFileSync(htmlFile, 'utf8'); @@ -199,6 +336,7 @@ function createApp() { const uiFile = path.join(__dirname, '..', 'ui', 'index.html'); const scriptHashes = getInlineCspHashes(uiFile, 'script'); const styleHashes = getInlineCspHashes(uiFile, 'style'); + const apiBoundaryConfig = getApiBoundaryConfig(); if (process.env.TRUST_PROXY) { const trustProxy = Number(process.env.TRUST_PROXY); @@ -265,9 +403,12 @@ function createApp() { // CORS — default: same-origin only app.use(cors({ - origin: process.env.CORS_ORIGIN || false, + origin(origin, callback) { + const parsed = parseOrigin(origin); + callback(null, Boolean(parsed && apiBoundaryConfig.origins.has(parsed.origin)) && origin); + }, methods: ['GET', 'POST', 'PUT', 'DELETE'], - allowedHeaders: ['Content-Type', 'X-API-Key'], + allowedHeaders: ['Content-Type'], })); // Request logging @@ -291,9 +432,14 @@ function createApp() { }, })); - // Body parsers + // Reject DNS-rebinding and cross-site browser requests before parsing API + // bodies. The boundary deliberately does not trust proxy-derived client data; + // public reverse-proxy hosts/origins must be configured explicitly. + app.use('/api', createApiBoundary(apiBoundaryConfig)); + + // JSON is the only non-multipart API body format. Avoid accepting HTML form + // submissions implicitly; Multer parses the two upload routes locally. app.use(express.json({ limit: '1mb' })); - app.use(express.urlencoded({ extended: true, limit: '1mb' })); // Liveness only answers whether the Node process can serve requests. app.get('/health/live', (_req, res) => { @@ -301,7 +447,7 @@ function createApp() { }); // Readiness verifies every dependency needed for the core workflow. - app.get('/health', (req, res) => { + app.get('/health', (_req, res) => { const driversBase = process.env.DRIVERS_BASE || '/srv/linbo/drivers'; const linboDir = process.env.LINBO_DIR || '/srv/linbo'; const imagesDir = path.join(linboDir, 'images'); @@ -314,7 +460,6 @@ function createApp() { const imageDirectoryAccess = inspectImageDirectoryAccess(imagesDir); const checks = { - apiKey: apiKeyConfigured(), driversExists: canAccess(driversBase, fs.constants.R_OK | fs.constants.X_OK), driversWritable: canAccess(driversBase, fs.constants.W_OK | fs.constants.X_OK), imagesExists: canAccess(imagesDir, fs.constants.R_OK | fs.constants.X_OK), @@ -323,8 +468,7 @@ function createApp() { sshReady: sshStatus.ready, startupReconciled: startupStatus.ready, }; - const ready = checks.apiKey - && checks.driversExists + const ready = checks.driversExists && checks.driversWritable && checks.imagesExists && checks.imageDirectoriesWritable @@ -359,8 +503,7 @@ function createApp() { app.use(rateLimiter.global); // Public, immutable setup helper; it contains no host-specific data or - // credentials and must be obtainable before the API key is configured in a - // browser. The script verifies the privileged task after registration. + // credentials. The script verifies the privileged task after registration. app.get('/downloads/Install-LinboPatchlessTask.ps1', (_req, res, next) => { const helper = path.join(__dirname, '..', 'windows', 'Install-LinboPatchlessTask.ps1'); res.download(helper, 'Install-LinboPatchlessTask.ps1', (error) => { @@ -368,11 +511,11 @@ function createApp() { }); }); - // Serve Mini Web-UI (static, API remains protected). + // Serve Mini Web-UI. app.use(express.static(path.join(__dirname, '..', 'ui'))); - // API routes — protected by API-Key auth - app.use('/api', auth); + // API routes. Network exposure is controlled by PATCHLESS_HOST and the + // linuxmuster server's firewall/reverse-proxy configuration. app.use('/api/v1/drivers', require('./routes/drivers')); app.use('/api', (_req, res) => { res.status(404).json({ error: { code: 'NOT_FOUND', message: 'API endpoint not found' } }); @@ -399,7 +542,6 @@ function createApp() { // Start server only when run directly (not when imported for tests) if (require.main === module) { const PORT = process.env.PATCHLESS_PORT || 3001; - const HOST = process.env.PATCHLESS_HOST || '127.0.0.1'; // Process-level error handlers process.on('uncaughtException', (err) => { @@ -413,14 +555,19 @@ if (require.main === module) { }); async function startServer() { + const host = validateBindHost( + process.env.PATCHLESS_HOST || '127.0.0.1', + process.env.PATCHLESS_ALLOW_PLAINTEXT_REMOTE + ); + // Repair interrupted commits and regenerate persisted companion hooks // before accepting another mutation. Failures keep the API available for // diagnosis, while readiness remains degraded until the next clean restart. await reconcileStartupState(); const app = createApp(); - const server = app.listen(PORT, HOST, () => { - logger.info({ host: HOST, port: PORT }, 'LINBO Patchless Driver Manager started'); + const server = app.listen(PORT, host, () => { + logger.info({ host, port: PORT }, 'LINBO Patchless Driver Manager started'); logger.info(' UI: http://localhost:%d/', PORT); logger.info(' API: http://localhost:%d/api/v1/drivers', PORT); logger.info(' Health: http://localhost:%d/health', PORT); @@ -460,5 +607,11 @@ module.exports = { inspectImageDirectoryAccess, reconcileStartupState, startupStatus, + validateBindHost, + isLoopbackHost, + parseAuthority, + parseOrigin, + getApiBoundaryConfig, + createApiBoundary, }, }; diff --git a/src/lib/logger.js b/src/lib/logger.js index 413af6c..147dee0 100644 --- a/src/lib/logger.js +++ b/src/lib/logger.js @@ -20,9 +20,7 @@ if (!globalThis[LOGGER_KEY]) { level: process.env.LOG_LEVEL || 'info', redact: { paths: [ - 'req.headers["x-api-key"]', 'req.headers.authorization', - 'headers["x-api-key"]', 'headers.authorization', ], censor: '[REDACTED]', diff --git a/src/middleware/auth.js b/src/middleware/auth.js deleted file mode 100644 index 2b4499e..0000000 --- a/src/middleware/auth.js +++ /dev/null @@ -1,81 +0,0 @@ -/** - * API-Key authentication middleware. - * - * Key sources (checked in order): - * 1. File: /etc/linbo-patchless/api-key - * 2. Environment variable: PATCHLESS_API_KEY - * - * Clients send the key via the X-API-Key header. - */ - -const fs = require('fs'); -const crypto = require('crypto'); -const logger = require('../lib/logger'); - -const DEFAULT_KEY_FILE = '/etc/linbo-patchless/api-key'; - -// undefined = not loaded yet, null = checked but not configured. -let _cachedKey; - -/** - * Load API key once at first request. - * @returns {string|null} - */ -function loadApiKey() { - if (_cachedKey !== undefined) return _cachedKey; - - // Try file first - const keyFile = process.env.PATCHLESS_API_KEY_FILE || DEFAULT_KEY_FILE; - try { - const raw = fs.readFileSync(keyFile, 'utf8').trim(); - if (raw.length > 0) { - _cachedKey = raw; - logger.info({ path: keyFile }, 'API key loaded from file'); - return _cachedKey; - } - } catch { - // File not available, try env - } - - // Fallback to environment variable - if (process.env.PATCHLESS_API_KEY) { - _cachedKey = process.env.PATCHLESS_API_KEY.trim(); - logger.info('API key loaded from PATCHLESS_API_KEY env'); - return _cachedKey; - } - - logger.warn('No API key configured — all API requests will be rejected'); - _cachedKey = null; - return null; -} - -/** - * Express middleware: validates X-API-Key header. - */ -function authMiddleware(req, res, next) { - const apiKey = loadApiKey(); - - if (!apiKey) { - return res.status(503).json({ - error: { code: 'NO_API_KEY', message: 'Server has no API key configured' }, - }); - } - - const provided = req.headers['x-api-key']; - const providedBuffer = Buffer.from(typeof provided === 'string' ? provided : ''); - const expectedBuffer = Buffer.from(apiKey); - const valid = providedBuffer.length === expectedBuffer.length - && crypto.timingSafeEqual(providedBuffer, expectedBuffer); - if (!valid) { - return res.status(401).json({ - error: { code: 'UNAUTHORIZED', message: 'Invalid or missing API key' }, - }); - } - - next(); -} - -// Exposed for testing -authMiddleware._resetCache = () => { _cachedKey = undefined; }; - -module.exports = authMiddleware; diff --git a/src/routes/drivers.js b/src/routes/drivers.js index 9a20d5f..95a8866 100644 --- a/src/routes/drivers.js +++ b/src/routes/drivers.js @@ -86,8 +86,6 @@ const imageAssignSchema = z.object({ * post: * tags: [Drivers] * summary: Create a driver profile from a LINBO client's DMI data via SSH - * security: - * - bearerAuth: [] * requestBody: * required: true * content: @@ -209,8 +207,6 @@ router.post('/create-profile-from-inventory', async (req, res, next) => { * get: * tags: [Drivers] * summary: List available images (subdirectories containing native LINBO image files) - * security: - * - bearerAuth: [] * responses: * 200: { description: Array of available image names } */ @@ -232,8 +228,6 @@ router.get( * get: * tags: [Drivers] * summary: List all driver profiles (folders containing a match.conf) - * security: - * - bearerAuth: [] * responses: * 200: { description: Array of driver profile objects } */ @@ -255,8 +249,6 @@ router.get( * get: * tags: [Drivers] * summary: Get match.conf data for a specific driver profile - * security: - * - bearerAuth: [] * parameters: * - in: path * name: name @@ -296,8 +288,6 @@ router.get( * delete: * tags: [Drivers] * summary: Delete an entire driver profile folder - * security: - * - bearerAuth: [] * parameters: * - in: path * name: name @@ -337,8 +327,6 @@ router.delete( * get: * tags: [Drivers] * summary: List all files in a driver profile (excludes match.conf) - * security: - * - bearerAuth: [] * parameters: * - in: path * name: name @@ -379,8 +367,6 @@ router.get( * tags: [Drivers] * summary: Upload a file to a driver profile * description: Accepts multipart/form-data with a "file" field and an optional "path" field for the relative destination path. - * security: - * - bearerAuth: [] * parameters: * - in: path * name: name @@ -444,8 +430,6 @@ router.post( * tags: [Drivers] * summary: Extract an uploaded archive into a driver profile * description: Accepts a ZIP, EXE, or 7z archive via multipart/form-data and extracts its contents into the profile folder. - * security: - * - bearerAuth: [] * parameters: * - in: path * name: name @@ -518,8 +502,6 @@ router.post( * delete: * tags: [Drivers] * summary: Delete a file from a driver profile - * security: - * - bearerAuth: [] * parameters: * - in: path * name: name @@ -577,8 +559,6 @@ router.delete( * get: * tags: [Drivers] * summary: Get match.conf data and raw content for a profile - * security: - * - bearerAuth: [] * parameters: * - in: path * name: name @@ -618,8 +598,6 @@ router.get( * put: * tags: [Drivers] * summary: Update match.conf content for a profile - * security: - * - bearerAuth: [] * parameters: * - in: path * name: name @@ -677,8 +655,6 @@ router.put( * put: * tags: [Drivers] * summary: Assign an image to a driver profile - * security: - * - bearerAuth: [] * parameters: * - in: path * name: name @@ -737,8 +713,6 @@ router.put( * delete: * tags: [Drivers] * summary: Remove image assignment from a driver profile - * security: - * - bearerAuth: [] * parameters: * - in: path * name: name @@ -806,8 +780,6 @@ router.get('/hwinfo/server', async (req, res, next) => { * get: * tags: [Drivers] * summary: Return all cached hardware info entries from file cache - * security: - * - bearerAuth: [] * responses: * 200: { description: Array of cached hwinfo objects } */ @@ -827,8 +799,6 @@ router.get('/hwinfo/all', async (req, res, next) => { * tags: [Drivers] * summary: Get hardware info from a LINBO client (cache-first, live SSH fallback) * description: Returns cached hwinfo if available. Use ?refresh=true to force a live SSH scan. - * security: - * - bearerAuth: [] * parameters: * - in: path * name: ip diff --git a/tests/api/drivers.api.test.js b/tests/api/drivers.api.test.js index f82fb2f..3b12149 100644 --- a/tests/api/drivers.api.test.js +++ b/tests/api/drivers.api.test.js @@ -1,7 +1,7 @@ const fs = require('fs'); const path = require('path'); const zlib = require('zlib'); -const { createTestEnv, TEST_API_KEY } = require('../helpers/setup'); +const { createTestEnv } = require('../helpers/setup'); let env, app, agent; @@ -21,14 +21,16 @@ afterEach(() => { env.cleanup(); }); -// Helper for authenticated requests -const auth = (req) => req.set('X-API-Key', TEST_API_KEY); +// Keep API request construction readable while ensuring no credentials are +// attached implicitly by the test suite. +const apiRequest = request => request; -describe('Authentication', () => { - test('GET /health without key returns 200', async () => { +describe('Unauthenticated API access', () => { + test('GET /health without credentials returns 200', async () => { const res = await agent.get('/health'); expect(res.status).toBe(200); expect(res.body.status).toBe('healthy'); + expect(res.body.checks).not.toHaveProperty('apiKey'); }); test('GET /health returns version and uptime', async () => { @@ -96,32 +98,120 @@ describe('Authentication', () => { })); }); - test('GET /api/v1/drivers/profiles without key returns 401', async () => { + test('GET /api/v1/drivers/profiles works without credentials', async () => { const res = await agent.get('/api/v1/drivers/profiles'); - expect(res.status).toBe(401); - expect(res.body.error.code).toBe('UNAUTHORIZED'); + expect(res.status).toBe(200); + expect(res.body.data).toEqual([]); }); - test('GET /api/v1/drivers/profiles with wrong key returns 401', async () => { - const res = await agent.get('/api/v1/drivers/profiles') - .set('X-API-Key', 'totally-wrong'); - expect(res.status).toBe(401); + test('unknown API route returns structured 404 without credentials', async () => { + const res = await agent.get('/api/v1/does-not-exist'); + expect(res.status).toBe(404); + expect(res.body.error.code).toBe('NOT_FOUND'); }); +}); + +describe('Local API security boundary', () => { + function rebuildApp() { + jest.resetModules(); + const { createTestApp } = require('../helpers/app'); + const testApp = createTestApp(); + app = testApp.app; + agent = testApp.agent; + } + + test('accepts loopback binds and rejects remote binds unless explicitly acknowledged', () => { + const { _testing } = require('../../src/index'); - test('GET /api/v1/drivers/profiles with correct key returns 200', async () => { - const res = auth(agent.get('/api/v1/drivers/profiles')); - expect((await res).status).toBe(200); + expect(_testing.validateBindHost('127.0.0.1')).toBe('127.0.0.1'); + expect(_testing.validateBindHost('127.42.0.1')).toBe('127.42.0.1'); + expect(_testing.validateBindHost('[::1]')).toBe('::1'); + expect(_testing.validateBindHost('localhost')).toBe('localhost'); + expect(() => _testing.validateBindHost('0.0.0.0')).toThrow(/Refusing non-loopback/); + expect(() => _testing.validateBindHost('10.0.0.14', 'false')).toThrow(/Refusing non-loopback/); + expect(_testing.validateBindHost('0.0.0.0', ' TRUE ')).toBe('0.0.0.0'); }); - test('unknown protected API route returns structured 404', async () => { - const res = await auth(agent.get('/api/v1/does-not-exist')); - expect(res.status).toBe(404); - expect(res.body.error.code).toBe('NOT_FOUND'); + test('rejects an unconfigured Host header before serving the API', async () => { + const res = await agent + .get('/api/v1/drivers/profiles') + .set('Host', 'attacker.example'); + + expect(res.status).toBe(421); + expect(res.body.error.code).toBe('HOST_NOT_ALLOWED'); + }); + + test('does not use TRUST_PROXY or forwarded headers as Host trust', async () => { + process.env.TRUST_PROXY = '1'; + rebuildApp(); + + const res = await agent + .get('/api/v1/drivers/profiles') + .set('Host', 'attacker.example') + .set('X-Forwarded-Host', 'localhost:3001'); + + expect(res.status).toBe(421); + expect(res.body.error.code).toBe('HOST_NOT_ALLOWED'); + }); + + test('rejects cross-origin and cross-site browser requests', async () => { + const crossOrigin = await agent + .post('/api/v1/drivers/create-profile-from-inventory') + .set('Host', 'localhost:3001') + .set('Origin', 'https://attacker.example') + .send({ hostname: 'client01' }); + expect(crossOrigin.status).toBe(403); + expect(crossOrigin.body.error.code).toBe('CROSS_ORIGIN_REQUEST'); + + const crossSite = await agent + .get('/api/v1/drivers/profiles') + .set('Host', 'localhost:3001') + .set('Sec-Fetch-Site', 'cross-site'); + expect(crossSite.status).toBe(403); + expect(crossSite.body.error.code).toBe('CROSS_SITE_REQUEST'); + }); + + test('allows a same-origin request through an explicitly configured reverse-proxy host', async () => { + process.env.PATCHLESS_ALLOWED_HOSTS = 'drivers.example.internal'; + rebuildApp(); + + const res = await agent + .post('/api/v1/drivers/create-profile-from-inventory') + .set('Host', 'drivers.example.internal') + .set('Origin', 'https://drivers.example.internal') + .send({}); + + expect(res.status).toBe(400); + expect(res.body.error.code).toBe('INVALID_HOSTNAME'); + }); + + test('uses CORS_ORIGIN as an explicit public origin and Host allowlist', async () => { + process.env.CORS_ORIGIN = 'https://drivers.example.internal'; + rebuildApp(); + + const res = await agent + .get('/api/v1/drivers/profiles') + .set('Host', 'drivers.example.internal') + .set('Origin', 'https://drivers.example.internal'); + + expect(res.status).toBe(200); + expect(res.headers['access-control-allow-origin']).toBe('https://drivers.example.internal'); + }); + + test('does not parse HTML form bodies on JSON API routes', async () => { + const res = await agent + .post('/api/v1/drivers/create-profile-from-inventory') + .set('Host', 'localhost:3001') + .type('form') + .send({ hostname: 'client01' }); + + expect(res.status).toBe(400); + expect(res.body.error.code).toBe('INVALID_HOSTNAME'); }); }); describe('Security Headers', () => { - test('request log serializer never includes authentication headers', () => { + test('request log serializer never includes sensitive headers', () => { const { _testing } = require('../../src/index'); const serialized = _testing.serializeRequestForLog({ id: 'request-1', @@ -129,8 +219,8 @@ describe('Security Headers', () => { url: '/api/v1/drivers/profiles', remoteAddress: '10.0.0.1', headers: { - 'x-api-key': 'must-never-be-logged', authorization: 'Bearer secret', + cookie: 'session=secret', }, }); expect(serialized).toEqual(expect.objectContaining({ @@ -138,8 +228,8 @@ describe('Security Headers', () => { method: 'GET', url: '/api/v1/drivers/profiles', })); - expect(JSON.stringify(serialized)).not.toContain('must-never-be-logged'); expect(JSON.stringify(serialized)).not.toContain('Bearer secret'); + expect(JSON.stringify(serialized)).not.toContain('session=secret'); expect(serialized).not.toHaveProperty('headers'); }); @@ -169,25 +259,25 @@ describe('Security Headers', () => { describe('Profile CRUD', () => { test('GET /profiles returns empty array initially', async () => { - const res = await auth(agent.get('/api/v1/drivers/profiles')); + const res = await apiRequest(agent.get('/api/v1/drivers/profiles')); expect(res.status).toBe(200); expect(res.body.data).toEqual([]); }); test('GET /profiles/:name returns 404 for non-existent profile', async () => { - const res = await auth(agent.get('/api/v1/drivers/profiles/nonexistent')); + const res = await apiRequest(agent.get('/api/v1/drivers/profiles/nonexistent')); expect(res.status).toBe(404); }); test('GET /profiles/:name returns 400 for invalid name with special chars', async () => { // '..' gets normalized by Express URL parsing, so use a name that // passes URL normalization but fails sanitizeName validation - const res = await auth(agent.get('/api/v1/drivers/profiles/.hidden-profile')); + const res = await apiRequest(agent.get('/api/v1/drivers/profiles/.hidden-profile')); expect(res.status).toBe(400); }); test('DELETE /profiles/:name returns 404 for non-existent', async () => { - const res = await auth(agent.delete('/api/v1/drivers/profiles/nonexistent')); + const res = await apiRequest(agent.delete('/api/v1/drivers/profiles/nonexistent')); expect(res.status).toBe(404); }); @@ -199,48 +289,48 @@ describe('Profile CRUD', () => { '[match]\nvendor = Dell Inc.\nproduct = Latitude 5520\n'); // List should show it - let res = await auth(agent.get('/api/v1/drivers/profiles')); + let res = await apiRequest(agent.get('/api/v1/drivers/profiles')); expect(res.status).toBe(200); expect(res.body.data.length).toBe(1); expect(res.body.data[0].name || res.body.data[0].folder).toBe('TestProfile'); // Get details - res = await auth(agent.get('/api/v1/drivers/profiles/TestProfile')); + res = await apiRequest(agent.get('/api/v1/drivers/profiles/TestProfile')); expect(res.status).toBe(200); expect(res.body.data.vendor).toBe('Dell Inc.'); // Delete - res = await auth(agent.delete('/api/v1/drivers/profiles/TestProfile')); + res = await apiRequest(agent.delete('/api/v1/drivers/profiles/TestProfile')); expect(res.status).toBe(200); // Should be gone - res = await auth(agent.get('/api/v1/drivers/profiles')); + res = await apiRequest(agent.get('/api/v1/drivers/profiles')); expect(res.body.data).toEqual([]); }); }); describe('Path traversal protection', () => { test('rejects .. in profile name', async () => { - const res = await auth(agent.get('/api/v1/drivers/profiles/..%2F..%2Fetc')); + const res = await apiRequest(agent.get('/api/v1/drivers/profiles/..%2F..%2Fetc')); expect(res.status).toBe(400); }); test('rejects absolute path in profile name', async () => { - const res = await auth(agent.get('/api/v1/drivers/profiles/%2Fetc%2Fpasswd')); + const res = await apiRequest(agent.get('/api/v1/drivers/profiles/%2Fetc%2Fpasswd')); expect(res.status).toBe(400); }); }); describe('Input validation', () => { test('POST /create-profile rejects invalid IP', async () => { - const res = await auth(agent.post('/api/v1/drivers/create-profile')) + const res = await apiRequest(agent.post('/api/v1/drivers/create-profile')) .send({ hostIp: 'not-an-ip' }); expect(res.status).toBe(400); expect(res.body.error.code).toBe('INVALID_IP'); }); test('POST /create-profile rejects a public SSH target before connecting', async () => { - const res = await auth(agent.post('/api/v1/drivers/create-profile')) + const res = await apiRequest(agent.post('/api/v1/drivers/create-profile')) .send({ hostIp: '203.0.113.10' }); expect(res.status).toBe(403); expect(res.body.error.code).toBe('SSH_TARGET_NOT_ALLOWED'); @@ -252,25 +342,25 @@ describe('Input validation', () => { fs.mkdirSync(profileDir, { recursive: true }); fs.writeFileSync(path.join(profileDir, 'match.conf'), '[match]\nvendor=Test\n'); - const res = await auth(agent.put('/api/v1/drivers/profiles/ValidProfile/match-conf')) + const res = await apiRequest(agent.put('/api/v1/drivers/profiles/ValidProfile/match-conf')) .send({ content: '' }); expect(res.status).toBe(400); }); test('PUT /profiles/:name/image rejects invalid image name', async () => { - const res = await auth(agent.put('/api/v1/drivers/profiles/SomeProfile/image')) + const res = await apiRequest(agent.put('/api/v1/drivers/profiles/SomeProfile/image')) .send({ image: '../../../etc/passwd' }); expect(res.status).toBe(400); }); test('PUT /profiles/:name/image rejects dotted image basenames', async () => { - const res = await auth(agent.put('/api/v1/drivers/profiles/SomeProfile/image')) + const res = await apiRequest(agent.put('/api/v1/drivers/profiles/SomeProfile/image')) .send({ image: 'win.11' }); expect(res.status).toBe(400); }); test('DELETE /profiles/:name/files rejects missing path', async () => { - const res = await auth(agent.delete('/api/v1/drivers/profiles/SomeProfile/files')) + const res = await apiRequest(agent.delete('/api/v1/drivers/profiles/SomeProfile/files')) .send({}); expect(res.status).toBe(400); }); @@ -284,12 +374,12 @@ describe('File operations', () => { fs.writeFileSync(path.join(profileDir, 'match.conf'), '[match]\nvendor=Test\n'); // Upload a file - const res = await auth(agent.post('/api/v1/drivers/profiles/UploadTest/upload')) + const res = await apiRequest(agent.post('/api/v1/drivers/profiles/UploadTest/upload')) .attach('file', Buffer.from('driver data'), 'test-driver.inf'); expect(res.status).toBe(201); // List files - const listRes = await auth(agent.get('/api/v1/drivers/profiles/UploadTest/files')); + const listRes = await apiRequest(agent.get('/api/v1/drivers/profiles/UploadTest/files')); expect(listRes.status).toBe(200); const files = listRes.body.data; const fileNames = files.map(f => f.name || f.path); @@ -306,18 +396,18 @@ describe('Match.conf operations', () => { '[match]\nvendor = Original\nproduct = Model\n'); // Get - let res = await auth(agent.get('/api/v1/drivers/profiles/ConfTest/match-conf')); + let res = await apiRequest(agent.get('/api/v1/drivers/profiles/ConfTest/match-conf')); expect(res.status).toBe(200); expect(res.body.data.vendor).toBe('Original'); // Update const newContent = '[match]\nvendor = Updated Corp\nproduct = NewModel\n'; - res = await auth(agent.put('/api/v1/drivers/profiles/ConfTest/match-conf')) + res = await apiRequest(agent.put('/api/v1/drivers/profiles/ConfTest/match-conf')) .send({ content: newContent }); expect(res.status).toBe(200); // Verify update - res = await auth(agent.get('/api/v1/drivers/profiles/ConfTest/match-conf')); + res = await apiRequest(agent.get('/api/v1/drivers/profiles/ConfTest/match-conf')); expect(res.body.data.vendor).toBe('Updated Corp'); }); }); @@ -334,12 +424,12 @@ describe('Image assignment', () => { fs.writeFileSync(path.join(env.linboDir, 'images', 'win10-test', 'win10-test.qcow2'), ''); // Assign image - let res = await auth(agent.put('/api/v1/drivers/profiles/ImageTest/image')) + let res = await apiRequest(agent.put('/api/v1/drivers/profiles/ImageTest/image')) .send({ image: 'win10-test' }); expect(res.status).toBe(200); // Remove image - res = await auth(agent.delete('/api/v1/drivers/profiles/ImageTest/image')); + res = await apiRequest(agent.delete('/api/v1/drivers/profiles/ImageTest/image')); expect(res.status).toBe(200); }); }); @@ -369,13 +459,13 @@ describe('HWInfo routes', () => { } test('GET /hwinfo/all returns empty array with no cached data', async () => { - const res = await auth(agent.get('/api/v1/drivers/hwinfo/all')); + const res = await apiRequest(agent.get('/api/v1/drivers/hwinfo/all')); expect(res.status).toBe(200); expect(res.body.data).toEqual([]); }); test('GET /hwinfo/:ip rejects invalid IP', async () => { - const res = await auth(agent.get('/api/v1/drivers/hwinfo/not-an-ip')); + const res = await apiRequest(agent.get('/api/v1/drivers/hwinfo/not-an-ip')); expect(res.status).toBe(400); expect(res.body.error.code).toBe('VALIDATION_ERROR'); }); @@ -386,7 +476,7 @@ describe('HWInfo routes', () => { fs.writeFileSync(path.join(hwinfoDir, '10.0.0.50.json'), JSON.stringify({ ip: '10.0.0.50', vendor: 'Dell', product: 'Test', serial: 'secret' })); - const res = await auth(agent.get('/api/v1/drivers/hwinfo/10.0.0.50')); + const res = await apiRequest(agent.get('/api/v1/drivers/hwinfo/10.0.0.50')); expect(res.status).toBe(200); expect(res.body.data.vendor).toBe('Dell'); expect(res.body.data.cached).toBe(true); @@ -396,7 +486,7 @@ describe('HWInfo routes', () => { test('GET /hwinfo/server uses uploaded LINBO inventory and hides MAC addresses', async () => { writeServerInventory(); - const res = await auth(agent.get('/api/v1/drivers/hwinfo/server')); + const res = await apiRequest(agent.get('/api/v1/drivers/hwinfo/server')); expect(res.status).toBe(200); expect(res.body.data).toHaveLength(1); expect(res.body.data[0]).toEqual(expect.objectContaining({ @@ -410,7 +500,7 @@ describe('HWInfo routes', () => { expect(res.body.data[0]).not.toHaveProperty('mac'); expect(res.body.data[0]).not.toHaveProperty('devices'); - const details = await auth(agent.get('/api/v1/drivers/hwinfo/server?details=true')); + const details = await apiRequest(agent.get('/api/v1/drivers/hwinfo/server?details=true')); expect(details.status).toBe(200); expect(details.body.data[0].devices).toHaveLength(1); expect(details.body.data[0]).not.toHaveProperty('mac'); @@ -419,7 +509,7 @@ describe('HWInfo routes', () => { test('POST /create-profile-from-inventory creates a DMI profile without live SSH', async () => { writeServerInventory('client-02'); - const res = await auth(agent.post('/api/v1/drivers/create-profile-from-inventory')) + const res = await apiRequest(agent.post('/api/v1/drivers/create-profile-from-inventory')) .send({ hostname: 'client-02' }); expect(res.status).toBe(201); expect(res.body.data).toEqual(expect.objectContaining({ @@ -443,7 +533,7 @@ describe('Available images', () => { fs.mkdirSync(imgDir, { recursive: true }); fs.writeFileSync(path.join(imgDir, 'win11-pc.qcow2'), ''); - const res = await auth(agent.get('/api/v1/drivers/available-images')); + const res = await apiRequest(agent.get('/api/v1/drivers/available-images')); expect(res.status).toBe(200); expect(res.body.data.length).toBeGreaterThanOrEqual(1); expect(res.body.data.some(i => i.name === 'win11-pc')).toBe(true); diff --git a/tests/helpers/app.js b/tests/helpers/app.js index c097081..29ef791 100644 --- a/tests/helpers/app.js +++ b/tests/helpers/app.js @@ -4,7 +4,6 @@ const request = require('supertest'); const { createApp } = require('../../src/index'); -const { TEST_API_KEY } = require('./setup'); function createTestApp() { const app = createApp(); @@ -12,20 +11,4 @@ function createTestApp() { return { app, agent }; } -/** - * Helper to make authenticated requests. - */ -function authGet(agent, url) { - return agent.get(url).set('X-API-Key', TEST_API_KEY); -} -function authPost(agent, url) { - return agent.post(url).set('X-API-Key', TEST_API_KEY); -} -function authPut(agent, url) { - return agent.put(url).set('X-API-Key', TEST_API_KEY); -} -function authDelete(agent, url) { - return agent.delete(url).set('X-API-Key', TEST_API_KEY); -} - -module.exports = { createTestApp, authGet, authPost, authPut, authDelete, TEST_API_KEY }; +module.exports = { createTestApp }; diff --git a/tests/helpers/setup.js b/tests/helpers/setup.js index 5b5c10a..804c327 100644 --- a/tests/helpers/setup.js +++ b/tests/helpers/setup.js @@ -7,7 +7,6 @@ const path = require('path'); const os = require('os'); const crypto = require('crypto'); -const TEST_API_KEY = 'test-api-key-for-jest'; const TEST_PRIVATE_KEY_SYMBOL = Symbol.for('linbo-patchless.test-private-key'); if (!globalThis[TEST_PRIVATE_KEY_SYMBOL]) { globalThis[TEST_PRIVATE_KEY_SYMBOL] = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 }) @@ -28,8 +27,6 @@ function createTestEnv() { 'LINBO_HWINFO_DIR', 'LINBO_DEVICES_CSV', 'HWINFO_STALE_HOURS', - 'PATCHLESS_API_KEY', - 'PATCHLESS_API_KEY_FILE', 'LOG_LEVEL', 'LOG_PRETTY', 'NODE_ENV', @@ -44,6 +41,9 @@ function createTestEnv() { 'HEALTH_REQUIRE_INVENTORY', 'SEVEN_ZIP_BIN', 'CORS_ORIGIN', + 'PATCHLESS_ALLOWED_HOSTS', + 'PATCHLESS_ALLOW_PLAINTEXT_REMOTE', + 'PATCHLESS_HOST', 'TRUST_PROXY', ]; const previousEnv = Object.fromEntries(managedEnv.map(key => [key, process.env[key]])); @@ -70,8 +70,6 @@ function createTestEnv() { process.env.LINBO_HWINFO_DIR = serverHwinfoDir; process.env.LINBO_DEVICES_CSV = devicesCsv; process.env.HWINFO_STALE_HOURS = '168'; - process.env.PATCHLESS_API_KEY = TEST_API_KEY; - process.env.PATCHLESS_API_KEY_FILE = path.join(tmpRoot, 'missing-api-key'); process.env.LOG_LEVEL = 'silent'; process.env.LOG_PRETTY = 'false'; process.env.NODE_ENV = 'test'; @@ -88,6 +86,9 @@ function createTestEnv() { delete process.env.HEALTH_REQUIRE_INVENTORY; delete process.env.SEVEN_ZIP_BIN; delete process.env.CORS_ORIGIN; + delete process.env.PATCHLESS_ALLOWED_HOSTS; + delete process.env.PATCHLESS_ALLOW_PLAINTEXT_REMOTE; + delete process.env.PATCHLESS_HOST; delete process.env.TRUST_PROXY; return { @@ -108,4 +109,4 @@ function createTestEnv() { }; } -module.exports = { createTestEnv, TEST_API_KEY, TEST_PRIVATE_KEY }; +module.exports = { createTestEnv, TEST_PRIVATE_KEY }; diff --git a/tests/packaging/debian-package.test.js b/tests/packaging/debian-package.test.js new file mode 100644 index 0000000..05acbd6 --- /dev/null +++ b/tests/packaging/debian-package.test.js @@ -0,0 +1,106 @@ +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const { spawnSync } = require('child_process'); + +const root = path.resolve(__dirname, '..', '..'); +const read = (relativePath) => fs.readFileSync(path.join(root, relativePath), 'utf8'); + +describe('Debian package', () => { + test('declares the supported runtime and architecture-independent payload', () => { + const control = read('debian/control'); + + expect(control).toContain('Package: linbo-patchless'); + expect(control).toContain('Architecture: all'); + expect(control).toMatch(/linuxmuster-linbo7 \(>= 4\.3\.33-0\)/); + expect(control).toMatch(/nodejs \(>= 20\)/); + expect(control).toMatch(/7zip \(>= 25\.01\)/); + expect(control).toMatch(/\n curl,/); + }); + + test('bundles locked production dependencies without optional native modules', () => { + const rules = read('debian/rules'); + const builder = read('scripts/build-deb.sh'); + + expect(rules).toContain('npm ci --omit=dev --omit=optional'); + expect(rules).toContain('node_modules'); + expect(rules).toContain('generate-third-party-notices.js'); + expect(rules).toContain('THIRD-PARTY-NOTICES.txt'); + expect(rules).toContain('README.Debian'); + expect(read('debian/linbo-patchless.docs')).not.toContain('packaging/README.md'); + expect(builder).toContain('mktemp -d /tmp/linbo-patchless-build.'); + expect(builder).toContain('dpkg-buildpackage --build=binary --no-sign'); + expect(builder).toContain('APP_VERSION'); + expect(builder).toContain('DEBIAN_VERSION'); + }); + + test('uses a buildable native source format and records bundled licenses', () => { + const changelog = read('debian/changelog'); + const sourceFormat = read('debian/source/format'); + const copyright = read('debian/copyright'); + + expect(sourceFormat.trim()).toBe('3.0 (native)'); + expect(changelog).toMatch(/^linbo-patchless \(1\.0\.0\)/); + expect(copyright).toContain('THIRD-PARTY-NOTICES.txt'); + }); + + test('generates consolidated notices from the installed dependency tree', () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'patchless-notices-')); + const outputFile = path.join(tempDir, 'THIRD-PARTY-NOTICES.txt'); + + try { + const result = spawnSync(process.execPath, [ + path.join(root, 'scripts/generate-third-party-notices.js'), + path.join(root, 'node_modules'), + outputFile, + ], { encoding: 'utf8' }); + + expect(result.status).toBe(0); + const notices = fs.readFileSync(outputFile, 'utf8'); + expect(notices).toContain('express 4.22.2'); + expect(notices).toContain('Declared license: MIT'); + expect(notices).toMatch(/--- LICEN[CS]E/i); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }); + + test('installs the hardened service without an application API key', () => { + const unit = read('debian/linbo-patchless.service'); + const postinst = read('debian/linbo-patchless.postinst'); + const configurator = read('packaging/configure-host'); + + expect(unit).toContain('User=linbo-patchless'); + expect(unit).toContain('ProtectSystem=strict'); + expect(unit).not.toMatch(/api[-_]?key/i); + expect(postinst).toContain('/usr/sbin/linbo-patchless-configure'); + expect(postinst).toContain('systemctl is-active --quiet linbo-patchless.service'); + expect(postinst).toContain('/health/live'); + expect(configurator).toContain('ensure_env PATCHLESS_HOST "127.0.0.1"'); + expect(configurator).toContain('removed legacy API-key file'); + expect(configurator).toContain('WorkingDirectory=/opt/linbo-patchless'); + expect(configurator).toContain('LEGACY_WANTS_LINK'); + expect(configurator).toContain('removed the legacy source-installer systemd unit'); + }); + + test('purge never removes LINBO images or driver profiles', () => { + const postrm = read('debian/linbo-patchless.postrm'); + + expect(postrm).toContain('if [ "$1" = "purge" ]'); + expect(postrm).not.toMatch(/rm[^\n]*\/srv\/linbo/); + expect(postrm).toContain('update-linbofs'); + }); + + test.each([ + ['bash', 'packaging/configure-host'], + ['bash', 'scripts/build-deb.sh'], + ['sh', 'debian/linbo-patchless.postinst'], + ['sh', 'debian/linbo-patchless.postrm'], + ])('%s syntax is valid for %s', (shell, relativePath) => { + const result = spawnSync(shell, ['-n', path.join(root, relativePath)], { + encoding: 'utf8', + }); + expect(result.status).toBe(0); + expect(result.stderr).toBe(''); + }); +}); diff --git a/ui/index.html b/ui/index.html index 431ec0e..cd3b34d 100644 --- a/ui/index.html +++ b/ui/index.html @@ -11,7 +11,6 @@ .header { background: #16213e; padding: 1rem 2rem; border-bottom: 2px solid #0f3460; display: flex; align-items: center; gap: 1rem; flex-wrap: wrap; } .header h1 { font-size: 1.4rem; color: #e94560; } .header .subtitle { color: #888; font-size: 0.9rem; } - .header .spacer { flex: 1; } .container { max-width: 1200px; margin: 0 auto; padding: 2rem; } .card { background: #16213e; border-radius: 8px; padding: 1.5rem; margin-bottom: 1rem; border: 1px solid #0f3460; } .card h2, .panel h3 { color: #e94560; font-size: 1.1rem; } @@ -40,7 +39,6 @@ .badge-green { background: #1b5e20; color: #a5d6a7; } .badge-blue { background: #0d47a1; color: #90caf9; } .badge-gray { background: #333; color: #bbb; } - .badge-red { background: #7f1d1d; color: #fecaca; } .status { display: flex; gap: 1rem; margin-bottom: 1rem; flex-wrap: wrap; } .status-item { background: #0f3460; padding: 0.5rem 1rem; border-radius: 4px; min-width: 130px; } .status-item .label { font-size: 0.75rem; color: #aaa; } @@ -72,10 +70,8 @@ .muted { color: #888; } .modal-overlay { display: none; position: fixed; inset: 0; background: rgba(0,0,0,0.75); z-index: 100; align-items: center; justify-content: center; padding: 1rem; } .modal-overlay.active { display: flex; } - #keyModal { z-index: 110; } .modal { background: #16213e; border-radius: 8px; padding: 2rem; width: min(100%, 600px); border: 1px solid #0f3460; box-shadow: 0 20px 60px rgba(0,0,0,0.4); } .modal h3 { color: #e94560; margin-bottom: 1rem; } - .check-row { display: flex; gap: 0.5rem; align-items: center; color: #bbb; font-size: 0.85rem; } .separator { border: 0; border-top: 1px solid #0f3460; margin: 1rem 0; } #log { background: #0a0a1a; padding: 1rem; border-radius: 4px; font-family: ui-monospace, SFMono-Regular, Consolas, monospace; font-size: 0.85rem; max-height: 220px; min-height: 70px; overflow-y: auto; white-space: pre-wrap; overflow-wrap: anywhere; } @media (max-width: 800px) { @@ -91,9 +87,6 @@

LINBO Patchless

Treiber Manager -
- API-Key fehlt -
@@ -212,28 +205,6 @@

Log

- -