using AnsonBot.Core; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using System.Diagnostics; using System.Net.Sockets; namespace AnsonBot.Bot; /// /// Development-only. Holds open an SSH tunnel from a local TCP port to the /// home server's Docker socket, so DockerRconService can reach the daemon. /// public class SshTunnelService : IHostedService { private readonly SshTunnelOptions _options; private readonly ILogger _logger; private Process? _ssh; public SshTunnelService( IOptions options, ILogger logger) { _options = options.Value; _logger = logger; } public async Task StartAsync(CancellationToken ct) { // A tunnel may already be up from a previous debug session that was // killed hard (Stop Debugging doesn't always run StopAsync). if (await IsPortOpenAsync(_options.LocalPort, ct)) { _logger.LogInformation( "Port {Port} already open; assuming an existing tunnel.", _options.LocalPort); return; } var args = $"-N -o ExitOnForwardFailure=yes -o BatchMode=yes " + $"-L {_options.LocalPort}:{_options.RemoteSocket} " + $"{_options.User}@{_options.Host}"; _logger.LogInformation("Starting SSH tunnel: ssh {Args}", args); _ssh = Process.Start(new ProcessStartInfo("ssh", args) { RedirectStandardError = true, UseShellExecute = false, CreateNoWindow = true }) ?? throw new InvalidOperationException("Could not start ssh. Is it on PATH?"); // Don't let the bot start talking to Docker before the forward is live. for (var i = 0; i < _options.StartupTimeoutSeconds * 10; i++) { if (_ssh.HasExited) { var err = await _ssh.StandardError.ReadToEndAsync(ct); throw new InvalidOperationException($"SSH tunnel failed to start: {err}"); } if (await IsPortOpenAsync(_options.LocalPort, ct)) { _logger.LogInformation("SSH tunnel is up on port {Port}.", _options.LocalPort); return; } await Task.Delay(100, ct); } throw new TimeoutException( $"SSH tunnel did not open port {_options.LocalPort} within " + $"{_options.StartupTimeoutSeconds}s."); } public Task StopAsync(CancellationToken ct) { if (_ssh is { HasExited: false }) { _logger.LogInformation("Closing SSH tunnel."); _ssh.Kill(entireProcessTree: true); } _ssh?.Dispose(); _ssh = null; return Task.CompletedTask; } private static async Task IsPortOpenAsync(int port, CancellationToken ct) { try { using var client = new TcpClient(); await client.ConnectAsync("localhost", port, ct); return true; } catch { return false; } } } public class SshTunnelOptions { public string Host { get; set; } = "192.168.40.100"; public string User { get; set; } = "sam"; public int LocalPort { get; set; } = 2375; public string RemoteSocket { get; set; } = "/var/run/docker.sock"; public int StartupTimeoutSeconds { get; set; } = 15; }