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:
co-authored by
Claude Opus 5
parent
ceb977187e
commit
7a3a5dad69
@@ -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/<app> 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/<app> 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);
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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**
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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()]);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user