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