using System; using System.Collections.Generic; using System.Diagnostics; using System.Linq; 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.Text.Json.Nodes; 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 SshServerTcp = Microsoft.DevTunnels.Ssh.Tcp.SshServer; namespace JJServiceManager.Remote { /// /// 원격 접속 허용 (대상 역할). /// 관리 PC의 접속을 받아, 이 PC에 "등록된 서비스"에 한해서만 /// 목록 / 상태 / Start / Stop / 상세 / 로그 보기를 처리함. /// public sealed class RemoteServer : IDisposable { public const string Version = "1.1"; public const string SshUser = "jjsm"; private static readonly TimeSpan OpTimeout = TimeSpan.FromSeconds(60); private readonly Func> _services; private readonly Action _log; private readonly Action _changed; private readonly object _sync = new object(); private CancellationTokenSource _cts; private TcpListener _listener; private X509Certificate2 _cert; private SshServerTcp _ssh; private string _hash = "", _salt = ""; private readonly Queue _failures = new Queue(); public bool IsRunning { get; private set; } public RemoteMode Mode { get; private set; } public int Port { get; private set; } /// 등록된 서비스 목록 (스레드 안전한 복사본을 돌려줘야 함) /// 작업 로그 기록 /// 원격에서 Start/Stop 했을 때 (상태 갱신용) public RemoteServer(Func> services, Action log, Action changed) { _services = services; _log = log; _changed = changed; } /// 비밀번호만 바뀐 경우 (재시작 없이) public void UpdatePassword(string hash, string salt) { lock (_sync) { _hash = hash ?? ""; _salt = salt ?? ""; } } public void Start(RemoteMode mode, int port, string hash, string salt) { Stop(); UpdatePassword(hash, salt); Mode = mode; Port = port; _cts = new CancellationTokenSource(); try { if (mode == RemoteMode.Port) StartTls(port, _cts.Token); else StartSsh(port); IsRunning = true; _log($"원격 접속 허용 시작: {(mode == RemoteMode.Ssh ? "SSH" : "지정 포트(TLS)")} {port}", LogLevel.Success); } catch (Exception ex) { IsRunning = false; _log($"원격 접속 허용 시작 실패 (포트 {port}): {ex.Message}", LogLevel.Error); Stop(); } } public void Stop() { bool wasRunning = IsRunning; IsRunning = false; try { _cts?.Cancel(); } catch { } try { _listener?.Stop(); } catch { } try { _ssh?.Dispose(); } catch { } _listener = null; _ssh = null; _cts = null; if (wasRunning) _log("원격 접속 허용 중지", LogLevel.Info); } public void Dispose() => Stop(); // ───────────────────────── 지정 포트 + TLS ───────────────────────── private void StartTls(int port, CancellationToken ct) { _cert ??= CreateCertificate(); _listener = new TcpListener(IPAddress.Any, port); _listener.Start(); var listener = _listener; _ = Task.Run(async () => { while (!ct.IsCancellationRequested) { TcpClient tcp; try { tcp = await listener.AcceptTcpClientAsync(ct).ConfigureAwait(false); } catch { break; } _ = Task.Run(() => HandleTcpAsync(tcp, ct)); } }); } private async Task HandleTcpAsync(TcpClient tcp, CancellationToken ct) { string peer = (tcp.Client.RemoteEndPoint as IPEndPoint)?.Address.ToString() ?? "?"; 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); } var conn = new JsonLineConnection(ssl, tcp); await ServeAsync(conn, peer, requireHelloPassword: true, ct).ConfigureAwait(false); } catch { try { tcp.Dispose(); } catch { } } } /// TLS용 자체 서명 인증서 (실행할 때마다 새로 만듦, 지문 확인은 하지 않는 설정) private static X509Certificate2 CreateCertificate() { 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로 한 번 내보냈다가 다시 읽어야 함 return new X509Certificate2(cert.Export(X509ContentType.Pfx), (string)null, X509KeyStorageFlags.Exportable); } // ───────────────────────── 내장 SSH ───────────────────────── private void StartSsh(int port) { var hostKey = SshAlgorithms.PublicKey.ECDsaSha2Nistp384.GenerateKeyPair(); var ssh = new SshServerTcp(SshSessionConfiguration.Default, new TraceSource("JJServiceManager.SshServer")); ssh.Credentials = new SshServerCredentials(new[] { hostKey }); ssh.SessionAuthenticating += (s, e) => { if (e.AuthenticationType == SshAuthenticationType.ClientPassword) e.AuthenticationTask = CheckSshPasswordAsync(e.Username, e.Password); // 그 밖의 인증 방식(공개 키 등)은 허용하지 않음 }; 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; var token = _cts?.Token ?? CancellationToken.None; _ = Task.Run(() => ServeAsync(new JsonLineConnection(new SshStream(channel)), "SSH", requireHelloPassword: false, token)); }; }; ssh.ExceptionRaised += (s, ex) => { if (ex is SocketException se && se.SocketErrorCode == SocketError.AddressAlreadyInUse) _log($"원격 접속 허용 실패: 포트 {port}를 다른 프로그램이 사용 중입니다.", LogLevel.Error); }; _ssh = ssh; _ = ssh.AcceptSessionsAsync(port, IPAddress.Any).ContinueWith(t => { if (t.IsFaulted && IsRunning) { IsRunning = false; _log($"원격 접속 허용(SSH) 중단: {t.Exception?.GetBaseException().Message}", LogLevel.Error); } }, TaskScheduler.Default); } private async Task CheckSshPasswordAsync(string user, string password) { if (IsThrottled()) return null; if (CheckPassword(password)) return new ClaimsPrincipal(new ClaimsIdentity("password")); RecordFailure(); await Task.Delay(1000).ConfigureAwait(false); _log("원격 접속 거부 (SSH): 비밀번호가 맞지 않습니다.", LogLevel.Warning); return null; } // ───────────────────────── 인증 ───────────────────────── private bool CheckPassword(string password) { string h, s; lock (_sync) { h = _hash; s = _salt; } return RemoteSecurity.Verify(password, h, s); } /// 5분 동안 10번 넘게 틀리면 잠시 거부 private bool IsThrottled() { lock (_failures) { while (_failures.Count > 0 && DateTime.UtcNow - _failures.Peek() > TimeSpan.FromMinutes(5)) _failures.Dequeue(); return _failures.Count >= 10; } } private void RecordFailure() { lock (_failures) _failures.Enqueue(DateTime.UtcNow); } // ───────────────────────── 요청 처리 ───────────────────────── private async Task ServeAsync(JsonLineConnection conn, string peer, bool requireHelloPassword, CancellationToken ct) { bool authed = !requireHelloPassword; string who = peer; var streams = new Dictionary(); int nextStream = 1; try { while (!ct.IsCancellationRequested) { var msg = await conn.ReadAsync(ct).ConfigureAwait(false); if (msg == null) break; var op = Json.Str(msg, "op"); int id = Json.Int(msg, "id"); if (op == "hello") { var client = Json.Str(msg, "client"); who = string.IsNullOrEmpty(client) ? peer : $"{client}({peer})"; if (!authed) { if (IsThrottled()) { await Reply(conn, id, error: "비밀번호를 여러 번 틀려 잠시 접속이 막혔습니다. 몇 분 뒤 다시 시도하세요.", extra: new JsonObject { ["authFailed"] = true }); break; } if (!CheckPassword(Json.Str(msg, "password"))) { RecordFailure(); await Task.Delay(1000, ct).ConfigureAwait(false); _log($"원격 접속 거부: {who} 비밀번호가 맞지 않습니다.", LogLevel.Warning); await Reply(conn, id, error: "접속 비밀번호가 맞지 않습니다.", extra: new JsonObject { ["authFailed"] = true }); break; } authed = true; } _log($"원격 접속: {who}", LogLevel.Notice); await Reply(conn, id, result: new JsonObject { ["hostname"] = Environment.MachineName, ["version"] = Version, ["instance"] = AppInstance.Number }); continue; } if (!authed) { await Reply(conn, id, error: "먼저 인증해야 합니다."); break; } switch (op) { case "ping": await Reply(conn, id, result: new JsonObject()); break; case "list": _ = Task.Run(() => SafeReply(conn, id, () => ListServices())); break; case "start": case "stop": _ = Task.Run(() => SafeReply(conn, id, () => StartStop(op == "start", Json.Str(msg, "service"), who))); break; case "details": _ = Task.Run(() => SafeReply(conn, id, () => { var e = Registered(Json.Str(msg, "service")); var arr = new JsonArray(); foreach (var l in ServiceOps.GetDetails(e.ServiceName)) arr.Add(l); return new JsonObject { ["lines"] = arr }; })); break; case "logOpen": { try { var e = Registered(Json.Str(msg, "service")); if (string.IsNullOrWhiteSpace(e.LogPath)) throw new RemoteException("대상 PC에서 이 서비스의 로그 경로가 지정되지 않았습니다."); var enc = Json.Str(msg, "encoding"); var copy = new ServiceEntry { ServiceName = e.ServiceName, DisplayName = e.DisplayName, LogPath = e.LogPath, LogIsFolder = e.LogIsFolder, LogEncoding = string.IsNullOrEmpty(enc) ? e.LogEncoding : enc }; int sid = nextStream++; var tailer = new LogTailer(copy, (lines, level) => SendLog(conn, sid, lines, level)) { SkipInitial = Json.Bool(msg, "newOnly") }; tailer.FileChanged += f => Send(conn, new JsonObject { ["event"] = "logfile", ["stream"] = sid, ["path"] = f }); lock (streams) streams[sid] = tailer; await Reply(conn, id, result: new JsonObject { ["stream"] = sid, ["path"] = e.LogPath, ["isFolder"] = e.LogIsFolder }); tailer.Start(); } catch (Exception ex) { await Reply(conn, id, error: ex.Message); } break; } case "logClose": { int sid = Json.Int(msg, "stream"); lock (streams) { if (streams.Remove(sid, out var t)) t.Dispose(); } await Reply(conn, id, result: new JsonObject()); break; } default: await Reply(conn, id, error: "알 수 없는 요청: " + op); break; } } } catch { // 연결 끊김 } finally { lock (streams) { foreach (var t in streams.Values) t.Dispose(); streams.Clear(); } conn.Dispose(); if (authed && who != peer) _log($"원격 연결 종료: {who}", LogLevel.Info); } } private ServiceEntry Registered(string serviceName) { var e = _services().FirstOrDefault(x => string.Equals(x.ServiceName, serviceName, StringComparison.OrdinalIgnoreCase)); if (e == null) throw new RemoteException("대상 PC에 등록되지 않은 서비스입니다: " + serviceName); return e; } private JsonObject ListServices() { var arr = new JsonArray(); foreach (var e in _services()) { var st = ServiceOps.GetStatus(e.ServiceName); arr.Add(new JsonObject { ["name"] = e.ServiceName, ["display"] = e.DisplayName, ["status"] = st.HasValue ? (int)st.Value : null, ["logPath"] = e.LogPath ?? "", ["logIsFolder"] = e.LogIsFolder }); } return new JsonObject { ["services"] = arr }; } private JsonObject StartStop(bool start, string serviceName, string who) { var e = Registered(serviceName); var name = string.IsNullOrEmpty(e.DisplayName) ? e.ServiceName : e.DisplayName; var what = start ? "시작" : "중지"; _log($"[원격 {who}] [{name}] {what} 요청", LogLevel.Notice); var sw = Stopwatch.StartNew(); try { if (start) ServiceOps.Start(e.ServiceName, OpTimeout); else ServiceOps.Stop(e.ServiceName, OpTimeout); _log($"[원격 {who}] [{name}] {what} 완료 ({sw.Elapsed.TotalSeconds:0.0}초)", LogLevel.Success); return new JsonObject { ["seconds"] = Math.Round(sw.Elapsed.TotalSeconds, 1) }; } catch (InfoException ex) { throw new RemoteException(ex.Message, isInfo: true); } catch (Exception ex) { var msg = ServiceOps.Describe(ex); _log($"[원격 {who}] [{name}] {what} 실패: {msg}", LogLevel.Error); throw new RemoteException(msg); } finally { _changed?.Invoke(); } } private static async Task SafeReply(JsonLineConnection conn, int id, Func work) { try { await Reply(conn, id, result: work()); } catch (RemoteException ex) { await Reply(conn, id, error: ex.Message, extra: ex.IsInfo ? new JsonObject { ["info"] = true } : null); } catch (Exception ex) { await Reply(conn, id, error: ex.Message); } } private static async Task Reply(JsonLineConnection conn, int id, JsonObject result = null, string error = null, JsonObject extra = null) { var msg = new JsonObject { ["id"] = id, ["ok"] = error == null }; if (error == null) msg["result"] = result ?? new JsonObject(); else msg["error"] = error; if (extra != null) foreach (var kv in extra.ToList()) { extra.Remove(kv.Key); msg[kv.Key] = kv.Value; } try { await conn.SendAsync(msg).ConfigureAwait(false); } catch { } } private static void SendLog(JsonLineConnection conn, int sid, IList lines, LogLevel level) { var arr = new JsonArray(); foreach (var l in lines) arr.Add(l); Send(conn, new JsonObject { ["event"] = "log", ["stream"] = sid, ["level"] = level.ToString(), ["lines"] = arr }); } /// 로그 이벤트는 순서가 중요하므로 보낼 때까지 기다림 (로그 타이머 스레드에서 호출) private static void Send(JsonLineConnection conn, JsonObject msg) { if (conn.IsDisposed) return; try { conn.SendAsync(msg).GetAwaiter().GetResult(); } catch { } } } }