500 lines
21 KiB
C#
500 lines
21 KiB
C#
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
|
|
{
|
|
/// <summary>
|
|
/// 원격 접속 허용 (대상 역할).
|
|
/// 관리 PC의 접속을 받아, 이 PC에 "등록된 서비스"에 한해서만
|
|
/// 목록 / 상태 / Start / Stop / 상세 / 로그 보기를 처리함.
|
|
/// </summary>
|
|
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<IReadOnlyList<ServiceEntry>> _services;
|
|
private readonly Action<string, LogLevel> _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<DateTime> _failures = new Queue<DateTime>();
|
|
|
|
public bool IsRunning { get; private set; }
|
|
public RemoteMode Mode { get; private set; }
|
|
public int Port { get; private set; }
|
|
|
|
/// <param name="services">등록된 서비스 목록 (스레드 안전한 복사본을 돌려줘야 함)</param>
|
|
/// <param name="log">작업 로그 기록</param>
|
|
/// <param name="changed">원격에서 Start/Stop 했을 때 (상태 갱신용)</param>
|
|
public RemoteServer(Func<IReadOnlyList<ServiceEntry>> services, Action<string, LogLevel> log, Action changed)
|
|
{
|
|
_services = services;
|
|
_log = log;
|
|
_changed = changed;
|
|
}
|
|
|
|
/// <summary>비밀번호만 바뀐 경우 (재시작 없이)</summary>
|
|
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 { }
|
|
}
|
|
}
|
|
|
|
/// <summary>TLS용 자체 서명 인증서 (실행할 때마다 새로 만듦, 지문 확인은 하지 않는 설정)</summary>
|
|
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<ClaimsPrincipal> 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);
|
|
}
|
|
|
|
/// <summary>5분 동안 10번 넘게 틀리면 잠시 거부</summary>
|
|
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, LogTailer>();
|
|
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<JsonObject> 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<string> 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 });
|
|
}
|
|
|
|
/// <summary>로그 이벤트는 순서가 중요하므로 보낼 때까지 기다림 (로그 타이머 스레드에서 호출)</summary>
|
|
private static void Send(JsonLineConnection conn, JsonObject msg)
|
|
{
|
|
if (conn.IsDisposed) return;
|
|
try { conn.SendAsync(msg).GetAwaiter().GetResult(); } catch { }
|
|
}
|
|
}
|
|
}
|