258 lines
12 KiB
C#
258 lines
12 KiB
C#
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
|
|
{
|
|
/// <summary>
|
|
/// 접속을 받는 쪽 (지정 포트 TLS 또는 내장 SSH).
|
|
/// Active: 대상 PC가 사용 / Passive: 관리 PC가 사용
|
|
/// </summary>
|
|
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; }
|
|
|
|
/// <summary>SSH가 비정상 종료되었을 때</summary>
|
|
public event Action<string> Failed;
|
|
|
|
private RemoteListener(RemoteMode mode, int port)
|
|
{
|
|
Mode = mode;
|
|
Port = port;
|
|
}
|
|
|
|
/// <param name="sshAuth">SSH 비밀번호 확인 (user, password) → 허용 여부</param>
|
|
/// <param name="onConnection">연결 하나가 생길 때 (연결, 상대 표시, 취소)</param>
|
|
public static RemoteListener Start(RemoteMode mode, int port,
|
|
Func<string, string, Task<bool>> sshAuth,
|
|
Func<JsonLineConnection, string, CancellationToken, Task> 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<JsonLineConnection, string, CancellationToken, Task> 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<string, string, Task<bool>> sshAuth, Func<JsonLineConnection, string, CancellationToken, Task> 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 { }
|
|
}
|
|
|
|
/// <summary>임의의 스트림(중계 통로) 위에서 TLS 서버 쪽 연결</summary>
|
|
public static async Task<SslStream> 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;
|
|
}
|
|
|
|
/// <summary>TLS용 자체 서명 인증서 (실행할 때마다 새로 만듦, 지문 확인은 하지 않는 설정)</summary>
|
|
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;
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>접속하는 쪽 (지정 포트 TLS 또는 내장 SSH)</summary>
|
|
public static class RemoteDialer
|
|
{
|
|
/// <summary>임의의 스트림(중계 통로) 위에서 TLS 클라이언트 쪽 연결 (암호화만, 지문 확인 안 함)</summary>
|
|
public static async Task<SslStream> 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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// 접속. SSH면 sshUser/sshPassword로 인증까지 함 (실패 시 RemoteAuthException).
|
|
/// TLS는 암호화만 하고 서버 지문은 확인하지 않음.
|
|
/// </summary>
|
|
public static async Task<JsonLineConnection> 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;
|
|
}
|
|
}
|
|
}
|
|
}
|