375 lines
21 KiB
C#
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);
|
|
}
|
|
}
|