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 서버 쪽 연결 public static async Task TlsServerAsync(System.IO.Stream inner, CancellationToken ct) { var ssl = new SslStream(inner, false); using var hs = CancellationTokenSource.CreateLinkedTokenSource(ct); hs.CancelAfter(TimeSpan.FromSeconds(20)); await ssl.AuthenticateAsServerAsync(new SslServerAuthenticationOptions { ServerCertificate = Certificate(), ClientCertificateRequired = false }, hs.Token).ConfigureAwait(false); return ssl; } /// TLS용 자체 서명 인증서 (실행할 때마다 새로 만듦, 지문 확인은 하지 않는 설정) internal 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 { /// 임의의 스트림(중계 통로) 위에서 TLS 클라이언트 쪽 연결 (암호화만, 지문 확인 안 함) public static async Task TlsClientAsync(System.IO.Stream inner, CancellationToken ct) { var ssl = new SslStream(inner, false); using var hs = CancellationTokenSource.CreateLinkedTokenSource(ct); hs.CancelAfter(TimeSpan.FromSeconds(20)); await ssl.AuthenticateAsClientAsync(new SslClientAuthenticationOptions { TargetHost = "JJServiceManager", RemoteCertificateValidationCallback = (a, b, c, d) => true }, hs.Token).ConfigureAwait(false); return ssl; } /// /// 접속. 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; } } } }