111 lines
3.4 KiB
C#
111 lines
3.4 KiB
C#
|
|
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;
|
||
|
|
|
||
|
|
/// <summary>
|
||
|
|
/// 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.
|
||
|
|
/// </summary>
|
||
|
|
public class SshTunnelService : IHostedService
|
||
|
|
{
|
||
|
|
private readonly SshTunnelOptions _options;
|
||
|
|
private readonly ILogger<SshTunnelService> _logger;
|
||
|
|
private Process? _ssh;
|
||
|
|
|
||
|
|
public SshTunnelService(
|
||
|
|
IOptions<SshTunnelOptions> options,
|
||
|
|
ILogger<SshTunnelService> 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<bool> 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;
|
||
|
|
}
|