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..eecb95b 100644 --- a/.env.example +++ b/.env.example @@ -1,24 +1,28 @@ # 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 +# HTTP server. The Debian configurator replaces these safe template defaults +# on a fresh linuxmuster installation only when it can identify one private, +# locally assigned addc server address unambiguously. 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..eec295b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -47,3 +47,114 @@ 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" + test ! -e "$package_root/usr/lib/linbo-patchless/package-lock.json" + node -e ' + const pkg = require(process.argv[1]); + if (pkg.devDependencies || pkg.scripts) process.exit(1); + ' "$package_root/usr/lib/linbo-patchless/package.json" + 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 + if find "$package_root/usr/lib/linbo-patchless/node_modules" -type f \ + \( -name '.eslintrc' -o -name '.eslintrc.*' -o -name '.eslintignore' \ + -o -name '.npmignore' -o -name '.gitattributes' -o -name '.gitignore' \ + -o -name '.nycrc' -o -name '.nycrc.*' -o -name '.travis.yml' \ + -o -name '.coveralls.yml' \ + -o -name '.prettierignore' -o -name '.prettierrc' -o -name '.prettierrc.*' \ + -o -name 'eslint.config.js' -o -name 'eslint.config.mjs' \ + -o -name 'tsconfig.json' -o -name 'Jenkinsfile' -o -name 'Makefile' \) \ + -print -quit | grep -q .; then + echo 'Package contains npm development metadata rejected by Lintian' >&2 + exit 1 + fi + if find "$package_root/usr/lib/linbo-patchless/node_modules" -type d \ + \( -iname test -o -iname tests -o -iname bench -o -iname benchmark \ + -o -iname benchmarks -o -iname example -o -iname examples \ + -o -iname fixture -o -iname fixtures -o -iname help \ + -o -iname doc -o -iname docs \ + -o -name '.*' \) \ + -print -quit | grep -q .; then + echo 'Package contains npm tests, benchmarks, or CI metadata' >&2 + exit 1 + fi + if find "$package_root/usr/lib/linbo-patchless/node_modules" -type f \ + \( -iname 'bench.js' -o -iname 'benchmark.js' -o -iname '*.bench.js' \ + -o -iname 'test.js' -o -iname 'test-*.js' -o -iname '*.test.js' \ + -o -iname '*-test.js' -o -iname 'tests.js' -o -iname '*.test-d.ts' \ + -o -iname 'example.js' -o -iname 'example-*.js' -o -iname '*-example.js' \ + -o -iname '*.d.ts' -o -iname '*.d.cts' -o -iname '*.d.mts' \ + -o -iname '*.ts' -o -iname '*.cts' -o -iname '*.mts' -o -iname '*.map' \ + -o -iname '*.gyp' -o -iname '*.gypi' -o -iname '*.c' -o -iname '*.cc' \ + -o -iname '*.bat' -o -iname '*.exe' -o -iname '*.sh' \ + -o -iname '*.png' -o -iname '*.jpg' -o -iname '*.jpeg' -o -iname '*.gif' \ + -o -iname '*.ico' -o -iname '*.svg' -o -name 'CNAME' -o -name '.*' \ + -o -name 'id_rsa*' \ + -o -name 'id_dsa*' -o -name 'id_ecdsa*' -o -name 'id_ed25519*' \ + -o -name '*.ppk' -o -name '*_key.pem' \) \ + -print -quit | grep -q .; then + echo 'Package contains npm benchmark files or private test keys' >&2 + exit 1 + fi + if find "$package_root/usr/lib/linbo-patchless/node_modules" -type f \ + \( -iname '*.md' -o -iname '*.markdown' \) \ + ! -iname 'license*' ! -iname 'copying*' ! -iname 'notice*' \ + -print -quit | grep -q .; then + echo 'Package contains non-license npm documentation' >&2 + exit 1 + fi + test ! -e "$package_root/usr/lib/linbo-patchless/node_modules/.bin" + test ! -e "$package_root/usr/lib/linbo-patchless/node_modules/mime/src/build.js" + test ! -e "$package_root/usr/lib/linbo-patchless/node_modules/pino-pretty/coverage-map.js" + test ! -e "$package_root/usr/lib/linbo-patchless/node_modules/pino/index.html" + node scripts/sanitize-production-manifests.js \ + "$package_root/usr/lib/linbo-patchless/node_modules" --check + 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..7f10f20 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 @@ -35,10 +35,13 @@ LINBO-Client -> *_hwinfo.gz auf Server -> Treiberprofil -> Image-Zuweisung -> .driverpostsync -> LINBO-Sync -> DMI-Match -> Windows pnputil ``` -Das Serverinventar wird von LINBO automatisch erzeugt, wenn im Client-Cache -noch kein `hwinfo.gz` liegt. Deshalb zeigt die Oberfläche Alter und -„veraltet“-Status an. Live-SSH ist ein bewusster Fallback, nicht mehr der -Standardweg. +Das Serverinventar wird von LINBO automatisch erzeugt, wenn im gemounteten +Client-Cache noch kein `hwinfo.gz` liegt. Eine funktionierende Cache-Partition +ist dafür Voraussetzung; ein lediglich in linuxmuster angelegter oder nur bis +zur LINBO-Oberfläche gestarteter Client besitzt noch nicht zwingend ein +Inventar. Deshalb zeigt die Oberfläche Alter und „veraltet“-Status an. Live-SSH +ist für neue oder noch nicht initialisierte Clients ein bewusster Fallback, +nicht der Standardweg. ## Was „patchless“ konkret bedeutet @@ -72,16 +75,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,32 +113,45 @@ 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.1_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: - -```text -/etc/linbo-patchless/api-key -``` - -Der Dienst lauscht sicherheitsbewusst nur auf `http://127.0.0.1:3001/`. Für -einen einzelnen Administrationszugriff genügt ein SSH-Tunnel: +- richtet Benutzer, Konfiguration, Rechte, ACLs und den systemd-Dienst ein, +- behält Konfiguration und Schlüssel bei Paketupdates bei. + +`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. + +Bei einer frischen Debian-Installation ermittelt der Konfigurator die interne +Adresse des linuxmuster-Servers bevorzugt aus dessen `addc`-Eintrag in der +`devices.csv`. Die Adresse muss privat und tatsächlich auf dem Server gebunden +sein. Patchless ist danach direkt unter +`http://:3001/` erreichbar. Auf dem Referenzserver lautet +die URL beispielsweise `http://10.0.0.14:3001/`; diese IP ist nicht im Paket +fest einprogrammiert. + +Findet der Konfigurator keine eindeutige private Serveradresse, bleibt der +Dienst auf `127.0.0.1` beschränkt. Dann genügt für einen einzelnen +Administrationszugriff ein SSH-Tunnel: ```bash ssh -L 3001:127.0.0.1:3001 root@ @@ -132,10 +160,32 @@ 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. + +Die automatisch erkannte Konfiguration entspricht diesen drei Werten in +`/etc/linbo-patchless/env`: + +```ini +PATCHLESS_HOST=10.0.0.14 +PATCHLESS_ALLOW_PLAINTEXT_REMOTE=true +PATCHLESS_ALLOWED_HOSTS=10.0.0.14 +``` + +Anschließend genügt: + +```bash +systemctl restart linbo-patchless +``` + +Die Oberfläche ist dann ohne Tunnel unter der jeweiligen Server-IP erreichbar. +Die Bindung an eine konkrete interne IP ist sicherer als `0.0.0.0`, stellt aber +keine Anmeldung bereit: Jeder Client, der diese Adresse und den Port erreichen +kann, kann Patchless bedienen. Produktiv sollte der Zugriff deshalb auf ein +Admin-VLAN bzw. per Firewall eingeschränkt oder über einen authentifizierenden +HTTPS-Reverse-Proxy veröffentlicht werden. ## Bedienung @@ -187,16 +237,32 @@ 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 ein aus den LINBO-Inventaren zusammengefasstes Hardwaremodell 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. +Der dabei intern verwendete Host ist nur eine Hardwareprobe: Sein Hostname wird +nicht im Profil gespeichert. Patchless übernimmt ausschließlich DMI-Hersteller +und -Modell. Nach der Zuweisung zu einem LINBO-Image wird das Profil automatisch +auf beliebig vielen Clients dieses Images mit passender Hardware berücksichtigt. +Die Oberfläche gruppiert beispielsweise +87 exakt gleiche DMI-Werte zu einem Eintrag +`Dell Inc. · Latitude 5520 — 87 Inventare`. Diese Zahl ist eine Orientierung, +keine feste Zielhostliste: Da `product` beim Sync als Teilstring geprüft wird, +kann das Profil auch weitere passende Modellvarianten treffen. Jeder Client +entscheidet anhand seiner eigenen DMI-Daten, ob das Profil passt. + +Auch mehrere Modelle desselben Herstellers können ein gemeinsames Treiberprofil +verwenden, sofern das Paket wirklich für alle Modelle geeignet ist. Dazu werden +weitere `product`-Zeilen ergänzt; sie sind als ODER-Verknüpfung zu verstehen. +Raum, linuxmuster-Gruppe und Hostname sind derzeit keine Matching-Kriterien. Ein +Profil ist außerdem aktuell genau einem LINBO-Image zugeordnet. + Beispiel für `match.conf`: ```ini @@ -216,12 +282,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 +305,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 @@ -252,8 +317,9 @@ 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_HOST` | bei frischer Paketinstallation automatisch erkannte interne Server-IP; bei uneindeutiger Erkennung `127.0.0.1` | +| `PATCHLESS_ALLOW_PLAINTEXT_REMOTE` | ausdrückliche Betreiberbestätigung für einen unsicheren Remote-Bind | +| `PATCHLESS_ALLOWED_HOSTS` | explizite Hostnamen oder IP-Adressen für HTTP-Anfragen; keine Wildcards | | `DRIVERS_BASE` | `/srv/linbo/drivers` | | `LINBO_DIR` | `/srv/linbo` | | `LINBO_HWINFO_DIR` | `/var/log/linuxmuster/linbo` | @@ -276,7 +342,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 +377,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..9519f08 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -18,12 +18,16 @@ 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 - `127.0.0.1`; Remotezugriff erfolgt per SSH-Tunnel oder HTTPS-Reverse-Proxy. +- Die Anwendung besitzt bewusst keine eigene Benutzeranmeldung. Ihre API kann + Treiberprofile, Dateien und Image-Hooks verändern und gehört deshalb nicht + in ein öffentliches Netz. Eine frische Paketinstallation bindet an die + automatisch erkannte, konkrete private Adresse des linuxmuster-Servers. Diese + Adresse darf nur aus einem vertrauenswürdigen Verwaltungsnetz erreichbar sein. + Andernfalls sind ein administrativer SSH-Tunnel oder ein + **authentifizierender** HTTPS-Reverse-Proxy zu verwenden. +- HTTP verschlüsselt Uploads und Verwaltungsdaten nicht. Der eingebaute Server + terminiert bewusst kein TLS. Der automatisch erkannte LAN-Bind darf deshalb + nicht über öffentliche oder unkontrollierte Netze geroutet werden. Alte, implizite `0.0.0.0`-Konfigurationen werden beim Update auf localhost migriert, sofern der Betreiber Klartext-Remotezugriff nicht ausdrücklich bestätigt. @@ -63,5 +67,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..640be79 --- /dev/null +++ b/debian/changelog @@ -0,0 +1,40 @@ +linbo-patchless (1.0.4) unstable; urgency=medium + + * Group LINBO inventories by DMI hardware model in the profile workflow so + one model profile visibly covers every matching client. + + -- amolani Tue, 14 Jul 2026 20:48:00 +0200 + +linbo-patchless (1.0.3) unstable; urgency=medium + + * Keep same-origin browser API requests on HTTP for deliberate internal LAN + deployments instead of rewriting them to an unavailable HTTPS listener. + + -- amolani Tue, 14 Jul 2026 20:25:00 +0200 + +linbo-patchless (1.0.2) unstable; urgency=medium + + * Redesign the web interface around the hardware, driver, and image workflow. + * Detect the local linuxmuster server address for direct access on fresh + installations, while preserving existing administrator configuration. + + -- amolani Tue, 14 Jul 2026 20:15:00 +0200 + +linbo-patchless (1.0.1) unstable; urgency=medium + + * Give the service account exclusive ownership of its SSH private key so + OpenSSH accepts the key while the unprivileged service can still read it. + * Remove non-runtime npm tests, benchmarks, CI metadata, test fixtures, and + development-only manifest entries from the binary package. + * Parse quoted environment values without awk warnings and document the + host configuration helper. + + -- amolani Tue, 14 Jul 2026 19:45:00 +0200 + +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..9bd982a --- /dev/null +++ b/debian/control @@ -0,0 +1,36 @@ +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, + iproute2, + 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-configure.8 b/debian/linbo-patchless-configure.8 new file mode 100644 index 0000000..65d2924 --- /dev/null +++ b/debian/linbo-patchless-configure.8 @@ -0,0 +1,53 @@ +.TH LINBO-PATCHLESS-CONFIGURE 8 "July 2026" "linbo-patchless 1.0.4" "System Administration" +.SH NAME +linbo-patchless-configure \- configure LINBO Patchless on a linuxmuster server +.SH SYNOPSIS +.B linbo-patchless-configure +.SH DESCRIPTION +.B linbo-patchless-configure +validates the supported linuxmuster and Ubuntu environment, prepares the +unprivileged service account, creates its dedicated SSH key, embeds the public +key in linbofs, writes the local service configuration, and applies the ACLs +needed for driver profiles and LINBO image sidecar hooks. +.PP +The command is run automatically by the Debian package maintainer script. It +is safe to run repeatedly: an unchanged SSH key is not embedded in linbofs a +second time. +.SH NETWORK ACCESS +On a fresh package installation, the configurator detects the private address +of the linuxmuster addc host in devices.csv and verifies that it belongs to a +local interface. It binds Patchless to that exact address and adds it to the +allowed HTTP hosts. The reference-server address below is an example and is not +hard-coded. If detection is ambiguous, the listener remains on 127.0.0.1. +.PP +The resulting values in +.I /etc/linbo-patchless/env +are: +.PP +.nf +PATCHLESS_HOST=10.0.0.14 +PATCHLESS_ALLOW_PLAINTEXT_REMOTE=true +PATCHLESS_ALLOWED_HOSTS=10.0.0.14 +.fi +.PP +Restart the service with +.B systemctl restart linbo-patchless +after changing the file. Bind to the concrete internal address rather than +0.0.0.0. Patchless does not provide user authentication, so firewall or VLAN +policy must prevent untrusted clients from reaching the port. +.SH FILES +.TP +.I /etc/linbo-patchless/env +Service configuration. The HTTP listener defaults to 127.0.0.1:3001. +.TP +.I /etc/linbo-patchless/client_key +Private Ed25519 key owned exclusively by the linbo-patchless service account. +.TP +.I /root/.ssh/id_linbo_patchless.pub +Matching public key embedded in linbofs for LINBO client access. +.SH EXIT STATUS +Returns zero after successful configuration and non-zero when a required host +invariant cannot be established. +.SH SEE ALSO +.BR systemctl (1), +.BR update-linbofs (8) 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..d2c154f --- /dev/null +++ b/debian/linbo-patchless.postinst @@ -0,0 +1,80 @@ +#!/bin/sh +set -e + +verify_service=0 +case "$1" in + configure|reconfigure) + 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 + # A package that never completed its first configuration can reach this + # upgrade with an empty previous-version argument. Debhelper then starts, + # rather than restarts, an already active service. Force one restart here + # so the liveness check can never approve an old in-memory application. + if ! systemctl restart linbo-patchless.service; then + echo "linbo-patchless: service restart failed after package configuration" >&2 + systemctl --no-pager --full status linbo-patchless.service >&2 || true + exit 1 + fi + + read_env_value() { + awk -v key="$1" ' + $0 ~ "^[[:space:]]*" key "[[:space:]]*=" { + value = $0 + sub("^[[:space:]]*" key "[[:space:]]*=[[:space:]]*", "", value) + sub(/[[:space:]]+$/, "", value) + first = substr(value, 1, 1) + last = substr(value, length(value), 1) + single_quote = sprintf("%c", 39) + if ((first == "\"" && last == "\"") || + (first == single_quote && last == single_quote)) { + 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..b4368c9 --- /dev/null +++ b/debian/linbo-patchless.postrm @@ -0,0 +1,43 @@ +#!/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 + # Repair the mode left by the broken 1.0.0 postinst before asking + # OpenSSH to derive the key. The file is removed later in this purge. + chmod 0600 "$client_key" + 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..e4b7e05 --- /dev/null +++ b/debian/rules @@ -0,0 +1,98 @@ +#!/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" + # npm tarballs may contain upstream tests, benchmarks, CI metadata and test + # credentials that are never loaded at runtime. Keep package manifests and + # license files, but do not ship those development-only artifacts. + find "$(NODE_BUILD_DIR)/node_modules" -depth -type d \ + \( -iname test -o -iname tests -o -iname bench -o -iname benchmark \ + -o -iname benchmarks -o -iname example -o -iname examples \ + -o -iname fixture -o -iname fixtures -o -iname help \ + -o -iname doc -o -iname docs \ + -o -name '.*' \) \ + -exec rm -rf -- {} + + find "$(NODE_BUILD_DIR)/node_modules" -type f \ + \( -name '.eslintrc' -o -name '.eslintrc.*' -o -name '.eslintignore' \ + -o -name '.npmignore' -o -name '.gitattributes' -o -name '.gitignore' \ + -o -name '.nycrc' -o -name '.nycrc.*' -o -name '.travis.yml' \ + -o -name '.coveralls.yml' \ + -o -name '.prettierignore' -o -name '.prettierrc' -o -name '.prettierrc.*' \ + -o -name 'eslint.config.js' -o -name 'eslint.config.mjs' \ + -o -name 'tsconfig.json' -o -name 'Jenkinsfile' -o -name 'Makefile' \ + -o -iname 'bench.js' -o -iname 'benchmark.js' -o -iname '*.bench.js' \ + -o -iname 'test.js' -o -iname 'test-*.js' -o -iname '*.test.js' \ + -o -iname '*-test.js' -o -iname 'tests.js' -o -iname '*.test-d.ts' \ + -o -iname 'example.js' -o -iname 'example-*.js' -o -iname '*-example.js' \ + -o -iname '*.d.ts' -o -iname '*.d.cts' -o -iname '*.d.mts' \ + -o -iname '*.ts' -o -iname '*.cts' -o -iname '*.mts' -o -iname '*.map' \ + -o -iname '*.gyp' -o -iname '*.gypi' -o -iname '*.c' -o -iname '*.cc' \ + -o -iname '*.bat' -o -iname '*.exe' -o -iname '*.sh' \ + -o -iname '*.png' -o -iname '*.jpg' -o -iname '*.jpeg' -o -iname '*.gif' \ + -o -iname '*.ico' -o -iname '*.svg' -o -name 'CNAME' -o -name '.*' \) \ + -delete + find "$(NODE_BUILD_DIR)/node_modules" -type f \ + \( -iname '*.md' -o -iname '*.markdown' \) \ + ! -iname 'license*' ! -iname 'copying*' ! -iname 'notice*' -delete + rm -rf "$(NODE_BUILD_DIR)/node_modules/.bin" + rm -rf "$(NODE_BUILD_DIR)/node_modules/@pinojs/redact/scripts" + rm -rf "$(NODE_BUILD_DIR)/node_modules/pino-http/scripts" + rm -f "$(NODE_BUILD_DIR)/node_modules/mime/src/build.js" + rm -f "$(NODE_BUILD_DIR)/node_modules/mime/cli.js" + rm -f "$(NODE_BUILD_DIR)/node_modules/pino/bin.js" + rm -f "$(NODE_BUILD_DIR)/node_modules/pino-pretty/bin.js" + rm -f "$(NODE_BUILD_DIR)/node_modules/pino-pretty/coverage-map.js" + rm -f "$(NODE_BUILD_DIR)/node_modules/pino/index.html" + chmod 0644 "$(NODE_BUILD_DIR)/node_modules/secure-json-parse/LICENSE" + node scripts/sanitize-production-manifests.js \ + "$(NODE_BUILD_DIR)/node_modules" + # The installed manifest is used only for application metadata and module + # resolution. Do not advertise development commands or dependencies on the + # production server, and do not install the build-only lock file. + node -e 'const fs=require("fs"); const file=process.argv[1]; const pkg=JSON.parse(fs.readFileSync(file,"utf8")); delete pkg.devDependencies; delete pkg.scripts; fs.writeFileSync(file, JSON.stringify(pkg,null,2)+"\n");' \ + "$(NODE_BUILD_DIR)/package.json" + +# 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 "$(NODE_BUILD_DIR)/package.json" "$(APP_DIR)/package.json" + 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" + install -d "$(PACKAGE_DIR)/usr/share/man/man8" + install -m 0644 debian/linbo-patchless-configure.8 \ + "$(PACKAGE_DIR)/usr/share/man/man8/linbo-patchless-configure.8" + +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/REFERENCE-SERVER.md b/docs/REFERENCE-SERVER.md index 925d528..471ca0c 100644 --- a/docs/REFERENCE-SERVER.md +++ b/docs/REFERENCE-SERVER.md @@ -88,6 +88,9 @@ LINBO legt pro Client automatisch - Linux-Treiber und Module-Aliases Die Erfassung wird im Client nur neu erzeugt, wenn `/cache/hwinfo.gz` fehlt. +Dafür muss die konfigurierte Cache-Partition bereits existieren und unter +`/cache` eingebunden werden können. Ein neuer Client kann daher LINBO erreichen +und ein `*_linbo.log` hochladen, ohne dass schon ein `*_hwinfo.gz` entsteht. Alter und Stale-Status sind deshalb Bestandteil der API. Raum, Gruppe und IP werden optional aus Sophomorix' `devices.csv` ergänzt. 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..7a777a7 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 @@ -262,8 +262,11 @@ if [[ ! -s "$CLIENT_KEY" ]]; then ssh-keygen -q -t ed25519 -N '' -C linbo-patchless -f "$CLIENT_KEY" log_ok "Dedicated Ed25519 client key generated" fi -chown root:"$SERVICE_GROUP" "$CLIENT_KEY" -chmod 0640 "$CLIENT_KEY" +# OpenSSH rejects private keys with any group-readable mode, even when that +# group is the dedicated service account. Exclusive service ownership lets the +# unprivileged process read the key while keeping the required 0600 mode. +chown "$SERVICE_USER":"$SERVICE_GROUP" "$CLIENT_KEY" +chmod 0600 "$CLIENT_KEY" [[ -f "$CLIENT_KEY.pub" ]] && { chown root:root "$CLIENT_KEY.pub"; chmod 0644 "$CLIENT_KEY.pub"; } install -d -o root -g root -m 0700 /root/.ssh @@ -369,6 +372,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" ' @@ -376,7 +391,11 @@ read_env_value() { value = $0 sub("^[[:space:]]*" key "[[:space:]]*=[[:space:]]*", "", value) sub(/[[:space:]]+$/, "", value) - if (value ~ /^\".*\"$/ || value ~ /^\047.*\047$/) { + first = substr(value, 1, 1) + last = substr(value, length(value), 1) + single_quote = sprintf("%c", 39) + if ((first == "\"" && last == "\"") || + (first == single_quote && last == single_quote)) { value = substr(value, 2, length(value) - 2) } } @@ -384,10 +403,73 @@ read_env_value() { ' "$ENV_FILE" } -ensure_env PATCHLESS_HOST "127.0.0.1" -ensure_env PATCHLESS_ALLOW_PLAINTEXT_REMOTE "false" +is_private_ipv4() { + local address="$1" first second third fourth + [[ "$address" =~ ^[0-9]{1,3}(\.[0-9]{1,3}){3}$ ]] || return 1 + IFS=. read -r first second third fourth <<< "$address" + (( 10#$first <= 255 && 10#$second <= 255 && 10#$third <= 255 && 10#$fourth <= 255 )) || return 1 + (( 10#$first == 10 )) && return 0 + (( 10#$first == 172 && 10#$second >= 16 && 10#$second <= 31 )) && return 0 + (( 10#$first == 192 && 10#$second == 168 )) +} + +is_local_ipv4() { + local expected="$1" + ip -4 -o address show scope global 2>/dev/null \ + | awk '{ value = $4; sub(/\/.*/, "", value); print value }' \ + | grep -Fqx -- "$expected" +} + +detect_server_ipv4() { + local device address role candidate="" count=0 + + if [[ -r "$DEVICES_CSV" ]]; then + while IFS=';' read -r device _ _ _ address _ _ _ role _; do + if [[ "$device" == "server" && "$role" == "addc" ]] && \ + is_private_ipv4 "$address" && is_local_ipv4 "$address"; then + printf '%s\n' "$address" + return 0 + fi + done < "$DEVICES_CSV" + fi + + while read -r address; do + is_private_ipv4 "$address" || continue + if [[ "$address" != "$candidate" ]]; then + candidate="$address" + count=$((count + 1)) + fi + done < <(ip -4 -o address show scope global 2>/dev/null \ + | awk '{ value = $4; sub(/\/.*/, "", value); print value }' \ + | sort -u) + + if (( count == 1 )); then + printf '%s\n' "$candidate" + return 0 + fi + return 1 +} + +DEFAULT_PATCHLESS_HOST="127.0.0.1" +DEFAULT_ALLOW_PLAINTEXT_REMOTE="false" +if [[ "$ENV_WAS_PRESENT" -eq 0 ]]; then + if DETECTED_SERVER_IP="$(detect_server_ipv4)"; then + DEFAULT_PATCHLESS_HOST="$DETECTED_SERVER_IP" + DEFAULT_ALLOW_PLAINTEXT_REMOTE="true" + log_info "Fresh installation will listen on detected internal server address $DETECTED_SERVER_IP" + else + log_warn "Could not determine one unambiguous private server address; keeping the loopback-only listener" + fi +fi + +ensure_env PATCHLESS_HOST "$DEFAULT_PATCHLESS_HOST" +ensure_env PATCHLESS_ALLOW_PLAINTEXT_REMOTE "$DEFAULT_ALLOW_PLAINTEXT_REMOTE" ensure_env PATCHLESS_PORT "3001" -upsert_env PATCHLESS_API_KEY_FILE "$API_KEY_FILE" +if [[ "$DEFAULT_ALLOW_PLAINTEXT_REMOTE" == "true" ]]; then + ensure_env PATCHLESS_ALLOWED_HOSTS "$DEFAULT_PATCHLESS_HOST" +fi +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 +487,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 +503,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 +639,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 +758,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/package-lock.json b/package-lock.json index cca8d52..1757ecc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "linbo-patchless", - "version": "1.0.0", + "version": "1.0.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "linbo-patchless", - "version": "1.0.0", + "version": "1.0.4", "license": "GPL-3.0-only", "dependencies": { "cors": "^2.8.6", diff --git a/package.json b/package.json index 0f94315..51dfd71 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "linbo-patchless", - "version": "1.0.0", + "version": "1.0.4", "private": true, "description": "Automatic patchless Windows driver integration for linuxmuster-linbo7", "main": "src/index.js", diff --git a/packaging/README.md b/packaging/README.md new file mode 100644 index 0000000..239a0ef --- /dev/null +++ b/packaging/README.md @@ -0,0 +1,103 @@ +# 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.1_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 +``` + +On a fresh installation, the configurator detects the private address of the +linuxmuster `addc` host from `devices.csv`, verifies that it is assigned locally, +and binds Patchless to that exact address. It writes the same address to +`PATCHLESS_ALLOWED_HOSTS`. The package never hard-codes the reference-server +address. If no single private address can be determined, it falls back to +`127.0.0.1:3001`. + +A local HTTPS reverse proxy can 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 +``` + +The detected direct interface bind consists of `PATCHLESS_HOST`, +`PATCHLESS_ALLOW_PLAINTEXT_REMOTE=true`, and the exposed address in +`PATCHLESS_ALLOWED_HOSTS`. For example on the reference server: + +```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..cf5b619 --- /dev/null +++ b/packaging/configure-host @@ -0,0 +1,404 @@ +#!/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=() +ENV_WAS_PRESENT=0 + +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 + ENV_WAS_PRESENT=1 +else + 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 +# OpenSSH rejects private keys with any group-readable mode, even when that +# group is the dedicated service account. Exclusive service ownership lets the +# unprivileged process read the key while keeping the required 0600 mode. +chown "$SERVICE_USER":"$SERVICE_GROUP" "$CLIENT_KEY" +chmod 0600 "$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) + first = substr(value, 1, 1) + last = substr(value, length(value), 1) + single_quote = sprintf("%c", 39) + if ((first == "\"" && last == "\"") || + (first == single_quote && last == single_quote)) { + value = substr(value, 2, length(value) - 2) + } + } + END { print value } + ' "$ENV_FILE" +} + +is_private_ipv4() { + local address="$1" first second third fourth + [[ "$address" =~ ^[0-9]{1,3}(\.[0-9]{1,3}){3}$ ]] || return 1 + IFS=. read -r first second third fourth <<< "$address" + (( 10#$first <= 255 && 10#$second <= 255 && 10#$third <= 255 && 10#$fourth <= 255 )) || return 1 + (( 10#$first == 10 )) && return 0 + (( 10#$first == 172 && 10#$second >= 16 && 10#$second <= 31 )) && return 0 + (( 10#$first == 192 && 10#$second == 168 )) +} + +is_local_ipv4() { + local expected="$1" + ip -4 -o address show scope global 2>/dev/null \ + | awk '{ value = $4; sub(/\/.*/, "", value); print value }' \ + | grep -Fqx -- "$expected" +} + +detect_server_ipv4() { + local device address role candidate="" count=0 + + if [[ -r "$DEVICES_CSV" ]]; then + while IFS=';' read -r device _ _ _ address _ _ _ role _; do + if [[ "$device" == "server" && "$role" == "addc" ]] && \ + is_private_ipv4 "$address" && is_local_ipv4 "$address"; then + printf '%s\n' "$address" + return 0 + fi + done < "$DEVICES_CSV" + fi + + while read -r address; do + is_private_ipv4 "$address" || continue + if [[ "$address" != "$candidate" ]]; then + candidate="$address" + count=$((count + 1)) + fi + done < <(ip -4 -o address show scope global 2>/dev/null \ + | awk '{ value = $4; sub(/\/.*/, "", value); print value }' \ + | sort -u) + + if (( count == 1 )); then + printf '%s\n' "$candidate" + return 0 + fi + return 1 +} + +DEFAULT_PATCHLESS_HOST="127.0.0.1" +DEFAULT_ALLOW_PLAINTEXT_REMOTE="false" +if [[ "$ENV_WAS_PRESENT" -eq 0 ]]; then + if DETECTED_SERVER_IP="$(detect_server_ipv4)"; then + DEFAULT_PATCHLESS_HOST="$DETECTED_SERVER_IP" + DEFAULT_ALLOW_PLAINTEXT_REMOTE="true" + log "fresh installation will listen on detected internal server address $DETECTED_SERVER_IP" + else + warn "could not determine one unambiguous private server address; keeping the loopback-only listener" + fi +fi + +ensure_env PATCHLESS_HOST "$DEFAULT_PATCHLESS_HOST" +ensure_env PATCHLESS_ALLOW_PLAINTEXT_REMOTE "$DEFAULT_ALLOW_PLAINTEXT_REMOTE" +ensure_env PATCHLESS_PORT "3001" +if [[ "$DEFAULT_ALLOW_PLAINTEXT_REMOTE" == "true" ]]; then + ensure_env PATCHLESS_ALLOWED_HOSTS "$DEFAULT_PATCHLESS_HOST" +fi +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..289ddd9 --- /dev/null +++ b/scripts/generate-third-party-notices.js @@ -0,0 +1,148 @@ +#!/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 readmePattern = /^readme(?:\..*)?$/i; +const packages = new Map(); + +function extractReadmeLicense(contents) { + const lines = contents.replace(/\r\n/g, '\n').split('\n'); + const start = lines.findIndex(line => /^#{1,6}\s+licen[cs]e\s*$/i.test(line.trim())); + if (start < 0) return ''; + + const level = (lines[start].match(/^#+/) || [''])[0].length; + let end = lines.length; + for (let index = start + 1; index < lines.length; index += 1) { + const heading = lines[index].match(/^(#{1,6})\s+/); + if (heading && heading[1].length <= level) { + end = index; + break; + } + } + return lines.slice(start + 1, end).join('\n').trim(); +} + +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 packageFiles = fs.readdirSync(entryPath, { withFileTypes: true }); + const licenseFiles = packageFiles + .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); + + if (licenseFiles.length === 0) { + const readme = packageFiles.find(file => file.isFile() && readmePattern.test(file.name)); + if (readme) { + const licenseSection = extractReadmeLicense( + fs.readFileSync(path.join(entryPath, readme.name), 'utf8') + ); + if (licenseSection) { + licenseFiles.push({ + name: `${readme.name} (License section)`, + contents: licenseSection, + }); + } + } + } + + 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/scripts/sanitize-production-manifests.js b/scripts/sanitize-production-manifests.js new file mode 100644 index 0000000..147edc0 --- /dev/null +++ b/scripts/sanitize-production-manifests.js @@ -0,0 +1,73 @@ +#!/usr/bin/env node + +'use strict'; + +const fs = require('fs'); +const path = require('path'); + +const [modulesDirArg, mode] = process.argv.slice(2); +if (!modulesDirArg || (mode && mode !== '--check')) { + console.error('Usage: sanitize-production-manifests.js [--check]'); + process.exit(2); +} + +const modulesDir = path.resolve(modulesDirArg); +const checkOnly = mode === '--check'; +const removableFields = [ + 'bin', + 'devDependencies', + 'directories', + 'files', + 'man', + 'scripts', + 'types', + 'typesVersions', + 'typings', +]; +const changed = []; + +function stripTypeConditions(value) { + if (Array.isArray(value)) return value.map(stripTypeConditions); + if (!value || typeof value !== 'object') return value; + + return Object.fromEntries(Object.entries(value) + .filter(([key]) => key !== 'types' && !key.startsWith('types@')) + .map(([key, nested]) => [key, stripTypeConditions(nested)])); +} + +function sanitizeManifest(file) { + const original = fs.readFileSync(file, 'utf8'); + const metadata = JSON.parse(original); + + for (const field of removableFields) delete metadata[field]; + if (metadata.exports) metadata.exports = stripTypeConditions(metadata.exports); + + const sanitized = `${JSON.stringify(metadata, null, 2)}\n`; + if (sanitized === original) return; + + changed.push(path.relative(modulesDir, file)); + if (!checkOnly) fs.writeFileSync(file, sanitized, { mode: 0o644 }); +} + +function walk(directory) { + for (const entry of fs.readdirSync(directory, { withFileTypes: true })) { + const entryPath = path.join(directory, entry.name); + if (entry.isDirectory()) { + walk(entryPath); + } else if (entry.isFile() && entry.name === 'package.json') { + sanitizeManifest(entryPath); + } + } +} + +if (!fs.existsSync(modulesDir)) { + throw new Error(`node_modules directory not found: ${modulesDir}`); +} + +walk(modulesDir); +if (checkOnly && changed.length > 0) { + console.error(`Unsanitized production manifests:\n${changed.join('\n')}`); + process.exit(1); +} + +console.log(`${checkOnly ? 'Checked' : 'Sanitized'} production package manifests: ${changed.length}`); diff --git a/src/index.js b/src/index.js index af1cca8..4c2e9ab 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); @@ -259,15 +397,23 @@ function createApp() { baseUri: ["'none'"], frameAncestors: ["'none'"], formAction: ["'self'"], + // The packaged service can deliberately serve one trusted internal + // interface over HTTP. Helmet's default directive would rewrite the + // UI's relative fetches to HTTPS even though this service terminates + // no TLS, causing every browser API call to fail before reaching us. + upgradeInsecureRequests: null, }, }, })); // 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 +437,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 +452,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 +465,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 +473,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 +508,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 +516,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 +547,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 +560,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 +612,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/src/services/drivers.service.js b/src/services/drivers.service.js index 7cf0376..3558033 100644 --- a/src/services/drivers.service.js +++ b/src/services/drivers.service.js @@ -557,10 +557,13 @@ async function listProfiles() { const content = await fs.readFile(matchConfPath, 'utf-8'); const parsed = parseMatchConf(content); - // Check if dir has files beyond match.conf + // Profile metadata alone does not make a driver profile deployable. const dirPath = path.join(DRIVERS_BASE, profileName); - const files = await fs.readdir(dirPath); - const hasDrivers = files.some(f => f !== 'match.conf'); + const metadataFiles = new Set(['match.conf', 'image.conf', '.extracting']); + const profileEntries = await listDirRecursive(dirPath, ''); + const hasDrivers = profileEntries.some(file => ( + !file.isDirectory && !metadataFiles.has(file.path) + )); // Count files and total size for the profile const fileCount = await countFiles(dirPath); diff --git a/tests/api/drivers.api.test.js b/tests/api/drivers.api.test.js index f82fb2f..e5f2695 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'); }); @@ -164,30 +254,31 @@ describe('Security Headers', () => { expect(csp).toContain("script-src-attr 'none'"); expect(csp).toContain("style-src-attr 'none'"); expect(csp).not.toContain("'unsafe-inline'"); + expect(csp).not.toContain('upgrade-insecure-requests'); }); }); 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 +290,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 +343,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 +375,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 +397,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 +425,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 +460,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 +477,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 +487,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 +501,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 +510,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 +534,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..a9e8a3c --- /dev/null +++ b/tests/packaging/debian-package.test.js @@ -0,0 +1,225 @@ +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'); + const workflow = read('.github/workflows/ci.yml'); + + 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('sanitize-production-manifests.js'); + expect(rules).toContain('THIRD-PARTY-NOTICES.txt'); + expect(rules).toContain('README.Debian'); + expect(rules).toContain('linbo-patchless-configure.8'); + expect(read('debian/linbo-patchless-configure.8')).toContain('.TH LINBO-PATCHLESS-CONFIGURE 8'); + expect(rules).toContain("-name '.eslintrc.*'"); + expect(rules).toContain("-name '.npmignore'"); + expect(rules).toContain("-iname tests"); + expect(rules).toContain("-iname benchmarks"); + expect(rules).toContain("-name '.gitattributes'"); + expect(rules).toContain("-name 'tsconfig.json'"); + expect(rules).toContain("-name '.nycrc'"); + expect(rules).toContain("-name '.coveralls.yml'"); + expect(rules).toContain("-iname '*.test.js'"); + expect(rules).toContain("-iname 'example-*.js'"); + expect(rules).toContain("-iname 'bench.js'"); + expect(rules).toContain("-iname examples"); + expect(rules).toContain("-iname fixtures"); + expect(rules).toContain("-iname help"); + expect(rules).toContain("-name '.*'"); + expect(rules).toContain("-iname '*.d.ts'"); + expect(rules).toContain("-iname '*.cts'"); + expect(rules).toContain("-iname '*.gypi'"); + expect(rules).toContain("-iname '*.png'"); + expect(rules).toContain("! -iname 'license*'"); + expect(rules).toContain('pino-http/scripts'); + expect(rules).toContain('node_modules/.bin'); + expect(rules).toContain('mime/src/build.js'); + expect(rules).toContain('pino-pretty/coverage-map.js'); + expect(rules).toContain('pino/index.html'); + expect(rules).toContain('delete pkg.devDependencies'); + expect(rules).toContain('delete pkg.scripts'); + expect(rules).not.toContain('install -m 0644 package.json package-lock.json'); + expect(workflow).toContain('Package contains npm development metadata rejected by Lintian'); + expect(workflow).toContain('Package contains npm tests, benchmarks, or CI metadata'); + expect(workflow).toContain('Package contains npm benchmark files or private test keys'); + expect(workflow).toContain('Package contains non-license npm documentation'); + expect(workflow).toContain("-name 'id_rsa*'"); + expect(workflow).toContain("-name 'Jenkinsfile'"); + expect(workflow).toContain("-iname 'test-*.js'"); + expect(workflow).toContain("-iname '*.bat'"); + expect(workflow).toContain('test ! -e "$package_root/usr/lib/linbo-patchless/package-lock.json"'); + expect(workflow).toContain('sanitize-production-manifests.js'); + expect(workflow).toContain('--check'); + 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'); + const appVersion = JSON.parse(read('package.json')).version; + + expect(sourceFormat.trim()).toBe('3.0 (native)'); + expect(changelog.startsWith(`linbo-patchless (${appVersion})`)).toBe(true); + 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); + expect(notices).toContain('cookie-signature 1.0.7'); + const cookieNotice = notices.split('='.repeat(78)) + .find(section => section.includes('cookie-signature 1.0.7')); + expect(cookieNotice).toContain('Readme.md (License section)'); + expect(cookieNotice).toContain('Copyright (c) 2012 LearnBoost'); + expect(cookieNotice).not.toContain('License file: none included by upstream'); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }); + + test('sanitizes development-only package metadata without breaking runtime exports', () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'patchless-manifests-')); + const moduleDir = path.join(tempDir, 'node_modules', 'example'); + const manifestFile = path.join(moduleDir, 'package.json'); + + try { + fs.mkdirSync(moduleDir, { recursive: true }); + fs.writeFileSync(manifestFile, JSON.stringify({ + name: 'example', + version: '1.0.0', + main: 'index.js', + bin: { example: 'missing-cli.js' }, + scripts: { test: 'exit 1' }, + devDependencies: { jest: '^30.0.0' }, + types: 'missing.d.ts', + exports: { + '.': { types: './missing.d.ts', require: './index.js' }, + }, + })); + + const sanitizer = path.join(root, 'scripts/sanitize-production-manifests.js'); + const result = spawnSync(process.execPath, [sanitizer, path.join(tempDir, 'node_modules')], { + encoding: 'utf8', + }); + expect(result.status).toBe(0); + + const manifest = JSON.parse(fs.readFileSync(manifestFile, 'utf8')); + expect(manifest.bin).toBeUndefined(); + expect(manifest.scripts).toBeUndefined(); + expect(manifest.devDependencies).toBeUndefined(); + expect(manifest.types).toBeUndefined(); + expect(manifest.exports['.']).toEqual({ require: './index.js' }); + + const check = spawnSync(process.execPath, [ + sanitizer, + path.join(tempDir, 'node_modules'), + '--check', + ], { encoding: 'utf8' }); + expect(check.status).toBe(0); + } 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('linbo-patchless-configure'); + expect(postinst).not.toContain('/usr/sbin/linbo-patchless-configure'); + expect(postinst).toContain('systemctl is-active --quiet linbo-patchless.service'); + expect(postinst).toContain('systemctl restart linbo-patchless.service'); + expect(postinst.indexOf('systemctl restart linbo-patchless.service')) + .toBeLessThan(postinst.indexOf('/health/live')); + expect(postinst).toContain('/health/live'); + expect(postinst).toContain('single_quote = sprintf("%c", 39)'); + expect(postinst).not.toContain(String.raw`value ~ /^\"`); + expect(configurator).toContain('detect_server_ipv4'); + expect(configurator).toContain('device" == "server" && "$role" == "addc"'); + expect(configurator).toContain('DEFAULT_PATCHLESS_HOST="127.0.0.1"'); + expect(configurator).toContain('ensure_env PATCHLESS_HOST "$DEFAULT_PATCHLESS_HOST"'); + expect(configurator).toContain('ensure_env PATCHLESS_ALLOWED_HOSTS "$DEFAULT_PATCHLESS_HOST"'); + expect(configurator).toContain('chown "$SERVICE_USER":"$SERVICE_GROUP" "$CLIENT_KEY"'); + expect(configurator).toContain('chmod 0600 "$CLIENT_KEY"'); + expect(configurator).toContain('single_quote = sprintf("%c", 39)'); + expect(configurator).not.toContain('chmod 0640 "$CLIENT_KEY"'); + expect(configurator).not.toContain('chown root:"$SERVICE_GROUP" "$CLIENT_KEY"'); + const sourceInstaller = read('install.sh'); + expect(sourceInstaller).toContain('chown "$SERVICE_USER":"$SERVICE_GROUP" "$CLIENT_KEY"'); + expect(sourceInstaller).toContain('chmod 0600 "$CLIENT_KEY"'); + expect(sourceInstaller).toContain('single_quote = sprintf("%c", 39)'); + expect(sourceInstaller).not.toContain('chown root:"$SERVICE_GROUP" "$CLIENT_KEY"'); + 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.indexOf('chmod 0600 "$client_key"')) + .toBeLessThan(postrm.indexOf('ssh-keygen -y -f "$client_key"')); + expect(postrm).toContain('ssh-keygen -y -f "$client_key"'); + expect(postrm).toContain('cmp -s "$derived_public_key" "$root_public_key"'); + expect(postrm).toContain('public_key_removed=1'); + 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..22f57fe 100644 --- a/ui/index.html +++ b/ui/index.html @@ -5,261 +5,657 @@ LINBO Patchless — Treiber Manager
-

LINBO Patchless

- Treiber Manager -
- API-Key fehlt - +
+ +
+

LINBO Patchless

+
Windows-Treiber zentral verwalten
+
+
+
+ + Status wird geprüft + +
- -
- -
-
-

Treiber-Profile

+ +
+
+
+
Treiberverwaltung
+

Treiberprofile

+

Hardware erkennen, passende Treiber ablegen und einem LINBO-Image zuweisen.

+
- - + + +
+
+ +
+
+ Profile gesamt + 0 + erkannte Hardwaremodelle +
+
+ Einsatzbereit + 0 + mit Treibern und Image +
+
+ Noch offen + 0 + benötigt eine Zuweisung +
+
+ LINBO-Images + 0 + zur Zuweisung verfügbar
-
- - - - - -
ProfilHardwareTreiberImageAktionen
+ + + +
+
+

Vorhandene Profile

+

Wähle ein Hardwaremodell aus, um Treiber und Image-Zuweisung zu bearbeiten.

+
+
+ +
+ Systemdetails anzeigen +
+
+
+
-
- + +