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; 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("add"); psi.ArgumentList.Add(tunnelHost); psi.ArgumentList.Add(localPort.ToString()); _process = Process.Start(psi) ?? throw new InvalidOperationException("Failed to spawn tunnelto.exe"); _process.OutputDataReceived += (_, e) => ParseLine(e.Data); _process.ErrorDataReceived += (_, e) => ParseLine(e.Data); _process.BeginOutputReadLine(); _process.BeginErrorReadLine(); using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(ct); timeoutCts.CancelAfter(TimeSpan.FromSeconds(45)); PublicUrl = await _urlReady.Task.WaitAsync(timeoutCts.Token); 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 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 || _urlReady is null || _urlReady.Task.IsCompleted) return; var match = UrlRegex.Match(line); if (match.Success) _urlReady.TrySetResult(match.Value); } public async ValueTask DisposeAsync() { if (_process is null) return; try { if (!_process.HasExited) _process.Kill(entireProcessTree: true); await _process.WaitForExitAsync(); } catch { } _process.Dispose(); } }