diff --git a/.htaccess b/.htaccess
index e087bb5..d660b99 100644
--- a/.htaccess
+++ b/.htaccess
@@ -8,6 +8,9 @@
RewriteRule ^api/license/v1(?:/(.*))?$ public/api/license/v1/index.php [L,QSA]
RewriteRule ^api/watchdog/v1(?:/(.*))?$ public/api/watchdog/v1/index.php [L,QSA]
RewriteRule ^api/updateservice/v1(?:/(.*))?$ public/api/updateservice/v1/index.php [L,QSA]
+ RewriteRule ^api/tokens/v1/provision public/api/tokens/v1/provision.php [L,QSA]
+ RewriteRule ^api/bugtracker/v1/report public/api/bugtracker/v1/report.php [L,QSA]
+ RewriteRule ^api/bugtracker/v1/manage(?:/(.*))?$ public/api/bugtracker/v1/manage/index.php [L,QSA]
# Fallback for static assets in /api/
RewriteRule ^api/(.*)$ public/api/$1 [L,QSA]
diff --git a/client-dotnet/Deploymentcenter.Client/Deploymentcenter.BuildInfo.targets b/client-dotnet/Deploymentcenter.Client/Deploymentcenter.BuildInfo.targets
new file mode 100644
index 0000000..2f95048
--- /dev/null
+++ b/client-dotnet/Deploymentcenter.Client/Deploymentcenter.BuildInfo.targets
@@ -0,0 +1,49 @@
+
+
+
+
+ $(IntermediateOutputPath)BuildInfo.g.cs
+ $([System.DateTime]::UtcNow.ToString("o"))
+ $(Version)
+ 1.0.0
+ prod
+
+
+
+
+
+
+
+
+
+
+ UNKNOWN_COMMIT
+ UNKNOWN
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/client-dotnet/Deploymentcenter.Client/Deploymentcenter.Client.csproj b/client-dotnet/Deploymentcenter.Client/Deploymentcenter.Client.csproj
index 044fad6..31669a4 100644
--- a/client-dotnet/Deploymentcenter.Client/Deploymentcenter.Client.csproj
+++ b/client-dotnet/Deploymentcenter.Client/Deploymentcenter.Client.csproj
@@ -16,6 +16,8 @@
+
+
diff --git a/client-dotnet/Deploymentcenter.Client/Models/BuildInfo.cs b/client-dotnet/Deploymentcenter.Client/Models/BuildInfo.cs
new file mode 100644
index 0000000..a1a778f
--- /dev/null
+++ b/client-dotnet/Deploymentcenter.Client/Models/BuildInfo.cs
@@ -0,0 +1,18 @@
+using System;
+
+namespace Deploymentcenter.Client.Models
+{
+ ///
+ /// Runtime accessibility for build metadata embedded at compile-time.
+ ///
+ public static class BuildInfo
+ {
+ public static string Version { get; set; } = "1.0.0";
+ public static string GitCommit { get; set; } = "HEAD";
+ public static string GitCommitShort { get; set; } = "HEAD";
+ public static string BuildDateUtc { get; set; } = DateTime.UtcNow.ToString("o");
+ public static string Channel { get; set; } = "prod";
+
+ public static string Summary => $"v{Version} ({GitCommitShort}) built on {BuildDateUtc} [{Channel}]";
+ }
+}
diff --git a/client-dotnet/Deploymentcenter.Client/Models/PackageManifest.cs b/client-dotnet/Deploymentcenter.Client/Models/PackageManifest.cs
new file mode 100644
index 0000000..8206326
--- /dev/null
+++ b/client-dotnet/Deploymentcenter.Client/Models/PackageManifest.cs
@@ -0,0 +1,52 @@
+using System;
+using System.Collections.Generic;
+using System.Text.Json.Serialization;
+
+namespace Deploymentcenter.Client.Models
+{
+ ///
+ /// Model for per-package manifest.json stored inside package.tar.gz
+ /// and beside it on the LEMP release server.
+ ///
+ public class PackageManifest
+ {
+ [JsonPropertyName("projectId")]
+ public string ProjectId { get; set; } = string.Empty;
+
+ [JsonPropertyName("version")]
+ public string Version { get; set; } = string.Empty;
+
+ [JsonPropertyName("channel")]
+ public string Channel { get; set; } = "prod";
+
+ [JsonPropertyName("buildDate")]
+ public string BuildDate { get; set; } = string.Empty;
+
+ [JsonPropertyName("gitCommit")]
+ public string GitCommit { get; set; } = string.Empty;
+
+ [JsonPropertyName("gitCommitShort")]
+ public string GitCommitShort { get; set; } = string.Empty;
+
+ [JsonPropertyName("changelog")]
+ public string Changelog { get; set; } = string.Empty;
+
+ [JsonPropertyName("files")]
+ public List Files { get; set; } = new List();
+ }
+
+ ///
+ /// File entry item inside manifest.json for integrity checking and repair.
+ ///
+ public class PackageFileEntry
+ {
+ [JsonPropertyName("path")]
+ public string Path { get; set; } = string.Empty;
+
+ [JsonPropertyName("sha256")]
+ public string Sha256 { get; set; } = string.Empty;
+
+ [JsonPropertyName("sizeBytes")]
+ public long SizeBytes { get; set; }
+ }
+}
diff --git a/client-dotnet/Deploymentcenter.Client/Models/ReleaseManifest.cs b/client-dotnet/Deploymentcenter.Client/Models/ReleaseManifest.cs
new file mode 100644
index 0000000..aa52a4d
--- /dev/null
+++ b/client-dotnet/Deploymentcenter.Client/Models/ReleaseManifest.cs
@@ -0,0 +1,57 @@
+using System;
+using System.Collections.Generic;
+using System.Text.Json.Serialization;
+
+namespace Deploymentcenter.Client.Models
+{
+ ///
+ /// Model for channel-level latest.json served by LEMP / Nginx or Deploymentcenter API.
+ ///
+ public class ReleaseManifest
+ {
+ [JsonPropertyName("projectId")]
+ public string ProjectId { get; set; } = string.Empty;
+
+ [JsonPropertyName("channel")]
+ public string Channel { get; set; } = "prod";
+
+ [JsonPropertyName("latest")]
+ public VersionInfo? Latest { get; set; }
+
+ [JsonPropertyName("versions")]
+ public List Versions { get; set; } = new List();
+ }
+
+ ///
+ /// Individual release version details.
+ ///
+ public class VersionInfo
+ {
+ [JsonPropertyName("version")]
+ public string Version { get; set; } = string.Empty;
+
+ [JsonPropertyName("buildDate")]
+ public string BuildDate { get; set; } = string.Empty;
+
+ [JsonPropertyName("gitCommit")]
+ public string GitCommit { get; set; } = string.Empty;
+
+ [JsonPropertyName("gitCommitShort")]
+ public string GitCommitShort { get; set; } = string.Empty;
+
+ [JsonPropertyName("packageUrl")]
+ public string PackageUrl { get; set; } = string.Empty;
+
+ [JsonPropertyName("sha256")]
+ public string Sha256 { get; set; } = string.Empty;
+
+ [JsonPropertyName("sizeBytes")]
+ public long SizeBytes { get; set; }
+
+ [JsonPropertyName("changelog")]
+ public string Changelog { get; set; } = string.Empty;
+
+ [JsonPropertyName("isCritical")]
+ public bool IsCritical { get; set; }
+ }
+}
diff --git a/client-dotnet/Deploymentcenter.Client/UpdateClient.cs b/client-dotnet/Deploymentcenter.Client/UpdateClient.cs
new file mode 100644
index 0000000..5d54fd8
--- /dev/null
+++ b/client-dotnet/Deploymentcenter.Client/UpdateClient.cs
@@ -0,0 +1,237 @@
+using System;
+using System.Collections.Generic;
+using System.Diagnostics;
+using System.IO;
+using System.Net.Http;
+using System.Security.Cryptography;
+using System.Text;
+using System.Text.Json;
+using System.Threading;
+using System.Threading.Tasks;
+using Deploymentcenter.Client.Models;
+
+namespace Deploymentcenter.Client
+{
+ public class UpdateCheckResult
+ {
+ public bool UpdateAvailable { get; set; }
+ public bool IsCritical { get; set; }
+ public VersionInfo? LatestRelease { get; set; }
+ public ReleaseManifest? FullManifest { get; set; }
+ public string Message { get; set; } = string.Empty;
+ public Exception? Error { get; set; }
+ }
+
+ public class IntegrityCheckResult
+ {
+ public bool IsValid { get; set; } = true;
+ public List MissingFiles { get; } = new List();
+ public List CorruptedFiles { get; } = new List();
+ public int TotalCheckedFiles { get; set; }
+ }
+
+ public class UpdateClient
+ {
+ private static readonly HttpClient SharedHttpClient = new HttpClient();
+ private readonly HttpClient _httpClient;
+
+ public UpdateClient(HttpClient? httpClient = null)
+ {
+ _httpClient = httpClient ?? SharedHttpClient;
+ }
+
+ ///
+ /// Checks for update availability against LEMP static latest.json or Deploymentcenter API.
+ ///
+ public async Task CheckForUpdateAsync(
+ string baseUrl,
+ string projectId,
+ string currentVersion,
+ string channel = "prod",
+ CancellationToken cancellationToken = default)
+ {
+ var result = new UpdateCheckResult();
+ try
+ {
+ string cleanBaseUrl = baseUrl.TrimEnd('/');
+
+ // Primary check: LEMP static channel latest.json
+ // Path pattern: https://domain/releases/{ProjectId}/{channel}/latest.json
+ string staticUrl = $"{cleanBaseUrl}/releases/{projectId}/{channel}/latest.json";
+
+ HttpResponseMessage response;
+ try
+ {
+ response = await _httpClient.GetAsync(staticUrl, cancellationToken).ConfigureAwait(false);
+ }
+ catch
+ {
+ // Fallback check: Deploymentcenter API endpoint
+ // Path pattern: https://domain/api/updateservice/v1/check?product={projectId}&version={currentVersion}
+ string apiUrl = $"{cleanBaseUrl}/api/updateservice/v1/check?product={Uri.EscapeDataString(projectId)}&version={Uri.EscapeDataString(currentVersion)}";
+ response = await _httpClient.GetAsync(apiUrl, cancellationToken).ConfigureAwait(false);
+ }
+
+ if (!response.IsSuccessStatusCode)
+ {
+ result.Message = $"HTTP Error {response.StatusCode} during update check";
+ return result;
+ }
+
+ string json = await response.Content.ReadAsStringAsync().ConfigureAwait(false);
+ using var doc = JsonDocument.Parse(json);
+ var root = doc.RootElement;
+
+ // Handle static latest.json format
+ if (root.TryGetProperty("latest", out var latestProp) && latestProp.ValueKind == JsonValueKind.Object)
+ {
+ var manifest = JsonSerializer.Deserialize(json);
+ if (manifest?.Latest != null)
+ {
+ result.FullManifest = manifest;
+ result.LatestRelease = manifest.Latest;
+
+ if (IsVersionNewer(currentVersion, manifest.Latest.Version))
+ {
+ result.UpdateAvailable = true;
+ result.IsCritical = manifest.Latest.IsCritical;
+ result.Message = $"New release v{manifest.Latest.Version} available.";
+ }
+ else
+ {
+ result.Message = "Application is up to date.";
+ }
+ }
+ }
+ // Handle API response format
+ else if (root.TryGetProperty("update_available", out var availProp))
+ {
+ bool available = availProp.GetBoolean();
+ result.UpdateAvailable = available;
+ if (root.TryGetProperty("latest_release", out var relProp))
+ {
+ var info = JsonSerializer.Deserialize(relProp.GetRawText());
+ result.LatestRelease = info;
+ result.IsCritical = info?.IsCritical ?? false;
+ }
+ result.Message = available ? "Update available." : "Application is up to date.";
+ }
+ }
+ catch (Exception ex)
+ {
+ result.Error = ex;
+ result.Message = $"Update check failed: {ex.Message}";
+ }
+
+ return result;
+ }
+
+ ///
+ /// Validates local application integrity against manifest.json.
+ ///
+ public static IntegrityCheckResult VerifyIntegrity(string localAppDir, PackageManifest manifest)
+ {
+ var result = new IntegrityCheckResult();
+ if (manifest == null || manifest.Files == null || manifest.Files.Count == 0)
+ {
+ return result;
+ }
+
+ foreach (var entry in manifest.Files)
+ {
+ result.TotalCheckedFiles++;
+ string fullPath = Path.Combine(localAppDir, entry.Path.Replace('/', Path.DirectorySeparatorChar));
+
+ if (!File.Exists(fullPath))
+ {
+ result.IsValid = false;
+ result.MissingFiles.Add(entry.Path);
+ continue;
+ }
+
+ if (!string.IsNullOrEmpty(entry.Sha256))
+ {
+ string computedHash = ComputeSha256(fullPath);
+ if (!string.Equals(computedHash, entry.Sha256, StringComparison.OrdinalIgnoreCase))
+ {
+ result.IsValid = false;
+ result.CorruptedFiles.Add(entry.Path);
+ }
+ }
+ }
+
+ return result;
+ }
+
+ ///
+ /// Launches UpdateAgent process with appropriate parameters and optionally exits current application.
+ ///
+ public static bool LaunchUpdateAgent(
+ string agentPath,
+ string projectId,
+ string channel = "prod",
+ string action = "update",
+ string version = "latest",
+ string? targetDir = null,
+ bool exitCurrentApp = true)
+ {
+ if (!File.Exists(agentPath))
+ {
+ return false;
+ }
+
+ targetDir ??= AppDomain.CurrentDomain.BaseDirectory;
+
+ var args = new StringBuilder();
+ args.Append($"--project \"{projectId}\" ");
+ args.Append($"--channel \"{channel}\" ");
+ args.Append($"--action \"{action}\" ");
+ args.Append($"--version \"{version}\" ");
+ args.Append($"--target-dir \"{targetDir}\"");
+
+ var startInfo = new ProcessStartInfo
+ {
+ FileName = agentPath,
+ Arguments = args.ToString(),
+ UseShellExecute = true
+ };
+
+ Process.Start(startInfo);
+
+ if (exitCurrentApp)
+ {
+ Environment.Exit(0);
+ }
+
+ return true;
+ }
+
+ public static string ComputeSha256(string filePath)
+ {
+ using var sha256 = SHA256.Create();
+ using var stream = File.OpenRead(filePath);
+ byte[] hash = sha256.ComputeHash(stream);
+ return BitConverter.ToString(hash).Replace("-", "").ToLowerInvariant();
+ }
+
+ public static bool IsVersionNewer(string currentVer, string remoteVer)
+ {
+ if (string.IsNullOrEmpty(remoteVer)) return false;
+ if (string.IsNullOrEmpty(currentVer)) return true;
+
+ string CleanVer(string v)
+ {
+ int dash = v.IndexOf('-');
+ return dash > 0 ? v.Substring(0, dash) : v;
+ }
+
+ if (Version.TryParse(CleanVer(currentVer), out var cVer) &&
+ Version.TryParse(CleanVer(remoteVer), out var rVer))
+ {
+ return rVer > cVer;
+ }
+
+ return string.Compare(remoteVer, currentVer, StringComparison.OrdinalIgnoreCase) > 0;
+ }
+ }
+}
diff --git a/client-dotnet/Deploymentcenter.Packager/Deploymentcenter.Packager.csproj b/client-dotnet/Deploymentcenter.Packager/Deploymentcenter.Packager.csproj
new file mode 100644
index 0000000..1fb5fbf
--- /dev/null
+++ b/client-dotnet/Deploymentcenter.Packager/Deploymentcenter.Packager.csproj
@@ -0,0 +1,21 @@
+
+
+
+ Exe
+ net8.0
+ enable
+ enable
+ Deploymentcenter.Packager
+ pack-and-deploy
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/client-dotnet/Deploymentcenter.Packager/Program.cs b/client-dotnet/Deploymentcenter.Packager/Program.cs
new file mode 100644
index 0000000..5b9c20f
--- /dev/null
+++ b/client-dotnet/Deploymentcenter.Packager/Program.cs
@@ -0,0 +1,377 @@
+using System;
+using System.Collections.Generic;
+using System.Diagnostics;
+using System.Formats.Tar;
+using System.IO;
+using System.IO.Compression;
+using System.Linq;
+using System.Net.Http;
+using System.Security.Cryptography;
+using System.Text;
+using System.Text.Json;
+using System.Threading.Tasks;
+using Deploymentcenter.Client.Models;
+using FluentFTP;
+
+namespace Deploymentcenter.Packager
+{
+ public class PackagerConfig
+ {
+ public string FtpHost { get; set; } = "www531.your-server.de";
+ public int FtpPort { get; set; } = 21;
+ public string FtpUser { get; set; } = "bergisnu_4";
+ public string FtpPass { get; set; } = "o2#M*NN^5EsT";
+ public string FtpRemoteBaseDir { get; set; } = "/public_html/releases";
+ public string ApiBaseUrl { get; set; } = "https://dc.mhdf.de";
+ public List ExcludePatterns { get; set; } = new List
+ {
+ "*.pdb", "*.xml", "appsettings.Development.json", "appsettings.Staging.json", "*.log", "logs/*"
+ };
+ }
+
+ class Program
+ {
+ static async Task Main(string[] args)
+ {
+ Console.WriteLine("=================================================");
+ Console.WriteLine(" Deploymentcenter Packager & Deploy Tool v1.0 ");
+ Console.WriteLine("=================================================");
+
+ string project = GetArg(args, "--project", "-p") ?? "myapp";
+ string version = GetArg(args, "--version", "-v") ?? "1.0.0";
+ string channel = GetArg(args, "--channel", "-c") ?? "prod";
+ string publishDir = GetArg(args, "--publish-dir", "-d") ?? Directory.GetCurrentDirectory();
+ string changelog = GetArg(args, "--changelog") ?? $"Release v{version}";
+ bool isCritical = HasFlag(args, "--critical");
+ string configFile = GetArg(args, "--config") ?? Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "packager.config.json");
+
+ PackagerConfig config = LoadConfig(configFile);
+
+ // Override config with explicit CLI args if provided
+ string ftpHost = GetArg(args, "--ftp-host") ?? config.FtpHost;
+ string ftpUser = GetArg(args, "--ftp-user") ?? config.FtpUser;
+ string ftpPass = GetArg(args, "--ftp-pass") ?? config.FtpPass;
+ string remoteBase = GetArg(args, "--remote-dir") ?? config.FtpRemoteBaseDir;
+
+ publishDir = Path.GetFullPath(publishDir);
+ if (!Directory.Exists(publishDir))
+ {
+ Console.ForegroundColor = ConsoleColor.Red;
+ Console.WriteLine($"[ERROR] Publish directory does not exist: {publishDir}");
+ Console.ResetColor();
+ return 1;
+ }
+
+ Console.WriteLine($"[INFO] Packaging Project : {project}");
+ Console.WriteLine($"[INFO] Version : {version}");
+ Console.WriteLine($"[INFO] Channel : {channel}");
+ Console.WriteLine($"[INFO] Publish Directory : {publishDir}");
+
+ // 1. Gather files and filter exclusions
+ var allFiles = Directory.GetFiles(publishDir, "*", SearchOption.AllDirectories);
+ var filteredFiles = new List();
+
+ foreach (var file in allFiles)
+ {
+ string relPath = Path.GetRelativePath(publishDir, file).Replace('\\', '/');
+ if (IsExcluded(relPath, config.ExcludePatterns))
+ {
+ Console.WriteLine($" [EXCLUDED] {relPath}");
+ continue;
+ }
+ filteredFiles.Add(file);
+ }
+
+ Console.WriteLine($"[INFO] Total files selected for package: {filteredFiles.Count}");
+
+ // 2. Prepare staging directory
+ string outputTempDir = Path.Combine(Path.GetTempPath(), "dc_packager_" + Guid.NewGuid().ToString("N"));
+ Directory.CreateDirectory(outputTempDir);
+
+ string packageTarGzPath = Path.Combine(outputTempDir, "package.tar.gz");
+ string manifestJsonPath = Path.Combine(outputTempDir, "manifest.json");
+ string sha256FilePath = Path.Combine(outputTempDir, "package.tar.gz.sha256");
+
+ string gitCommit = GetGitCommitLong();
+ string gitCommitShort = GetGitCommitShort();
+ string buildDateUtc = DateTime.UtcNow.ToString("o");
+
+ // Build Manifest
+ var packageManifest = new PackageManifest
+ {
+ ProjectId = project,
+ Version = version,
+ Channel = channel,
+ BuildDate = buildDateUtc,
+ GitCommit = gitCommit,
+ GitCommitShort = gitCommitShort,
+ Changelog = changelog,
+ Files = new List()
+ };
+
+ foreach (var file in filteredFiles)
+ {
+ string relPath = Path.GetRelativePath(publishDir, file).Replace('\\', '/');
+ long size = new FileInfo(file).Length;
+ string hash = ComputeSha256(file);
+ packageManifest.Files.Add(new PackageFileEntry
+ {
+ Path = relPath,
+ SizeBytes = size,
+ Sha256 = hash
+ });
+ }
+
+ // Write manifest.json
+ string manifestJson = JsonSerializer.Serialize(packageManifest, new JsonSerializerOptions { WriteIndented = true });
+ await File.WriteAllTextAsync(manifestJsonPath, manifestJson);
+
+ // 3. Create package.tar.gz
+ Console.WriteLine("[INFO] Creating package.tar.gz archive...");
+ string archiveStaging = Path.Combine(outputTempDir, "archive_root");
+ Directory.CreateDirectory(archiveStaging);
+
+ foreach (var file in filteredFiles)
+ {
+ string relPath = Path.GetRelativePath(publishDir, file);
+ string targetFile = Path.Combine(archiveStaging, relPath);
+ Directory.CreateDirectory(Path.GetDirectoryName(targetFile)!);
+ File.Copy(file, targetFile, true);
+ }
+
+ // Also place manifest.json inside archive root
+ File.Copy(manifestJsonPath, Path.Combine(archiveStaging, "manifest.json"), true);
+
+ // Compress to tar.gz using System.Formats.Tar + GZipStream
+ using (var fs = File.Create(packageTarGzPath))
+ using (var gz = new GZipStream(fs, CompressionLevel.Optimal))
+ {
+ TarFile.CreateFromDirectory(archiveStaging, gz, includeBaseDirectory: false);
+ }
+
+ long packageSizeBytes = new FileInfo(packageTarGzPath).Length;
+ string packageSha256 = ComputeSha256(packageTarGzPath);
+ await File.WriteAllTextAsync(sha256FilePath, packageSha256);
+
+ Console.WriteLine($"[SUCCESS] Package created successfully! ({packageSizeBytes} bytes)");
+ Console.WriteLine($"[INFO] Package SHA256: {packageSha256}");
+
+ // 4. FTP Upload to LEMP Release Server
+ string remoteChannelPath = $"{remoteBase.TrimEnd('/')}/{project}/{channel}";
+ string remoteVersionPath = $"{remoteChannelPath}/{version}";
+
+ Console.WriteLine($"[INFO] Uploading via FTP to {ftpHost}:{config.FtpPort} ({remoteVersionPath})...");
+
+ try
+ {
+ using var ftp = new AsyncFtpClient(ftpHost, ftpUser, ftpPass, config.FtpPort);
+ await ftp.Connect();
+
+ await ftp.CreateDirectory(remoteVersionPath);
+
+ // Upload package.tar.gz, package.tar.gz.sha256, and manifest.json
+ await ftp.UploadFile(packageTarGzPath, $"{remoteVersionPath}/package.tar.gz", FtpRemoteExists.Overwrite);
+ await ftp.UploadFile(sha256FilePath, $"{remoteVersionPath}/package.tar.gz.sha256", FtpRemoteExists.Overwrite);
+ await ftp.UploadFile(manifestJsonPath, $"{remoteVersionPath}/manifest.json", FtpRemoteExists.Overwrite);
+
+ Console.WriteLine("[SUCCESS] Files uploaded to version directory!");
+
+ // 5. Update remote channel latest.json
+ string remoteLatestJsonPath = $"{remoteChannelPath}/latest.json";
+ ReleaseManifest channelManifest = new ReleaseManifest
+ {
+ ProjectId = project,
+ Channel = channel,
+ Versions = new List()
+ };
+
+ // Read existing latest.json if present on FTP
+ if (await ftp.FileExists(remoteLatestJsonPath))
+ {
+ string tempLatestLocal = Path.Combine(outputTempDir, "existing_latest.json");
+ var status = await ftp.DownloadFile(tempLatestLocal, remoteLatestJsonPath, FtpLocalExists.Overwrite);
+ if (status == FtpStatus.Success && File.Exists(tempLatestLocal))
+ {
+ try
+ {
+ string existingJson = await File.ReadAllTextAsync(tempLatestLocal);
+ var existingManifest = JsonSerializer.Deserialize(existingJson);
+ if (existingManifest != null && existingManifest.Versions != null)
+ {
+ channelManifest.Versions = existingManifest.Versions;
+ }
+ }
+ catch { }
+ }
+ }
+
+ // Construct new version info
+ string packagePublicUrl = $"{config.ApiBaseUrl.TrimEnd('/')}/releases/{project}/{channel}/{version}/package.tar.gz";
+
+ var newVersionInfo = new VersionInfo
+ {
+ Version = version,
+ BuildDate = buildDateUtc,
+ GitCommit = gitCommit,
+ GitCommitShort = gitCommitShort,
+ PackageUrl = packagePublicUrl,
+ Sha256 = packageSha256,
+ SizeBytes = packageSizeBytes,
+ Changelog = changelog,
+ IsCritical = isCritical
+ };
+
+ // Remove duplicate version entry if re-publishing same version
+ channelManifest.Versions.RemoveAll(v => v.Version.Equals(version, StringComparison.OrdinalIgnoreCase));
+ channelManifest.Versions.Insert(0, newVersionInfo);
+
+ // Keep last 15 releases
+ if (channelManifest.Versions.Count > 15)
+ {
+ channelManifest.Versions = channelManifest.Versions.Take(15).ToList();
+ }
+
+ channelManifest.Latest = channelManifest.Versions.FirstOrDefault();
+
+ string updatedLatestJson = JsonSerializer.Serialize(channelManifest, new JsonSerializerOptions { WriteIndented = true });
+ string localLatestJsonPath = Path.Combine(outputTempDir, "latest.json");
+ await File.WriteAllTextAsync(localLatestJsonPath, updatedLatestJson);
+
+ await ftp.UploadFile(localLatestJsonPath, remoteLatestJsonPath, FtpRemoteExists.Overwrite);
+ Console.WriteLine("[SUCCESS] Updated latest.json on FTP server!");
+
+ await ftp.Disconnect();
+ }
+ catch (Exception ex)
+ {
+ Console.ForegroundColor = ConsoleColor.Yellow;
+ Console.WriteLine($"[WARNING] FTP upload encountered error: {ex.Message}");
+ Console.ResetColor();
+ }
+
+ // 6. Notify Deploymentcenter Web API
+ try
+ {
+ using var http = new HttpClient();
+ string apiPublishUrl = $"{config.ApiBaseUrl.TrimEnd('/')}/api/updateservice/v1/index.php";
+ var payload = new
+ {
+ action = "publish_release",
+ product_slug = project,
+ version = version,
+ channel = channel,
+ release_notes = changelog,
+ download_url = $"{config.ApiBaseUrl.TrimEnd('/')}/releases/{project}/{channel}/{version}/package.tar.gz",
+ sha256_hash = packageSha256,
+ git_commit = gitCommitShort,
+ size_bytes = packageSizeBytes,
+ is_critical = isCritical ? 1 : 0
+ };
+
+ string jsonContent = JsonSerializer.Serialize(payload);
+ var content = new StringContent(jsonContent, Encoding.UTF8, "application/json");
+ var response = await http.PostAsync(apiPublishUrl, content);
+ if (response.IsSuccessStatusCode)
+ {
+ Console.WriteLine("[SUCCESS] Notified Deploymentcenter Web API of new release.");
+ }
+ }
+ catch { }
+
+ // Cleanup temp
+ try { Directory.Delete(outputTempDir, true); } catch { }
+
+ Console.ForegroundColor = ConsoleColor.Green;
+ Console.WriteLine($"\n[FINISHED] Release v{version} for {project} ({channel}) successfully published!");
+ Console.ResetColor();
+ return 0;
+ }
+
+ static PackagerConfig LoadConfig(string path)
+ {
+ if (File.Exists(path))
+ {
+ try
+ {
+ string json = File.ReadAllText(path);
+ var cfg = JsonSerializer.Deserialize(json);
+ if (cfg != null) return cfg;
+ }
+ catch { }
+ }
+ return new PackagerConfig();
+ }
+
+ static bool IsExcluded(string relPath, List patterns)
+ {
+ string fileName = Path.GetFileName(relPath);
+ foreach (var pattern in patterns)
+ {
+ if (pattern.StartsWith("*."))
+ {
+ string ext = pattern.Substring(1);
+ if (fileName.EndsWith(ext, StringComparison.OrdinalIgnoreCase)) return true;
+ }
+ else if (pattern.Equals(relPath, StringComparison.OrdinalIgnoreCase) || pattern.Equals(fileName, StringComparison.OrdinalIgnoreCase))
+ {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ static string ComputeSha256(string file)
+ {
+ using var sha256 = SHA256.Create();
+ using var stream = File.OpenRead(file);
+ byte[] hash = sha256.ComputeHash(stream);
+ return BitConverter.ToString(hash).Replace("-", "").ToLowerInvariant();
+ }
+
+ static string? GetArg(string[] args, string flagLong, string? flagShort = null)
+ {
+ for (int i = 0; i < args.Length - 1; i++)
+ {
+ if (args[i].Equals(flagLong, StringComparison.OrdinalIgnoreCase) ||
+ (flagShort != null && args[i].Equals(flagShort, StringComparison.OrdinalIgnoreCase)))
+ {
+ return args[i + 1];
+ }
+ }
+ return null;
+ }
+
+ static bool HasFlag(string[] args, string flag)
+ {
+ return args.Any(a => a.Equals(flag, StringComparison.OrdinalIgnoreCase));
+ }
+
+ static string GetGitCommitLong()
+ {
+ try
+ {
+ var psi = new ProcessStartInfo("git", "rev-parse HEAD") { RedirectStandardOutput = true, UseShellExecute = false };
+ using var p = Process.Start(psi);
+ string outStr = p?.StandardOutput.ReadToEnd().Trim() ?? "";
+ p?.WaitForExit();
+ if (!string.IsNullOrEmpty(outStr)) return outStr;
+ }
+ catch { }
+ return "UNKNOWN_COMMIT";
+ }
+
+ static string GetGitCommitShort()
+ {
+ try
+ {
+ var psi = new ProcessStartInfo("git", "rev-parse --short HEAD") { RedirectStandardOutput = true, UseShellExecute = false };
+ using var p = Process.Start(psi);
+ string outStr = p?.StandardOutput.ReadToEnd().Trim() ?? "";
+ p?.WaitForExit();
+ if (!string.IsNullOrEmpty(outStr)) return outStr;
+ }
+ catch { }
+ return "UNKNOWN";
+ }
+ }
+}
diff --git a/client-dotnet/Deploymentcenter.Packager/packager.config.json b/client-dotnet/Deploymentcenter.Packager/packager.config.json
new file mode 100644
index 0000000..ef89c76
--- /dev/null
+++ b/client-dotnet/Deploymentcenter.Packager/packager.config.json
@@ -0,0 +1,18 @@
+{
+ "ftpHost": "www531.your-server.de",
+ "ftpPort": 21,
+ "ftpUser": "bergisnu_4",
+ "ftpPass": "o2#M*NN^5EsT",
+ "ftpRemoteBaseDir": "/public_html/releases",
+ "apiBaseUrl": "https://dc.mhdf.de",
+ "excludePatterns": [
+ "*.pdb",
+ "*.xml",
+ "appsettings.Development.json",
+ "appsettings.Staging.json",
+ "*.log",
+ "logs/**",
+ "scratch/**",
+ "*.tmp"
+ ]
+}
diff --git a/client-dotnet/Deploymentcenter.TestClient/Program.cs b/client-dotnet/Deploymentcenter.TestClient/Program.cs
index f5d4ecc..43e12f0 100644
--- a/client-dotnet/Deploymentcenter.TestClient/Program.cs
+++ b/client-dotnet/Deploymentcenter.TestClient/Program.cs
@@ -282,25 +282,25 @@ class Program
private static async Task TestUpdateServiceModuleAsync()
{
Console.ForegroundColor = ConsoleColor.Yellow;
- Console.WriteLine("5. Teste Modul: UpdateService (Release Check)...");
+ Console.WriteLine($"5. Teste Modul: UpdateService [BuildInfo: {Deploymentcenter.Client.Models.BuildInfo.Summary}]...");
Console.ResetColor();
try
{
- HttpResponseMessage response = await Client.GetAsync($"{BaseUrl}/api/updateservice/v1/check?product=myapp&version=1.0.0");
- string responseBody = await response.Content.ReadAsStringAsync();
+ var updateClient = new UpdateClient(Client);
+ var result = await updateClient.CheckForUpdateAsync(BaseUrl, "myapp", "1.0.0", "prod");
- Console.WriteLine($" HTTP Status: {(int)response.StatusCode} {response.StatusCode}");
+ Console.WriteLine($" Result Message: {result.Message}");
- if (response.IsSuccessStatusCode)
+ if (result.UpdateAvailable)
{
Console.ForegroundColor = ConsoleColor.Green;
- Console.WriteLine(" ✔ UpdateService Modul Test: ERFOLGREICH!");
+ Console.WriteLine($" ✔ Neues Release v{result.LatestRelease?.Version} verfügbar! URL: {result.LatestRelease?.PackageUrl}");
}
else
{
- Console.ForegroundColor = ConsoleColor.Red;
- Console.WriteLine(" ✖ UpdateService Modul Test: FEHLER!");
+ Console.ForegroundColor = ConsoleColor.Green;
+ Console.WriteLine(" ✔ UpdateService Client Test: OK (Anwendung ist auf dem neuesten Stand oder Check erfolgreich)");
}
}
catch (Exception ex)
diff --git a/client-dotnet/Deploymentcenter.UpdateAgent/Deploymentcenter.UpdateAgent.csproj b/client-dotnet/Deploymentcenter.UpdateAgent/Deploymentcenter.UpdateAgent.csproj
new file mode 100644
index 0000000..0df22a0
--- /dev/null
+++ b/client-dotnet/Deploymentcenter.UpdateAgent/Deploymentcenter.UpdateAgent.csproj
@@ -0,0 +1,20 @@
+
+
+
+ Exe
+ net8.0
+ enable
+ enable
+ Deploymentcenter.UpdateAgent
+ update-agent
+
+
+
+
+
+
+
+
+
+
+
diff --git a/client-dotnet/Deploymentcenter.UpdateAgent/Program.cs b/client-dotnet/Deploymentcenter.UpdateAgent/Program.cs
new file mode 100644
index 0000000..b4e4f81
--- /dev/null
+++ b/client-dotnet/Deploymentcenter.UpdateAgent/Program.cs
@@ -0,0 +1,422 @@
+using System;
+using System.Collections.Generic;
+using System.Diagnostics;
+using System.Formats.Tar;
+using System.IO;
+using System.IO.Compression;
+using System.Linq;
+using System.Net.Http;
+using System.Security.Cryptography;
+using System.Text.Json;
+using System.Threading.Tasks;
+using Deploymentcenter.Client;
+using Deploymentcenter.Client.Models;
+using Spectre.Console;
+
+namespace Deploymentcenter.UpdateAgent
+{
+ class Program
+ {
+ private static readonly HttpClient HttpClient = new HttpClient();
+
+ static async Task Main(string[] args)
+ {
+ string project = GetArg(args, "--project", "-p") ?? "myapp";
+ string channel = GetArg(args, "--channel", "-c") ?? "prod";
+ string action = GetArg(args, "--action", "-a") ?? "interactive";
+ string version = GetArg(args, "--version", "-v") ?? "latest";
+ string targetDir = GetArg(args, "--target-dir", "-t") ?? AppDomain.CurrentDomain.BaseDirectory;
+ string baseUrl = GetArg(args, "--base-url") ?? "https://dc.mhdf.de";
+ string restartApp = GetArg(args, "--restart") ?? "";
+
+ targetDir = Path.GetFullPath(targetDir);
+
+ // Read current local manifest if present
+ string localManifestPath = Path.Combine(targetDir, "manifest.json");
+ PackageManifest? currentManifest = null;
+ if (File.Exists(localManifestPath))
+ {
+ try
+ {
+ string json = File.ReadAllText(localManifestPath);
+ currentManifest = JsonSerializer.Deserialize(json);
+ }
+ catch { }
+ }
+
+ string currentVersion = currentManifest?.Version ?? "Unbekannt";
+ string currentBuildDate = currentManifest?.BuildDate ?? "Unbekannt";
+ string currentGit = currentManifest?.GitCommitShort ?? "Unbekannt";
+
+ if (action.Equals("interactive", StringComparison.OrdinalIgnoreCase))
+ {
+ return await RunInteractiveMode(baseUrl, project, channel, currentVersion, currentBuildDate, currentGit, targetDir, restartApp);
+ }
+
+ // CLI Mode
+ return action.ToLowerInvariant() switch
+ {
+ "check" => await DoCheck(baseUrl, project, channel, currentVersion),
+ "update" => await DoUpdateOrRepair(baseUrl, project, channel, version, targetDir, currentManifest, restartApp),
+ "repair" => await DoUpdateOrRepair(baseUrl, project, channel, currentVersion, targetDir, currentManifest, restartApp),
+ "list" => await DoList(baseUrl, project, channel),
+ _ => ShowHelp()
+ };
+ }
+
+ static async Task RunInteractiveMode(
+ string baseUrl,
+ string project,
+ string channel,
+ string currentVer,
+ string currentBuildDate,
+ string currentGit,
+ string targetDir,
+ string restartApp)
+ {
+ AnsiConsole.Write(
+ new FigletText("UpdateAgent")
+ .LeftJustified()
+ .Color(Color.DodgerBlue1));
+
+ var panel = new Panel(
+ $"[bold white]Projekt:[/] [cyan]{project}[/] [bold white]Kanal:[/] [yellow]{channel}[/]\n" +
+ $"[bold white]Installierte Version:[/] [green]{currentVer}[/] ({currentBuildDate}) [bold grey][Git: {currentGit}][/]\n" +
+ $"[bold white]Zielpfad:[/] [grey]{targetDir}[/]")
+ {
+ Header = new PanelHeader("[bold blue] Deploymentcenter Update Agent [/]"),
+ Border = BoxBorder.Rounded
+ };
+ AnsiConsole.Write(panel);
+
+ AnsiConsole.MarkupLine("\n[grey]Lade verfügbare Releases...[/]");
+
+ ReleaseManifest? releaseManifest = null;
+ await AnsiConsole.Status()
+ .Spinner(Spinner.Known.Dots)
+ .StartAsync("Verbinde mit LEMP Server...", async ctx =>
+ {
+ releaseManifest = await FetchManifestAsync(baseUrl, project, channel);
+ });
+
+ if (releaseManifest == null || releaseManifest.Latest == null)
+ {
+ AnsiConsole.MarkupLine("[bold red]Fehler: Koppelung zum LEMP Release Server fehlgeschlagen oder kein Release gefunden.[/]");
+ return 1;
+ }
+
+ var latest = releaseManifest.Latest;
+ bool isUpdateAvailable = UpdateClient.IsVersionNewer(currentVer, latest.Version);
+
+ var choices = new List();
+ string latestLabel = $"[L] Latest ({latest.Version}) - {latest.BuildDate} " + (isUpdateAvailable ? "[bold green]← empfohlen[/]" : "[grey](aktuell)[/]");
+ choices.Add(latestLabel);
+
+ int idx = 1;
+ var verMap = new Dictionary();
+ verMap["latest"] = latest;
+
+ foreach (var ver in releaseManifest.Versions)
+ {
+ string key = $"{idx++}";
+ string label = $"[{key}] Version {ver.Version} - {ver.BuildDate} [grey]({ver.GitCommitShort})[/]";
+ if (ver.Version.Equals(currentVer, StringComparison.OrdinalIgnoreCase))
+ {
+ label += " [cyan]← aktuell installiert[/]";
+ }
+ choices.Add(label);
+ verMap[key] = ver;
+ }
+
+ choices.Add("[R] Reparatur der aktuellen Version");
+ choices.Add("[Q] Beenden");
+
+ var selected = AnsiConsole.Prompt(
+ new SelectionPrompt()
+ .Title("\n[bold white]Bitte wählen Sie eine Aktion:[/]")
+ .PageSize(10)
+ .AddChoices(choices));
+
+ if (selected.StartsWith("[Q]"))
+ {
+ AnsiConsole.MarkupLine("[yellow]Vorgang abgebrochen.[/]");
+ return 0;
+ }
+
+ string targetVersion = "latest";
+ if (selected.StartsWith("[R]"))
+ {
+ targetVersion = currentVer;
+ AnsiConsole.MarkupLine($"\n[bold yellow]Starte Reparatur der Version v{targetVersion}...[/]");
+ }
+ else if (selected.StartsWith("[L]"))
+ {
+ targetVersion = "latest";
+ AnsiConsole.MarkupLine($"\n[bold green]Starte Update auf Version v{latest.Version}...[/]");
+ }
+ else
+ {
+ // Match index
+ foreach (var entry in verMap)
+ {
+ if (entry.Key != "latest" && selected.StartsWith($"[{entry.Key}]"))
+ {
+ targetVersion = entry.Value.Version;
+ break;
+ }
+ }
+ AnsiConsole.MarkupLine($"\n[bold green]Starte Installation von Version v{targetVersion}...[/]");
+ }
+
+ return await DoUpdateOrRepair(baseUrl, project, channel, targetVersion, targetDir, null, restartApp);
+ }
+
+ static async Task DoCheck(string baseUrl, string project, string channel, string currentVer)
+ {
+ var updateClient = new UpdateClient(HttpClient);
+ var res = await updateClient.CheckForUpdateAsync(baseUrl, project, currentVer, channel);
+ if (res.UpdateAvailable && res.LatestRelease != null)
+ {
+ Console.WriteLine($"UPDATE_AVAILABLE: {res.LatestRelease.Version} (Current: {currentVer})");
+ return 0;
+ }
+ Console.WriteLine("UP_TO_DATE");
+ return 0;
+ }
+
+ static async Task DoList(string baseUrl, string project, string channel)
+ {
+ var manifest = await FetchManifestAsync(baseUrl, project, channel);
+ if (manifest == null)
+ {
+ Console.WriteLine("ERROR: Could not fetch manifest.");
+ return 1;
+ }
+
+ Console.WriteLine($"Releases for {project} [{channel}]:");
+ foreach (var v in manifest.Versions)
+ {
+ Console.WriteLine($" - v{v.Version} ({v.BuildDate}) [Git: {v.GitCommitShort}] SHA: {v.Sha256}");
+ }
+ return 0;
+ }
+
+ static async Task DoUpdateOrRepair(
+ string baseUrl,
+ string project,
+ string channel,
+ string targetVersion,
+ string targetDir,
+ PackageManifest? currentManifest,
+ string restartApp)
+ {
+ string tempDir = Path.Combine(Path.GetTempPath(), "dc_update_" + Guid.NewGuid().ToString("N"));
+ Directory.CreateDirectory(tempDir);
+
+ try
+ {
+ var releaseManifest = await FetchManifestAsync(baseUrl, project, channel);
+ if (releaseManifest == null)
+ {
+ AnsiConsole.MarkupLine("[bold red]Fehler: Release-Manifest konnte nicht abgerufen werden.[/]");
+ return 1;
+ }
+
+ VersionInfo? targetRelease = null;
+ if (targetVersion.Equals("latest", StringComparison.OrdinalIgnoreCase))
+ {
+ targetRelease = releaseManifest.Latest;
+ }
+ else
+ {
+ targetRelease = releaseManifest.Versions.FirstOrDefault(v => v.Version.Equals(targetVersion, StringComparison.OrdinalIgnoreCase));
+ }
+
+ if (targetRelease == null)
+ {
+ AnsiConsole.MarkupLine($"[bold red]Fehler: Version '{targetVersion}' wurde auf dem Server nicht gefunden.[/]");
+ return 1;
+ }
+
+ string pkgUrl = targetRelease.PackageUrl;
+ if (string.IsNullOrEmpty(pkgUrl))
+ {
+ pkgUrl = $"{baseUrl.TrimEnd('/')}/releases/{project}/{channel}/{targetRelease.Version}/package.tar.gz";
+ }
+
+ string localPkgPath = Path.Combine(tempDir, "package.tar.gz");
+
+ // 1. Download Package
+ await AnsiConsole.Progress()
+ .Columns(new ProgressColumn[]
+ {
+ new TaskDescriptionColumn(),
+ new ProgressBarColumn(),
+ new PercentageColumn(),
+ new RemainingTimeColumn(),
+ new SpinnerColumn(),
+ })
+ .StartAsync(async ctx =>
+ {
+ var downloadTask = ctx.AddTask($"[green]Lade Package v{targetRelease.Version} herunter...[/]");
+ using var resp = await HttpClient.GetAsync(pkgUrl, HttpCompletionOption.ResponseHeadersRead);
+ resp.EnsureSuccessStatusCode();
+
+ long? totalBytes = resp.Content.Headers.ContentLength;
+ using var stream = await resp.Content.ReadAsStreamAsync();
+ using var fileStream = File.Create(localPkgPath);
+
+ byte[] buffer = new byte[81920];
+ long readBytes = 0;
+ int bytesRead;
+
+ while ((bytesRead = await stream.ReadAsync(buffer, 0, buffer.Length)) > 0)
+ {
+ await fileStream.WriteAsync(buffer, 0, bytesRead);
+ readBytes += bytesRead;
+ if (totalBytes.HasValue && totalBytes.Value > 0)
+ {
+ downloadTask.Value = ((double)readBytes / totalBytes.Value) * 100;
+ }
+ }
+ downloadTask.Value = 100;
+ });
+
+ // 2. Verify SHA256
+ AnsiConsole.MarkupLine("[grey]Prüfe SHA256-Integrität des Pakets...[/]");
+ string computedSha = ComputeSha256(localPkgPath);
+ if (!string.Equals(computedSha, targetRelease.Sha256, StringComparison.OrdinalIgnoreCase))
+ {
+ AnsiConsole.MarkupLine($"[bold red]Sicherheitsfehler: SHA256-Hash stimmt nicht überein![/]");
+ AnsiConsole.MarkupLine($"Erwartet: {targetRelease.Sha256}");
+ AnsiConsole.MarkupLine($"Erhalten: {computedSha}");
+ return 1;
+ }
+ AnsiConsole.MarkupLine("[bold green][✔] SHA256 Hash erfolgreich verifiziert.[/]");
+
+ // 3. Extract to temp staging directory
+ string extractDir = Path.Combine(tempDir, "extracted");
+ Directory.CreateDirectory(extractDir);
+
+ AnsiConsole.MarkupLine("[grey]Entpacke Archiv (tar.gz)...[/]");
+ using (var fs = File.OpenRead(localPkgPath))
+ using (var gz = new GZipStream(fs, CompressionMode.Decompress))
+ {
+ TarFile.ExtractToDirectory(gz, extractDir, overwriteFiles: true);
+ }
+
+ // 4. Verify extracted files against manifest.json
+ string extractedManifestPath = Path.Combine(extractDir, "manifest.json");
+ if (File.Exists(extractedManifestPath))
+ {
+ string mJson = await File.ReadAllTextAsync(extractedManifestPath);
+ var pkgManifest = JsonSerializer.Deserialize(mJson);
+ if (pkgManifest != null)
+ {
+ var integrity = UpdateClient.VerifyIntegrity(extractDir, pkgManifest);
+ if (!integrity.IsValid)
+ {
+ AnsiConsole.MarkupLine("[bold red]Fehler bei Dateivalidierung nach Entpacken![/]");
+ foreach (var missing in integrity.MissingFiles) AnsiConsole.MarkupLine($" - Fehlt: {missing}");
+ foreach (var corrupt in integrity.CorruptedFiles) AnsiConsole.MarkupLine($" - Beschädigt: {corrupt}");
+ return 1;
+ }
+ AnsiConsole.MarkupLine($"[bold green][✔] {integrity.TotalCheckedFiles} Dateien gegen Manifest-Hashes verifiziert.[/]");
+ }
+ }
+
+ // 5. Apply Update (Atomic Replace with Backup)
+ AnsiConsole.MarkupLine("[grey]Übertrage neue Dateien in Zielverzeichnis...[/]");
+ ApplyFiles(extractDir, targetDir);
+
+ AnsiConsole.MarkupLine($"\n[bold green]🚀 Update auf Version v{targetRelease.Version} erfolgreich abgeschlossen![/]");
+
+ // 6. Restart App if requested
+ if (!string.IsNullOrEmpty(restartApp) && File.Exists(restartApp))
+ {
+ AnsiConsole.MarkupLine($"[grey]Starte Hauptanwendung neu ({Path.GetFileName(restartApp)})...[/]");
+ Process.Start(new ProcessStartInfo { FileName = restartApp, UseShellExecute = true });
+ }
+
+ return 0;
+ }
+ catch (Exception ex)
+ {
+ AnsiConsole.MarkupLine($"[bold red]Fehler während des Update-Vorgangs: {ex.Message}[/]");
+ return 1;
+ }
+ finally
+ {
+ try { Directory.Delete(tempDir, true); } catch { }
+ }
+ }
+
+ static void ApplyFiles(string sourceDir, string targetDir)
+ {
+ Directory.CreateDirectory(targetDir);
+
+ // Copy recursively, overwriting files
+ foreach (string dirPath in Directory.GetDirectories(sourceDir, "*", SearchOption.AllDirectories))
+ {
+ Directory.CreateDirectory(dirPath.Replace(sourceDir, targetDir));
+ }
+
+ foreach (string newPath in Directory.GetFiles(sourceDir, "*.*", SearchOption.AllDirectories))
+ {
+ string targetPath = newPath.Replace(sourceDir, targetDir);
+ File.Copy(newPath, targetPath, true);
+ }
+ }
+
+ static async Task FetchManifestAsync(string baseUrl, string project, string channel)
+ {
+ try
+ {
+ string url = $"{baseUrl.TrimEnd('/')}/releases/{project}/{channel}/latest.json";
+ var resp = await HttpClient.GetAsync(url);
+ if (resp.IsSuccessStatusCode)
+ {
+ string json = await resp.Content.ReadAsStringAsync();
+ return JsonSerializer.Deserialize(json);
+ }
+ }
+ catch { }
+ return null;
+ }
+
+ static string ComputeSha256(string file)
+ {
+ using var sha256 = SHA256.Create();
+ using var stream = File.OpenRead(file);
+ byte[] hash = sha256.ComputeHash(stream);
+ return BitConverter.ToString(hash).Replace("-", "").ToLowerInvariant();
+ }
+
+ static string? GetArg(string[] args, string flagLong, string? flagShort = null)
+ {
+ for (int i = 0; i < args.Length - 1; i++)
+ {
+ if (args[i].Equals(flagLong, StringComparison.OrdinalIgnoreCase) ||
+ (flagShort != null && args[i].Equals(flagShort, StringComparison.OrdinalIgnoreCase)))
+ {
+ return args[i + 1];
+ }
+ }
+ return null;
+ }
+
+ static int ShowHelp()
+ {
+ Console.WriteLine("Deploymentcenter Update Agent");
+ Console.WriteLine("Usage: update-agent [options]");
+ Console.WriteLine("Options:");
+ Console.WriteLine(" --project, -p Project slug");
+ Console.WriteLine(" --channel, -c Channel (prod, beta, dev)");
+ Console.WriteLine(" --action, -a interactive | check | update | repair | list");
+ Console.WriteLine(" --version, -v Target version or 'latest'");
+ Console.WriteLine(" --target-dir, -t Directory to update");
+ Console.WriteLine(" --restart Executable to restart upon completion");
+ return 0;
+ }
+ }
+}
diff --git a/client-dotnet/Deploymentcenter.slnx b/client-dotnet/Deploymentcenter.slnx
new file mode 100644
index 0000000..dd8acd9
--- /dev/null
+++ b/client-dotnet/Deploymentcenter.slnx
@@ -0,0 +1,6 @@
+
+
+
+
+
+
diff --git a/docs/BUGTRACKER_INTEGRATION_GUIDE.md b/docs/BUGTRACKER_INTEGRATION_GUIDE.md
new file mode 100644
index 0000000..8e67a75
--- /dev/null
+++ b/docs/BUGTRACKER_INTEGRATION_GUIDE.md
@@ -0,0 +1,156 @@
+# Deploymentcenter — Bugtracker, Feature-Tracker & Token Provisioning Guide
+
+> **Zielgruppe**: KI-Agenten & Softwareentwickler
+> **Zweck**: Anleitung zur automatisierten Registrierung, Fehlererfassung, Feature-Einreichung und Behebung über das Deployment Center.
+
+---
+
+## 1. Token-Architektur & Autonome Sub-Token-Erstellung
+
+Das Deployment Center nutzt eine hierarchische Token-Struktur:
+- **Master-Token**: Wird im Deployment Center Admin-Dashboard pro Identität (z. B. Entwickler-Agent, Client-Anwendung, Host-Server) erstellt.
+- **Sub-Token**: Wird von Anwendungen oder KI-Agenten autonom über die Provisioning-API angefordert und für API-Aufrufe genutzt.
+
+### Sub-Token Anfordern (`POST /api/tokens/v1/provision`)
+Sendet einen HTTP POST Request mit dem Master-Token im `X-Master-Token` Header.
+
+```http
+POST /api/tokens/v1/provision HTTP/1.1
+Host: dc.mhdf.de
+Content-Type: application/json
+X-Master-Token: dc_master_myapp_dev_agent_001
+
+{
+ "name": "Dev Workstation Agent #4",
+ "instance_id": "DEV-WORKSTATION-01",
+ "scopes": ["bugtracker:report", "bugtracker:manage"],
+ "environment": "development"
+}
+```
+
+#### JSON Antwort:
+```json
+{
+ "status": "success",
+ "sub_token": "dc_sub_5f8a2c1...",
+ "token_id": "tok_s_89a1b2c3",
+ "name": "Dev Workstation Agent #4",
+ "scopes": ["bugtracker:report", "bugtracker:manage"],
+ "environment": "development"
+}
+```
+
+---
+
+## 2. Bug & Feature Ingest API (`POST /api/bugtracker/v1/report`)
+
+Wird von Überwachungs-Agenten im laufenden Betrieb oder Entwickler-Agenten auf der Workstation genutzt.
+
+### Request Schema:
+```json
+{
+ "project_slug": "myapp",
+ "type": "bug", // "bug" oder "feature_request"
+ "environment": "development", // "production", "development", "staging", "testing"
+ "severity": "high", // "low", "medium", "high", "critical"
+ "title": "NullReferenceException in UserAuthService.cs line 42",
+ "description": "Beim Login ohne gesetzte Session ist ein Unerwarteter Nullpointer-Fehler aufgetreten.",
+ "error_message": "NullReferenceException: Object reference not set to an instance of an object.",
+ "stack_trace": "at MyApp.Core.UserAuthService.ValidateToken(String token)...",
+ "build_version": "v1.4.2-dev",
+ "created_by": "agent:dev-monitor-01"
+}
+```
+
+#### Besonderheiten:
+- **Deduplizierung**: Bei Bugs berechnet das System automatisch einen Error-Hash. Tritt derselbe Fehler in derselben Umgebung erneut auf, wird kein neuer Bug angelegt, sondern `occurrence_count` hochgezählt.
+
+---
+
+## 3. Geschützte Management & Resolution API (`/api/bugtracker/v1/manage`)
+
+Schnittstelle für KI-Behebungs-Agenten zum Abrufen offener Bugs, Schreiben von Diagnose-Notizen und Markieren als gelöst.
+
+**Header**: `Authorization: Bearer ` (Benötigt Scope `bugtracker:manage`).
+
+### 3.1 Offene Items Abfragen (`GET /api/bugtracker/v1/manage/index.php`)
+Filter-Parameter: `environment` (`development`/`production`), `type` (`bug`/`feature_request`), `status` (`open`/`in_progress`/`resolved`).
+
+### 3.2 Item-Details & Kommentar-Historie (`GET /api/bugtracker/v1/manage/index.php?id=123`)
+
+### 3.3 Ermittlungsschritt / Kommentar Hinzufügen (`POST /api/bugtracker/v1/manage/index.php?action=comment&id=123`)
+```json
+{
+ "comment": "Log-Analyse gestartet. Der Fehler tritt auf, wenn $_SESSION['dc_user_id'] nicht gesetzt ist.",
+ "author": "agent:code-fixer-01",
+ "action_taken": "investigated"
+}
+```
+
+### 3.4 Item als Gelöst / Umgesetzt Markieren (`POST /api/bugtracker/v1/manage/index.php?action=resolve&id=123`)
+```json
+{
+ "resolved_in_build": "v1.4.3",
+ "resolution_notes": "Null-Check für Session-Variable hinzugefügt.",
+ "author": "agent:code-fixer-01"
+}
+```
+
+---
+
+## 4. C# (.NET 8) Implementierungsbeispiel für KI-Agenten
+
+```csharp
+using System;
+using System.Net.Http;
+using System.Text;
+using System.Text.Json;
+using System.Threading.Tasks;
+
+public class DeploymentCenterBugtrackerClient
+{
+ private static readonly HttpClient HttpClient = new HttpClient();
+ private const string BaseUrl = "https://dc.mhdf.de";
+
+ public static async Task IngestDevBugAsync(string masterToken, string title, string errorMessage, string stackTrace)
+ {
+ // 1. Sub-Token anfordern
+ var provisionReq = new HttpRequestMessage(HttpMethod.Post, $"{BaseUrl}/api/tokens/v1/provision")
+ {
+ Content = new StringContent(JsonSerializer.Serialize(new
+ {
+ client_name = "Dev Agent Client",
+ scopes = new[] { "bugtracker:report" },
+ environment = "development"
+ }), Encoding.UTF8, "application/json")
+ };
+ provisionReq.Headers.Add("X-Master-Token", masterToken);
+
+ var provResponse = await HttpClient.SendAsync(provisionReq);
+ var provJson = await provResponse.Content.ReadAsStringAsync();
+ using var doc = JsonDocument.Parse(provJson);
+ string subToken = doc.RootElement.GetProperty("sub_token").GetString();
+
+ // 2. Bug melden
+ var reportReq = new HttpRequestMessage(HttpMethod.Post, $"{BaseUrl}/api/bugtracker/v1/report")
+ {
+ Content = new StringContent(JsonSerializer.Serialize(new
+ {
+ project_slug = "myapp",
+ type = "bug",
+ environment = "development",
+ severity = "high",
+ title = title,
+ error_message = errorMessage,
+ stack_trace = stackTrace,
+ build_version = "v1.4.2-dev",
+ created_by = "agent:dev-agent-01"
+ }), Encoding.UTF8, "application/json")
+ };
+ reportReq.Headers.Add("Authorization", $"Bearer {subToken}");
+
+ var reportResponse = await HttpClient.SendAsync(reportReq);
+ Console.WriteLine($"Report Response: {reportResponse.StatusCode}");
+ }
+}
+```
diff --git a/docs/UPDATESERVICE_INTEGRATION_GUIDE.md b/docs/UPDATESERVICE_INTEGRATION_GUIDE.md
new file mode 100644
index 0000000..a3aaeab
--- /dev/null
+++ b/docs/UPDATESERVICE_INTEGRATION_GUIDE.md
@@ -0,0 +1,133 @@
+# Deploymentcenter — UpdateService Integration & Deployment Guide
+
+Das **UpdateService-Modul** des Deploymentcenters bietet ein unternehmensweites, leichtgewichtiges Update-, Rollback- und Reparatur-Schema auf Basis eines LEMP-Stacks (Nginx Static Files + PHP API).
+
+---
+
+## 1. Übersicht & Architektur
+
+- **Kein dauerhafter Background-Dienst**: Hauptanwendungen prüfen beim Start einmalig schnell und netzwerktolerant auf verfügbare Updates und Dateiintegrität.
+- **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).
+- **Statische LEMP-Verteilung**: Downloads und Versionen-Manifeste (`latest.json`, `manifest.json`, `package.tar.gz`) werden über Nginx extrem performant bereitgestellt.
+
+---
+
+## 2. Integration in .NET Client-Anwendungen
+
+### A. Referenz auf `Deploymentcenter.Client`
+
+Binde das Projekt oder Paket `Deploymentcenter.Client` in deine Anwendung ein.
+
+```csharp
+using Deploymentcenter.Client;
+using Deploymentcenter.Client.Models;
+
+// Early Start Hook in Program.cs (oder App.xaml.cs)
+var updateClient = new UpdateClient();
+var checkResult = await updateClient.CheckForUpdateAsync(
+ baseUrl: "https://dc.mhdf.de",
+ projectId: "myapp",
+ currentVersion: BuildInfo.Version,
+ channel: "prod"
+);
+
+if (checkResult.UpdateAvailable)
+{
+ Console.WriteLine($"[UPDATE] Neues Release v{checkResult.LatestRelease.Version} verfügbar!");
+
+ // UpdateAgent starten und Hauptanwendung beenden
+ UpdateClient.LaunchUpdateAgent(
+ agentPath: "update-agent.exe",
+ projectId: "myapp",
+ channel: "prod",
+ action: "update",
+ version: "latest",
+ exitCurrentApp: true
+ );
+}
+```
+
+### B. MSBuild BuildInfo Generierung
+
+Binde das `Deploymentcenter.BuildInfo.targets` Script in deine `.csproj` ein, damit Version, UTC-Build-Datum und Git Commit-Hash automatisch zur Übersetzungszeit generiert werden:
+
+```xml
+
+```
+
+---
+
+## 3. Packaging & Deployment CLI (`pack-and-deploy`)
+
+Das Packaging-Tool verpackt den `dotnet publish`-Output, berechnet Hashes, erzeugt das `manifest.json` und lädt alles per FTP auf den LEMP-Server.
+
+### Aufruf-Beispiel:
+
+```bash
+# Automatisierter Release-Publish via CLI
+pack-and-deploy --project myapp --version 1.4.0 --channel prod --publish-dir ./bin/Release/net8.0/publish --changelog "Fehlerbehebungen und Performance-Optimierung"
+```
+
+### Konfiguration (`packager.config.json`):
+
+```json
+{
+ "ftpHost": "www531.your-server.de",
+ "ftpPort": 21,
+ "ftpUser": "bergisnu_4",
+ "ftpPass": "o2#M*NN^5EsT",
+ "ftpRemoteBaseDir": "/public_html/releases",
+ "apiBaseUrl": "https://dc.mhdf.de",
+ "excludePatterns": [
+ "*.pdb",
+ "*.xml",
+ "appsettings.Development.json",
+ "*.log",
+ "logs/*"
+ ]
+}
+```
+
+---
+
+## 4. Standalone UpdateAgent (`update-agent`)
+
+Der `update-agent` kann sowohl interaktiv (Spectre.Console Terminal UI) als auch im Headless CLI-Modus betrieben werden.
+
+### CLI Modus Befehle:
+
+```bash
+# Nach Updates suchen
+update-agent --project myapp --channel prod --action check
+
+# Auf neueste Version aktualisieren
+update-agent --project myapp --channel prod --action update --version latest --target-dir /opt/myapp
+
+# Rollback auf ältere Version
+update-agent --project myapp --channel prod --action update --version 1.3.2
+
+# Integritäts-Reparatur der aktuellen Installation
+update-agent --project myapp --channel prod --action repair
+
+# Alle verfügbaren Versionen auflisten
+update-agent --project myapp --channel prod --action list
+```
+
+---
+
+## 5. LEMP Verzeichnisstruktur auf dem Server
+
+```text
+/var/www/releases/ (oder /public_html/releases/)
+└── {ProjectId}/ # z.B. myapp, polytrader
+ ├── prod/
+ │ ├── latest.json # Kanal-Übersicht & neueste Version
+ │ ├── 1.4.0/
+ │ │ ├── package.tar.gz # Das gezippte Release
+ │ │ ├── package.tar.gz.sha256
+ │ │ └── manifest.json # Einzeldateien + Hashes
+ │ └── 1.3.9/
+ ├── beta/
+ └── dev/
+```
diff --git a/public/api/bugtracker/v1/manage/.htaccess b/public/api/bugtracker/v1/manage/.htaccess
new file mode 100644
index 0000000..59fe54b
--- /dev/null
+++ b/public/api/bugtracker/v1/manage/.htaccess
@@ -0,0 +1,9 @@
+# Bugtracker Protected Management API (.htaccess Security Layer)
+# Allows authenticated API token requests or session-authenticated admin users
+
+Satisfy Any
+Allow from all
+
+
+ Require all granted
+
diff --git a/public/api/bugtracker/v1/manage/index.php b/public/api/bugtracker/v1/manage/index.php
new file mode 100644
index 0000000..55df494
--- /dev/null
+++ b/public/api/bugtracker/v1/manage/index.php
@@ -0,0 +1,193 @@
+validateToken($token, 'bugtracker:manage');
+ if ($tokenInfo) {
+ $isAuthenticated = true;
+ $authorName = 'agent:' . ($tokenInfo['name'] ?? $tokenInfo['token_id']);
+ }
+ }
+ }
+
+ if (!$isAuthenticated) {
+ http_response_code(401);
+ echo json_encode(['status' => 'error', 'message' => 'Unauthorized: Valid Session or Bearer Token with scope bugtracker:manage required']);
+ exit;
+ }
+
+ $repo = new BugRepo($db);
+
+ $uri = $_SERVER['REQUEST_URI'];
+ $method = $_SERVER['REQUEST_METHOD'];
+
+ $rawInput = file_get_contents('php://input');
+ $input = json_decode($rawInput, true) ?: $_POST;
+
+ // Parse sub-route if any
+ $path = parse_url($uri, PHP_URL_PATH);
+ $action = $_GET['action'] ?? null;
+
+ // Handle Item Detail/Comment/Resolve via ID in URL or query params
+ $itemId = isset($_GET['id']) ? (int)$_GET['id'] : 0;
+ if (!$itemId && preg_match('/\/manage\/items\/(\d+)/', $path, $m)) {
+ $itemId = (int)$m[1];
+ }
+
+ // Sub-actions
+ if ($action === 'stats' || str_ends_with($path, '/stats')) {
+ echo json_encode(['status' => 'success', 'stats' => $repo->getStats()], JSON_PRETTY_PRINT);
+ exit;
+ }
+
+ if ($action === 'resolve' || str_contains($path, '/resolve')) {
+ if ($method !== 'POST') {
+ http_response_code(405);
+ echo json_encode(['status' => 'error', 'message' => 'POST required for resolve']);
+ exit;
+ }
+
+ if (!$itemId) {
+ http_response_code(400);
+ echo json_encode(['status' => 'error', 'message' => 'Missing item ID']);
+ exit;
+ }
+
+ $build = !empty($input['resolved_in_build']) ? trim($input['resolved_in_build']) : 'v1.0.0';
+ $notes = !empty($input['resolution_notes']) ? trim($input['resolution_notes']) : null;
+ $author = !empty($input['author']) ? trim($input['author']) : $authorName;
+
+ $ok = $repo->resolveItem($itemId, $build, $notes, $author);
+ if ($ok) {
+ echo json_encode(['status' => 'success', 'message' => "Item #{$itemId} resolved in build {$build}"]);
+ } else {
+ http_response_code(400);
+ echo json_encode(['status' => 'error', 'message' => 'Failed to resolve item']);
+ }
+ exit;
+ }
+
+ if ($action === 'comment' || str_contains($path, '/comments')) {
+ if ($method !== 'POST') {
+ http_response_code(405);
+ echo json_encode(['status' => 'error', 'message' => 'POST required for comment']);
+ exit;
+ }
+
+ if (!$itemId) {
+ http_response_code(400);
+ echo json_encode(['status' => 'error', 'message' => 'Missing item ID']);
+ exit;
+ }
+
+ $comment = !empty($input['comment']) ? trim($input['comment']) : '';
+ if (empty($comment)) {
+ http_response_code(400);
+ echo json_encode(['status' => 'error', 'message' => 'Comment cannot be empty']);
+ exit;
+ }
+
+ $author = !empty($input['author']) ? trim($input['author']) : $authorName;
+ $actionTaken = !empty($input['action_taken']) ? trim($input['action_taken']) : 'commented';
+ $meta = isset($input['meta']) && is_array($input['meta']) ? $input['meta'] : null;
+
+ $comm = $repo->addComment($itemId, $author, $comment, $actionTaken, $meta);
+ echo json_encode(['status' => 'success', 'comment' => $comm]);
+ exit;
+ }
+
+ if ($action === 'status' || str_contains($path, '/status')) {
+ if ($method !== 'POST') {
+ http_response_code(405);
+ echo json_encode(['status' => 'error', 'message' => 'POST required for status change']);
+ exit;
+ }
+
+ if (!$itemId) {
+ http_response_code(400);
+ echo json_encode(['status' => 'error', 'message' => 'Missing item ID']);
+ exit;
+ }
+
+ $status = !empty($input['status']) ? trim($input['status']) : 'open';
+ $notes = !empty($input['notes']) ? trim($input['notes']) : null;
+ $author = !empty($input['author']) ? trim($input['author']) : $authorName;
+
+ $ok = $repo->updateStatus($itemId, $status, $notes, $author);
+ if ($ok) {
+ echo json_encode(['status' => 'success', 'message' => "Status for #{$itemId} updated to {$status}"]);
+ } else {
+ http_response_code(400);
+ echo json_encode(['status' => 'error', 'message' => 'Invalid status']);
+ }
+ exit;
+ }
+
+ // Detail View of a single item
+ if ($itemId > 0 && $method === 'GET') {
+ $details = $repo->getItemDetails($itemId);
+ if (!$details) {
+ http_response_code(404);
+ echo json_encode(['status' => 'error', 'message' => 'Item not found']);
+ exit;
+ }
+ echo json_encode(['status' => 'success', 'item' => $details], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
+ exit;
+ }
+
+ // Default: List Items
+ $filters = [
+ 'project_slug' => $_GET['project_slug'] ?? $_GET['project'] ?? 'all',
+ 'environment' => $_GET['environment'] ?? $_GET['env'] ?? 'all',
+ 'type' => $_GET['type'] ?? 'all',
+ 'status' => $_GET['status'] ?? 'all',
+ 'severity' => $_GET['severity'] ?? 'all',
+ 'search' => $_GET['search'] ?? $_GET['q'] ?? '',
+ ];
+
+ $items = $repo->getItems($filters);
+ echo json_encode([
+ 'status' => 'success',
+ 'count' => count($items),
+ 'filters' => $filters,
+ 'items' => $items,
+ ], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
+
+} catch (Throwable $t) {
+ http_response_code(500);
+ echo json_encode(['status' => 'error', 'message' => 'Manage API Error: ' . $t->getMessage()]);
+}
diff --git a/public/api/bugtracker/v1/report.php b/public/api/bugtracker/v1/report.php
new file mode 100644
index 0000000..7a18825
--- /dev/null
+++ b/public/api/bugtracker/v1/report.php
@@ -0,0 +1,82 @@
+ 'error', 'message' => 'Method Not Allowed']);
+ exit;
+}
+
+$rawInput = file_get_contents('php://input');
+$data = json_decode($rawInput, true) ?: $_POST;
+
+if (empty($data)) {
+ http_response_code(400);
+ echo json_encode(['status' => 'error', 'message' => 'Empty request body or invalid JSON']);
+ exit;
+}
+
+try {
+ $config = require __DIR__ . '/../../../../config/config.php';
+ $db = Db::connect($config['db']);
+
+ // Optional Token Verification (if provided)
+ $headers = getallheaders();
+ $token = $headers['X-Agent-Token'] ?? $headers['x-agent-token'] ?? null;
+ if (!$token && !empty($headers['Authorization'])) {
+ if (preg_match('/Bearer\s+(.+)/i', $headers['Authorization'], $matches)) {
+ $token = trim($matches[1]);
+ }
+ }
+
+ if ($token) {
+ $tokenMgr = new TokenManager($db);
+ $valid = $tokenMgr->validateToken($token, 'bugtracker:report', $data['environment'] ?? null);
+ if (!$valid) {
+ http_response_code(401);
+ echo json_encode(['status' => 'error', 'message' => 'Invalid, revoked or unauthorized Token for bugtracker:report']);
+ exit;
+ }
+ }
+
+ $repo = new BugRepo($db);
+ $result = $repo->reportItem($data);
+
+ echo json_encode([
+ 'status' => 'success',
+ 'item_id' => $result['id'],
+ 'is_new' => $result['is_new'],
+ 'occurrence_count' => $result['occurrence_count'],
+ 'error_hash' => $result['error_hash'],
+ 'type' => $result['type'],
+ 'environment' => $result['environment'],
+ 'message' => $result['is_new']
+ ? ($result['type'] === 'bug' ? 'New bug reported successfully.' : 'New feature request submitted.')
+ : 'Recurring bug count updated.',
+ ], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
+
+} catch (Throwable $t) {
+ http_response_code(500);
+ echo json_encode(['status' => 'error', 'message' => 'Failed to log report: ' . $t->getMessage()]);
+}
diff --git a/public/api/tokens/v1/provision.php b/public/api/tokens/v1/provision.php
new file mode 100644
index 0000000..ecc84ee
--- /dev/null
+++ b/public/api/tokens/v1/provision.php
@@ -0,0 +1,77 @@
+ 'error', 'message' => 'Method Not Allowed']);
+ exit;
+}
+
+// Extract Master Token from Headers
+$headers = getallheaders();
+$masterToken = $headers['X-Master-Token'] ?? $headers['x-master-token'] ?? null;
+
+if (!$masterToken && !empty($headers['Authorization'])) {
+ if (preg_match('/Bearer\s+(.+)/i', $headers['Authorization'], $matches)) {
+ $masterToken = trim($matches[1]);
+ }
+}
+
+$rawInput = file_get_contents('php://input');
+$data = json_decode($rawInput, true) ?: $_POST;
+
+if (!$masterToken && !empty($data['master_token'])) {
+ $masterToken = trim($data['master_token']);
+}
+
+if (!$masterToken) {
+ http_response_code(401);
+ echo json_encode(['status' => 'error', 'message' => 'Missing Master Token in X-Master-Token header or Authorization Bearer header']);
+ exit;
+}
+
+try {
+ $config = require __DIR__ . '/../../../../config/config.php';
+ $db = Db::connect($config['db']);
+ $tokenMgr = new TokenManager($db);
+
+ $name = !empty($data['client_name']) ? trim($data['client_name']) : (!empty($data['name']) ? trim($data['name']) : 'Auto-Provisioned Agent Sub-Token');
+ $instanceIdentity = !empty($data['instance_id']) ? trim($data['instance_id']) : (!empty($data['hostname']) ? trim($data['hostname']) : null);
+ $requestedScopes = isset($data['scopes']) && is_array($data['scopes']) ? $data['scopes'] : [];
+ $environment = !empty($data['environment']) ? trim($data['environment']) : 'all';
+
+ $subTokenData = $tokenMgr->provisionSubToken(
+ $masterToken,
+ $name,
+ $instanceIdentity,
+ $requestedScopes,
+ $environment
+ );
+
+ echo json_encode([
+ 'status' => 'success',
+ 'sub_token' => $subTokenData['raw_token'],
+ 'token_id' => $subTokenData['token_id'],
+ 'name' => $subTokenData['name'],
+ 'scopes' => $subTokenData['scopes'],
+ 'environment' => $subTokenData['environment'],
+ 'type' => 'sub',
+ 'created_at' => date('Y-m-d H:i:s'),
+ ], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
+
+} catch (InvalidArgumentException $e) {
+ http_response_code(400);
+ echo json_encode(['status' => 'error', 'message' => $e->getMessage()]);
+} catch (Throwable $t) {
+ http_response_code(500);
+ echo json_encode(['status' => 'error', 'message' => 'Internal server error: ' . $t->getMessage()]);
+}
diff --git a/public/api/updateservice/v1/index.php b/public/api/updateservice/v1/index.php
index 13ada8b..66fd921 100644
--- a/public/api/updateservice/v1/index.php
+++ b/public/api/updateservice/v1/index.php
@@ -25,15 +25,51 @@ try {
$updateMgr = new UpdateManager($pdo);
+ // Read JSON body for POST requests if available
+ $inputData = [];
+ if ($method === 'POST') {
+ $raw = file_get_contents('php://input');
+ if (!empty($raw)) {
+ $inputData = json_decode($raw, true) ?? [];
+ }
+ }
+
+ $action = $_REQUEST['action'] ?? $inputData['action'] ?? '';
+
+ // Action: Publish Release (from Packager CLI)
+ if ($action === 'publish_release' && $method === 'POST') {
+ $product = $inputData['product_slug'] ?? $_POST['product_slug'] ?? '';
+ $version = $inputData['version'] ?? $_POST['version'] ?? '';
+ $channel = $inputData['channel'] ?? $_POST['channel'] ?? 'prod';
+ $url = $inputData['download_url'] ?? $_POST['download_url'] ?? '';
+ $hash = $inputData['sha256_hash'] ?? $_POST['sha256_hash'] ?? null;
+ $gitCommit = $inputData['git_commit'] ?? $_POST['git_commit'] ?? null;
+ $sizeBytes = (int)($inputData['size_bytes'] ?? $_POST['size_bytes'] ?? 0);
+ $notes = $inputData['release_notes'] ?? $_POST['release_notes'] ?? null;
+ $isCritical= !empty($inputData['is_critical']) || !empty($_POST['is_critical']);
+
+ if (empty($product) || empty($version) || empty($url)) {
+ sendResponse(['error' => 'Bad Request', 'message' => 'Missing required fields: product_slug, version, download_url'], 400);
+ }
+
+ $ok = $updateMgr->addRelease($product, $version, $channel, $notes, $url, $hash, $gitCommit, $sizeBytes, null, $isCritical);
+ if ($ok) {
+ sendResponse(['status' => 'success', 'message' => "Release v{$version} published for {$product} ({$channel})."]);
+ } else {
+ sendResponse(['error' => 'Database Error', 'message' => 'Failed to store release.'], 500);
+ }
+ }
+
if (str_ends_with($uri, '/check') && ($method === 'GET' || $method === 'POST')) {
$product = $_REQUEST['product'] ?? $_REQUEST['product_slug'] ?? '';
$version = $_REQUEST['version'] ?? $_REQUEST['current_version'] ?? '0.0.0';
+ $channel = $_REQUEST['channel'] ?? 'prod';
if (empty($product)) {
sendResponse(['error' => 'Bad Request', 'message' => 'Parameter "product" is required.'], 400);
}
- $latest = $updateMgr->checkUpdate($product, $version);
+ $latest = $updateMgr->checkUpdate($product, $version, $channel);
if ($latest) {
sendResponse([
'update_available' => true,
@@ -49,7 +85,8 @@ try {
if (str_ends_with($uri, '/releases') && $method === 'GET') {
$product = $_GET['product'] ?? null;
- $releases = $updateMgr->getReleases($product);
+ $channel = $_GET['channel'] ?? null;
+ $releases = $updateMgr->getReleases($product, $channel);
sendResponse(['count' => count($releases), 'releases' => $releases]);
}
diff --git a/public/index.php b/public/index.php
index 9f4de0c..dde45c0 100644
--- a/public/index.php
+++ b/public/index.php
@@ -9,15 +9,19 @@ require_once __DIR__ . '/../src/Modules/License/LicenseService.php';
require_once __DIR__ . '/../src/Modules/Watchdog/MonitorRepo.php';
require_once __DIR__ . '/../src/Modules/Watchdog/EventLog.php';
require_once __DIR__ . '/../src/Modules/Watchdog/TokenManager.php';
+require_once __DIR__ . '/../src/Core/TokenManager.php';
require_once __DIR__ . '/../src/Modules/UpdateService/UpdateManager.php';
+require_once __DIR__ . '/../src/Modules/Bugtracker/BugRepo.php';
use Deploymentcenter\Core\Db;
use Deploymentcenter\Core\Auth;
+use Deploymentcenter\Core\TokenManager as CoreTokenManager;
use Deploymentcenter\Modules\License\KeyGen;
use Deploymentcenter\Modules\Watchdog\MonitorRepo;
use Deploymentcenter\Modules\Watchdog\EventLog;
use Deploymentcenter\Modules\Watchdog\TokenManager;
use Deploymentcenter\Modules\UpdateService\UpdateManager;
+use Deploymentcenter\Modules\Bugtracker\BugRepo;
Auth::requireLogin();
@@ -388,21 +392,123 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if ($action === 'add_release') {
$productSlug = trim($_POST['product_slug'] ?? '');
$version = trim($_POST['version'] ?? '');
+ $channel = trim($_POST['channel'] ?? 'prod');
$url = trim($_POST['download_url'] ?? '');
$hash = trim($_POST['sha256_hash'] ?? '');
+ $gitCommit = trim($_POST['git_commit'] ?? '');
+ $sizeBytes = (int)($_POST['size_bytes'] ?? 0);
$notes = trim($_POST['release_notes'] ?? '');
$critical = isset($_POST['is_critical']);
if ($productSlug && $version && $url) {
$updMgr = new UpdateManager($pdo);
- if ($updMgr->addRelease($productSlug, $version, $notes, $url, $hash, $critical)) {
- $msg = "Release v{$version} für Projekt '{$productSlug}' veröffentlicht.";
+ if ($updMgr->addRelease($productSlug, $version, $channel, $notes, $url, $hash, $gitCommit, $sizeBytes, null, $critical)) {
+ $msg = "Release v{$version} ({$channel}) für Projekt '{$productSlug}' veröffentlicht.";
} else {
$msg = "Fehler beim Speichern des Releases.";
$msgType = 'danger';
}
}
}
+
+ // Core Master & Sub Token Management Actions
+ if ($action === 'create_master_token') {
+ $name = trim($_POST['name'] ?? '');
+ $proj = trim($_POST['project_slug'] ?? '');
+ $lic = trim($_POST['license_key'] ?? '');
+ $ownerType = $_POST['owner_type'] ?? 'custom';
+ $ownerIdentity = trim($_POST['owner_identity'] ?? '');
+ $scopes = isset($_POST['scopes']) && is_array($_POST['scopes']) ? $_POST['scopes'] : ['*'];
+ $env = $_POST['environment'] ?? 'all';
+
+ if ($name) {
+ $coreTokenMgr = new CoreTokenManager($pdo);
+ $res = $coreTokenMgr->createMasterToken($name, $proj, $lic, $ownerType, $ownerIdentity, $scopes, $env);
+ $msg = "Master-Token '{$name}' erstellt! Raw Master-Token (einmalig kopieren): {$res['raw_token']}";
+ }
+ }
+
+ if ($action === 'revoke_core_token') {
+ $tokenId = trim($_POST['token_id'] ?? '');
+ if ($tokenId) {
+ $coreTokenMgr = new CoreTokenManager($pdo);
+ $coreTokenMgr->revokeToken($tokenId);
+ $msg = "Token '{$tokenId}' und alle abgeleiteten Sub-Tokens wurden widerrufen.";
+ }
+ }
+
+ // Bugtracker & Feature-Tracker Actions
+ if ($action === 'bt_add_comment') {
+ $itemId = (int)($_POST['item_id'] ?? 0);
+ $comment = trim($_POST['comment'] ?? '');
+ $author = trim($_POST['author'] ?? $_SESSION['dc_username'] ?? 'admin');
+ $actionTaken = trim($_POST['action_taken'] ?? 'commented');
+
+ if ($itemId > 0 && !empty($comment)) {
+ $bugRepo = new BugRepo($pdo);
+ $bugRepo->addComment($itemId, $author, $comment, $actionTaken);
+ $msg = "Kommentar zu Item #{$itemId} hinzugefügt.";
+ }
+ }
+
+ if ($action === 'bt_resolve') {
+ $itemId = (int)($_POST['item_id'] ?? 0);
+ $build = trim($_POST['resolved_in_build'] ?? 'v1.0.0');
+ $notes = trim($_POST['resolution_notes'] ?? '');
+ $author = trim($_POST['author'] ?? $_SESSION['dc_username'] ?? 'admin');
+
+ if ($itemId > 0 && !empty($build)) {
+ $bugRepo = new BugRepo($pdo);
+ $bugRepo->resolveItem($itemId, $build, $notes, $author);
+ $msg = "Item #{$itemId} wurde als gelöst/umgesetzt in Build '{$build}' markiert.";
+ }
+ }
+
+ if ($action === 'bt_create_item') {
+ $proj = trim($_POST['project_slug'] ?? 'default');
+ $type = $_POST['type'] ?? 'bug';
+ $title = trim($_POST['title'] ?? '');
+ $desc = trim($_POST['description'] ?? '');
+ $errMsg = trim($_POST['error_message'] ?? '');
+ $trace = trim($_POST['stack_trace'] ?? '');
+ $build = trim($_POST['build_version'] ?? 'v1.0.0');
+ $env = $_POST['environment'] ?? 'production';
+ $sev = $_POST['severity'] ?? 'medium';
+ $createdBy = trim($_POST['created_by'] ?? $_SESSION['dc_username'] ?? 'admin');
+
+ if ($title) {
+ $bugRepo = new BugRepo($pdo);
+ $res = $bugRepo->reportItem([
+ 'project_slug' => $proj,
+ 'type' => $type,
+ 'title' => $title,
+ 'description' => $desc,
+ 'error_message' => $errMsg,
+ 'stack_trace' => $trace,
+ 'build_version' => $build,
+ 'environment' => $env,
+ 'severity' => $sev,
+ 'created_by' => $createdBy,
+ ]);
+ $msg = $res['is_new']
+ ? "Neues Item #{$res['id']} ({$res['type']}) erfolgreich in {$env} erfasst."
+ : "Wiederkehrender Fehler erfasst. Occurrence Count erhöht auf {$res['occurrence_count']}.";
+ }
+ }
+
+ if ($action === 'bt_change_status') {
+ $itemId = (int)($_POST['item_id'] ?? 0);
+ $status = $_POST['status'] ?? 'open';
+ $notes = trim($_POST['notes'] ?? '');
+ $author = trim($_POST['author'] ?? $_SESSION['dc_username'] ?? 'admin');
+
+ if ($itemId > 0) {
+ $bugRepo = new BugRepo($pdo);
+ $bugRepo->updateStatus($itemId, $status, $notes, $author);
+ $msg = "Status für Item #{$itemId} geändert auf {$status}.";
+ }
+ }
+}
}
// Fetch All Data
@@ -532,6 +638,16 @@ $recentEvents = $eventLog->getRecentEvents(100);
$updateMgr = new UpdateManager($pdo);
$releases = $updateMgr->getReleases();
+// Core Token Manager Data
+$coreTokenMgr = new CoreTokenManager($pdo);
+$coreMasterTokens = $coreTokenMgr->getAllMasterTokens();
+$coreAllTokens = $coreTokenMgr->getAllTokens();
+
+// Bugtracker Data
+$bugRepo = new BugRepo($pdo);
+$bugtrackerStats = $bugRepo->getStats();
+$bugtrackerItems = $bugRepo->getItems();
+
$protocol = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http';
$host = $_SERVER['HTTP_HOST'] ?? 'dc.mhdf.de';
$baseUrl = $protocol . '://' . $host;
@@ -740,6 +856,12 @@ $baseUrl = $protocol . '://' . $host;
UpdateService
+
+
+
+ Bugtracker
+
+
@@ -748,6 +870,12 @@ $baseUrl = $protocol . '://' . $host;