diff --git a/cli/tick.php b/cli/tick.php index 98c57c2..3092951 100644 --- a/cli/tick.php +++ b/cli/tick.php @@ -159,6 +159,29 @@ foreach ($guardResult['errors'] as $guardError) { Logger::warning('Release-Zugangsschutz', ['error' => $guardError]); } +// Nach einer Neuerzeugung gegenpruefen, ob der Schutz wirklich greift. +// Vorhandene Dateien beweisen nichts: Unter Nginx werden sie ignoriert, und +// dann waeren die Verzeichnisse offen, ohne dass es jemandem auffiele. +if ($guardResult['ran']) { + $guardTest = \Deploymentcenter\Modules\UpdateService\ReleaseGuard::selfTest($db); + + foreach ($guardTest['failed'] as $failure) { + Logger::error('Release-Zugangsschutz greift nicht', [ + 'product' => $failure['slug'] ?? '?', + 'status' => $failure['status'] ?? null, + 'reason' => $failure['reason'] ?? '', + ]); + } + + if (!$options['quiet'] && $guardTest['failed'] !== []) { + printf( + "ACHTUNG: Zugangsschutz greift bei %d von %d Produkt(en) NICHT.\n", + count($guardTest['failed']), + $guardTest['checked'] + ); + } +} + $result = Evaluator::run($db); $wallMs = (int)round((microtime(true) - $startedAt) * 1000); diff --git a/client-dotnet/Deploymentcenter.Client/Models/SetupDefinition.cs b/client-dotnet/Deploymentcenter.Client/Models/SetupDefinition.cs index 75271ec..9a15fdb 100644 --- a/client-dotnet/Deploymentcenter.Client/Models/SetupDefinition.cs +++ b/client-dotnet/Deploymentcenter.Client/Models/SetupDefinition.cs @@ -43,10 +43,30 @@ namespace Deploymentcenter.Client.Models /// Eine Datei, in die Werte geschrieben werden. public class SetupTarget { - /// Pfad relativ zum Installationsverzeichnis. + /// + /// Pfad, bezogen auf . Umgebungsvariablen sind + /// in beiden Schreibweisen erlaubt: ${XDG_CONFIG_HOME} und + /// %APPDATA%. Ein absoluter Pfad wird unveraendert benutzt. + /// [JsonPropertyName("file")] public string File { get; set; } = string.Empty; + /// + /// Wo die Datei liegt: + /// + /// install (Vorgabe) - im Installationsverzeichnis + /// config - %APPDATA% bzw. $XDG_CONFIG_HOME (~/.config) + /// data - %LOCALAPPDATA% bzw. $XDG_DATA_HOME + /// home - Benutzerverzeichnis + /// + /// Anwendungen, die sich unter Linux richtig verhalten, legen ihre + /// Konfiguration nicht neben das Programm - /opt/<app> ist fuer den + /// Dienstbenutzer meist nicht schreibbar. Ohne diese Angabe schriebe + /// der Installer dorthin, wo die Anwendung nie nachsieht. + /// + [JsonPropertyName("location")] + public string Location { get; set; } = "install"; + /// "json" oder "env". [JsonPropertyName("format")] public string Format { get; set; } = "json"; diff --git a/client-dotnet/Deploymentcenter.Client/ReleaseCredentials.cs b/client-dotnet/Deploymentcenter.Client/ReleaseCredentials.cs index b13f32e..72bb42e 100644 --- a/client-dotnet/Deploymentcenter.Client/ReleaseCredentials.cs +++ b/client-dotnet/Deploymentcenter.Client/ReleaseCredentials.cs @@ -31,14 +31,41 @@ namespace Deploymentcenter.Client /// /// Zugang ueber den Lizenzschluessel. /// - /// Benutzername und Passwort sind beide der Schluessel: Basic Auth - /// verlangt zwei Felder, es gibt aber nur ein Geheimnis, und - /// Benutzernamen muessen innerhalb einer .htpasswd eindeutig sein. + /// Der Benutzername wird aus dem Schluessel abgeleitet, das Passwort + /// ist der Schluessel selbst. Der Grund liegt im htpasswd-Format: + /// gehasht wird dort nur die Passwortspalte. Stuende der Schluessel + /// auch als Benutzername in der Datei, waere sie eine vollstaendige + /// Klartext-Kundenliste und der Hash daneben blosse Dekoration. + /// + /// MUSS zeichengenau mit ReleaseGuard::licenseUsername() auf dem + /// Server uebereinstimmen. /// public static ReleaseCredentials? FromLicenseKey(string? licenseKey) { string key = (licenseKey ?? string.Empty).Trim(); - return key.Length == 0 ? null : new ReleaseCredentials(key, key); + + if (key.Length == 0) + return null; + + return new ReleaseCredentials(UsernameForLicenseKey(key), key); + } + + /// + /// Ableitung des Benutzernamens: "lic_" plus die ersten 16 Hexzeichen + /// des SHA-256 ueber den Schluessel. + /// + public static string UsernameForLicenseKey(string licenseKey) + { + using var sha256 = System.Security.Cryptography.SHA256.Create(); + byte[] hash = sha256.ComputeHash(Encoding.UTF8.GetBytes((licenseKey ?? string.Empty).Trim())); + + var builder = new StringBuilder("lic_", 20); + for (int i = 0; i < 8; i++) + { + builder.Append(hash[i].ToString("x2")); + } + + return builder.ToString(); } /// Zugang ueber ein Installationskonto. diff --git a/client-dotnet/Deploymentcenter.Client/SetupPaths.cs b/client-dotnet/Deploymentcenter.Client/SetupPaths.cs new file mode 100644 index 0000000..684cd85 --- /dev/null +++ b/client-dotnet/Deploymentcenter.Client/SetupPaths.cs @@ -0,0 +1,199 @@ +using System; +using System.IO; +using System.Text; +using Deploymentcenter.Client.Models; + +namespace Deploymentcenter.Client +{ + /// + /// Loest die Zielpfade einer setup.json plattformrichtig auf. + /// + /// Ohne das schrieb der Installer ausschliesslich ins + /// Installationsverzeichnis. Eine Anwendung, die sich unter Linux richtig + /// verhaelt, liest ihre Konfiguration aber aus $XDG_CONFIG_HOME und unter + /// Windows aus %APPDATA% - schon deshalb, weil /opt/<app> fuer den + /// Dienstbenutzer typischerweise nicht schreibbar ist. Der Installer legte + /// die Datei also dorthin, wo die Anwendung nie nachsieht. + /// + public static class SetupPaths + { + /// + /// Ermittelt den vollstaendigen Pfad eines Ziels. + /// + /// Ziel aus der setup.json. + /// Installationsverzeichnis der Anwendung. + public static string Resolve(SetupTarget target, string installDir) + { + string file = Expand(target?.File ?? string.Empty); + + // Ein absoluter Pfad ist bereits die Antwort - etwa nachdem + // ${XDG_CONFIG_HOME} ersetzt wurde. + if (Path.IsPathRooted(file)) + { + return Path.GetFullPath(file); + } + + string root = BaseDirectory(target?.Location, installDir); + return Path.GetFullPath(Path.Combine(root, file.Replace('/', Path.DirectorySeparatorChar))); + } + + /// + /// Wurzelverzeichnis fuer eine Ortsangabe. + /// + /// install (Vorgabe) - das Installationsverzeichnis + /// config - %APPDATA% bzw. $XDG_CONFIG_HOME (~/.config) + /// data - %LOCALAPPDATA% bzw. $XDG_DATA_HOME (~/.local/share) + /// home - Benutzerverzeichnis + /// + public static string BaseDirectory(string? location, string installDir) + { + switch ((location ?? string.Empty).Trim().ToLowerInvariant()) + { + case "config": + return ConfigDirectory(); + + case "data": + return DataDirectory(); + + case "home": + return HomeDirectory(); + + case "": + case "install": + return installDir; + + default: + // Eine unbekannte Angabe ins Installationsverzeichnis + // umzubiegen ist die harmlosere Auslegung: dort landet die + // Datei sichtbar statt an einem geratenen Ort. + return installDir; + } + } + + public static string ConfigDirectory() + { + if (OperatingSystemHelpers.IsWindows()) + { + string appData = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData); + return string.IsNullOrWhiteSpace(appData) + ? Path.Combine(HomeDirectory(), "AppData", "Roaming") + : appData; + } + + string xdg = Environment.GetEnvironmentVariable("XDG_CONFIG_HOME") ?? string.Empty; + return string.IsNullOrWhiteSpace(xdg) + ? Path.Combine(HomeDirectory(), ".config") + : xdg; + } + + public static string DataDirectory() + { + if (OperatingSystemHelpers.IsWindows()) + { + string localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); + return string.IsNullOrWhiteSpace(localAppData) + ? Path.Combine(HomeDirectory(), "AppData", "Local") + : localAppData; + } + + string xdg = Environment.GetEnvironmentVariable("XDG_DATA_HOME") ?? string.Empty; + return string.IsNullOrWhiteSpace(xdg) + ? Path.Combine(HomeDirectory(), ".local", "share") + : xdg; + } + + public static string HomeDirectory() + { + string home = Environment.GetEnvironmentVariable("HOME") ?? string.Empty; + + if (string.IsNullOrWhiteSpace(home)) + { + home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + } + + return string.IsNullOrWhiteSpace(home) ? Directory.GetCurrentDirectory() : home; + } + + /// + /// Ersetzt Umgebungsvariablen in beiden gebraeuchlichen Schreibweisen: + /// ${NAME} und %NAME%. Unbekannte Namen bleiben stehen - + /// eine leere Ersetzung wuerde stillschweigend einen falschen Pfad + /// ergeben, ein stehengebliebener Platzhalter faellt dagegen auf. + /// + public static string Expand(string value) + { + if (string.IsNullOrEmpty(value)) + return string.Empty; + + var builder = new StringBuilder(value.Length); + + for (int i = 0; i < value.Length; i++) + { + // ${NAME} + if (value[i] == '$' && i + 1 < value.Length && value[i + 1] == '{') + { + int close = value.IndexOf('}', i + 2); + if (close > i + 2) + { + string name = value.Substring(i + 2, close - i - 2); + string? resolved = Lookup(name); + + if (resolved != null) + { + builder.Append(resolved); + i = close; + continue; + } + } + } + + // %NAME% + if (value[i] == '%') + { + int close = value.IndexOf('%', i + 1); + if (close > i + 1) + { + string name = value.Substring(i + 1, close - i - 1); + string? resolved = Lookup(name); + + if (resolved != null) + { + builder.Append(resolved); + i = close; + continue; + } + } + } + + builder.Append(value[i]); + } + + return builder.ToString(); + } + + /// + /// Schlaegt einen Namen nach. Neben echten Umgebungsvariablen werden + /// die XDG-Namen auch dann beantwortet, wenn sie nicht gesetzt sind - + /// sonst muesste jede setup.json den Rueckfall auf ~/.config selbst + /// ausformulieren. + /// + private static string? Lookup(string name) + { + string value = Environment.GetEnvironmentVariable(name) ?? string.Empty; + + if (!string.IsNullOrWhiteSpace(value)) + return value; + + switch (name.ToUpperInvariant()) + { + case "XDG_CONFIG_HOME": return ConfigDirectory(); + case "XDG_DATA_HOME": return DataDirectory(); + case "HOME": + case "USERPROFILE": return HomeDirectory(); + case "APPDATA": return ConfigDirectory(); + case "LOCALAPPDATA": return DataDirectory(); + default: return null; + } + } + } +} diff --git a/client-dotnet/Deploymentcenter.UpdateAgent/Installation.cs b/client-dotnet/Deploymentcenter.UpdateAgent/Installation.cs index e564d3b..14522f7 100644 --- a/client-dotnet/Deploymentcenter.UpdateAgent/Installation.cs +++ b/client-dotnet/Deploymentcenter.UpdateAgent/Installation.cs @@ -306,12 +306,17 @@ namespace Deploymentcenter.UpdateAgent if (forTarget.Count == 0) continue; - string path = Path.Combine(options.TargetDir, target.File.Replace('/', Path.DirectorySeparatorChar)); + string path = SetupPaths.Resolve(target, options.TargetDir); try { SetupWriter.Write(path, target.Format, forTarget); - AnsiConsole.MarkupLine($"[green]Geschrieben:[/] {Markup.Escape(target.File)} " + + // Den aufgeloesten Pfad ausgeben, nicht die Angabe aus der + // setup.json: Bei location=config entscheidet die Umgebung, + // und wer den Installer unter einem anderen Konto startet + // als den Dienst, muss das sehen koennen. + AnsiConsole.MarkupLine($"[green]Geschrieben:[/] {Markup.Escape(path)} " + $"[grey]({forTarget.Count} Wert(e))[/]"); } catch (Exception ex) @@ -360,8 +365,9 @@ namespace Deploymentcenter.UpdateAgent var target = FindTarget(field, targets); if (target != null && string.Equals(target.Format, "json", StringComparison.OrdinalIgnoreCase)) { - string path = Path.Combine(options.TargetDir, - target.File.Replace('/', Path.DirectorySeparatorChar)); + // Muss denselben Pfad ergeben wie beim Schreiben - sonst + // prueft der Installer eine andere Datei, als er anlegt. + string path = SetupPaths.Resolve(target, options.TargetDir); string? existing = SetupWriter.ReadExistingJson(path, field.Key); diff --git a/docs/SETUP_INTEGRATION_GUIDE.md b/docs/SETUP_INTEGRATION_GUIDE.md index 5d9fbaf..4f4dc20 100644 --- a/docs/SETUP_INTEGRATION_GUIDE.md +++ b/docs/SETUP_INTEGRATION_GUIDE.md @@ -182,6 +182,43 @@ entpackt sie dann nur und fragt nichts ab. | `validate` | Regulärer Ausdruck | | `target` | `id` des Ziels, wenn es mehrere gibt | +### Wohin geschrieben wird + +Ein Ziel hat neben `file` eine Ortsangabe: + +```json +"targets": [ + { "id": "app", "file": "appsettings.json", "format": "json" }, + { "id": "user", "file": "clawddotnet/Settings.json", + "location": "config", "format": "json" } +] +``` + +| `location` | Windows | Linux / macOS | +|---|---|---| +| `install` (Vorgabe) | Installationsverzeichnis | Installationsverzeichnis | +| `config` | `%APPDATA%` | `$XDG_CONFIG_HOME`, sonst `~/.config` | +| `data` | `%LOCALAPPDATA%` | `$XDG_DATA_HOME`, sonst `~/.local/share` | +| `home` | Benutzerprofil | `$HOME` | + +**Warum das nötig ist:** Eine Anwendung, die sich unter Linux richtig verhält, +legt ihre Konfiguration nicht neben das Programm — `/opt/` ist für den +Dienstbenutzer typischerweise nicht schreibbar. Ohne Ortsangabe schriebe der +Installer die Datei dorthin, wo die Anwendung nie nachsieht. + +In `file` sind Umgebungsvariablen in beiden Schreibweisen erlaubt: +`${XDG_CONFIG_HOME}/app/Settings.json` und `%APPDATA%\app\Settings.json`. Ein +absoluter Pfad wird unverändert benutzt. Unbekannte Variablen bleiben stehen, +statt zu einer leeren Zeichenkette zu werden — ein stehengebliebener +Platzhalter fällt auf, ein stillschweigend falscher Pfad nicht. + +> **Wer den Installer startet, entscheidet mit.** `config`, `data` und `home` +> beziehen sich auf das Konto, unter dem der Installer läuft. Wird er als +> `root` oder Administrator gestartet, der Dienst aber unter einem eigenen +> Konto betrieben, landet die Datei im falschen Profil. Der Installer gibt +> deshalb den **aufgelösten Pfad** aus — prüfe ihn. Für Dienste ist ein +> absoluter Pfad oft die ehrlichere Angabe. + `type` bestimmt auch, **wie** geschrieben wird: `int` und `bool` landen als JSON-Zahl beziehungsweise -Wahrheitswert, nicht als Zeichenkette. Sonst scheitert die Bindung in der Anwendung. diff --git a/docs/UPDATESERVICE_INTEGRATION_GUIDE.md b/docs/UPDATESERVICE_INTEGRATION_GUIDE.md index aaf1d36..2daead4 100644 --- a/docs/UPDATESERVICE_INTEGRATION_GUIDE.md +++ b/docs/UPDATESERVICE_INTEGRATION_GUIDE.md @@ -27,7 +27,20 @@ > siehe **[§2A](#a-referenz-auf-deploymentcenterclient)**. -Das **UpdateService-Modul** des Deploymentcenters bietet ein unternehmensweites, leichtgewichtiges Update-, Rollback- und Reparatur-Schema auf Basis eines LEMP-Stacks (Nginx Static Files + PHP API). +Das **UpdateService-Modul** des Deploymentcenters bietet ein unternehmensweites, leichtgewichtiges Update-, Rollback- und Reparatur-Schema: statisch ausgelieferte Pakete plus eine PHP-API. + +> **⚠️ Der Webserver ist nicht beliebig.** Ältere Fassungen dieser Anleitung +> beschrieben den Stack durchgehend als „LEMP (Nginx + PHP)". Der +> Zugangsschutz aus **[§5A](#5a-zugangsschutz-der-release-verzeichnisse)** +> beruht auf `.htaccess` und wird **von Nginx vollständig ignoriert** — dort +> wären die Release-Verzeichnisse offen und die `.htpasswd` sogar öffentlich +> abrufbar, während die Oberfläche „geschützt" meldete. +> +> `dc.mhdf.de` läuft auf **Apache** mit aktivem `AllowOverride`, dort trägt es. +> Wer auf Nginx ausrollt, muss den Schutz in der Serverkonfiguration +> nachbilden — die Vorlage steht in [§5A](#nginx-statt-apache). Verlass dich +> nicht auf die Anzeige, sondern auf den **Selbsttest**: er ruft die eigene +> Paket-Adresse ohne Zugangsdaten ab und erwartet 401. --- @@ -37,7 +50,8 @@ Das **UpdateService-Modul** des Deploymentcenters bietet ein unternehmensweites, - **Entkoppelte Ausführung**: Bei Handlungsbedarf beendet sich die Hauptanwendung sauber und übergibt die Kontrolle an den eigenständigen Console Agent (`update-agent.exe` / `update-agent`). - **3-Kanal-System**: Kanäle `prod` (Produktiv), `beta` (Vorab-Test), `dev` (Entwicklung). - **Plattform-Dimension**: je Kanal getrennte Pakete für `win-x64`, `linux-x64` usw. -- **Statische LEMP-Verteilung**: Downloads und Versionen-Manifeste (`latest.json`, `manifest.json`, `package.tar.gz`) werden über Nginx extrem performant bereitgestellt. +- **Statische Verteilung**: Downloads und Versionen-Manifeste (`latest.json`, `manifest.json`, `package.tar.gz`) liefert der Webserver direkt aus, ohne PHP im Weg. +- **Zugangsschutz über den Lizenzschlüssel** — setzt Apache voraus, siehe Kasten oben. --- @@ -604,12 +618,21 @@ Darin stehen: | Eintrag | Benutzername | Passwort | |---|---|---| -| Gültige Lizenz | der Lizenzschlüssel | derselbe Schlüssel | +| Gültige Lizenz | `lic_` + erste 16 Hexzeichen von SHA-256(Schlüssel) | der Schlüssel | | Installationskonto | DC-Benutzername | dessen Passwort | -Beim Lizenzschlüssel sind Benutzername und Passwort identisch: Basic Auth -verlangt zwei Felder, es gibt aber nur ein Geheimnis, und Benutzernamen müssen -eindeutig sein. +**Der Benutzername ist eine Ableitung, nicht der Schlüssel selbst.** Das +htpasswd-Format hasht nur die Passwortspalte. Stünde der Lizenzschlüssel auch +als Benutzername darin, wäre die Datei eine vollständige Klartext-Kundenliste +und der bcrypt-Hash daneben bloße Dekoration — ein einziger +Konfigurationsfehler vom Leak entfernt. So enthält sie nur eine +Einwegableitung und einen Hash über einen hochentropen Schlüssel; selbst +offengelegt ist damit nichts anzufangen. + +Die Ableitung muss auf beiden Seiten zeichengenau übereinstimmen: +`ReleaseGuard::licenseUsername()` serverseitig, +`ReleaseCredentials.UsernameForLicenseKey()` im SDK. Weichen sie voneinander +ab, kommt niemand mehr an seine Updates. Die Installationskonten stehen in **jeder** Datei — bei einer Erstinstallation gibt es noch keinen Lizenzschlüssel, mit dem sich das Paket holen ließe. Ihr @@ -669,10 +692,52 @@ Produkte geschützt sind und wie viele Zugänge jeweils eingetragen sind. | Anzeige | Bedeutung | |---|---| -| GESCHÜTZT | alles in Ordnung | +| GESCHÜTZT | die Dateien liegen vor — **das allein beweist nichts** | | OFFEN | keine `.htaccess` — jeder im Internet kann laden | | GESPERRT | Datei vorhanden, aber leer: weder gültige Lizenzen noch Installationskonten | +### Der Selbsttest ist die einzige belastbare Aussage + +Dass `.htaccess` und `.htpasswd` existieren, sagt nichts darüber, ob sie +ausgewertet werden. Unter Nginx werden sie ignoriert, bei abgeschaltetem +`AllowOverride` ebenso, und ein Tippfehler in der Datei führt zu 500 statt 401. +In allen drei Fällen stünde in der Übersicht „GESCHÜTZT", während die Pakete +offen im Netz lägen. + +Der Selbsttest ruft deshalb die **eigene Paket-Adresse ohne Zugangsdaten** ab +und erwartet 401. Er läuft bei jedem manuellen Erzeugen mit und nach jeder +automatischen Neuerzeugung durch `cli/tick.php`; das Ergebnis steht in der +Oberfläche und bei Fehlschlag im Log. + +Von Hand nachprüfen: + +```bash +curl -I https://dc.mhdf.de/releases//prod///package.tar.gz # 401 +curl -I https://dc.mhdf.de/releases//.htpasswd # 403 +``` + +### Nginx statt Apache + +Dort greift `.htaccess` nicht. Der Schutz muss in die Serverkonfiguration: + +```nginx +location ^~ /releases/ { + # Je Produkt eine eigene Datei - sonst öffnet eine Lizenz für A auch B. + # $1 ist der Produkt-Slug aus dem Pfad. + location ~ ^/releases/([^/]+)/ { + auth_basic "Deploymentcenter Releases"; + auth_basic_user_file /pfad/zum/webroot/releases/$1/.htpasswd; + } + + # Die Zugangsdateien selbst nie ausliefern. + location ~ /\.ht { deny all; } +} +``` + +`ReleaseGuard` erzeugt die `.htpasswd`-Dateien unverändert weiter — nur die +`.htaccess` bleibt dort wirkungslos. Der Selbsttest bestätigt anschließend, +dass es trägt. + ### Grenzen **Das macht Pakete nicht sicher.** Jeder lizenzierte Kunde kann sie weiterhin @@ -775,7 +840,7 @@ Veröffentlichte Releases können sowohl über das Web-Interface als auch über - Antwort (200 OK): ```json { - "status": "ok", + "status": "success", "release_id": 42, "updated": true, "platform": "win-x64", @@ -791,7 +856,7 @@ Veröffentlichte Releases können sowohl über das Web-Interface als auch über - Antwort (200 OK): ```json { - "status": "ok", + "status": "success", "release_id": 42, "deleted": true, "message": "Release v1.2.0 (prod, win-x64) fuer \"myapp\" geloescht." diff --git a/docs/UPGRADE.md b/docs/UPGRADE.md index 6a9fe5b..b2a6d51 100644 --- a/docs/UPGRADE.md +++ b/docs/UPGRADE.md @@ -529,6 +529,21 @@ Läuft `cli/tick.php` als Cron, erzeugt es den Schutz beim ersten Lauf nach dem Deployment **von selbst**. Wer die Reihenfolge einhalten will, spielt den Serverteil also erst dann ein, wenn Schritt 3 erledigt ist. +> **Neue Produkte: die Reihenfolge oben gilt für sie nicht.** +> +> `ReleaseGuard` überspringt Verzeichnisse, die es nicht gibt — für ein Produkt +> ohne Release ist `/releases//` schlicht nicht vorhanden und damit auch +> nicht geschützt. Das Verzeichnis entsteht erst mit dem **ersten** Upload, und +> der nächste `tick.php`-Lauf legt den Schutz darin an. +> +> Es gibt also kein Zeitfenster, in dem sich ein noch ungeschütztes Release +> herunterladen ließe, um dann das SDK nachzuziehen. **Der erste ausgelieferte +> Build muss die Zugangsdaten bereits mitbringen** — sonst fällt die Tür hinter +> ihm zu. +> +> Für Neuprodukte lautet die Reihenfolge daher schlicht: SDK 2.4 einbinden, +> `licenseKey` übergeben, *dann* das erste Release veröffentlichen. + ### 16.2 Was wo eingetragen wird Je Produktverzeichnis eine `.htpasswd` mit den aktiven, nicht abgelaufenen @@ -552,9 +567,15 @@ wget -qO- https://dc.mhdf.de/installer/install.sh | sh ### 16.4 Prüfen +- [ ] **Selbsttest** im WebUI unter *UpdateService → 🔒 Zugangsschutz* — er ruft + die eigene Paket-Adresse ohne Zugangsdaten ab und erwartet 401. Die + Anzeige „GESCHÜTZT" allein beweist nichts: Sie prüft nur, ob die Dateien + existieren, nicht ob der Server sie auswertet. - [ ] `curl -I https://dc.mhdf.de/releases//prod///package.tar.gz` → **401** -- [ ] Mit `-u ":"` → **200** +- [ ] Mit `-u "$(printf 'lic_%s' "$(printf %s "" | sha256sum | cut -c1-16)"):"` → **200** + (Benutzername ist die Ableitung, nicht der Schlüssel — siehe + [§5A](./UPDATESERVICE_INTEGRATION_GUIDE.md#5a-zugangsschutz-der-release-verzeichnisse)) - [ ] `curl -I https://dc.mhdf.de/releases//.htpasswd` → **403** - [ ] `https://dc.mhdf.de/installer/update-agent-linux-x64` → **200**, weiterhin offen - [ ] Eine Lizenz widerrufen und erneut mit ihr laden → **401** diff --git a/docs/setup.example.json b/docs/setup.example.json index 11fad65..d80abff 100644 --- a/docs/setup.example.json +++ b/docs/setup.example.json @@ -5,10 +5,11 @@ "displayName": "Beispielanwendung", "description": "Fuer den Betrieb werden eine Datenbank und ein Zugang zum Kursdatenanbieter benoetigt.", - "_targets_comment": "Wohin geschrieben wird. Pfade sind relativ zum Installationsverzeichnis. format ist 'json' oder 'env'. Ueber 'id' lassen sich einzelne Felder gezielt einer Datei zuordnen; ohne Angabe schreibt ein Feld in das erste Ziel.", + "_targets_comment": "Wohin geschrieben wird. 'location' bestimmt den Bezugspunkt: install (Vorgabe, Installationsverzeichnis), config (%APPDATA% bzw. $XDG_CONFIG_HOME), data, home. In 'file' sind ${VAR} und %VAR% erlaubt, ein absoluter Pfad wird unveraendert benutzt. format ist 'json' oder 'env'. Ueber 'id' lassen sich einzelne Felder gezielt einer Datei zuordnen; ohne Angabe schreibt ein Feld in das erste Ziel.", "targets": [ - { "id": "app", "file": "appsettings.json", "format": "json" }, - { "id": "env", "file": ".env", "format": "env" } + { "id": "app", "file": "appsettings.json", "location": "install", "format": "json" }, + { "id": "user", "file": "beispielanwendung/Settings.json", "location": "config", "format": "json" }, + { "id": "env", "file": ".env", "location": "install", "format": "env" } ], "_fields_comment": "source steuert, ob gefragt wird: 'ask' (Vorgabe) fragt nach, 'detect:...' leitet ab, 'provision' laesst ein Token ausstellen. Ist der Wert in der Zieldatei bereits gesetzt und kein Platzhalter, wird er uebernommen und nicht erneut erfragt.", diff --git a/public/index.php b/public/index.php index a31b74d..b815db5 100644 --- a/public/index.php +++ b/public/index.php @@ -1268,6 +1268,23 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { case 'regenerate_release_guard': { $guard = ReleaseGuard::regenerateAll($pdo); + // Direkt gegenpruefen, ob der Schutz tatsaechlich greift. + // Vorhandene Dateien beweisen nichts - unter Nginx werden sie + // ignoriert, und die Oberflaeche meldete trotzdem Erfolg. + $test = ReleaseGuard::selfTest($pdo); + + if ($test['failed'] !== []) { + $reasons = array_map( + static fn(array $f): string => ($f['slug'] ?? '?') . ': ' . ($f['reason'] ?? ''), + $test['failed'] + ); + dc_flash( + 'Zugangsschutz erzeugt, aber der Selbsttest schlaegt fehl! ' . e(implode(' | ', $reasons)), + 'danger' + ); + dc_redirect('#sub-update-guard'); + } + if ($guard['errors'] !== []) { dc_flash( 'Zugangsschutz teilweise erneuert. Fehler: ' . e(implode(' | ', $guard['errors'])), @@ -1275,9 +1292,11 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { ); } else { dc_flash(sprintf( - 'Zugangsschutz erneuert: %d Produkt(e), %d Zugang/Zugaenge.%s', + 'Zugangsschutz erneuert und geprueft: %d Produkt(e), %d Zugang/Zugaenge, ' + . '%d Adresse(n) antworten ohne Zugangsdaten korrekt mit 401.%s', $guard['projects'], $guard['entries'], + $test['ok'], $guard['skipped'] !== [] ? ' Ohne Zugaenge und damit gesperrt: ' . e(implode(', ', $guard['skipped'])) . '.' : '' @@ -1704,6 +1723,10 @@ $usersSchemaReady = $users === [] || array_key_exists('role', $users[0]); // Zustand des Zugangsschutzes der Release-Verzeichnisse. $releaseGuard = ReleaseGuard::status(); +// Ergebnis des letzten Selbsttests. Bewusst nur gelesen: ein HTTP-Abruf je +// Produkt bei jedem Seitenaufbau waere Verschwendung. +$releaseGuardTest = ReleaseGuard::lastSelfTest($pdo); + // Installer-Downloads. Die Binaries liegen unter /installer/ im Webroot und // werden nicht von deploy.py uebertragen, sondern von // scripts/upload_installer.py - der Quelltext des Agenten gehoert nicht auf @@ -3189,6 +3212,34 @@ Antwort: + + +
+ Selbsttest vom UTC: + von + Adresse(n) antworten ohne + Zugangsdaten korrekt mit 401. + +
    + +
  • :
  • + +
+ +
+ + +

+ Der Selbsttest ist die einzige belastbare Aussage. Dass die + Dateien vorhanden sind, beweist nichts: Unter Nginx werden + .htaccess-Dateien vollständig ignoriert — die Verzeichnisse wären + dann offen und die .htpasswd öffentlich abrufbar, während hier + „GESCHÜTZT“ stünde. Der Test ruft die eigene Paket-Adresse ohne Zugangsdaten + ab und erwartet 401. +

+

OFFEN heißt: jeder im Internet kann die Pakete laden. GESPERRT heißt: niemand kommt heran, weil es weder gültige diff --git a/src/Modules/UpdateService/ReleaseGuard.php b/src/Modules/UpdateService/ReleaseGuard.php index c0d5fb5..2132b49 100644 --- a/src/Modules/UpdateService/ReleaseGuard.php +++ b/src/Modules/UpdateService/ReleaseGuard.php @@ -47,6 +47,18 @@ final class ReleaseGuard /** Von Apache verstandene Hash-Praefixe. */ private const APACHE_BCRYPT_PREFIXES = ['$2y$', '$2a$', '$2b$']; + /** + * Leitet den Basic-Auth-Benutzernamen aus einem Lizenzschluessel ab. + * + * MUSS zeichengenau mit ReleaseCredentials.FromLicenseKey() im .NET-SDK + * uebereinstimmen - weichen die beiden voneinander ab, kommt niemand mehr + * an seine Updates. + */ + public static function licenseUsername(string $licenseKey): string + { + return 'lic_' . substr(hash('sha256', trim($licenseKey)), 0, 16); + } + /** Wurzelverzeichnis der Releases im Dateisystem. */ public static function releasesRoot(): string { @@ -103,10 +115,15 @@ final class ReleaseGuard } } + // Auch der Weg ueber die Oberflaeche vermerkt das Format - sonst + // sieht der naechste Tick eine Formataenderung, die es nicht gibt. + self::writeSetting($db, self::FORMAT_KEY, (string)self::FORMAT_VERSION); + Logger::info('Release-Zugangsschutz neu erzeugt', [ 'projects' => $result['projects'], 'entries' => $result['entries'], 'errors' => count($result['errors']), + 'format' => self::FORMAT_VERSION, ]); return $result; @@ -165,9 +182,16 @@ final class ReleaseGuard // 1. Gueltige Lizenzen dieses Produkts. // - // Benutzername und Passwort sind beide der Lizenzschluessel: Basic Auth - // verlangt zwei Felder, es gibt aber nur ein Geheimnis, und - // Benutzernamen muessen eindeutig sein. + // Der Benutzername wird aus dem Schluessel ABGELEITET, er ist nicht der + // Schluessel selbst. Das htpasswd-Format hasht nur die Passwortspalte; + // stuende der Schluessel als Benutzername daneben, waere die Datei eine + // vollstaendige Klartext-Kundenliste und der bcrypt-Hash daneben blosse + // Dekoration. So enthaelt sie nur eine Einwegableitung und einen Hash + // ueber einen hochentropen Schluessel - selbst offengelegt ist damit + // nichts anzufangen. + // + // Die Ableitung muss zeichengenau der clientseitigen in + // ReleaseCredentials.FromLicenseKey() entsprechen. try { $stmt = $db->prepare(" SELECT l.license_key @@ -185,7 +209,8 @@ final class ReleaseGuard continue; } - $entries[$key] = password_hash($key, PASSWORD_BCRYPT, ['cost' => self::LICENSE_HASH_COST]); + $entries[self::licenseUsername($key)] = + password_hash($key, PASSWORD_BCRYPT, ['cost' => self::LICENSE_HASH_COST]); } } catch (\Throwable $e) { Logger::warning('Lizenzen fuer den Release-Schutz nicht lesbar', [ @@ -304,6 +329,171 @@ final class ReleaseGuard /** Schluessel in dc_settings, unter dem der letzte Lauf vermerkt wird. */ private const LAST_RUN_KEY = 'release_guard_last_run'; + /** Schluessel in dc_settings fuer das Ergebnis des letzten Selbsttests. */ + private const SELFTEST_KEY = 'release_guard_selftest'; + + /** Schluessel in dc_settings fuer das Format der erzeugten Dateien. */ + private const FORMAT_KEY = 'release_guard_format'; + + /** + * Format der erzeugten .htpasswd. Bei einer Aenderung muessen alle Dateien + * neu geschrieben werden, sonst passen Server und Client nicht mehr + * zusammen. + * + * 1 - Benutzername = Lizenzschluessel im Klartext + * 2 - Benutzername = lic_ + * + * Ohne diese Kennung waere der Umstieg auf 2 unbemerkt liegengeblieben: + * reconcile() sah keinen Anlass, und die Dateien behielten bis zum + * naechsten turnusmaessigen Lauf das alte Format - waehrend die Clients + * bereits das neue schicken. + */ + private const FORMAT_VERSION = 2; + + /** + * Prueft, ob der Schutz tatsaechlich greift - durch einen echten Abruf. + * + * Dass .htaccess und .htpasswd vorhanden sind, beweist nichts. Unter Nginx + * werden .htaccess-Dateien vollstaendig ignoriert; die Verzeichnisse + * waeren dann offen und die .htpasswd oeffentlich abrufbar, waehrend die + * Oberflaeche "geschuetzt" meldet. Dasselbe gilt bei abgeschaltetem + * AllowOverride oder einem Tippfehler in der erzeugten Datei. + * + * Nur ein Abruf der eigenen Paket-Adresse ohne Zugangsdaten beantwortet + * die Frage, und der findet alle drei Faelle auf einmal. + * + * @return array{checked:int,ok:int,failed:list>,ran_at:string} + */ + public static function selfTest(PDO $db, ?string $slug = null): array + { + $baseUrl = rtrim((string)\Deploymentcenter\Core\Config::get('app.url', ''), '/'); + + $result = ['checked' => 0, 'ok' => 0, 'failed' => [], 'ran_at' => gmdate('Y-m-d H:i:s')]; + + if ($baseUrl === '') { + $result['failed'][] = ['slug' => '-', 'reason' => 'app.url ist nicht konfiguriert.']; + return $result; + } + + $sql = ' + SELECT product_slug, download_url + FROM updateservice_releases + WHERE download_url <> "" + '; + $params = []; + + if ($slug !== null && $slug !== '') { + $sql .= ' AND product_slug = :slug'; + $params[':slug'] = $slug; + } + + $sql .= ' ORDER BY product_slug ASC, created_at DESC'; + + try { + $stmt = $db->prepare($sql); + $stmt->execute($params); + $rows = $stmt->fetchAll() ?: []; + } catch (\Throwable $e) { + $result['failed'][] = ['slug' => '-', 'reason' => 'Releases nicht lesbar: ' . $e->getMessage()]; + return $result; + } + + // Je Produkt genuegt eine Adresse. + $seen = []; + + foreach ($rows as $row) { + $product = (string)$row['product_slug']; + if (isset($seen[$product])) { + continue; + } + $seen[$product] = true; + + $url = (string)$row['download_url']; + $result['checked']++; + + $status = self::probe($url); + + if ($status === 401) { + $result['ok']++; + continue; + } + + $result['failed'][] = [ + 'slug' => $product, + 'status' => $status, + 'url' => $url, + 'reason' => $status === 200 + ? 'Das Paket ist OHNE Zugangsdaten abrufbar. Wird .htaccess ausgewertet? ' + . 'Unter Nginx wirkungslos, unter Apache AllowOverride pruefen.' + : sprintf('Unerwarteter Status %s - erwartet wurde 401.', $status === null ? 'keiner' : (string)$status), + ]; + } + + self::writeSetting($db, self::SELFTEST_KEY, json_encode($result) ?: ''); + + Logger::info('Release-Selbsttest', [ + 'checked' => $result['checked'], + 'ok' => $result['ok'], + 'failed' => count($result['failed']), + ]); + + return $result; + } + + /** Ergebnis des letzten Selbsttests, oder null. */ + public static function lastSelfTest(PDO $db): ?array + { + $raw = self::readSetting($db, self::SELFTEST_KEY); + if ($raw === null) { + return null; + } + + $decoded = json_decode($raw, true); + return is_array($decoded) ? $decoded : null; + } + + /** + * Ruft eine Adresse ohne Zugangsdaten ab und liefert den Statuscode. + * Bewusst nur der Kopf der Antwort - es geht um den Status, nicht um + * mehrere Megabyte Paket. + */ + private static function probe(string $url): ?int + { + if (function_exists('curl_init')) { + $ch = curl_init($url); + curl_setopt_array($ch, [ + CURLOPT_NOBODY => true, + CURLOPT_RETURNTRANSFER => true, + CURLOPT_TIMEOUT => 10, + CURLOPT_CONNECTTIMEOUT => 5, + CURLOPT_FOLLOWLOCATION => false, + // Bewusst kein Zugangsdaten-Header: geprueft wird ja gerade, + // ob der Abruf ohne scheitert. + ]); + curl_exec($ch); + $status = (int)curl_getinfo($ch, CURLINFO_RESPONSE_CODE); + curl_close($ch); + + return $status > 0 ? $status : null; + } + + $context = stream_context_create(['http' => [ + 'method' => 'HEAD', + 'timeout' => 10, + 'ignore_errors' => true, + ]]); + + @file_get_contents($url, false, $context); + + foreach ($http_response_header ?? [] as $header) { + if (preg_match('#^HTTP/\S+\s+(\d{3})#', $header, $m) === 1) { + return (int)$m[1]; + } + } + + return null; + } + /** * Fuer den Cron gedacht: erzeugt nur dann neu, wenn es noetig ist. * @@ -325,6 +515,8 @@ final class ReleaseGuard if ($lastRun === null) { $reason = 'noch nie gelaufen'; + } elseif ((int)(self::readSetting($db, self::FORMAT_KEY) ?? '1') !== self::FORMAT_VERSION) { + $reason = 'Dateiformat geaendert'; } elseif (self::hasExpiredSince($db, $lastRun)) { $reason = 'Lizenz abgelaufen'; } elseif ((time() - strtotime($lastRun . ' UTC')) >= $maxAge) { @@ -366,10 +558,21 @@ final class ReleaseGuard } private static function readLastRun(PDO $db): ?string + { + return self::readSetting($db, self::LAST_RUN_KEY); + } + + private static function writeLastRun(PDO $db): void + { + self::writeSetting($db, self::LAST_RUN_KEY, gmdate('Y-m-d H:i:s')); + self::writeSetting($db, self::FORMAT_KEY, (string)self::FORMAT_VERSION); + } + + private static function readSetting(PDO $db, string $key): ?string { try { $stmt = $db->prepare('SELECT svalue FROM dc_settings WHERE skey = :k LIMIT 1'); - $stmt->execute([':k' => self::LAST_RUN_KEY]); + $stmt->execute([':k' => $key]); $value = $stmt->fetchColumn(); return is_string($value) && $value !== '' ? $value : null; } catch (\Throwable $e) { @@ -377,16 +580,16 @@ final class ReleaseGuard } } - private static function writeLastRun(PDO $db): void + private static function writeSetting(PDO $db, string $key, string $value): void { try { $stmt = $db->prepare(' INSERT INTO dc_settings (skey, svalue) VALUES (:k, :v) ON DUPLICATE KEY UPDATE svalue = VALUES(svalue) '); - $stmt->execute([':k' => self::LAST_RUN_KEY, ':v' => gmdate('Y-m-d H:i:s')]); + $stmt->execute([':k' => $key, ':v' => $value]); } catch (\Throwable $e) { - Logger::warning('Zeitpunkt des Release-Schutzes nicht vermerkt', ['error' => $e->getMessage()]); + Logger::warning('Einstellung nicht gespeichert', ['key' => $key, 'error' => $e->getMessage()]); } }