XFE Git
XFE Studio Git
Git 首页 全局搜索
XFE 主站 文档 NuGet

LumaTunnel

【WinUI】LumaTunnel 是一个面向个人多设备的 Windows 代理系统。客户端在本机提供 HTTP/HTTPS CONNECT 与 SOCKS5 TCP 代理,并通过一个受信任 TLS 证书保护的 WSS 会话,将多个 TCP 流复用到自建 Windows Server 节点。

公开
关注 0 Fork 0 Star 0
UTF-8
using System.Diagnostics;
using System.Security.Principal;
using System.Text.Json;
using LumaTunnel.Server.Core.Configuration;
using LumaTunnel.Server.Core.Persistence;
using LumaTunnel.Server.Core.Runtime;
using LumaTunnel.Shared.Protocol;

namespace LumaTunnel.Server;

public static class ServerCommandLine
{
    private static readonly string[] s_roots = ["service", "pair", "device", "config", "help", "--help", "-h"];

    public static bool IsCommand(string[] args) => args.Length > 0 && s_roots.Contains(args[0], StringComparer.OrdinalIgnoreCase);

    public static async Task<int> RunAsync(string[] args)
    {
        try
        {
            var command = string.Join(' ', args.Take(2)).ToLowerInvariant();
            return command switch
            {
                "service install" => await InstallServiceAsync(args[2..]).ConfigureAwait(false),
                "service uninstall" => await UninstallServiceAsync().ConfigureAwait(false),
                "service status" => RunProcess("sc.exe", ["query", "LumaTunnelServer"]),
                "pair create" => await CreatePairingCodeAsync(args[2..]).ConfigureAwait(false),
                "device list" => await ListDevicesAsync().ConfigureAwait(false),
                "device rename" => await RenameDeviceAsync(args[2..]).ConfigureAwait(false),
                "device revoke" => await RevokeDeviceAsync(args[2..]).ConfigureAwait(false),
                "config validate" => await ValidateConfigurationAsync().ConfigureAwait(false),
                _ => PrintHelp()
            };
        }
        catch (Exception exception)
        {
            Console.Error.WriteLine($"Error: {exception.Message}");
            return 1;
        }
    }

    private static async Task<int> InstallServiceAsync(string[] args)
    {
        RequireAdministrator();
        var host = ReadOption(args, "--host") ?? throw new ArgumentException("--host is required.");
        var thumbprint = (ReadOption(args, "--thumbprint") ?? throw new ArgumentException("--thumbprint is required.")).Replace(" ", string.Empty, StringComparison.Ordinal);
        var appId = Guid.Parse("bfeefef9-b9a0-4d26-92ae-f3740a56fc13");
        var paths = ServerPaths.CreateDefault();
        paths.EnsureDirectories();
        var store = new AtomicJsonStore<ServerSettings>(paths.SettingsFile);
        var current = await store.LoadOrCreateAsync(static () => new ServerSettings()).ConfigureAwait(false);
        var settings = current with
        {
            BindingUrls = ["https://+:443/"],
            PublicApiUrl = $"https://{host}/api",
            PublicTunnelUrl = $"wss://{host}/tunnel/v1"
        };
        settings.Validate();
        await store.SaveAsync(settings).ConfigureAwait(false);

        var executable = Environment.ProcessPath ?? throw new InvalidOperationException("Unable to locate the service executable.");
        RunRequired("sc.exe", ["create", "LumaTunnelServer", "binPath=", $"\"{executable}\"", "start=", "auto", "obj=", "NT AUTHORITY\\NetworkService", "DisplayName=", "LumaTunnel Server"]);
        RunRequired("sc.exe", ["description", "LumaTunnelServer", "LumaTunnel secure self-hosted tunnel server"]);
        RunRequired("sc.exe", ["failure", "LumaTunnelServer", "reset=", "86400", "actions=", "restart/5000/restart/15000/restart/60000"]);
        RunRequired("netsh.exe", ["http", "add", "urlacl", "url=https://+:443/", "user=NT AUTHORITY\\NETWORK SERVICE"]);
        RunRequired("netsh.exe", ["http", "add", "sslcert", $"hostnameport={host}:443", $"certhash={thumbprint}", $"appid={{{appId}}}", "certstorename=MY"]);
        RunRequired("netsh.exe", ["advfirewall", "firewall", "add", "rule", "name=LumaTunnel Server HTTPS", "dir=in", "action=allow", "protocol=TCP", "localport=443"]);
        RunRequired("sc.exe", ["start", "LumaTunnelServer"]);
        Console.WriteLine("LumaTunnelServer was installed and started.");
        return 0;
    }

    private static async Task<int> UninstallServiceAsync()
    {
        RequireAdministrator();
        var paths = ServerPaths.CreateDefault();
        var host = "localhost";
        if (File.Exists(paths.SettingsFile))
        {
            var settings = await new AtomicJsonStore<ServerSettings>(paths.SettingsFile).LoadOrCreateAsync(static () => new ServerSettings()).ConfigureAwait(false);
            host = new Uri(settings.PublicApiUrl).Host;
        }

        RunProcess("sc.exe", ["stop", "LumaTunnelServer"]);
        RunProcess("sc.exe", ["delete", "LumaTunnelServer"]);
        RunProcess("netsh.exe", ["http", "delete", "urlacl", "url=https://+:443/"]);
        RunProcess("netsh.exe", ["http", "delete", "sslcert", $"hostnameport={host}:443"]);
        RunProcess("netsh.exe", ["advfirewall", "firewall", "delete", "rule", "name=LumaTunnel Server HTTPS"]);
        Console.WriteLine("LumaTunnelServer service and HTTP.sys/firewall registrations were removed. Data was retained under ProgramData.");
        return 0;
    }

    private static async Task<int> CreatePairingCodeAsync(string[] args)
    {
        var ttl = ParseDuration(ReadOption(args, "--ttl") ?? "10m");
        var runtime = await ServerRuntime.InitializeAsync().ConfigureAwait(false);
        var result = await runtime.PairingCodes.CreateAsync(ttl).ConfigureAwait(false);
        Console.WriteLine(result.Code);
        Console.WriteLine($"ExpiresUtc: {result.ExpiresAtUtc:O}");
        return 0;
    }

    private static async Task<int> ListDevicesAsync()
    {
        var runtime = await ServerRuntime.InitializeAsync().ConfigureAwait(false);
        foreach (var device in runtime.Devices.Devices)
            Console.WriteLine($"{device.DeviceId}\t{device.DeviceName}\t{(device.Enabled ? "enabled" : "revoked")}\t{device.LastSeenAtUtc:O}");
        return 0;
    }

    private static async Task<int> RenameDeviceAsync(string[] args)
    {
        if (args.Length < 2)
            throw new ArgumentException("Usage: device rename <device-id> <new-name>");
        var runtime = await ServerRuntime.InitializeAsync().ConfigureAwait(false);
        return await runtime.Devices.RenameAsync(args[0], string.Join(' ', args[1..])).ConfigureAwait(false) ? 0 : 2;
    }

    private static async Task<int> RevokeDeviceAsync(string[] args)
    {
        if (args.Length != 1)
            throw new ArgumentException("Usage: device revoke <device-id>");
        var runtime = await ServerRuntime.InitializeAsync().ConfigureAwait(false);
        return await runtime.Devices.RevokeAsync(args[0]).ConfigureAwait(false) ? 0 : 2;
    }

    private static async Task<int> ValidateConfigurationAsync()
    {
        var paths = ServerPaths.CreateDefault();
        var settings = await new AtomicJsonStore<ServerSettings>(paths.SettingsFile).LoadOrCreateAsync(static () => new ServerSettings()).ConfigureAwait(false);
        settings.Validate();
        Console.WriteLine(JsonSerializer.Serialize(settings, TunnelMessageSerializer.Options));
        Console.WriteLine("Configuration is valid.");
        return 0;
    }

    private static int PrintHelp()
    {
        Console.WriteLine("""
            LumaTunnel.Server

              service install --host <domain> --thumbprint <sha1>
              service uninstall
              service status
              pair create [--ttl 10m]
              device list
              device rename <device-id> <name>
              device revoke <device-id>
              config validate
            """);
        return 0;
    }

    private static string? ReadOption(string[] args, string name)
    {
        var index = Array.FindIndex(args, value => value.Equals(name, StringComparison.OrdinalIgnoreCase));
        return index >= 0 && index + 1 < args.Length ? args[index + 1] : null;
    }

    private static TimeSpan ParseDuration(string value)
    {
        if (value.Length < 2 || !double.TryParse(value[..^1], System.Globalization.CultureInfo.InvariantCulture, out var amount))
            throw new FormatException("Duration must look like 10m, 1h, or 30s.");
        return char.ToLowerInvariant(value[^1]) switch
        {
            's' => TimeSpan.FromSeconds(amount),
            'm' => TimeSpan.FromMinutes(amount),
            'h' => TimeSpan.FromHours(amount),
            _ => throw new FormatException("Duration unit must be s, m, or h.")
        };
    }

    private static void RequireAdministrator()
    {
        using var identity = WindowsIdentity.GetCurrent();
        if (!new WindowsPrincipal(identity).IsInRole(WindowsBuiltInRole.Administrator))
            throw new UnauthorizedAccessException("Run this command from an elevated terminal.");
    }

    private static void RunRequired(string fileName, IReadOnlyList<string> arguments)
    {
        if (RunProcess(fileName, arguments) != 0)
            throw new InvalidOperationException($"{fileName} failed: {string.Join(' ', arguments)}");
    }

    private static int RunProcess(string fileName, IReadOnlyList<string> arguments)
    {
        using var process = new Process { StartInfo = new ProcessStartInfo(fileName) { UseShellExecute = false } };
        foreach (var argument in arguments)
            process.StartInfo.ArgumentList.Add(argument);
        process.Start();
        process.WaitForExit();
        return process.ExitCode;
    }
}