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
@@ -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;
}
}
}
}