using System.Diagnostics; using System.IO; using System.Security.Cryptography; using System.Text.RegularExpressions; namespace DorkNet.Launcher.Backend; /// Wraps Tunnelto for remote hosting with provider-owned /// subdomains. The user supplies a wildcard-capable base such as /// dorknet.tunnelto.me; the server then publishes /// api.dorknet.tunnelto.me, auth.dorknet.tunnelto.me, /// and friends. public sealed class TunneltoTunnel : IAsyncDisposable { private Process? _process; private TaskCompletionSource? _urlReady; private readonly object _outputLock = new(); private readonly Queue _recentOutput = new(); public string? PublicUrl { get; private set; } public async Task StartAsync( string requestedHost, int localPort, CancellationToken ct = default) { var exe = ResolveExecutable(); var tunnelHost = NormalizeHost(requestedHost); if (string.IsNullOrWhiteSpace(tunnelHost)) throw new InvalidOperationException("Enter a Tunnelto base host first, e.g. dorknet.tunnelto.me."); _urlReady = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var psi = new ProcessStartInfo { FileName = exe, UseShellExecute = false, CreateNoWindow = true, RedirectStandardOutput = true, RedirectStandardError = true, }; psi.ArgumentList.Add("--host"); psi.ArgumentList.Add("127.0.0.1"); psi.ArgumentList.Add("--port"); psi.ArgumentList.Add(localPort.ToString()); if (TryGetTunneltoSubdomain(tunnelHost, out var subdomain)) { psi.ArgumentList.Add("--subdomain"); psi.ArgumentList.Add(subdomain); } _process = Process.Start(psi) ?? throw new InvalidOperationException("Failed to spawn tunnelto.exe"); _process.EnableRaisingEvents = true; _process.Exited += (_, _) => { if (_urlReady is null || _urlReady.Task.IsCompleted) return; _urlReady.TrySetException(new InvalidOperationException( "Tunnelto exited before reporting a public URL." + FormatRecentOutput())); }; _process.OutputDataReceived += (_, e) => ParseLine(e.Data); _process.ErrorDataReceived += (_, e) => ParseLine(e.Data); _process.BeginOutputReadLine(); _process.BeginErrorReadLine(); var readyTask = _urlReady.Task; var timeoutTask = Task.Delay(TimeSpan.FromSeconds(45), ct); var completed = await Task.WhenAny(readyTask, timeoutTask); if (completed == timeoutTask) { ct.ThrowIfCancellationRequested(); throw new TimeoutException( "Tunnelto didn't report a public URL within 45 seconds." + FormatRecentOutput()); } PublicUrl = await readyTask; return PublicUrl; } public static string NormalizeHost(string value) { var host = value.Trim(); if (host.StartsWith("http://", StringComparison.OrdinalIgnoreCase) || host.StartsWith("https://", StringComparison.OrdinalIgnoreCase)) { host = new Uri(host).Host; } host = host.Trim().TrimEnd('/').ToLowerInvariant(); if (host.StartsWith("*.", StringComparison.Ordinal)) host = host[2..]; return host; } public static string GenerateBaseHost() { var bytes = RandomNumberGenerator.GetBytes(8); var token = Convert.ToHexString(bytes).ToLowerInvariant(); return $"dorknet-{token}.tunnelto.me"; } private static bool TryGetTunneltoSubdomain(string host, out string subdomain) { const string suffix = ".tunnelto.me"; subdomain = ""; if (!host.EndsWith(suffix, StringComparison.OrdinalIgnoreCase)) return false; subdomain = host[..^suffix.Length]; return !string.IsNullOrWhiteSpace(subdomain); } private static string ResolveExecutable() { var isWindows = OperatingSystem.IsWindows(); var name = isWindows ? "tunnelto.exe" : "tunnelto"; var sideBySide = Path.Combine(AppContext.BaseDirectory, name); if (File.Exists(sideBySide)) return sideBySide; var local = Path.Combine(AppPaths.LocalRoot, name); if (File.Exists(local)) return local; if (isWindows) { var scoopShim = Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "scoop", "shims", name); if (File.Exists(scoopShim)) return scoopShim; } var onPath = Environment.GetEnvironmentVariable("PATH")? .Split(Path.PathSeparator) .Select(p => Path.Combine(p.Trim(), name)) .FirstOrDefault(File.Exists); if (onPath is not null) return onPath; var hint = isWindows ? "scoop bucket add tunnelto https://github.com/asabi/scoop-tunnelto && scoop install tunnelto" : "cargo install tunnelto # or grab a binary from github.com/agrinman/tunnelto/releases"; throw new FileNotFoundException( $"{name} was not found. Install Tunnelto first: {hint}"); } private static readonly Regex UrlRegex = new( @"https://(?!dashboard\.)(?:[a-z0-9][a-z0-9\-]*\.)+tunnelto\.me", RegexOptions.Compiled | RegexOptions.IgnoreCase); private void ParseLine(string? line) { if (line is null) return; lock (_outputLock) { _recentOutput.Enqueue(line); while (_recentOutput.Count > 8) _recentOutput.Dequeue(); } if (_urlReady is null || _urlReady.Task.IsCompleted) return; var match = UrlRegex.Match(line); if (match.Success) _urlReady.TrySetResult(match.Value); } private string FormatRecentOutput() { lock (_outputLock) { if (_recentOutput.Count == 0) return ""; return " Last Tunnelto output: " + string.Join(" | ", _recentOutput); } } public async ValueTask DisposeAsync() { if (_process is null) return; try { if (!_process.HasExited) _process.Kill(entireProcessTree: true); await _process.WaitForExitAsync(); } catch { } _process.Dispose(); } }