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 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 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(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 UninstallServiceAsync() { RequireAdministrator(); var paths = ServerPaths.CreateDefault(); var host = "localhost"; if (File.Exists(paths.SettingsFile)) { var settings = await new AtomicJsonStore(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 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 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 RenameDeviceAsync(string[] args) { if (args.Length < 2) throw new ArgumentException("Usage: device rename "); 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 RevokeDeviceAsync(string[] args) { if (args.Length != 1) throw new ArgumentException("Usage: device revoke "); var runtime = await ServerRuntime.InitializeAsync().ConfigureAwait(false); return await runtime.Devices.RevokeAsync(args[0]).ConfigureAwait(false) ? 0 : 2; } private static async Task ValidateConfigurationAsync() { var paths = ServerPaths.CreateDefault(); var settings = await new AtomicJsonStore(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 --thumbprint service uninstall service status pair create [--ttl 10m] device list device rename device revoke 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 arguments) { if (RunProcess(fileName, arguments) != 0) throw new InvalidOperationException($"{fileName} failed: {string.Join(' ', arguments)}"); } private static int RunProcess(string fileName, IReadOnlyList 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; } }