fix(releases): Lizenzschluessel nicht mehr im Klartext, Selbsttest, Zielorte

Vier Befunde aus einer externen Durchsicht der 2.4-Integration.

1. Die .htpasswd war eine Klartext-Kundenliste
   Das htpasswd-Format hasht nur die Passwortspalte. Benutzername UND Passwort
   waren der Lizenzschluessel - der Schluessel stand also im Klartext direkt
   neben seinem eigenen bcrypt-Hash, und der Hash war Dekoration. Geschuetzt
   hat das Ganze nur die FilesMatch-Regel in derselben Datei.
   Der Benutzername wird jetzt abgeleitet: lic_<sha256(schluessel), 16 Hex>.
   Die Datei enthaelt damit nur noch eine Einwegableitung und einen Hash ueber
   einen hochentropen Schluessel.
   Server und SDK muessen dabei zeichengenau uebereinstimmen; ein Test prueft
   die C#-Ableitung gegen die PHP-Formel.

2. Ein Formatwechsel blieb unbemerkt liegen
   Beim Umbau auf 1. faellt auf: reconcile() sah keinen Anlass zur
   Neuerzeugung, die Dateien behielten das alte Format, waehrend die Clients
   bereits das neue schickten. Die erzeugten Dateien tragen deshalb jetzt eine
   Formatkennung; weicht sie ab, wird neu erzeugt.

3. Doku beschrieb Nginx, der Schutz ist Apache-only
   .htaccess wird von Nginx ignoriert - dort waeren die Verzeichnisse offen und
   die .htpasswd oeffentlich abrufbar. Die Statusanzeige pruefte nur, ob die
   Dateien existieren, und haette in dem Fall "GESCHUETZT" gemeldet.
   Neu: ein echter Selbsttest ruft die eigene Paket-Adresse OHNE Zugangsdaten
   ab und erwartet 401. Er laeuft beim manuellen Erzeugen und nach jeder
   automatischen Neuerzeugung; das Ergebnis steht in der Oberflaeche, ein
   Fehlschlag im Log. Er findet nebenbei auch abgeschaltetes AllowOverride und
   Tippfehler in der erzeugten Datei. Doku korrigiert, Nginx-Vorlage ergaenzt.

4. Erstinstallation schrieb an einen Ort, an dem Linux-Anwendungen nicht lesen
   setup.json-Ziele waren immer installationsrelativ. Eine Anwendung, die sich
   unter Linux richtig verhaelt, liest aus $XDG_CONFIG_HOME - /opt/<app> ist
   fuer den Dienstbenutzer meist nicht schreibbar. Der Installer legte die
   Datei also dorthin, wo nie jemand nachsieht.
   Ziele haben jetzt ein "location": install (Vorgabe), config, data, home,
   plus ${VAR}- und %VAR%-Ersetzung in "file". Unbekannte Variablen bleiben
   stehen statt leer zu werden - ein Platzhalter faellt auf, ein falscher Pfad
   nicht. Der Installer gibt den aufgeloesten Pfad aus, weil bei config das
   Konto entscheidet, unter dem er laeuft.

Ausserdem
- Doku zeigte "status": "ok" fuer update/delete; Http::ok() erzeugt
  "status": "success".
- UPGRADE §16.1 deckte Neuprodukte nicht ab: Fuer ein Produkt ohne Release
  existiert /releases/<slug>/ nicht und wird uebersprungen. Das Verzeichnis
  entsteht erst mit dem ersten Upload, der naechste Tick schuetzt es. Der erste
  ausgelieferte Build muss die Zugangsdaten also schon mitbringen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Deploymentcenter Bot
2026-08-13 11:47:26 +02:00
co-authored by Claude Opus 5
parent ceb977187e
commit 7a3a5dad69
11 changed files with 684 additions and 31 deletions
+23
View File
@@ -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);
@@ -43,10 +43,30 @@ namespace Deploymentcenter.Client.Models
/// <summary>Eine Datei, in die Werte geschrieben werden.</summary>
public class SetupTarget
{
/// <summary>Pfad relativ zum Installationsverzeichnis.</summary>
/// <summary>
/// Pfad, bezogen auf <see cref="Location"/>. Umgebungsvariablen sind
/// in beiden Schreibweisen erlaubt: <c>${XDG_CONFIG_HOME}</c> und
/// <c>%APPDATA%</c>. Ein absoluter Pfad wird unveraendert benutzt.
/// </summary>
[JsonPropertyName("file")]
public string File { get; set; } = string.Empty;
/// <summary>
/// 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/&lt;app&gt; ist fuer den
/// Dienstbenutzer meist nicht schreibbar. Ohne diese Angabe schriebe
/// der Installer dorthin, wo die Anwendung nie nachsieht.
/// </summary>
[JsonPropertyName("location")]
public string Location { get; set; } = "install";
/// <summary>"json" oder "env".</summary>
[JsonPropertyName("format")]
public string Format { get; set; } = "json";
@@ -31,14 +31,41 @@ namespace Deploymentcenter.Client
/// <summary>
/// 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.
/// </summary>
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);
}
/// <summary>
/// Ableitung des Benutzernamens: "lic_" plus die ersten 16 Hexzeichen
/// des SHA-256 ueber den Schluessel.
/// </summary>
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();
}
/// <summary>Zugang ueber ein Installationskonto.</summary>
@@ -0,0 +1,199 @@
using System;
using System.IO;
using System.Text;
using Deploymentcenter.Client.Models;
namespace Deploymentcenter.Client
{
/// <summary>
/// Loest die Zielpfade einer <c>setup.json</c> 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/&lt;app&gt; fuer den
/// Dienstbenutzer typischerweise nicht schreibbar ist. Der Installer legte
/// die Datei also dorthin, wo die Anwendung nie nachsieht.
/// </summary>
public static class SetupPaths
{
/// <summary>
/// Ermittelt den vollstaendigen Pfad eines Ziels.
/// </summary>
/// <param name="target">Ziel aus der setup.json.</param>
/// <param name="installDir">Installationsverzeichnis der Anwendung.</param>
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)));
}
/// <summary>
/// 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
/// </summary>
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;
}
/// <summary>
/// Ersetzt Umgebungsvariablen in beiden gebraeuchlichen Schreibweisen:
/// <c>${NAME}</c> und <c>%NAME%</c>. Unbekannte Namen bleiben stehen -
/// eine leere Ersetzung wuerde stillschweigend einen falschen Pfad
/// ergeben, ein stehengebliebener Platzhalter faellt dagegen auf.
/// </summary>
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();
}
/// <summary>
/// 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.
/// </summary>
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;
}
}
}
}
@@ -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);
+37
View File
@@ -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/<app>` 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.
+74 -9
View File
@@ -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/<produkt>/prod/<rid>/<version>/package.tar.gz # 401
curl -I https://dc.mhdf.de/releases/<produkt>/.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."
+22 -1
View File
@@ -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/<slug>/` 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/<produkt>/prod/<rid>/<version>/package.tar.gz`
**401**
- [ ] Mit `-u "<lizenzschlüssel>:<lizenzschlüssel>"`**200**
- [ ] Mit `-u "$(printf 'lic_%s' "$(printf %s "<schlüssel>" | sha256sum | cut -c1-16)"):<schlüssel>"`**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/<produkt>/.htpasswd`**403**
- [ ] `https://dc.mhdf.de/installer/update-agent-linux-x64`**200**, weiterhin offen
- [ ] Eine Lizenz widerrufen und erneut mit ihr laden → **401**
+4 -3
View File
@@ -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.",
+52 -1
View File
@@ -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:
</tbody>
</table>
<?php if ($releaseGuardTest !== null): ?>
<?php $testFailed = !empty($releaseGuardTest['failed']); ?>
<div class="alert" style="margin-top:1rem; <?= $testFailed
? 'background:rgba(255,70,70,0.12); border:1px solid rgba(255,70,70,0.35); color:#fecaca;'
: 'background:rgba(60,200,120,0.12); border:1px solid rgba(60,200,120,0.3); color:#bbf7d0;' ?>">
<strong>Selbsttest vom <?= e((string)($releaseGuardTest['ran_at'] ?? '?')) ?> UTC:</strong>
<?= (int)($releaseGuardTest['ok'] ?? 0) ?> von
<?= (int)($releaseGuardTest['checked'] ?? 0) ?> Adresse(n) antworten ohne
Zugangsdaten korrekt mit 401.
<?php if ($testFailed): ?>
<ul style="margin:0.5rem 0 0 1rem;">
<?php foreach ($releaseGuardTest['failed'] as $f): ?>
<li><strong><?= e((string)($f['slug'] ?? '?')) ?>:</strong> <?= e((string)($f['reason'] ?? '')) ?></li>
<?php endforeach; ?>
</ul>
<?php endif; ?>
</div>
<?php endif; ?>
<p style="color:var(--text-muted); font-size:0.8rem; margin-top:1rem;">
<strong>Der Selbsttest ist die einzige belastbare Aussage.</strong> Dass die
Dateien vorhanden sind, beweist nichts: Unter Nginx werden
<code>.htaccess</code>-Dateien vollständig ignoriert die Verzeichnisse wären
dann offen und die <code>.htpasswd</code> öffentlich abrufbar, während hier
„GESCHÜTZT“ stünde. Der Test ruft die eigene Paket-Adresse ohne Zugangsdaten
ab und erwartet 401.
</p>
<p style="color:var(--text-muted); font-size:0.8rem; margin-top:1rem;">
<strong>OFFEN</strong> heißt: jeder im Internet kann die Pakete laden.
<strong>GESPERRT</strong> heißt: niemand kommt heran, weil es weder gültige
+211 -8
View File
@@ -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_<sha256(schluessel), 16 Hexzeichen>
*
* 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<array<string,mixed>>,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()]);
}
}