Files
QVPN/src/QVPN.Setup/Installer.cs
T

375 lines
21 KiB
C#

using Microsoft.Win32;
using System.Diagnostics;
using System.IO.Compression;
using System.Reflection;
using System.Runtime.InteropServices;
using System.Security.AccessControl;
using System.Security.Principal;
using System.Text.Json;
namespace QVPN.Setup;
internal sealed class Installer(Action<string> progress)
{
internal static readonly string InstallRoot = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), "QVPN");
internal static readonly string DataRoot = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData), "QVPN");
private static readonly string ServiceExe = Path.Combine(InstallRoot, "service", "QVPN.Service.exe");
private const string SettingsKey = @"SOFTWARE\QVPN";
private const string UninstallKey = @"SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\QVPN";
private static readonly SecurityIdentifier SystemSid = new(WellKnownSidType.LocalSystemSid, null);
private static readonly SecurityIdentifier AdminSid = new(WellKnownSidType.BuiltinAdministratorsSid, null);
private static readonly SecurityIdentifier UsersSid = new(WellKnownSidType.BuiltinUsersSid, null);
public void Install(string? suppliedOwner)
{
RequireAdministrator();
GuardTree(InstallRoot); GuardTree(DataRoot);
NativeService.ValidateBinary(ServiceExe);
var owner = ResolveOwner(suppliedOwner);
bool previousService = NativeService.Exists(), wasRunning = NativeService.IsRunning();
bool oldBundle = Directory.Exists(InstallRoot);
string suffix = DateTime.UtcNow.ToString("yyyyMMddHHmmss") + "-" + Guid.NewGuid().ToString("N")[..8];
string stage = InstallRoot + ".stage-" + suffix, backup = InstallRoot + ".backup-" + suffix;
bool moved = false, installed = false, stopped = false;
try
{
progress("Распаковка Windows-приложения и VPN-ядра…");
CreateProtectedDirectory(stage, false);
ExtractPayload(stage);
File.Copy(Environment.ProcessPath ?? throw new InvalidOperationException("Не найден установщик."), Path.Combine(stage, "QVPN-Setup.exe"));
VerifyBundle(stage);
progress("Остановка предыдущей службы…");
StopInstalledInterface();
NativeService.Stop();
stopped = true;
WaitForInstalledProcesses();
GuardTree(InstallRoot); GuardTree(DataRoot);
if (oldBundle)
{
Directory.Move(InstallRoot, backup);
moved = true;
progress("Резервная копия программы: " + backup);
}
Directory.Move(stage, InstallRoot);
installed = true;
ProtectTree(InstallRoot, false);
if (!Directory.Exists(DataRoot)) CreateProtectedDirectory(DataRoot, true);
ProtectTree(DataRoot, true);
using (var key = Registry.LocalMachine.CreateSubKey(SettingsKey, true))
{
key.SetValue("OwnerSid", owner.Value);
key.SetValue("InstallPath", InstallRoot);
key.SetValue("DataPath", DataRoot);
key.SetValue("Version", "1.0.1");
}
progress("Настройка автоматического запуска и восстановления службы…");
ConfigureService(previousService);
NativeService.AllowOwnerToStart(owner);
NativeService.Start();
RegisterUninstall();
CreateShortcut();
progress("QVPN установлен. Служба работает. Подписку можно добавить в приложении.");
}
catch
{
progress("Ошибка установки. Восстановление предыдущего состояния…");
try
{
if (stopped || installed) { NativeService.Stop(); WaitForInstalledProcesses(); }
if (installed) RemoveGuardedTree(InstallRoot);
if (moved) Directory.Move(backup, InstallRoot);
if (!previousService && NativeService.Exists()) RunSc("delete", NativeService.Name);
if (!oldBundle)
{
Registry.LocalMachine.DeleteSubKeyTree(SettingsKey, false);
Registry.LocalMachine.DeleteSubKeyTree(UninstallKey, false);
}
if (previousService && stopped)
{
if (moved) ConfigureService(true);
if (wasRunning) NativeService.Start();
}
}
catch (Exception rollback) { progress("Восстановление требует проверки: " + rollback.Message); }
throw;
}
finally { if (Directory.Exists(stage)) RemoveGuardedTree(stage); }
}
public void Uninstall(bool removeData)
{
RequireAdministrator();
GuardTree(InstallRoot); GuardTree(DataRoot);
NativeService.ValidateBinary(ServiceExe);
progress("Остановка VPN и Windows-службы…");
StopInstalledInterface();
NativeService.Stop();
WaitForInstalledProcesses();
if (NativeService.Exists()) RunSc("delete", NativeService.Name);
RemoveShortcut();
RemoveOwnerAutorun();
RemoveGuardedTree(InstallRoot);
Registry.LocalMachine.DeleteSubKeyTree(UninstallKey, false);
if (removeData)
{
progress("Удаление сохранённой подписки и настроек…");
RemoveGuardedTree(DataRoot);
Registry.LocalMachine.DeleteSubKeyTree(SettingsKey, false);
}
progress(removeData ? "QVPN и сохранённые настройки удалены." : "QVPN удалён. Подписка и настройки сохранены для повторной установки.");
}
private static SecurityIdentifier ResolveOwner(string? supplied)
{
using var key = Registry.LocalMachine.OpenSubKey(SettingsKey);
string? existing = key?.GetValue("OwnerSid") as string;
var interactive = NativeService.InteractiveUserSid();
var owner = new SecurityIdentifier(supplied ?? existing ?? interactive.Value);
NativeService.ValidateUserSid(owner);
if (owner.Value != interactive.Value)
throw new InvalidOperationException("SID владельца отличается от пользователя текущего Windows-сеанса. Запустите установку из сеанса владельца VPN с параметром --owner и его SID.");
if (existing is not null && owner.Value != existing)
throw new InvalidOperationException("Эта установка принадлежит другой учётной записи. Её настройки не изменены.");
return owner;
}
private static void ExtractPayload(string destination)
{
using var payload = Assembly.GetExecutingAssembly().GetManifestResourceStream("QVPN.Payload.zip")
?? throw new InvalidOperationException("Установщик не содержит программы. Запустите scripts/build.ps1.");
using var zip = new ZipArchive(payload, ZipArchiveMode.Read);
foreach (var entry in zip.Entries)
{
string relative = entry.FullName.Replace('/', Path.DirectorySeparatorChar);
string target = Path.GetFullPath(Path.Combine(destination, relative));
if (!target.StartsWith(destination + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase) ||
relative.Contains(':') || (entry.ExternalAttributes & 0x400) != 0 || ((entry.ExternalAttributes >> 16) & 0xF000) == 0xA000)
throw new InvalidDataException("Недопустимый путь в пакете: " + entry.FullName);
if (entry.FullName.EndsWith('/')) { Directory.CreateDirectory(target); continue; }
Directory.CreateDirectory(Path.GetDirectoryName(target)!);
using var source = entry.Open();
using var file = new FileStream(target, FileMode.CreateNew, FileAccess.Write, FileShare.None);
source.CopyTo(file);
}
}
private static void VerifyBundle(string root)
{
string[] required = [@"app\QVPN.exe", @"service\QVPN.Service.exe", @"core\sing-box.exe", @"core\libcronet.dll", @"core\LICENSE"];
foreach (string relative in required)
if (!File.Exists(Path.Combine(root, relative))) throw new InvalidDataException("В установщике отсутствует " + relative);
if (!Directory.Exists(Path.Combine(root, "rules")) || Directory.GetFiles(Path.Combine(root, "rules"), "*.srs").Length == 0)
throw new InvalidDataException("В установщике отсутствуют правила маршрутизации.");
string manifestPath = Path.Combine(root, "payload-manifest.json");
var manifest = JsonSerializer.Deserialize<Dictionary<string, string>>(File.ReadAllText(manifestPath))
?? throw new InvalidDataException("Повреждён список файлов установщика.");
foreach (var entry in manifest)
{
string file = Path.GetFullPath(Path.Combine(root, entry.Key));
if (!file.StartsWith(root + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase))
throw new InvalidDataException("Недопустимый путь в списке файлов.");
using var input = File.OpenRead(file);
string hash = Convert.ToHexString(System.Security.Cryptography.SHA256.HashData(input));
if (!string.Equals(hash, entry.Value, StringComparison.OrdinalIgnoreCase))
throw new InvalidDataException("Повреждён файл установщика: " + entry.Key);
}
}
private static void ConfigureService(bool exists)
{
RunSc(exists ? "config" : "create", NativeService.Name, "binPath=", "\"" + ServiceExe + "\"", "start=", "auto", "obj=", "LocalSystem", "DisplayName=", "QVPN — Blanc VPN");
RunSc("description", NativeService.Name, "QVPN: VPN-туннель Blanc VPN с автоматическим восстановлением подключения.");
RunSc("failure", NativeService.Name, "reset=", "86400", "actions=", "restart/5000/restart/10000/restart/30000");
RunSc("failureflag", NativeService.Name, "1");
}
private static void RunSc(params string[] arguments)
{
var start = new ProcessStartInfo(Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.System), "sc.exe"))
{ UseShellExecute = false, CreateNoWindow = true, RedirectStandardOutput = true, RedirectStandardError = true };
foreach (string argument in arguments) start.ArgumentList.Add(argument);
using var process = Process.Start(start) ?? throw new InvalidOperationException("Не удалось запустить диспетчер служб.");
var output = process.StandardOutput.ReadToEndAsync();
var errors = process.StandardError.ReadToEndAsync();
if (!process.WaitForExit(15000)) { process.Kill(true); throw new TimeoutException("Windows не завершила настройку службы."); }
Task.WaitAll(output, errors);
if (process.ExitCode != 0) throw new InvalidOperationException("Ошибка диспетчера служб " + process.ExitCode + ": " + output.Result + errors.Result);
}
private static void WaitForInstalledProcesses()
{
var deadline = DateTime.UtcNow.AddSeconds(15);
while (true)
{
bool found = false;
foreach (var process in Process.GetProcesses())
{
using (process)
{
if (process.Id == Environment.ProcessId) continue;
string? path;
try { path = process.MainModule?.FileName; } catch { continue; }
if (path is null || !path.StartsWith(InstallRoot + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase)) continue;
found = true;
if (Path.GetFileName(path).Equals("QVPN.exe", StringComparison.OrdinalIgnoreCase))
{
try { process.CloseMainWindow(); if (!process.WaitForExit(1500)) { process.Kill(true); process.WaitForExit(3000); } }
catch (InvalidOperationException) { }
}
}
}
if (!found) return;
if (DateTime.UtcNow > deadline) throw new IOException("Процессы предыдущей установки ещё работают. Закройте QVPN и повторите установку.");
Thread.Sleep(250);
}
}
private static void StopInstalledInterface()
{
foreach (var process in Process.GetProcessesByName("QVPN"))
{
using (process)
{
string? path;
try { path = process.MainModule?.FileName; } catch { continue; }
if (!string.Equals(path, Path.Combine(InstallRoot, "app", "QVPN.exe"), StringComparison.OrdinalIgnoreCase)) continue;
try
{
process.CloseMainWindow();
if (!process.WaitForExit(1500)) { process.Kill(true); process.WaitForExit(3000); }
}
catch (InvalidOperationException) { }
}
}
}
private static void RegisterUninstall()
{
using var key = Registry.LocalMachine.CreateSubKey(UninstallKey, true);
string command = "\"" + Path.Combine(InstallRoot, "QVPN-Setup.exe") + "\" --uninstall";
key.SetValue("DisplayName", "QVPN — Blanc VPN"); key.SetValue("DisplayVersion", "1.0.1");
key.SetValue("Publisher", "QVPN"); key.SetValue("InstallLocation", InstallRoot);
key.SetValue("DisplayIcon", Path.Combine(InstallRoot, "app", "QVPN.exe"));
key.SetValue("UninstallString", command); key.SetValue("QuietUninstallString", command + " --silent");
key.SetValue("NoModify", 1, RegistryValueKind.DWord); key.SetValue("NoRepair", 1, RegistryValueKind.DWord);
}
private static string ShortcutPath => Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.CommonPrograms), "QVPN.lnk");
private static void CreateShortcut()
{
GuardPath(ShortcutPath);
Type shellType = Type.GetTypeFromProgID("WScript.Shell") ?? throw new InvalidOperationException("Недоступны Windows-ярлыки.");
dynamic shell = Activator.CreateInstance(shellType)!;
dynamic shortcut = shell.CreateShortcut(ShortcutPath);
try
{
if (File.Exists(ShortcutPath) && !string.Equals((string)shortcut.TargetPath, Path.Combine(InstallRoot, "app", "QVPN.exe"), StringComparison.OrdinalIgnoreCase))
throw new IOException("Ярлык QVPN в меню Пуск принадлежит другой программе.");
shortcut.TargetPath = Path.Combine(InstallRoot, "app", "QVPN.exe");
shortcut.WorkingDirectory = Path.Combine(InstallRoot, "app");
shortcut.Description = "QVPN — подключение к Blanc VPN";
shortcut.Save();
}
finally { Marshal.FinalReleaseComObject(shortcut); Marshal.FinalReleaseComObject(shell); }
}
private static void RemoveShortcut()
{
GuardPath(ShortcutPath);
if (!File.Exists(ShortcutPath)) return;
Type? shellType = Type.GetTypeFromProgID("WScript.Shell");
if (shellType is null) return;
dynamic shell = Activator.CreateInstance(shellType)!;
dynamic shortcut = shell.CreateShortcut(ShortcutPath);
try { if (string.Equals((string)shortcut.TargetPath, Path.Combine(InstallRoot, "app", "QVPN.exe"), StringComparison.OrdinalIgnoreCase)) File.Delete(ShortcutPath); }
finally { Marshal.FinalReleaseComObject(shortcut); Marshal.FinalReleaseComObject(shell); }
}
private static void RemoveOwnerAutorun()
{
using var settings = Registry.LocalMachine.OpenSubKey(SettingsKey);
string? owner = settings?.GetValue("OwnerSid") as string;
if (owner is null) return;
using var run = Registry.Users.OpenSubKey(owner + @"\Software\Microsoft\Windows\CurrentVersion\Run", true);
if (run?.GetValue("QVPN") is string command && command.Contains(Path.Combine(InstallRoot, "app", "QVPN.exe"), StringComparison.OrdinalIgnoreCase))
run.DeleteValue("QVPN", false);
}
internal static void RequireAdministrator()
{
using var identity = WindowsIdentity.GetCurrent();
if (!new WindowsPrincipal(identity).IsInRole(WindowsBuiltInRole.Administrator))
throw new UnauthorizedAccessException("Установка службы QVPN требует подтверждения администратора Windows.");
}
internal static void GuardPath(string path)
{
string? current = Path.GetFullPath(path);
while (!string.IsNullOrEmpty(current))
{
if ((Directory.Exists(current) || File.Exists(current)) && (File.GetAttributes(current) & FileAttributes.ReparsePoint) != 0)
throw new IOException("Установка остановлена: путь содержит ссылку или точку перенаправления: " + current);
current = Path.GetDirectoryName(current);
}
}
private static void GuardTree(string root)
{
GuardPath(root);
if (!Directory.Exists(root)) return;
var queue = new Queue<string>(); queue.Enqueue(root);
while (queue.TryDequeue(out string? directory))
foreach (string entry in Directory.EnumerateFileSystemEntries(directory))
{
FileAttributes attributes = File.GetAttributes(entry);
if ((attributes & FileAttributes.ReparsePoint) != 0) throw new IOException("В каталоге QVPN обнаружено перенаправление: " + entry);
if ((attributes & FileAttributes.Directory) != 0) queue.Enqueue(entry);
}
}
internal static void ProtectDirectory(string path, bool privateData)
{
new DirectoryInfo(path).SetAccessControl(DirectoryAcl(privateData));
}
internal static void CreateProtectedDirectory(string path, bool privateData)
{
GuardPath(path);
new DirectoryInfo(path).Create(DirectoryAcl(privateData));
}
private static DirectorySecurity DirectoryAcl(bool privateData)
{
var security = new DirectorySecurity();
security.SetAccessRuleProtection(true, false); security.SetOwner(AdminSid);
const InheritanceFlags flags = InheritanceFlags.ContainerInherit | InheritanceFlags.ObjectInherit;
security.AddAccessRule(new FileSystemAccessRule(SystemSid, FileSystemRights.FullControl, flags, PropagationFlags.None, AccessControlType.Allow));
security.AddAccessRule(new FileSystemAccessRule(AdminSid, FileSystemRights.FullControl, flags, PropagationFlags.None, AccessControlType.Allow));
if (!privateData) security.AddAccessRule(new FileSystemAccessRule(UsersSid, FileSystemRights.ReadAndExecute, flags, PropagationFlags.None, AccessControlType.Allow));
return security;
}
private static void ProtectTree(string root, bool privateData)
{
GuardTree(root);
ProtectDirectory(root, privateData);
foreach (string directory in Directory.EnumerateDirectories(root, "*", SearchOption.AllDirectories)) ProtectDirectory(directory, privateData);
foreach (string file in Directory.EnumerateFiles(root, "*", SearchOption.AllDirectories))
{
var security = new FileSecurity(); security.SetAccessRuleProtection(false, false); security.SetOwner(AdminSid);
new FileInfo(file).SetAccessControl(security);
}
}
private static void RemoveGuardedTree(string path)
{
string full = Path.GetFullPath(path);
string expectedParent = Path.GetDirectoryName(InstallRoot)!;
bool binaryTarget = Path.GetDirectoryName(full)!.Equals(expectedParent, StringComparison.OrdinalIgnoreCase) &&
(full.Equals(InstallRoot, StringComparison.OrdinalIgnoreCase) || full.StartsWith(InstallRoot + ".stage-", StringComparison.OrdinalIgnoreCase));
if (!binaryTarget && !full.Equals(DataRoot, StringComparison.OrdinalIgnoreCase)) throw new InvalidOperationException("Небезопасный путь удаления.");
GuardTree(full);
if (Directory.Exists(full)) Directory.Delete(full, true);
}
}