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 { }
}
}
}