diff --git a/cli/tick.php b/cli/tick.php index 4d9635c..98c57c2 100644 --- a/cli/tick.php +++ b/cli/tick.php @@ -137,6 +137,28 @@ if ($options['report']) { } } +// Zugangsschutz der Release-Verzeichnisse abgleichen. +// +// Abgelaufene Lizenzen loesen anders als ein Widerruf nichts aus - der +// Zeitpunkt vergeht einfach. Ohne diesen Abgleich behielten sie ihren Zugang. +// reconcile() prueft selbst, ob es etwas zu tun gibt, und macht im Normalfall +// nichts; ein minuetlicher Aufruf ist deshalb unbedenklich. +$guardResult = \Deploymentcenter\Modules\UpdateService\ReleaseGuard::reconcile($db); + +if ($guardResult['ran'] && !$options['quiet']) { + printf( + "Release-Zugangsschutz erneuert (%s): %d Produkt(e), %d Zugang/Zugaenge%s\n", + $guardResult['reason'], + $guardResult['projects'], + $guardResult['entries'], + $guardResult['errors'] !== [] ? ', ' . count($guardResult['errors']) . ' Fehler' : '' + ); +} + +foreach ($guardResult['errors'] as $guardError) { + Logger::warning('Release-Zugangsschutz', ['error' => $guardError]); +} + $result = Evaluator::run($db); $wallMs = (int)round((microtime(true) - $startedAt) * 1000); diff --git a/client-dotnet/Deploymentcenter.Client/ReleaseCredentials.cs b/client-dotnet/Deploymentcenter.Client/ReleaseCredentials.cs new file mode 100644 index 0000000..b13f32e --- /dev/null +++ b/client-dotnet/Deploymentcenter.Client/ReleaseCredentials.cs @@ -0,0 +1,93 @@ +using System; +using System.Net.Http; +using System.Text; + +namespace Deploymentcenter.Client +{ + /// + /// Zugangsdaten fuer die Release-Ablage. + /// + /// Die Verzeichnisse unter /releases/ liegen hinter HTTP-Basic-Auth. Wer + /// herunterladen darf, entscheidet der Lizenzschluessel: Eine Anwendung + /// mit gueltiger Lizenz versorgt sich damit selbst weiter, ohne dass + /// zusaetzliche Geheimnisse verteilt werden muessten. Zuvor war die Ablage + /// offen - und damit auch fuer jeden im Internet herunterladbar. + /// + /// Bei einer Erstinstallation gibt es noch keinen Schluessel; dort treten + /// die Zugangsdaten des Installationskontos an seine Stelle. + /// + public sealed class ReleaseCredentials + { + private ReleaseCredentials(string user, string password) + { + User = user; + Password = password; + } + + public string User { get; } + + public string Password { get; } + + /// + /// 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. + /// + public static ReleaseCredentials? FromLicenseKey(string? licenseKey) + { + string key = (licenseKey ?? string.Empty).Trim(); + return key.Length == 0 ? null : new ReleaseCredentials(key, key); + } + + /// Zugang ueber ein Installationskonto. + public static ReleaseCredentials? FromUser(string? user, string? password) + { + string u = (user ?? string.Empty).Trim(); + string p = password ?? string.Empty; + + return u.Length == 0 ? null : new ReleaseCredentials(u, p); + } + + /// Wert fuer den Authorization-Header. + public string ToHeaderValue() + { + string raw = User + ":" + Password; + return "Basic " + Convert.ToBase64String(Encoding.UTF8.GetBytes(raw)); + } + + /// + /// Haengt den Header an eine Anfrage. Ohne Zugangsdaten passiert + /// nichts - eine noch ungeschuetzte Ablage bleibt damit erreichbar. + /// + public static void Apply(HttpRequestMessage request, ReleaseCredentials? credentials) + { + if (credentials == null || request == null) + return; + + // Nicht doppelt setzen, falls der Aufrufer schon etwas mitgibt. + if (request.Headers.Contains("Authorization")) + return; + + request.Headers.TryAddWithoutValidation("Authorization", credentials.ToHeaderValue()); + } + + /// + /// Erklaerung fuer den Menschen davor, wenn der Server 401 antwortet. + /// Ohne diesen Hinweis sieht ein abgelaufener Vertrag aus wie ein + /// Netzwerkfehler, und man sucht an der falschen Stelle. + /// + public static string DescribeUnauthorized(ReleaseCredentials? credentials) + { + if (credentials == null) + { + return "Die Release-Ablage verlangt Zugangsdaten, es wurden aber keine mitgegeben. " + + "Erwartet wird der Lizenzschluessel dieser Installation."; + } + + return "Die Release-Ablage hat die Zugangsdaten abgelehnt. Moegliche Gruende: die Lizenz " + + "ist abgelaufen, wurde widerrufen oder gehoert zu einem anderen Produkt."; + } + } +} diff --git a/client-dotnet/Deploymentcenter.Client/UpdateClient.cs b/client-dotnet/Deploymentcenter.Client/UpdateClient.cs index ff24ab8..dca506b 100644 --- a/client-dotnet/Deploymentcenter.Client/UpdateClient.cs +++ b/client-dotnet/Deploymentcenter.Client/UpdateClient.cs @@ -2,6 +2,7 @@ using System; using System.Collections.Generic; using System.Diagnostics; using System.IO; +using System.Net; using System.Net.Http; using System.Security.Cryptography; using System.Text; @@ -20,6 +21,14 @@ namespace Deploymentcenter.Client public ReleaseManifest? FullManifest { get; set; } public string Message { get; set; } = string.Empty; public Exception? Error { get; set; } + + /// + /// Die Release-Ablage hat die Zugangsdaten abgelehnt - typischerweise + /// eine abgelaufene oder widerrufene Lizenz. Bewusst von einem + /// gewoehnlichen Fehler unterschieden: Sonst sieht ein abgelaufener + /// Vertrag aus wie eine Netzwerkstoerung. + /// + public bool Unauthorized { get; set; } } public class IntegrityCheckResult @@ -55,6 +64,7 @@ namespace Deploymentcenter.Client string currentVersion, string channel = "prod", string? platform = null, + ReleaseCredentials? credentials = null, CancellationToken cancellationToken = default) { var result = new UpdateCheckResult(); @@ -80,13 +90,26 @@ namespace Deploymentcenter.Client try { - response = await _httpClient.GetAsync(staticUrl, cancellationToken).ConfigureAwait(false); + response = await SendWithCredentialsAsync(staticUrl, credentials, cancellationToken) + .ConfigureAwait(false); } catch { response = null; } + // Ein 401 auf die statische Ablage ist etwas anderes als ein + // fehlendes Release: Die Lizenz traegt nicht mehr. Das ueber + // den API-Rueckfall zu verschlucken hiesse, den Anwender im + // Unklaren zu lassen, warum keine Updates mehr kommen. + if (response != null && response.StatusCode == HttpStatusCode.Unauthorized) + { + result.Unauthorized = true; + result.Message = ReleaseCredentials.DescribeUnauthorized(credentials); + response.Dispose(); + return result; + } + if (response == null || !response.IsSuccessStatusCode) { response?.Dispose(); @@ -179,6 +202,21 @@ namespace Deploymentcenter.Client return result; } + /// + /// Fuehrt eine GET-Anfrage aus und legt bei Bedarf die Zugangsdaten + /// der Release-Ablage an. + /// + private async Task SendWithCredentialsAsync( + string url, + ReleaseCredentials? credentials, + CancellationToken cancellationToken) + { + using var request = new HttpRequestMessage(HttpMethod.Get, url); + ReleaseCredentials.Apply(request, credentials); + + return await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false); + } + /// /// Liest einen Wahrheitswert, der als Boolean, Zahl oder Zeichenkette /// ankommen kann. MySQL liefert TINYINT(1), PHP gibt es je nach @@ -300,7 +338,8 @@ namespace Deploymentcenter.Client string? restartPath = null, string? currentVersion = null, string? platform = null, - bool waitForCurrentProcess = true) + bool waitForCurrentProcess = true, + string? licenseKey = null) { if (!File.Exists(agentPath)) { @@ -322,6 +361,13 @@ namespace Deploymentcenter.Client AppendArg(args, "--current-version", currentVersion!); } + // Ohne den Schluessel kommt der Agent nicht mehr an die Pakete - + // die Release-Ablage verlangt seit Version 2.4 Zugangsdaten. + if (!string.IsNullOrWhiteSpace(licenseKey)) + { + AppendArg(args, "--license-key", licenseKey!); + } + if (waitForCurrentProcess) { AppendArg(args, "--wait-for-pid", diff --git a/client-dotnet/Deploymentcenter.UpdateAgent/Installation.cs b/client-dotnet/Deploymentcenter.UpdateAgent/Installation.cs index 39c47ad..e564d3b 100644 --- a/client-dotnet/Deploymentcenter.UpdateAgent/Installation.cs +++ b/client-dotnet/Deploymentcenter.UpdateAgent/Installation.cs @@ -58,6 +58,13 @@ namespace Deploymentcenter.UpdateAgent try { session = await client.LoginAsync(username, password, Environment.MachineName); + + // Dieselben Zugangsdaten oeffnen die Release-Ablage. Das + // Setup-Token taugt dafuer nicht: /releases/ liegt hinter + // HTTP-Basic-Auth und wird von Apache geprueft, nicht von + // der Anwendung. Bei einer Erstinstallation gibt es zudem + // noch keinen Lizenzschluessel. + options.Credentials = ReleaseCredentials.FromUser(username, password); break; } catch (SetupException ex) @@ -230,6 +237,7 @@ namespace Deploymentcenter.UpdateAgent try { await client.LoginAsync(username, password, Environment.MachineName); + options.Credentials = ReleaseCredentials.FromUser(username, password); } catch (SetupException ex) { diff --git a/client-dotnet/Deploymentcenter.UpdateAgent/Program.cs b/client-dotnet/Deploymentcenter.UpdateAgent/Program.cs index 0c86fab..b72fe10 100644 --- a/client-dotnet/Deploymentcenter.UpdateAgent/Program.cs +++ b/client-dotnet/Deploymentcenter.UpdateAgent/Program.cs @@ -67,7 +67,16 @@ namespace Deploymentcenter.UpdateAgent PublicKeyPath = GetArg(args, "--pubkey"), AgentDirectory = agentDir, WaitForPid = ParsePid(GetArg(args, "--wait-for-pid")), - WaitTimeoutSeconds = ParseInt(GetArg(args, "--wait-timeout"), 60) + WaitTimeoutSeconds = ParseInt(GetArg(args, "--wait-timeout"), 60), + + // Die Release-Ablage verlangt Zugangsdaten. Im laufenden + // Betrieb ist das der Lizenzschluessel der Anwendung; bei + // einer Erstinstallation gibt es den noch nicht, dort treten + // die Zugangsdaten des Installationskontos an seine Stelle. + Credentials = ReleaseCredentials.FromLicenseKey(GetArg(args, "--license-key")) + ?? ReleaseCredentials.FromUser( + GetArg(args, "--download-user"), + GetArg(args, "--download-password")) }; PackageManifest? installedManifest = ReadInstalledManifest(targetDir); @@ -249,7 +258,14 @@ namespace Deploymentcenter.UpdateAgent { var updateClient = new UpdateClient(HttpClient); var res = await updateClient.CheckForUpdateAsync( - options.BaseUrl, options.Project, currentVer, options.Channel, options.Platform); + options.BaseUrl, options.Project, currentVer, options.Channel, + options.Platform, options.Credentials); + + if (res.Unauthorized) + { + Console.WriteLine("UNAUTHORIZED: " + res.Message); + return 2; + } if (res.UpdateAvailable && res.LatestRelease != null) { @@ -350,7 +366,18 @@ namespace Deploymentcenter.UpdateAgent .StartAsync(async ctx => { var downloadTask = ctx.AddTask($"[green]Lade Paket v{targetRelease.Version} ...[/]"); - using var resp = await HttpClient.GetAsync(pkgUrl, HttpCompletionOption.ResponseHeadersRead); + + using var pkgRequest = new HttpRequestMessage(HttpMethod.Get, pkgUrl); + ReleaseCredentials.Apply(pkgRequest, options.Credentials); + + using var resp = await HttpClient.SendAsync(pkgRequest, HttpCompletionOption.ResponseHeadersRead); + + if (resp.StatusCode == System.Net.HttpStatusCode.Unauthorized) + { + throw new UnauthorizedAccessException( + ReleaseCredentials.DescribeUnauthorized(options.Credentials)); + } + resp.EnsureSuccessStatusCode(); long? totalBytes = resp.Content.Headers.ContentLength; @@ -562,7 +589,22 @@ namespace Deploymentcenter.UpdateAgent { try { - var resp = await HttpClient.GetAsync(url); + using var request = new HttpRequestMessage(HttpMethod.Get, url); + ReleaseCredentials.Apply(request, options.Credentials); + + var resp = await HttpClient.SendAsync(request); + + // Ein 401 ist keine "Datei fehlt"-Lage: die Lizenz traegt + // nicht mehr. Weiterzuprobieren wuerde die Ursache nur + // hinter einer allgemeinen Fehlermeldung verstecken. + if (resp.StatusCode == System.Net.HttpStatusCode.Unauthorized) + { + AnsiConsole.MarkupLine("[bold red]" + + Markup.Escape(ReleaseCredentials.DescribeUnauthorized(options.Credentials)) + + "[/]"); + return null; + } + if (!resp.IsSuccessStatusCode) continue; @@ -1071,6 +1113,9 @@ namespace Deploymentcenter.UpdateAgent Console.WriteLine(" --wait-for-pid Vor dem Anwenden auf das Ende dieses Prozesses warten"); Console.WriteLine(" --wait-timeout Geduld dabei (Vorgabe: 60)"); Console.WriteLine(" --restart Nach dem Update zu startende Anwendung"); + Console.WriteLine(" --license-key Zugang zur Release-Ablage (Regelfall im Betrieb)"); + Console.WriteLine(" --download-user Alternativ: Installationskonto"); + Console.WriteLine(" --download-password dazugehoeriges Passwort"); Console.WriteLine(" --pubkey Oeffentlicher Schluessel zur Signaturpruefung"); Console.WriteLine(" --require-signature Ohne gueltige Signatur nicht installieren"); Console.WriteLine(); @@ -1092,5 +1137,11 @@ namespace Deploymentcenter.UpdateAgent public string AgentDirectory { get; set; } = string.Empty; public int WaitForPid { get; set; } public int WaitTimeoutSeconds { get; set; } = 60; + + /// + /// Zugangsdaten fuer /releases/. Null bedeutet: ohne Anmeldung + /// versuchen - eine noch ungeschuetzte Ablage bleibt so erreichbar. + /// + public ReleaseCredentials? Credentials { get; set; } } } diff --git a/docs/SETUP_INTEGRATION_GUIDE.md b/docs/SETUP_INTEGRATION_GUIDE.md index f3bce2f..5d9fbaf 100644 --- a/docs/SETUP_INTEGRATION_GUIDE.md +++ b/docs/SETUP_INTEGRATION_GUIDE.md @@ -47,6 +47,16 @@ irm https://dc.mhdf.de/installer/install.ps1 | iex | `/installer/update-agent-linux-arm64` | Linux ARM64 | | `/installer/installer.json` | Version, Größen und Prüfsummen aller Binaries | +**Der Installer-Download selbst bleibt offen** — die Binaries enthalten keine +Zugangsdaten, und eine Anmeldung an dieser Stelle würde nur den Bootstrap +verkomplizieren (Zugangsdaten in `wget --user=…` landen in der Shell-History). + +Beim Ausführen fragt der Installer als Erstes nach Benutzername und Passwort. +**Dieselben Zugangsdaten öffnen auch die Release-Ablage**, die seit Version 2.4 +hinter HTTP-Basic-Auth liegt — bei einer Erstinstallation gibt es noch keinen +Lizenzschlüssel, mit dem sich das Paket holen ließe. Siehe +**[UPDATESERVICE_INTEGRATION_GUIDE §5A](./UPDATESERVICE_INTEGRATION_GUIDE.md#5a-zugangsschutz-der-release-verzeichnisse)**. + Zu jedem Binary liegt eine `.sha256` daneben. **Die Prüfsumme vergleichen** — „Programm herunterladen und Zugangsdaten eingeben" ist genau das Muster, das Phishing nachahmt; die Prüfsumme ist der Grund, warum man es hier trotzdem tun @@ -65,7 +75,7 @@ NativeAOT und ohne Trimming — Spectre.Console löst seine Eingabeaufforderunge ### Neu bauen und hochladen ```bash -pwsh scripts/build_installer.ps1 +powershell -File scripts/build_installer.ps1 python scripts/upload_installer.py ./artifacts/installer ``` diff --git a/docs/UPDATESERVICE_INTEGRATION_GUIDE.md b/docs/UPDATESERVICE_INTEGRATION_GUIDE.md index ecf394b..aaf1d36 100644 --- a/docs/UPDATESERVICE_INTEGRATION_GUIDE.md +++ b/docs/UPDATESERVICE_INTEGRATION_GUIDE.md @@ -573,6 +573,126 @@ darauf hin; wer weiter zurück muss, holt das Paket von Hand. --- +## 5A. Zugangsschutz der Release-Verzeichnisse + +> **Neu in Version 2.4.** Zuvor war `/releases/` offen — damit jede +> ausgelieferte Anwendung ohne Zugangsdaten nach Updates suchen konnte. Das +> bedeutete aber auch: **jeder im Internet konnte die vollständigen Pakete +> herunterladen**, mitsamt allem, was versehentlich darin lag. + +Der Zugang hängt jetzt am Lizenzschlüssel. Wer eine gültige Lizenz für ein +Produkt hat, kommt an dessen Updates; wer keine hat, braucht auch keine. Die +Anwendung kennt ihren Schlüssel ohnehin und versorgt sich damit selbst — es +muss nichts zusätzlich verteilt werden. + +### Wie es aufgebaut ist + +Je Produktverzeichnis eine eigene `.htpasswd`: + +```text +/releases/predictalytics/.htaccess ← AuthType Basic, Require valid-user +/releases/predictalytics/.htpasswd ← nur Schlüssel für predictalytics +/releases/warenwirtschaft/.htpasswd ← nur Schlüssel für warenwirtschaft +``` + +**Bewusst getrennt.** Eine gemeinsame Datei würde bedeuten, dass eine Lizenz für +Produkt A auch Produkt B öffnet. Da `license_licenses.product_id` jeden +Schlüssel ohnehin an genau ein Projekt bindet, fällt die Trennung beim Erzeugen +von selbst ab. + +Darin stehen: + +| Eintrag | Benutzername | Passwort | +|---|---|---| +| Gültige Lizenz | der Lizenzschlüssel | derselbe 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. + +Die Installationskonten stehen in **jeder** Datei — bei einer Erstinstallation +gibt es noch keinen Lizenzschlüssel, mit dem sich das Paket holen ließe. Ihr +Hash wird unverändert aus `dc_users` übernommen: PHPs `password_hash()` erzeugt +bcrypt im Format `$2y$`, und genau das versteht Apache. Ein Klartextpasswort +wird nirgends gebraucht. + +### Was sich für Clients ändert + +**Ohne Nachziehen bekommt keine bestehende Installation mehr Updates.** + +```csharp +var checkResult = await updateClient.CheckForUpdateAsync( + baseUrl: "https://dc.mhdf.de", + projectId: "myapp", + currentVersion: BuildInfo.Version, + channel: "prod", + credentials: ReleaseCredentials.FromLicenseKey(meineLizenz)); + +if (checkResult.Unauthorized) +{ + // Kein Netzwerkfehler: die Lizenz trägt nicht mehr. + Console.WriteLine(checkResult.Message); +} + +UpdateClient.LaunchUpdateAgent( + agentPath: UpdateClient.ResolveAgentPath()!, + projectId: "myapp", + licenseKey: meineLizenz); // reicht --license-key an den Agenten durch +``` + +Auf der Kommandozeile: + +```bash +update-agent --project myapp --action update --license-key XXXXX-XXXXX-XXXXX-XXXXX-XXXXX +``` + +Ersatzweise `--download-user` und `--download-password` für ein +Installationskonto — das nutzt der Installer bei der Erstinstallation. + +`UpdateCheckResult.Unauthorized` ist bewusst von einem gewöhnlichen Fehler +getrennt. Ohne diese Unterscheidung sieht ein abgelaufener Vertrag aus wie eine +Netzwerkstörung, und man sucht an der falschen Stelle. + +### Wann neu erzeugt wird + +Automatisch beim Anlegen, Bearbeiten, Widerrufen und Sperren einer Lizenz, beim +Löschen eines Projekts und bei jeder Änderung an einem Installationskonto. + +**Abgelaufene Lizenzen lösen von sich aus nichts aus** — der Zeitpunkt vergeht +einfach. Dafür gleicht `cli/tick.php` nach: Es prüft, ob seit dem letzten Lauf +etwas abgelaufen ist, und erzeugt spätestens alle sechs Stunden ohnehin neu. +Ein minütlicher Aufruf ist unbedenklich, im Normalfall passiert nichts. + +Von Hand: WebUI → **UpdateService → 🔒 Zugangsschutz**. Dort steht auch, welche +Produkte geschützt sind und wie viele Zugänge jeweils eingetragen sind. + +| Anzeige | Bedeutung | +|---|---| +| GESCHÜTZT | alles in Ordnung | +| OFFEN | keine `.htaccess` — jeder im Internet kann laden | +| GESPERRT | Datei vorhanden, aber leer: weder gültige Lizenzen noch Installationskonten | + +### Grenzen + +**Das macht Pakete nicht sicher.** Jeder lizenzierte Kunde kann sie weiterhin +auspacken. Ein eigener API-Schlüssel in einer mitgelieferten `appsettings.json` +wäre danach statt öffentlich eben für alle Kunden sichtbar — besser, aber immer +noch falsch. Was nicht ins Paket gehört, gehört auch mit Zugangsschutz nicht +hinein; siehe **[§3A](#3a-ausschließen-oder-schützen)**. + +Die Lese-Endpunkte der API bleiben offen. Sie verraten weiterhin, welche +Produkte und Versionen es gibt — die Download-Adressen sind ohne Zugangsdaten +aber wertlos. + +### Abschalten + +Die erzeugten `.htaccess`-Dateien in den Produktverzeichnissen löschen. Beim +nächsten Auslöser entstehen sie neu; wer es dauerhaft will, muss den Aufruf von +`ReleaseGuard` entfernen. + +--- + ## 6. Signatur der Releases Der SHA256 eines Pakets stammt aus derselben Quelle wie das Paket selbst. Wer diff --git a/docs/UPGRADE.md b/docs/UPGRADE.md index 22a17a0..6a9fe5b 100644 --- a/docs/UPGRADE.md +++ b/docs/UPGRADE.md @@ -12,6 +12,11 @@ Schnittstellen ändern. Bitte in dieser Reihenfolge vorgehen. > signierte Releases, Staging-Swap mit Rollback und geschützte Konfigurationsdateien. > **Vor dem nächsten Release einzuspielen** — > **[§15 Umstellung auf 2.2](#15-umstellung-auf-22)**. +> +> **Version 2.4** stellt die Release-Ablage hinter einen Zugangsschutz. +> **Reihenfolge beachten:** erst das SDK ausliefern, dann scharfschalten — +> sonst bekommen bestehende Installationen keine Updates mehr. +> **[§16 Umstellung auf 2.4](#16-umstellung-auf-24)**. --- @@ -496,3 +501,69 @@ entscheiden: - [ ] Ein Update auf einem Testsystem: `appsettings.json` behält danach die eingerichteten Werte, und eine Datei, die es im neuen Release nicht mehr gibt, ist verschwunden. + +--- + +## 16. Umstellung auf 2.4 + +Die Release-Ablage liegt jetzt hinter HTTP-Basic-Auth. Zugang hat, wer einen +gültigen Lizenzschlüssel für das Produkt besitzt — oder ein Installationskonto. + +Ausführlich: **[UPDATESERVICE_INTEGRATION_GUIDE §5A](./UPDATESERVICE_INTEGRATION_GUIDE.md#5a-zugangsschutz-der-release-verzeichnisse)** + +### 16.1 Reihenfolge — das ist der kritische Teil + +> **Erst ausliefern, dann scharfschalten.** Der Zugangsschutz sperrt jede +> Anwendung aus, die noch mit dem alten SDK gebaut ist: Sie schickt keine +> Zugangsdaten und bekommt ab dem Moment nur noch 401. Andersherum sperrst du +> deine eigene Installationsbasis aus. + +1. [ ] SDK auf 2.4 heben und `licenseKey` an `CheckForUpdateAsync` und + `LaunchUpdateAgent` übergeben. +2. [ ] Ein Release mit dem neuen SDK bauen und veröffentlichen. +3. [ ] Warten, bis die Installationen dieses Release gezogen haben. +4. [ ] **Erst dann** den Schutz erzeugen — WebUI → *UpdateService → + 🔒 Zugangsschutz → Zugangsschutz jetzt neu erzeugen*. + +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. + +### 16.2 Was wo eingetragen wird + +Je Produktverzeichnis eine `.htpasswd` mit den aktiven, nicht abgelaufenen +Lizenzen dieses Produkts (Benutzername = Passwort = Schlüssel) und allen +Installationskonten. Bestehende Lizenzen werden dabei automatisch übernommen — +es ist nichts von Hand nachzutragen. + +### 16.3 Neue Aufrufe + +```bash +update-agent --project myapp --action update \ + --license-key XXXXX-XXXXX-XXXXX-XXXXX-XXXXX +``` + +```bash +# Erstinstallation: der Installer fragt die Zugangsdaten ab und benutzt sie +# auch für den Paketabruf. Der Installer-Download selbst bleibt offen. +wget -qO- https://dc.mhdf.de/installer/install.sh | sh +./update-agent --action install +``` + +### 16.4 Prüfen + +- [ ] `curl -I https://dc.mhdf.de/releases//prod///package.tar.gz` + → **401** +- [ ] Mit `-u ":"` → **200** +- [ ] `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** + +### 16.5 Wenn etwas klemmt + +Ein 401 im Client heißt **Lizenz**, nicht Netzwerk. `UpdateCheckResult.Unauthorized` +unterscheidet beides; der Agent gibt `UNAUTHORIZED: …` aus und liefert +Rückgabewert 2. + +Zum Abschalten die erzeugten `.htaccess`-Dateien in den Produktverzeichnissen +löschen. Sie entstehen beim nächsten Auslöser neu. diff --git a/public/index.php b/public/index.php index 17a3abd..a31b74d 100644 --- a/public/index.php +++ b/public/index.php @@ -32,6 +32,7 @@ use Deploymentcenter\Modules\Bugtracker\BugRepo; use Deploymentcenter\Modules\Bugtracker\IgnoreRules; use Deploymentcenter\Modules\License\Audit; use Deploymentcenter\Modules\License\KeyGen; +use Deploymentcenter\Modules\UpdateService\ReleaseGuard; use Deploymentcenter\Modules\UpdateService\ReleaseSigner; use Deploymentcenter\Modules\UpdateService\UpdateManager; use Deploymentcenter\Modules\Watchdog\Evaluator; @@ -233,6 +234,34 @@ function dc_flash(string $message, string $type = 'success'): void * Installationskonten zaehlen bewusst nicht mit, sie duerfen sich hier gar * nicht anmelden. */ +/** + * Erzeugt den Zugangsschutz der Release-Verzeichnisse neu. + * + * Wird nach jeder Aenderung aufgerufen, die beeinflusst, wer herunterladen + * darf: Lizenzen und Installationskonten. Bewusst immer fuer alle Produkte - + * bei einer Handvoll Projekten kostet das kaum etwas und erspart die + * Fehlerquelle, den richtigen Slug zu ermitteln. + * + * Ein Fehlschlag darf die ausloesende Aktion nicht scheitern lassen: Eine + * angelegte Lizenz ist angelegt, auch wenn die Datei gerade nicht schreibbar + * war. Der naechtliche Abgleich holt es nach, und die Oberflaeche zeigt den + * Zustand unter UpdateService an. + */ +function dc_guard_releases(PDO $pdo): void +{ + try { + $result = ReleaseGuard::regenerateAll($pdo); + + if ($result['errors'] !== []) { + Logger::warning('Release-Zugangsschutz teilweise fehlgeschlagen', [ + 'errors' => $result['errors'], + ]); + } + } catch (Throwable $e) { + Logger::warning('Release-Zugangsschutz nicht erzeugt', ['error' => $e->getMessage()]); + } +} + function dc_active_admin_count(PDO $pdo): int { try { @@ -477,6 +506,9 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { $db->prepare('DELETE FROM dc_projects WHERE id = :id')->execute([':id' => $id]); }); + // Mit dem Projekt sind auch seine Lizenzen weg. + dc_guard_releases($pdo); + dc_flash(sprintf('Projekt "%s" und alle zugehoerigen Daten wurden geloescht.', $project['name'])); dc_redirect('#tab-projects'); } @@ -507,6 +539,10 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { ]); Audit::log($pdo, $actor, 'license.create', ['key' => $licenseKey]); + + // Der neue Schluessel muss sofort Updates ziehen koennen. + dc_guard_releases($pdo); + dc_flash('Lizenzschluessel erzeugt: ' . e($licenseKey) . ''); dc_redirect('#tab-license'); } @@ -538,6 +574,10 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { ]); Audit::log($pdo, $actor, 'license.update', ['license_id' => $id, 'status' => $status]); + + // Status oder Ablaufdatum koennen sich geaendert haben. + dc_guard_releases($pdo); + dc_flash($stmt->rowCount() > 0 ? 'Lizenzdaten aktualisiert.' : 'Keine Aenderung vorgenommen.'); dc_redirect('#tab-license'); } @@ -552,6 +592,12 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { $pdo->prepare('UPDATE license_licenses SET status = :status WHERE id = :id') ->execute([':status' => $newStatus, ':id' => $id]); Audit::log($pdo, $actor, 'license.status', ['license_id' => $id, 'status' => $newStatus]); + + // Eine widerrufene Lizenz muss den Zugang sofort verlieren - + // sonst zieht sie weiter Updates, bis der naechtliche + // Abgleich laeuft. + dc_guard_releases($pdo); + dc_flash('Lizenz-Status geaendert zu: ' . strtoupper($newStatus)); } dc_redirect('#tab-license'); @@ -1219,6 +1265,28 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { dc_redirect('#sub-system-rocketchat'); } + case 'regenerate_release_guard': { + $guard = ReleaseGuard::regenerateAll($pdo); + + if ($guard['errors'] !== []) { + dc_flash( + 'Zugangsschutz teilweise erneuert. Fehler: ' . e(implode(' | ', $guard['errors'])), + 'danger' + ); + } else { + dc_flash(sprintf( + 'Zugangsschutz erneuert: %d Produkt(e), %d Zugang/Zugaenge.%s', + $guard['projects'], + $guard['entries'], + $guard['skipped'] !== [] + ? ' Ohne Zugaenge und damit gesperrt: ' . e(implode(', ', $guard['skipped'])) . '.' + : '' + )); + } + + dc_redirect('#sub-update-guard'); + } + // ------------------------------------------------- Benutzerkonten case 'add_user': { $newUser = trim((string)($_POST['username'] ?? '')); @@ -1261,6 +1329,13 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { 'actor' => $actor, ]); + // Installationskonten stehen in jeder .htpasswd - ohne sie + // waere eine Erstinstallation nicht moeglich, weil es dabei + // noch keinen Lizenzschluessel gibt. + if ($newRole === 'installer') { + dc_guard_releases($pdo); + } + dc_flash(sprintf( 'Konto "%s" mit der Rolle %s angelegt.%s', e($newUser), @@ -1285,6 +1360,10 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { $upd->execute([':h' => password_hash($newPass, PASSWORD_DEFAULT), ':id' => $userId]); Logger::info('Passwort geaendert', ['user_id' => $userId, 'actor' => $actor]); + + // Der Hash steht auch in den .htpasswd-Dateien. + dc_guard_releases($pdo); + dc_flash($upd->rowCount() > 0 ? 'Passwort geaendert.' : 'Konto nicht gefunden.', $upd->rowCount() > 0 ? 'success' : 'danger'); dc_redirect('#sub-system-users'); @@ -1320,6 +1399,8 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { 'actor' => $actor, ]); + dc_guard_releases($pdo); + dc_flash(sprintf('Konto "%s" wurde %s.', e((string)$target['username']), $disable ? 'deaktiviert' : 'wieder aktiviert')); dc_redirect('#sub-system-users'); @@ -1355,6 +1436,8 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { 'actor' => $actor, ]); + dc_guard_releases($pdo); + dc_flash(sprintf('Konto "%s" wurde geloescht.', e((string)$target['username']))); dc_redirect('#sub-system-users'); } @@ -1618,6 +1701,9 @@ try { } $usersSchemaReady = $users === [] || array_key_exists('role', $users[0]); +// Zustand des Zugangsschutzes der Release-Verzeichnisse. +$releaseGuard = ReleaseGuard::status(); + // 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 @@ -3047,6 +3133,84 @@ Antwort: +
+
+

🔒 Zugangsschutz der Releases

+ +

+ Die Release-Verzeichnisse liegen hinter HTTP-Basic-Auth. Zugang hat, wer + einen gültigen Lizenzschlüssel für das jeweilige Produkt + besitzt — Benutzername und Passwort sind beide der Schlüssel. Die Anwendung + kennt ihn ohnehin und versorgt sich damit selbst. Zusätzlich stehen die + Installationskonten in jeder Datei: bei einer + Erstinstallation gibt es noch keinen Schlüssel. +

+ +
+ Je Produkt eine eigene Datei. Eine gemeinsame Liste würde + bedeuten, dass eine Lizenz für Produkt A auch Produkt B öffnet. Die Zuordnung + ergibt sich aus license_licenses.product_id. +
+ + + + + + + + + + + + + + + + + + + + + + + +
ProduktStatusZugängeZuletzt erzeugt
Es liegen noch keine Release-Verzeichnisse vor.
+ + OFFEN + + GESPERRT + + GESCHÜTZT + + + NICHT SCHREIBBAR + + + +
+ +

+ OFFEN heißt: jeder im Internet kann die Pakete laden. + GESPERRT heißt: niemand kommt heran, weil es weder gültige + Lizenzen noch Installationskonten gibt — bei internen Anwendungen ohne + Lizenzen ist das der Normalfall, solange kein Installationskonto existiert. +

+ +
+ + + + +
+ +

+ Geschieht automatisch bei jeder Lizenz- und Kontoänderung. Abgelaufene + Lizenzen lösen von sich aus nichts aus — dafür gleicht + cli/tick.php nach, spätestens alle sechs Stunden. +

+
+
+

⬇️ Installer herunterladen

@@ -4748,6 +4912,7 @@ SYSTEM 'updateservice': [ { id: 'sub-update-releases', label: '📊 Releases Overview', active: true }, { id: 'sub-update-installer', label: '⬇️ Installer' }, + { id: 'sub-update-guard', label: '🔒 Zugangsschutz' }, { id: 'sub-update-publish', label: '➕ Release Veröffentlichen' } ], 'bugtracker': [ diff --git a/scripts/deploy.py b/scripts/deploy.py index e9200e3..d3df8b8 100644 --- a/scripts/deploy.py +++ b/scripts/deploy.py @@ -25,6 +25,10 @@ IGNORE_PATTERNS = { 'log', '__pycache__', 'client-dotnet', + # Gebaute Installer-Binaries. Sie gehoeren nach /installer/ und werden + # von upload_installer.py dorthin gebracht - ueber diesen Weg landeten + # sonst ~100 MB zusaetzlich unter /artifacts/ im Webroot. + 'artifacts', } def load_config(): diff --git a/src/Modules/UpdateService/ReleaseGuard.php b/src/Modules/UpdateService/ReleaseGuard.php new file mode 100644 index 0000000..c0d5fb5 --- /dev/null +++ b/src/Modules/UpdateService/ReleaseGuard.php @@ -0,0 +1,438 @@ +,errors:list} + */ + public static function regenerateAll(PDO $db): array + { + $root = self::releasesRoot(); + + $result = ['projects' => 0, 'entries' => 0, 'skipped' => [], 'errors' => []]; + + if (!is_dir($root)) { + $result['errors'][] = sprintf('Release-Verzeichnis nicht gefunden: %s', $root); + return $result; + } + + // Massgeblich ist, was tatsaechlich auf der Platte liegt - ein Projekt + // ohne Release-Verzeichnis braucht keinen Schutz, und ein Verzeichnis + // ohne Projekt darf nicht offen bleiben. + $directories = array_filter( + scandir($root) ?: [], + static fn(string $entry): bool => $entry !== '.' && $entry !== '..' && is_dir($root . '/' . $entry) + ); + + foreach ($directories as $slug) { + $single = self::regenerateForProject($db, (string)$slug, $root); + + if ($single['error'] !== null) { + $result['errors'][] = $single['error']; + continue; + } + + $result['projects']++; + $result['entries'] += $single['entries']; + + if ($single['entries'] === 0) { + $result['skipped'][] = (string)$slug; + } + } + + Logger::info('Release-Zugangsschutz neu erzeugt', [ + 'projects' => $result['projects'], + 'entries' => $result['entries'], + 'errors' => count($result['errors']), + ]); + + return $result; + } + + /** + * Erzeugt .htaccess und .htpasswd fuer ein einzelnes Produkt. + * + * @return array{entries:int,error:?string} + */ + public static function regenerateForProject(PDO $db, string $slug, ?string $root = null): array + { + $root ??= self::releasesRoot(); + $dir = $root . '/' . $slug; + + if (!is_dir($dir)) { + return ['entries' => 0, 'error' => null]; + } + + if (!is_writable($dir)) { + return ['entries' => 0, 'error' => sprintf('Kein Schreibrecht in %s', $dir)]; + } + + $entries = self::collectEntries($db, $slug); + + $lines = []; + foreach ($entries as $username => $hash) { + $lines[] = $username . ':' . $hash; + } + + $htpasswd = implode("\n", $lines) . "\n"; + $htaccess = self::buildHtaccess($dir, $slug); + + // Erst schreiben, dann umbenennen: ein abgebrochener Schreibvorgang + // wuerde sonst eine halbe Datei hinterlassen, und Apache liesse in dem + // Moment die halbe Kundschaft nicht mehr herein. + if (!self::writeAtomic($dir . '/.htpasswd', $htpasswd)) { + return ['entries' => 0, 'error' => sprintf('.htpasswd in %s nicht schreibbar', $dir)]; + } + + if (!self::writeAtomic($dir . '/.htaccess', $htaccess)) { + return ['entries' => 0, 'error' => sprintf('.htaccess in %s nicht schreibbar', $dir)]; + } + + return ['entries' => count($entries), 'error' => null]; + } + + /** + * Stellt zusammen, wer dieses Produkt herunterladen darf. + * + * @return array Benutzername => Hash + */ + private static function collectEntries(PDO $db, string $slug): array + { + $entries = []; + + // 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. + try { + $stmt = $db->prepare(" + SELECT l.license_key + FROM license_licenses l + JOIN dc_projects p ON p.id = l.product_id + WHERE p.slug = :slug + AND l.status = 'active' + AND (l.expires_at IS NULL OR l.expires_at > UTC_TIMESTAMP()) + "); + $stmt->execute([':slug' => $slug]); + + foreach ($stmt->fetchAll() ?: [] as $row) { + $key = trim((string)$row['license_key']); + if ($key === '') { + continue; + } + + $entries[$key] = password_hash($key, PASSWORD_BCRYPT, ['cost' => self::LICENSE_HASH_COST]); + } + } catch (\Throwable $e) { + Logger::warning('Lizenzen fuer den Release-Schutz nicht lesbar', [ + 'slug' => $slug, + 'error' => $e->getMessage(), + ]); + } + + // 2. Installationskonten. + // + // Ohne sie waere eine Erstinstallation unmoeglich: Zu diesem Zeitpunkt + // ist die Anwendung noch nicht eingerichtet und hat keinen + // Lizenzschluessel, mit dem sie das Paket holen koennte. + // + // Der in dc_users abgelegte Hash wird unveraendert uebernommen - PHPs + // password_hash() erzeugt bcrypt im Format $2y$, genau das versteht + // Apache. Ein Klartextpasswort wird dafuer nirgends gebraucht. + try { + $stmt = $db->query(" + SELECT username, password_hash + FROM dc_users + WHERE role = 'installer' AND disabled = 0 + "); + + foreach ($stmt->fetchAll() ?: [] as $row) { + $username = trim((string)$row['username']); + $hash = (string)$row['password_hash']; + + if ($username === '' || $hash === '') { + continue; + } + + $usable = false; + foreach (self::APACHE_BCRYPT_PREFIXES as $prefix) { + if (str_starts_with($hash, $prefix)) { + $usable = true; + break; + } + } + + if (!$usable) { + // Etwa wenn PHPs Standardverfahren einmal auf Argon2 + // wechselt - das versteht Apache nicht. + Logger::warning('Installationskonto fuer den Release-Schutz uebersprungen', [ + 'username' => $username, + 'reason' => 'Hash-Verfahren wird von Apache nicht unterstuetzt', + ]); + continue; + } + + $entries[$username] = $hash; + } + } catch (\Throwable $e) { + Logger::warning('Installationskonten nicht lesbar', ['error' => $e->getMessage()]); + } + + return $entries; + } + + private static function buildHtaccess(string $dir, string $slug): string + { + $realm = 'Deploymentcenter Releases - ' . $slug; + + return << + + Require all denied + + + Order allow,deny + Deny from all + + + + HTACCESS; + } + + /** + * Schreibt ueber eine temporaere Datei und benennt sie um. Unter Linux ist + * rename() innerhalb desselben Dateisystems atomar - es gibt also keinen + * Moment, in dem Apache eine halb geschriebene Zugangsdatei liest. + */ + private static function writeAtomic(string $path, string $content): bool + { + $temp = $path . '.tmp-' . bin2hex(random_bytes(4)); + + if (@file_put_contents($temp, $content) === false) { + return false; + } + + @chmod($temp, 0644); + + if (!@rename($temp, $path)) { + @unlink($temp); + return false; + } + + return true; + } + + /** Schluessel in dc_settings, unter dem der letzte Lauf vermerkt wird. */ + private const LAST_RUN_KEY = 'release_guard_last_run'; + + /** + * Fuer den Cron gedacht: erzeugt nur dann neu, wenn es noetig ist. + * + * Der Tick laeuft minuetlich; jedes Mal alle Lizenzschluessel neu zu + * hashen waere Verschwendung. Zwei Anlaesse gibt es aber: + * + * - Eine Lizenz ist seit dem letzten Lauf abgelaufen. Anders als ein + * Widerruf loest das von sich aus nichts aus - der Zeitpunkt vergeht + * einfach, und ohne diese Pruefung behielte die Lizenz ihren Zugang. + * - Der letzte Lauf ist laenger her als $maxAge. Sicherheitsnetz gegen + * Aenderungen, die ausserhalb der Oberflaeche passiert sind. + * + * @return array{ran:bool,reason:string,projects:int,entries:int,errors:list} + */ + public static function reconcile(PDO $db, int $maxAge = 21600): array + { + $lastRun = self::readLastRun($db); + $reason = ''; + + if ($lastRun === null) { + $reason = 'noch nie gelaufen'; + } elseif (self::hasExpiredSince($db, $lastRun)) { + $reason = 'Lizenz abgelaufen'; + } elseif ((time() - strtotime($lastRun . ' UTC')) >= $maxAge) { + $reason = 'turnusmaessig'; + } + + if ($reason === '') { + return ['ran' => false, 'reason' => 'nichts zu tun', 'projects' => 0, 'entries' => 0, 'errors' => []]; + } + + $result = self::regenerateAll($db); + self::writeLastRun($db); + + return [ + 'ran' => true, + 'reason' => $reason, + 'projects' => $result['projects'], + 'entries' => $result['entries'], + 'errors' => $result['errors'], + ]; + } + + private static function hasExpiredSince(PDO $db, string $since): bool + { + try { + $stmt = $db->prepare(" + SELECT COUNT(*) FROM license_licenses + WHERE expires_at IS NOT NULL + AND expires_at <= UTC_TIMESTAMP() + AND expires_at > :since + "); + $stmt->execute([':since' => $since]); + return (int)$stmt->fetchColumn() > 0; + } catch (\Throwable $e) { + // Im Zweifel neu erzeugen - lieber einmal zu viel als eine + // abgelaufene Lizenz, die weiter herunterlaedt. + return true; + } + } + + private static function readLastRun(PDO $db): ?string + { + try { + $stmt = $db->prepare('SELECT svalue FROM dc_settings WHERE skey = :k LIMIT 1'); + $stmt->execute([':k' => self::LAST_RUN_KEY]); + $value = $stmt->fetchColumn(); + return is_string($value) && $value !== '' ? $value : null; + } catch (\Throwable $e) { + return null; + } + } + + private static function writeLastRun(PDO $db): 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')]); + } catch (\Throwable $e) { + Logger::warning('Zeitpunkt des Release-Schutzes nicht vermerkt', ['error' => $e->getMessage()]); + } + } + + /** + * Kurzbericht fuer die Oberflaeche: Welche Produkte sind geschuetzt, wie + * viele Zugaenge stehen darin, wann wurde zuletzt erzeugt? + * + * @return list> + */ + public static function status(): array + { + $root = self::releasesRoot(); + + if (!is_dir($root)) { + return []; + } + + $report = []; + + foreach (scandir($root) ?: [] as $entry) { + if ($entry === '.' || $entry === '..' || !is_dir($root . '/' . $entry)) { + continue; + } + + $dir = $root . '/' . $entry; + $htpasswd = $dir . '/.htpasswd'; + $htaccess = $dir . '/.htaccess'; + $protected = is_file($htpasswd) && is_file($htaccess); + + $count = 0; + if ($protected) { + $lines = file($htpasswd, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: []; + $count = count($lines); + } + + $report[] = [ + 'slug' => $entry, + 'protected' => $protected, + 'entries' => $count, + 'updated_at' => $protected ? gmdate('Y-m-d H:i:s', (int)filemtime($htpasswd)) : null, + 'writable' => is_writable($dir), + ]; + } + + usort($report, static fn(array $a, array $b): int => strcmp((string)$a['slug'], (string)$b['slug'])); + + return $report; + } +}