using System;
using System.Diagnostics;
using System.Net;
using System.Net.Security;
using System.Net.Sockets;
using System.Security.Claims;
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.DevTunnels.Ssh;
using Microsoft.DevTunnels.Ssh.Algorithms;
using Microsoft.DevTunnels.Ssh.Events;
using Microsoft.DevTunnels.Ssh.Messages;
using SshClientTcp = Microsoft.DevTunnels.Ssh.Tcp.SshClient;
using SshServerTcp = Microsoft.DevTunnels.Ssh.Tcp.SshServer;
namespace JJServiceManager.Remote
{
///
/// 접속을 받는 쪽 (지정 포트 TLS 또는 내장 SSH).
/// Active: 대상 PC가 사용 / Passive: 관리 PC가 사용
///
public sealed class RemoteListener : IDisposable
{
private static X509Certificate2 _cert;
private static readonly object CertLock = new object();
private readonly CancellationTokenSource _cts = new CancellationTokenSource();
private TcpListener _tcp;
private SshServerTcp _ssh;
public RemoteMode Mode { get; }
public int Port { get; }
/// SSH가 비정상 종료되었을 때
public event Action Failed;
private RemoteListener(RemoteMode mode, int port)
{
Mode = mode;
Port = port;
}
/// SSH 비밀번호 확인 (user, password) → 허용 여부
/// 연결 하나가 생길 때 (연결, 상대 표시, 취소)
public static RemoteListener Start(RemoteMode mode, int port,
Func> sshAuth,
Func onConnection)
{
var l = new RemoteListener(mode, port);
if (mode == RemoteMode.Port) l.StartTls(onConnection);
else l.StartSsh(sshAuth, onConnection);
return l;
}
private void StartTls(Func onConnection)
{
var cert = Certificate();
_tcp = new TcpListener(IPAddress.Any, Port);
_tcp.Start();
var listener = _tcp;
var ct = _cts.Token;
_ = Task.Run(async () =>
{
while (!ct.IsCancellationRequested)
{
TcpClient tcp;
try { tcp = await listener.AcceptTcpClientAsync(ct).ConfigureAwait(false); }
catch { break; }
_ = Task.Run(async () =>
{
string peer = (tcp.Client.RemoteEndPoint as IPEndPoint)?.Address.ToString() ?? "?";
if (peer.StartsWith("::ffff:")) peer = peer.Substring(7);
try
{
tcp.NoDelay = true;
var ssl = new SslStream(tcp.GetStream(), false);
using (var hs = CancellationTokenSource.CreateLinkedTokenSource(ct))
{
hs.CancelAfter(TimeSpan.FromSeconds(15));
await ssl.AuthenticateAsServerAsync(new SslServerAuthenticationOptions
{
ServerCertificate = cert,
ClientCertificateRequired = false
}, hs.Token).ConfigureAwait(false);
}
await onConnection(new JsonLineConnection(ssl, tcp), peer, ct).ConfigureAwait(false);
}
catch
{
try { tcp.Dispose(); } catch { }
}
});
}
});
}
private void StartSsh(Func> sshAuth, Func onConnection)
{
var hostKey = SshAlgorithms.PublicKey.ECDsaSha2Nistp384.GenerateKeyPair();
var ssh = new SshServerTcp(SshSessionConfiguration.Default, new TraceSource("JJServiceManager.SshServer"));
ssh.Credentials = new SshServerCredentials(new[] { hostKey });
var ct = _cts.Token;
ssh.SessionAuthenticating += (s, e) =>
{
if (e.AuthenticationType == SshAuthenticationType.ClientPassword)
{
var user = e.Username;
var pw = e.Password;
e.AuthenticationTask = Task.Run(async () =>
await sshAuth(user, pw).ConfigureAwait(false) ? new ClaimsPrincipal(new ClaimsIdentity("password")) : null);
}
// 그 밖의 인증 방식(공개 키 등)은 허용하지 않음
};
ssh.SessionOpened += (s, session) =>
{
session.ChannelOpening += (s2, e) =>
{
if (!e.IsRemoteRequest) return;
if (session.Principal == null)
{
e.FailureReason = SshChannelOpenFailureReason.AdministrativelyProhibited;
e.FailureDescription = "인증되지 않았습니다.";
return;
}
var channel = e.Channel;
_ = Task.Run(() => onConnection(new JsonLineConnection(new SshStream(channel)), "SSH", ct));
};
};
_ssh = ssh;
_ = ssh.AcceptSessionsAsync(Port, IPAddress.Any).ContinueWith(t =>
{
if (t.IsFaulted && !ct.IsCancellationRequested)
{
var ex = t.Exception?.GetBaseException();
var msg = ex is SocketException se && se.SocketErrorCode == SocketError.AddressAlreadyInUse
? $"포트 {Port}를 다른 프로그램이 사용 중입니다."
: ex?.Message ?? "알 수 없는 오류";
Failed?.Invoke(msg);
}
}, TaskScheduler.Default);
}
public void Dispose()
{
try { _cts.Cancel(); } catch { }
try { _tcp?.Stop(); } catch { }
try { _ssh?.Dispose(); } catch { }
}
/// TLS용 자체 서명 인증서 (실행할 때마다 새로 만듦, 지문 확인은 하지 않는 설정)
private static X509Certificate2 Certificate()
{
lock (CertLock)
{
if (_cert != null) return _cert;
using var rsa = RSA.Create(2048);
var req = new CertificateRequest("CN=JJServiceManager", rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
req.CertificateExtensions.Add(new X509KeyUsageExtension(X509KeyUsageFlags.DigitalSignature | X509KeyUsageFlags.KeyEncipherment, false));
req.CertificateExtensions.Add(new X509EnhancedKeyUsageExtension(new OidCollection { new Oid("1.3.6.1.5.5.7.3.1") }, false));
using var cert = req.CreateSelfSigned(DateTimeOffset.UtcNow.AddDays(-1), DateTimeOffset.UtcNow.AddYears(10));
// Windows(SChannel)에서 개인 키를 쓰려면 PFX로 한 번 내보냈다가 다시 읽어야 함
_cert = new X509Certificate2(cert.Export(X509ContentType.Pfx), (string)null, X509KeyStorageFlags.Exportable);
return _cert;
}
}
}
/// 접속하는 쪽 (지정 포트 TLS 또는 내장 SSH)
public static class RemoteDialer
{
///
/// 접속. SSH면 sshUser/sshPassword로 인증까지 함 (실패 시 RemoteAuthException).
/// TLS는 암호화만 하고 서버 지문은 확인하지 않음.
///
public static async Task DialAsync(RemoteMode mode, string host, int port,
string sshUser, string sshPassword, CancellationToken ct)
{
host = (host ?? "").Trim();
if (mode == RemoteMode.Port)
{
var tcp = new TcpClient { NoDelay = true };
try
{
await tcp.ConnectAsync(host, port, ct).ConfigureAwait(false);
var ssl = new SslStream(tcp.GetStream(), false);
await ssl.AuthenticateAsClientAsync(new SslClientAuthenticationOptions
{
TargetHost = "JJServiceManager",
RemoteCertificateValidationCallback = (a, b, c, d) => true
}, ct).ConfigureAwait(false);
return new JsonLineConnection(ssl, tcp);
}
catch
{
tcp.Dispose();
throw;
}
}
var client = new SshClientTcp(SshSessionConfiguration.Default, new TraceSource("JJServiceManager.SshClient"));
try
{
var session = await client.OpenSessionAsync(host, port, ct).ConfigureAwait(false);
// 서버 지문 확인 안 함 (설정: 확인 없이 암호화만)
session.Authenticating += (s, e) => e.AuthenticationTask = Task.FromResult(new ClaimsPrincipal());
bool ok = await session.AuthenticateAsync(new SshClientCredentials(sshUser, sshPassword), ct).ConfigureAwait(false);
if (!ok)
{
session.Dispose();
throw new RemoteAuthException(sshUser == RemoteServer.PassiveSshUser
? "관리 PC가 접속 코드를 받아들이지 않았습니다."
: "접속 비밀번호가 맞지 않습니다.");
}
var channel = await session.OpenChannelAsync(ct).ConfigureAwait(false);
return new JsonLineConnection(new SshStream(channel), session, client);
}
catch
{
client.Dispose();
throw;
}
}
}
}