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;
}