Files
Deploymentcenter/client-dotnet/Deploymentcenter.UpdateAgent/Installation.cs
T
Deploymentcenter BotandClaude Opus 5 56b1d2631a fix(setup): Dateirechte bei Geheimnissen, plattformabhaengige Zielnamen
Zwei Beobachtungen aus der laufenden Integration.

1. Der Installer schrieb Geheimnisse weltlesbar
   SetupWriter benutzte File.WriteAllText ohne Rechteanpassung - unter Linux
   also die Standardmaske und damit ueblicherweise 644. In genau dieser Datei
   stehen Lizenzschluessel und Anwendungstoken; jeder Benutzer des Systems
   konnte sie lesen. Der Lizenz-Cache in StateStore wird aus demselben Grund
   seit jeher auf 600 gesetzt - der Installer zog nicht nach.
   Enthaelt ein Ziel mindestens einen geheimen Wert, wird die Datei jetzt auf
   den eigenen Benutzer beschraenkt. Als geheim gilt type=secret UND
   source=provision: ein so geholtes Token traegt oft den Typ "string", ist
   aber genauso schutzbeduerftig. Unter Windows bleibt es beim Profil-ACL.

2. Zielnamen koennen plattformabhaengig unterschiedlich sein
   %APPDATA%\MeineAnwendung gegen $XDG_CONFIG_HOME/meineanwendung - eine
   setup.json kannte nur eine Schreibweise. Die kleingeschriebene Form allein
   traegt, weil NTFS die Schreibweise ignoriert, aber nur solange das
   Dateisystem tatsaechlich unempfindlich ist; auf APFS mit Beachtung der
   Schreibweise oder bei groesseren Unterschieden entstuende ein zweites,
   leeres Verzeichnis neben dem, aus dem die Anwendung liest.
   Ziele haben deshalb optional fileWindows, fileLinux und fileMacOS; ohne
   Angabe gilt weiterhin file.

Die Dokumentation haelt ausserdem fest, dass der Installer bewusst Klartext
schreibt und die Anwendung selbst entscheidet, ob und wie sie ihn danach
schuetzt - und dass eine Entschluesselung, die Klartext durchreicht, deshalb
kein Altlast-Zweig mehr ist, sondern ein aktiv genutzter Pfad.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 21:37:01 +02:00

694 lines
27 KiB
C#

using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Net.Http;
using System.Text.Json;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using Deploymentcenter.Client;
using Deploymentcenter.Client.Models;
using Spectre.Console;
namespace Deploymentcenter.UpdateAgent
{
/// <summary>
/// Erstinstallation einer Anwendung.
///
/// Der Ablauf ist bewusst so geschnitten, dass er den vorhandenen
/// Update-Weg wiederverwendet: erst kommen die Dateien ins Zielverzeichnis
/// (mit Pruefsumme, Signatur und Rollback wie bei jedem Update), danach
/// wird konfiguriert. Ein zweiter Download-Pfad waere ein zweiter Ort, an
/// dem dieselben Fehler wieder entstehen koennen.
///
/// Gefragt wird nur nach dem, was sich nicht anders ermitteln laesst -
/// siehe <see cref="ResolveFields"/>.
/// </summary>
internal static class Installation
{
private const string SetupFileName = "setup.json";
public static async Task<int> RunAsync(AgentOptions options, HttpClient http)
{
AnsiConsole.Write(
new FigletText("Installer")
.LeftJustified()
.Color(Color.DodgerBlue1));
// ----------------------------------------------------------
// 1. Anmeldung
// ----------------------------------------------------------
string baseUrl = AnsiConsole.Prompt(
new TextPrompt<string>("[bold]Deploymentcenter[/]:")
.DefaultValue(options.BaseUrl)
.Validate(value => value.StartsWith("http", StringComparison.OrdinalIgnoreCase)
? ValidationResult.Success()
: ValidationResult.Error("[red]Bitte eine vollstaendige Adresse angeben.[/]")));
options.BaseUrl = baseUrl.TrimEnd('/');
var client = new SetupClient(options.BaseUrl, http);
SetupSession session;
while (true)
{
string username = AnsiConsole.Prompt(new TextPrompt<string>("[bold]Benutzer[/]:"));
string password = AnsiConsole.Prompt(new TextPrompt<string>("[bold]Passwort[/]:").Secret());
try
{
session = await client.LoginAsync(username, password, Environment.MachineName);
// Dieselben Zugangsdaten oeffnen die Release-Ablage. Das
// Setup-Token taugt dafuer nicht: /releases/ liegt hinter
// HTTP-Basic-Auth und wird von Apache geprueft, nicht von
// der Anwendung. Bei einer Erstinstallation gibt es zudem
// noch keinen Lizenzschluessel.
options.Credentials = ReleaseCredentials.FromUser(username, password);
break;
}
catch (SetupException ex)
{
AnsiConsole.MarkupLine($"[red]{Markup.Escape(ex.Message)}[/]");
if (ex.Code == "locked_out" || ex.Code == "rate_limited")
{
return 1;
}
if (!AnsiConsole.Confirm("Erneut versuchen?"))
{
return 1;
}
}
catch (HttpRequestException ex)
{
AnsiConsole.MarkupLine($"[red]Keine Verbindung zum Deploymentcenter: {Markup.Escape(ex.Message)}[/]");
return 1;
}
}
AnsiConsole.MarkupLine($"[green]Angemeldet.[/] [grey]Die Sitzung laeuft in "
+ $"{(int)session.Remaining.TotalMinutes} Minuten ab.[/]");
if (session.Role == "admin")
{
AnsiConsole.MarkupLine(
"[yellow]Hinweis: Das ist ein Administratorkonto. Fuer Zielsysteme ist ein Konto "
+ "der Rolle [bold]installer[/] gedacht - es kann sich nicht am WebUI anmelden.[/]");
}
// ----------------------------------------------------------
// 2. Auswahl
// ----------------------------------------------------------
List<CatalogEntry> catalog;
try
{
catalog = await client.GetCatalogAsync(options.Platform);
}
catch (SetupException ex)
{
AnsiConsole.MarkupLine($"[red]Katalog nicht abrufbar: {Markup.Escape(ex.Message)}[/]");
return 1;
}
if (catalog.Count == 0)
{
AnsiConsole.MarkupLine(
$"[yellow]Fuer die Plattform [bold]{Markup.Escape(options.Platform)}[/] ist nichts hinterlegt.[/]");
AnsiConsole.MarkupLine("[grey]Releases ohne Plattformangabe gelten als \"any\" und waeren hier "
+ "sichtbar. Moeglicherweise wurde nur fuer eine andere Plattform veroeffentlicht.[/]");
return 1;
}
var chosen = AnsiConsole.Prompt(
new SelectionPrompt<CatalogEntry>()
.Title("\n[bold]Welche Anwendung soll installiert werden?[/]")
.PageSize(12)
.UseConverter(entry =>
{
string versions = string.Join(", ",
entry.Channels.Select(c => $"{c.Channel} {c.Version}"));
return $"{Markup.Escape(entry.Name)} [grey]({Markup.Escape(versions)})[/]";
})
.AddChoices(catalog));
CatalogChannel channel = chosen.Channels.Count == 1
? chosen.Channels[0]
: AnsiConsole.Prompt(
new SelectionPrompt<CatalogChannel>()
.Title($"\n[bold]Welcher Kanal von {Markup.Escape(chosen.Name)}?[/]")
.UseConverter(c =>
{
string mark = c.Signed ? "" : " [yellow](unsigniert)[/]";
return $"{c.Channel} - v{c.Version}{mark}";
})
.AddChoices(chosen.Channels));
if (!channel.Signed)
{
AnsiConsole.MarkupLine("[yellow]Dieses Release ist nicht signiert - die Herkunft laesst sich "
+ "nicht pruefen.[/]");
if (options.RequireSignature)
{
AnsiConsole.MarkupLine("[red]Abbruch: --require-signature ist gesetzt.[/]");
return 1;
}
}
// ----------------------------------------------------------
// 3. Zielverzeichnis
// ----------------------------------------------------------
string suggested = SuggestTargetDirectory(chosen.Slug);
string targetDir = AnsiConsole.Prompt(
new TextPrompt<string>("[bold]Zielverzeichnis[/]:").DefaultValue(suggested));
targetDir = Path.GetFullPath(targetDir);
if (Directory.Exists(targetDir) && Directory.GetFileSystemEntries(targetDir).Length > 0)
{
AnsiConsole.MarkupLine($"[yellow]{Markup.Escape(targetDir)} ist nicht leer.[/]");
if (!AnsiConsole.Confirm("Trotzdem dorthin installieren?", false))
{
return 1;
}
}
options.Project = chosen.Slug;
options.Channel = channel.Channel;
options.TargetDir = targetDir;
// ----------------------------------------------------------
// 4. Dateien uebertragen - ueber den regulaeren Update-Weg
// ----------------------------------------------------------
AnsiConsole.MarkupLine($"\n[bold]Installiere {Markup.Escape(chosen.Name)} v{channel.Version} "
+ $"nach {Markup.Escape(targetDir)}[/]\n");
int fileResult = await Program.DoUpdateOrRepair(options, channel.Version, null);
if (fileResult != 0)
{
return fileResult;
}
// ----------------------------------------------------------
// 5. Einrichten
// ----------------------------------------------------------
return await ConfigureAsync(options, client, chosen.Slug, interactive: true);
}
/// <summary>
/// Nur die Einrichtung, ohne Dateien anzufassen. Nuetzlich, wenn die
/// Installation stand, die Konfiguration aber nachgeholt oder
/// korrigiert werden muss.
/// </summary>
public static async Task<int> ConfigureOnlyAsync(AgentOptions options, HttpClient http)
{
string setupPath = Path.Combine(options.TargetDir, SetupFileName);
if (!File.Exists(setupPath))
{
AnsiConsole.MarkupLine($"[yellow]In {Markup.Escape(options.TargetDir)} liegt keine "
+ $"{SetupFileName} - es gibt nichts einzurichten.[/]");
return 0;
}
var client = new SetupClient(options.BaseUrl, http);
// Ein Token wird nur gebraucht, wenn die Beschreibung ein Feld mit
// source = provision enthaelt. Danach richtet sich, ob ueberhaupt
// nach Zugangsdaten gefragt wird.
var definition = ReadDefinition(setupPath);
if (definition == null)
{
return 1;
}
bool needsToken = definition.Fields.Any(f =>
string.Equals(f.Source, "provision", StringComparison.OrdinalIgnoreCase));
if (needsToken)
{
AnsiConsole.MarkupLine("[grey]Fuer ein Anwendungstoken ist eine Anmeldung noetig.[/]");
string username = AnsiConsole.Prompt(new TextPrompt<string>("[bold]Benutzer[/]:"));
string password = AnsiConsole.Prompt(new TextPrompt<string>("[bold]Passwort[/]:").Secret());
try
{
await client.LoginAsync(username, password, Environment.MachineName);
options.Credentials = ReleaseCredentials.FromUser(username, password);
}
catch (SetupException ex)
{
AnsiConsole.MarkupLine($"[red]{Markup.Escape(ex.Message)}[/]");
return 1;
}
}
return await ConfigureAsync(options, client, options.Project, interactive: true);
}
// ------------------------------------------------------------------
private static async Task<int> ConfigureAsync(
AgentOptions options,
SetupClient client,
string project,
bool interactive)
{
string setupPath = Path.Combine(options.TargetDir, SetupFileName);
if (!File.Exists(setupPath))
{
AnsiConsole.MarkupLine($"\n[green]Fertig.[/] [grey]Das Paket bringt keine {SetupFileName} mit, "
+ "es ist also nichts einzurichten.[/]");
return 0;
}
var definition = ReadDefinition(setupPath);
if (definition == null)
{
return 1;
}
if (definition.Schema > 1)
{
AnsiConsole.MarkupLine($"[yellow]Die {SetupFileName} verwendet Schema {definition.Schema}; "
+ "dieser Installer kennt Schema 1. Unbekannte Angaben werden uebergangen.[/]");
}
AnsiConsole.Write(new Rule("[bold]Einrichtung[/]").LeftJustified());
if (definition.Description.Length > 0)
{
AnsiConsole.MarkupLine($"[grey]{Markup.Escape(definition.Description)}[/]\n");
}
var resolved = await ResolveFields(definition, options, client, project, interactive);
if (resolved == null)
{
return 1;
}
// Werte den Zieldateien zuordnen und schreiben.
var targets = definition.Targets.Count > 0
? definition.Targets
: new List<SetupTarget> { new SetupTarget { File = "appsettings.json", Format = "json" } };
foreach (var target in targets)
{
var forTarget = resolved
.Where(pair => BelongsToTarget(pair.Field, target, targets))
// IsSecret ausdruecklich weiterreichen: ein per provision
// geholtes Token traegt oft den Typ "string", muss die
// Datei aber trotzdem auf den eigenen Benutzer beschraenken.
.Select(pair => new SetupValue(
pair.Field.Key, pair.Value, pair.Field.Type, pair.Field.IsSecret))
.ToList();
if (forTarget.Count == 0)
continue;
string path = SetupPaths.Resolve(target, options.TargetDir);
try
{
SetupWriter.Write(path, target.Format, forTarget);
// 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)
{
AnsiConsole.MarkupLine($"[red]{Markup.Escape(target.File)} konnte nicht geschrieben werden: "
+ $"{Markup.Escape(ex.Message)}[/]");
return 1;
}
}
AnsiConsole.MarkupLine("\n[bold green]Installation abgeschlossen.[/]");
AnsiConsole.MarkupLine($"[grey]Verzeichnis: {Markup.Escape(options.TargetDir)}[/]");
return 0;
}
/// <summary>
/// Ermittelt jeden Wert und fragt nur nach dem, was uebrig bleibt.
///
/// Reihenfolge je Feld:
/// 1. bereits in der Zieldatei gesetzt -> uebernehmen, nicht fragen
/// 2. source = detect:... -> aus dem System ableiten
/// 3. source = provision -> Token beim Server holen
/// 4. sonst -> fragen, mit default vorbelegt
/// </summary>
private static async Task<List<(SetupField Field, string Value)>?> ResolveFields(
SetupDefinition definition,
AgentOptions options,
SetupClient client,
string project,
bool interactive)
{
var resolved = new List<(SetupField, string)>();
var targets = definition.Targets;
int answered = 0;
int automatic = 0;
int kept = 0;
foreach (var field in definition.Fields)
{
if (string.IsNullOrWhiteSpace(field.Key))
continue;
// 1. Schon eingerichtet?
var target = FindTarget(field, targets);
if (target != null && string.Equals(target.Format, "json", StringComparison.OrdinalIgnoreCase))
{
// 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);
if (!string.IsNullOrWhiteSpace(existing) && !IsPlaceholder(existing!))
{
AnsiConsole.MarkupLine($"[grey]Bereits gesetzt:[/] {Markup.Escape(field.Key)}");
kept++;
continue;
}
}
// 2. Ableitbar?
string? detected = Detect(field.Source, options, project);
if (detected != null)
{
resolved.Add((field, detected));
AnsiConsole.MarkupLine($"[grey]Ermittelt:[/] {Markup.Escape(field.Key)} = {Markup.Escape(detected)}");
automatic++;
continue;
}
// 3. Token anfordern?
if (string.Equals(field.Source, "provision", StringComparison.OrdinalIgnoreCase))
{
try
{
var scopes = field.Scopes.Count > 0 ? field.Scopes : new List<string> { "watchdog:ping" };
string token = await client.RequestApplicationTokenAsync(
project, scopes, Environment.MachineName);
resolved.Add((field, token));
AnsiConsole.MarkupLine($"[grey]Token ausgestellt:[/] {Markup.Escape(field.Key)} "
+ $"[grey]({Markup.Escape(string.Join(", ", scopes))})[/]");
automatic++;
continue;
}
catch (SetupException ex)
{
AnsiConsole.MarkupLine($"[red]Token fuer {Markup.Escape(field.Key)} nicht erhalten: "
+ $"{Markup.Escape(ex.Message)}[/]");
if (field.Required)
{
return null;
}
continue;
}
}
// 4. Fragen.
if (!interactive)
{
if (field.Default.Length > 0)
{
resolved.Add((field, field.Default));
continue;
}
if (field.Required)
{
AnsiConsole.MarkupLine($"[red]Ohne Rueckfrage nicht ermittelbar: {Markup.Escape(field.Key)}[/]");
return null;
}
continue;
}
string? answer = Ask(field);
if (answer == null)
{
// Nicht ausgefuellt und nicht erforderlich.
continue;
}
resolved.Add((field, answer));
answered++;
}
AnsiConsole.MarkupLine($"\n[grey]{automatic} Wert(e) automatisch ermittelt, {kept} bereits "
+ $"vorhanden, {answered} abgefragt.[/]");
return resolved.Select(r => (r.Item1, r.Item2)).ToList();
}
private static string? Ask(SetupField field)
{
string label = field.Label.Length > 0 ? field.Label : field.Key;
if (field.Help.Length > 0)
{
AnsiConsole.MarkupLine($"[grey]{Markup.Escape(field.Help)}[/]");
}
// Auswahl aus festen Moeglichkeiten.
if (string.Equals(field.Type, "enum", StringComparison.OrdinalIgnoreCase) && field.Options.Count > 0)
{
return AnsiConsole.Prompt(
new SelectionPrompt<string>()
.Title($"[bold]{Markup.Escape(label)}[/]:")
.AddChoices(field.Options));
}
if (string.Equals(field.Type, "bool", StringComparison.OrdinalIgnoreCase))
{
bool preset = bool.TryParse(field.Default, out bool parsed) && parsed;
return AnsiConsole.Confirm($"[bold]{Markup.Escape(label)}[/]?", preset)
? "true"
: "false";
}
var prompt = new TextPrompt<string>($"[bold]{Markup.Escape(label)}[/]:");
if (field.IsSecret)
{
prompt.Secret();
}
if (field.Default.Length > 0)
{
prompt.DefaultValue(field.Default);
// Ein vorbelegtes Geheimnis darf nicht im Klartext erscheinen.
if (field.IsSecret)
{
prompt.HideDefaultValue();
}
}
else if (!field.Required)
{
prompt.AllowEmpty();
}
var validate = BuildValidator(field);
if (validate != null)
{
prompt.Validate(validate);
}
string answer = AnsiConsole.Prompt(prompt);
return answer.Length == 0 && !field.Required ? null : answer;
}
private static Func<string, ValidationResult>? BuildValidator(SetupField field)
{
Regex? pattern = null;
if (field.Validate.Length > 0)
{
try
{
pattern = new Regex(field.Validate, RegexOptions.CultureInvariant);
}
catch (ArgumentException)
{
// Ein fehlerhafter Ausdruck in der setup.json darf die
// Installation nicht verhindern - dann wird eben nicht
// geprueft.
AnsiConsole.MarkupLine($"[yellow]Pruefmuster fuer {Markup.Escape(field.Key)} ist ungueltig "
+ "und wird uebergangen.[/]");
}
}
string type = (field.Type ?? "string").ToLowerInvariant();
if (pattern == null && type != "int" && type != "url")
{
return null;
}
string message = field.ValidationMessage.Length > 0
? field.ValidationMessage
: "Die Eingabe passt nicht zum erwarteten Format.";
return value =>
{
if (value.Length == 0 && !field.Required)
{
return ValidationResult.Success();
}
if (type == "int" && !long.TryParse(value, out _))
{
return ValidationResult.Error("[red]Bitte eine ganze Zahl eingeben.[/]");
}
if (type == "url"
&& !Uri.TryCreate(value, UriKind.Absolute, out var uri))
{
return ValidationResult.Error("[red]Bitte eine vollstaendige Adresse eingeben.[/]");
}
if (pattern != null && !pattern.IsMatch(value))
{
return ValidationResult.Error($"[red]{Markup.Escape(message)}[/]");
}
return ValidationResult.Success();
};
}
/// <summary>Werte, die sich aus dem System selbst ergeben.</summary>
private static string? Detect(string source, AgentOptions options, string project)
{
if (string.IsNullOrWhiteSpace(source)
|| !source.StartsWith("detect:", StringComparison.OrdinalIgnoreCase))
{
return null;
}
string what = source.Substring("detect:".Length).Trim().ToLowerInvariant();
switch (what)
{
case "hostname": return SafeMachineName();
case "platform":
case "rid": return options.Platform;
case "installdir": return options.TargetDir;
case "project": return project;
case "baseurl": return options.BaseUrl;
case "username":
try { return Environment.UserName; } catch { return null; }
default:
return null;
}
}
private static SetupTarget? FindTarget(SetupField field, List<SetupTarget> targets)
{
if (targets.Count == 0)
return null;
if (field.Target.Length == 0)
return targets[0];
return targets.FirstOrDefault(t =>
string.Equals(t.Id, field.Target, StringComparison.OrdinalIgnoreCase))
?? targets[0];
}
private static bool BelongsToTarget(SetupField field, SetupTarget target, List<SetupTarget> all)
{
var resolved = FindTarget(field, all);
return resolved != null && ReferenceEquals(resolved, target);
}
/// <summary>
/// Erkennt Platzhalter, die zwar in der Datei stehen, aber keinen
/// eingerichteten Wert darstellen. Ohne diese Pruefung wuerde eine
/// mitgelieferte Vorlage als "schon konfiguriert" gelten und die Frage
/// entfiele - die Anwendung liefe dann mit dem Platzhalter los.
/// </summary>
private static bool IsPlaceholder(string value)
{
string trimmed = value.Trim();
if (trimmed.Length == 0)
return true;
string[] markers =
{
"changeme", "change-me", "todo", "xxx", "placeholder", "vorlage",
"your-", "dein-", "<", "bitte-", "example.com", "beispiel"
};
string lower = trimmed.ToLowerInvariant();
return markers.Any(marker => lower.Contains(marker));
}
private static SetupDefinition? ReadDefinition(string path)
{
try
{
var definition = JsonSerializer.Deserialize<SetupDefinition>(File.ReadAllText(path));
if (definition == null)
{
AnsiConsole.MarkupLine($"[red]{SetupFileName} ist leer oder unlesbar.[/]");
return null;
}
return definition;
}
catch (JsonException ex)
{
AnsiConsole.MarkupLine($"[red]{SetupFileName} ist kein gueltiges JSON: {Markup.Escape(ex.Message)}[/]");
return null;
}
catch (Exception ex)
{
AnsiConsole.MarkupLine($"[red]{SetupFileName} nicht lesbar: {Markup.Escape(ex.Message)}[/]");
return null;
}
}
private static string SuggestTargetDirectory(string slug)
{
if (OperatingSystemHelpers.IsWindows())
{
string programs = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles);
if (!string.IsNullOrWhiteSpace(programs))
{
return Path.Combine(programs, slug);
}
return Path.Combine(@"C:\Programme", slug);
}
return "/opt/" + slug;
}
private static string SafeMachineName()
{
try { return Environment.MachineName; } catch { return "unbekannt"; }
}
}
}