krona-staff/apps/KronaStaffTerminal/Services/UpdateService.cs
Ochenstarik 3464998168
Some checks are pending
ci / verify (push) Waiting to run
feat: add secure attendance portal and payroll foundation
2026-09-24 15:59:38 +07:00

390 lines
15 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using System.Diagnostics;
using System.Net;
using System.Net.Http.Headers;
using System.Reflection;
using System.Runtime.InteropServices;
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
using System.Text.Json;
using System.Text.Json.Serialization;
using KronaStaffTerminal.Models;
namespace KronaStaffTerminal.Services;
public sealed record AvailableUpdate(
Version Version,
string DisplayVersion,
string ReleaseName,
string ReleaseNotes,
Uri ReleasePage,
Uri InstallerUrl,
Uri ChecksumUrl,
string InstallerFileName);
public interface IUpdateSource
{
Task<AvailableUpdate?> FindUpdateAsync(TerminalSettings settings, CancellationToken cancellationToken);
Task<string> DownloadAndVerifyAsync(
AvailableUpdate update,
TerminalSettings settings,
IProgress<double>? progress,
CancellationToken cancellationToken);
}
public sealed class ForgejoUpdateSource : IUpdateSource
{
private const string InstallerPrefix = "KronaStaffTerminal-Setup-";
private static readonly HttpClient HttpClient = CreateHttpClient();
public async Task<AvailableUpdate?> FindUpdateAsync(
TerminalSettings settings,
CancellationToken cancellationToken)
{
if (!Uri.TryCreate(settings.UpdateFeedUrl, UriKind.Absolute, out var feedUri) ||
!IsSecureUpdateUri(feedUri))
{
throw new InvalidOperationException(
"Адрес обновлений должен быть защищённым HTTPS-адресом Forgejo.");
}
using var request = CreateRequest(HttpMethod.Get, feedUri, settings.UpdateAccessToken, feedUri);
using var response = await HttpClient.SendAsync(
request,
HttpCompletionOption.ResponseHeadersRead,
cancellationToken);
if (response.StatusCode is HttpStatusCode.Unauthorized or
HttpStatusCode.Forbidden or
HttpStatusCode.NotFound)
{
throw new InvalidOperationException(
"Нет доступа к обновлениям. Укажите токен чтения приватного репозитория в настройках.");
}
response.EnsureSuccessStatusCode();
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken);
var release = await JsonSerializer.DeserializeAsync<ForgejoRelease>(
stream,
cancellationToken: cancellationToken)
?? throw new InvalidDataException("Forgejo вернул пустое описание релиза.");
if (!TryParseVersion(release.TagName, out var releaseVersion) ||
releaseVersion <= CurrentVersion)
{
return null;
}
var installer = release.Assets.FirstOrDefault(asset =>
asset.Name.StartsWith(InstallerPrefix, StringComparison.OrdinalIgnoreCase) &&
asset.Name.EndsWith(".exe", StringComparison.OrdinalIgnoreCase));
var checksum = release.Assets.FirstOrDefault(asset =>
string.Equals(asset.Name, installer?.Name + ".sha256", StringComparison.OrdinalIgnoreCase));
if (installer is null ||
checksum is null ||
!Uri.TryCreate(installer.BrowserDownloadUrl, UriKind.Absolute, out var installerUri) ||
!Uri.TryCreate(checksum.BrowserDownloadUrl, UriKind.Absolute, out var checksumUri) ||
!Uri.TryCreate(release.HtmlUrl, UriKind.Absolute, out var releasePage) ||
!IsSameUpdateOrigin(feedUri, installerUri) ||
!IsSameUpdateOrigin(feedUri, checksumUri) ||
!IsSameUpdateOrigin(feedUri, releasePage))
{
throw new InvalidDataException(
"В релизе нет установщика Windows или файла контрольной суммы.");
}
return new AvailableUpdate(
releaseVersion,
releaseVersion.ToString(3),
string.IsNullOrWhiteSpace(release.Name)
? $"Krona Staff {releaseVersion.ToString(3)}"
: release.Name,
release.Body ?? "",
releasePage,
installerUri,
checksumUri,
installer.Name);
}
public async Task<string> DownloadAndVerifyAsync(
AvailableUpdate update,
TerminalSettings settings,
IProgress<double>? progress,
CancellationToken cancellationToken)
{
if (!Uri.TryCreate(settings.UpdateFeedUrl, UriKind.Absolute, out var feedUri) ||
!IsSecureUpdateUri(feedUri))
{
throw new InvalidOperationException("Адрес обновлений должен быть защищённым HTTPS-адресом Forgejo.");
}
var updateDirectory = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"Krona",
"StaffTerminal",
"updates",
update.DisplayVersion);
Directory.CreateDirectory(updateDirectory);
var installerPath = Path.Combine(
updateDirectory,
Path.GetFileName(update.InstallerFileName));
var temporaryPath = installerPath + ".download";
using var checksumRequest = CreateRequest(
HttpMethod.Get,
update.ChecksumUrl,
settings.UpdateAccessToken,
feedUri);
using var checksumResponse = await HttpClient.SendAsync(
checksumRequest,
cancellationToken);
checksumResponse.EnsureSuccessStatusCode();
var checksumText = await checksumResponse.Content.ReadAsStringAsync(cancellationToken);
var expectedHash = checksumText
.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries)
.FirstOrDefault();
if (expectedHash is null ||
expectedHash.Length != 64 ||
expectedHash.Any(character => !Uri.IsHexDigit(character)))
{
throw new InvalidDataException(
"Файл контрольной суммы обновления повреждён.");
}
using var installerRequest = CreateRequest(
HttpMethod.Get,
update.InstallerUrl,
settings.UpdateAccessToken,
feedUri);
using var installerResponse = await HttpClient.SendAsync(
installerRequest,
HttpCompletionOption.ResponseHeadersRead,
cancellationToken);
installerResponse.EnsureSuccessStatusCode();
var totalBytes = installerResponse.Content.Headers.ContentLength;
await using (var source = await installerResponse.Content.ReadAsStreamAsync(cancellationToken))
await using (var destination = new FileStream(
temporaryPath,
FileMode.Create,
FileAccess.Write,
FileShare.None,
81920,
FileOptions.Asynchronous))
{
var buffer = new byte[81920];
long received = 0;
int count;
while ((count = await source.ReadAsync(buffer, cancellationToken)) > 0)
{
await destination.WriteAsync(buffer.AsMemory(0, count), cancellationToken);
received += count;
if (totalBytes is > 0)
{
progress?.Report((double)received / totalBytes.Value);
}
}
await destination.FlushAsync(cancellationToken);
}
await using var downloaded = File.OpenRead(temporaryPath);
var actualHash = Convert.ToHexString(
await SHA256.HashDataAsync(downloaded, cancellationToken));
if (!CryptographicOperations.FixedTimeEquals(
Convert.FromHexString(expectedHash),
Convert.FromHexString(actualHash)))
{
File.Delete(temporaryPath);
throw new InvalidDataException(
"Контрольная сумма обновления не совпала. Установка отменена.");
}
VerifyInstallerPublisher(temporaryPath, settings.UpdateTrustedSignerThumbprint);
File.Move(temporaryPath, installerPath, true);
return installerPath;
}
public static Version CurrentVersion =>
Assembly.GetExecutingAssembly().GetName().Version ?? new Version(0, 0, 0);
public static void LaunchInstaller(string installerPath) =>
Process.Start(new ProcessStartInfo
{
FileName = installerPath,
UseShellExecute = true
});
private static HttpRequestMessage CreateRequest(
HttpMethod method,
Uri uri,
string token,
Uri trustedOrigin)
{
if (!IsSameUpdateOrigin(trustedOrigin, uri))
{
throw new InvalidDataException("Forgejo вернул файл обновления с недоверенного адреса.");
}
var request = new HttpRequestMessage(method, uri);
if (!string.IsNullOrWhiteSpace(token))
{
request.Headers.Authorization =
new AuthenticationHeaderValue("token", token.Trim());
}
return request;
}
private static HttpClient CreateHttpClient()
{
var client = new HttpClient(new HttpClientHandler { AllowAutoRedirect = false })
{
Timeout = TimeSpan.FromMinutes(10)
};
client.DefaultRequestHeaders.UserAgent.ParseAdd("KronaStaffTerminal/0.2");
return client;
}
internal static bool IsSameUpdateOrigin(Uri trustedOrigin, Uri candidate) =>
IsSecureUpdateUri(trustedOrigin) &&
IsSecureUpdateUri(candidate) &&
string.Equals(trustedOrigin.IdnHost, candidate.IdnHost, StringComparison.OrdinalIgnoreCase) &&
trustedOrigin.Port == candidate.Port;
private static bool IsSecureUpdateUri(Uri uri) =>
uri.Scheme == Uri.UriSchemeHttps &&
string.IsNullOrEmpty(uri.UserInfo) &&
!string.IsNullOrWhiteSpace(uri.IdnHost);
private static void VerifyInstallerPublisher(string installerPath, string configuredThumbprint)
{
var expectedThumbprint = NormalizeThumbprint(configuredThumbprint);
if (expectedThumbprint.Length == 0)
{
throw new InvalidOperationException(
"Автоматическая установка заблокирована: не настроен отпечаток сертификата издателя обновлений.");
}
if (!AuthenticodeTrust.IsTrusted(installerPath))
{
throw new InvalidDataException(
"Цифровая подпись установщика отсутствует, повреждена или не доверена Windows.");
}
#pragma warning disable SYSLIB0057
using var certificate = new X509Certificate2(X509Certificate.CreateFromSignedFile(installerPath));
#pragma warning restore SYSLIB0057
var actualThumbprint = NormalizeThumbprint(certificate.Thumbprint ?? "");
if (!CryptographicOperations.FixedTimeEquals(
Convert.FromHexString(expectedThumbprint),
Convert.FromHexString(actualThumbprint)))
{
throw new InvalidDataException("Установщик подписан неизвестным издателем. Установка отменена.");
}
}
private static string NormalizeThumbprint(string value)
{
var normalized = new string(value.Where(Uri.IsHexDigit).ToArray()).ToUpperInvariant();
return normalized.Length is 40 or 64 ? normalized : "";
}
private static bool TryParseVersion(string? value, out Version version)
{
var normalized = (value ?? "")
.Trim()
.TrimStart('v', 'V')
.Split('-', '+')[0];
if (Version.TryParse(normalized, out var parsed))
{
version = parsed;
return true;
}
version = new Version(0, 0, 0);
return false;
}
private sealed class ForgejoRelease
{
[JsonPropertyName("tag_name")]
public string TagName { get; set; } = "";
[JsonPropertyName("name")]
public string Name { get; set; } = "";
[JsonPropertyName("body")]
public string? Body { get; set; }
[JsonPropertyName("html_url")]
public string HtmlUrl { get; set; } = "";
[JsonPropertyName("assets")]
public List<ForgejoAsset> Assets { get; set; } = [];
}
private sealed class ForgejoAsset
{
[JsonPropertyName("name")]
public string Name { get; set; } = "";
[JsonPropertyName("browser_download_url")]
public string BrowserDownloadUrl { get; set; } = "";
}
private static class AuthenticodeTrust
{
private static readonly Guid VerifyAction = new("00AAC56B-CD44-11d0-8CC2-00C04FC295EE");
public static bool IsTrusted(string path)
{
using var fileInfo = new WinTrustFileInfo(path);
var fileInfoPointer = Marshal.AllocHGlobal(Marshal.SizeOf<WinTrustFileInfo>());
var trustDataPointer = IntPtr.Zero;
try
{
Marshal.StructureToPtr(fileInfo, fileInfoPointer, false);
var trustData = new WinTrustData(fileInfoPointer);
trustDataPointer = Marshal.AllocHGlobal(Marshal.SizeOf<WinTrustData>());
Marshal.StructureToPtr(trustData, trustDataPointer, false);
return WinVerifyTrust(new IntPtr(-1), VerifyAction, trustDataPointer) == 0;
}
finally
{
if (trustDataPointer != IntPtr.Zero) Marshal.FreeHGlobal(trustDataPointer);
Marshal.FreeHGlobal(fileInfoPointer);
}
}
[DllImport("wintrust.dll", ExactSpelling = true, CharSet = CharSet.Unicode)]
private static extern int WinVerifyTrust(
IntPtr windowHandle,
[MarshalAs(UnmanagedType.LPStruct)] Guid actionId,
IntPtr trustData);
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
private sealed class WinTrustFileInfo : IDisposable
{
public uint StructSize = (uint)Marshal.SizeOf<WinTrustFileInfo>();
public IntPtr FilePath;
public IntPtr FileHandle = IntPtr.Zero;
public IntPtr KnownSubject = IntPtr.Zero;
public WinTrustFileInfo(string path) => FilePath = Marshal.StringToCoTaskMemUni(path);
public void Dispose() => Marshal.FreeCoTaskMem(FilePath);
}
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
private sealed class WinTrustData
{
public uint StructSize = (uint)Marshal.SizeOf<WinTrustData>();
public IntPtr PolicyCallbackData = IntPtr.Zero;
public IntPtr SipClientData = IntPtr.Zero;
public uint UiChoice = 2;
public uint RevocationChecks = 1;
public uint UnionChoice = 1;
public IntPtr FileInfo;
public uint StateAction = 0;
public IntPtr StateData = IntPtr.Zero;
public IntPtr UrlReference = IntPtr.Zero;
public uint ProviderFlags = 0;
public uint UiContext = 0;
public WinTrustData(IntPtr fileInfo) => FileInfo = fileInfo;
}
}
}