Compare commits

..

1 Commits

Author SHA1 Message Date
Claude d2cd87526f
중계 서버 연결: 대상 PC 등록·온라인 유지, 관리 PC 로그인·기기 자동 표시, 직접→중계, 권한 티켓, [서버 관리] 탭
- Core/Remote/Relay.cs: WebSocket 스트림, 중계 서버 채널, 권한 티켓 확인, RelayAgent(대상 PC), RelayManager(관리 PC)
- RemoteServer: 티켓 서명·기기·만료 확인, 티켓 권한 밖 요청 거부, 실행한 작업을 서버 감사 로그로 보고
- RemoteClient: 중계 기기 연결 (직접 연결 3초 시도 → 중계 통로 위 TLS), 직접 연결 트래픽 보고
- 초기화면: 권한 있는 중계 기기를 자동으로 묶음 표시, 기기별 [비밀번호]
- 설정: 원격 접속 허용 → 중계 서버 접속(등록 코드), 관리 대상 PC → 중계 서버 로그인(첫 로그인 비밀번호 변경), [서버 관리] 탭
- tests/RelayIntegration: 통합 점검 32개 항목

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013U497kUNL8ArhV7jkzCpKa
2026-10-27 07:07:18 +00:00
19 changed files with 3217 additions and 58 deletions

View File

@ -65,7 +65,8 @@ namespace JJServiceManager
public enum ConnectDirection public enum ConnectDirection
{ {
Active = 0, // 관리 PC → 대상 PC 로 접속 (대상 PC가 포트를 엶) Active = 0, // 관리 PC → 대상 PC 로 접속 (대상 PC가 포트를 엶)
Passive = 1 // 대상 PC → 관리 PC 로 접속 (관리 PC가 포트를 엶) Passive = 1, // 대상 PC → 관리 PC 로 접속 (관리 PC가 포트를 엶)
Relay = 2 // 중계 서버를 거쳐 연결 (먼저 직접, 안 되면 중계). 설정 목록에는 저장하지 않음
} }
/// <summary>관리 대상 PC</summary> /// <summary>관리 대상 PC</summary>
@ -90,11 +91,28 @@ namespace JJServiceManager
/// <summary>Passive로 접속해 온 대상 PC의 IP (표시용, 자동)</summary> /// <summary>Passive로 접속해 온 대상 PC의 IP (표시용, 자동)</summary>
public string LastAddress { get; set; } = ""; public string LastAddress { get; set; } = "";
/// <summary>중계 서버의 기기 ID (Relay일 때)</summary>
public string RelayDeviceId { get; set; } = "";
/// <summary>중계 서버에서 정한 기기 이름 (Relay일 때 표시용)</summary>
public string RelayName { get; set; } = "";
/// <summary>Relay: 실제로 연결된 방식 ("direct" / "relay", 표시용)</summary>
[JsonIgnore]
public string RelayMode { get; set; } = "";
[JsonIgnore] [JsonIgnore]
public string Label public string Label
{ {
get get
{ {
if (Direction == ConnectDirection.Relay)
{
var name = string.IsNullOrWhiteSpace(RelayName) ? Hostname : RelayName;
if (!string.IsNullOrWhiteSpace(Hostname) && !string.Equals(name, Hostname, StringComparison.OrdinalIgnoreCase))
name = $"{name} / {Hostname}";
return $"{name} ({(RelayMode == "direct" ? "직접" : "중계")})";
}
if (Direction == ConnectDirection.Passive) if (Direction == ConnectDirection.Passive)
{ {
// 아직 한 번도 접속하지 않았으면 코드로 표시 // 아직 한 번도 접속하지 않았으면 코드로 표시
@ -248,6 +266,31 @@ namespace JJServiceManager
[JsonIgnore] [JsonIgnore]
public int PassiveListenPort => PassiveListenMode == RemoteMode.Ssh ? PassiveListenSshPort : PassiveListenTcpPort; public int PassiveListenPort => PassiveListenMode == RemoteMode.Ssh ? PassiveListenSshPort : PassiveListenTcpPort;
// ───────── 중계 서버 (대상 역할: 이 PC를 서버에 등록) ─────────
/// <summary>중계 서버 접속 사용 (원격 접속 허용이 켜져 있어야 동작)</summary>
public bool RelayAgentEnabled { get; set; }
public string RelayAgentUrl { get; set; } = "https://jjsm.joynala.com";
/// <summary>등록할 때 서버가 준 기기 ID</summary>
public string RelayDeviceId { get; set; } = "";
/// <summary>기기 키 (Windows 계정으로 암호화)</summary>
public string RelayDeviceKeyProtected { get; set; } = "";
/// <summary>권한 티켓 확인용 서버 공개키 (등록할 때 받음)</summary>
public string RelayServerPublicKey { get; set; } = "";
public string RelayDeviceName { get; set; } = "";
// ───────── 중계 서버 (관리 역할: 로그인) ─────────
public bool RelayManageEnabled { get; set; }
public string RelayManageUrl { get; set; } = "https://jjsm.joynala.com";
public string RelayUsername { get; set; } = "";
/// <summary>로그인 토큰 (Windows 계정으로 암호화)</summary>
public string RelayTokenProtected { get; set; } = "";
/// <summary>자동 로그인용 비밀번호 (선택, Windows 계정으로 암호화)</summary>
public string RelayPasswordProtected { get; set; } = "";
/// <summary>중계 기기별 대상 PC 접속 비밀번호 (기기 ID → 암호화된 비밀번호)</summary>
public Dictionary<string, string> RelayDevicePasswords { get; set; } = new Dictionary<string, string>();
/// <summary>초기화면에 열어둔 PC 탭 ("local" 또는 대상 Id)</summary> /// <summary>초기화면에 열어둔 PC 탭 ("local" 또는 대상 Id)</summary>
public List<string> OpenHostTabs { get; set; } = new List<string>(); public List<string> OpenHostTabs { get; set; } = new List<string>();
@ -288,6 +331,9 @@ namespace JJServiceManager
s.Targets ??= new List<RemoteTarget>(); s.Targets ??= new List<RemoteTarget>();
s.PassiveManagers ??= new List<PassiveManager>(); s.PassiveManagers ??= new List<PassiveManager>();
s.OpenHostTabs ??= new List<string>(); s.OpenHostTabs ??= new List<string>();
s.RelayDevicePasswords ??= new Dictionary<string, string>();
// 중계 기기는 서버에서 받아오므로 목록에 저장하지 않음
s.Targets.RemoveAll(t => t.Direction == ConnectDirection.Relay);
return s; return s;
} }
} }

1034
Core/Remote/Relay.cs Normal file

File diff suppressed because it is too large Load Diff

View File

@ -32,6 +32,9 @@ namespace JJServiceManager.Remote
private TaskCompletionSource<(JsonLineConnection conn, string peer)> _incoming; private TaskCompletionSource<(JsonLineConnection conn, string peer)> _incoming;
private JsonLineConnection _conn; private JsonLineConnection _conn;
private int _nextId; private int _nextId;
private readonly RelayManager _relay;
private TaskCompletionSource<bool> _wake = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
private string _relaySession = "";
private sealed class LogSubscriber private sealed class LogSubscriber
{ {
@ -41,6 +44,13 @@ namespace JJServiceManager.Remote
public RemoteTarget Target => _target; public RemoteTarget Target => _target;
public bool IsPassive => _target.Direction == ConnectDirection.Passive; public bool IsPassive => _target.Direction == ConnectDirection.Passive;
public bool IsRelay => _target.Direction == ConnectDirection.Relay;
/// <summary>Relay: 실제 연결 방식 ("direct" / "relay", 연결 전에는 빈 값)</summary>
public string RelayMode { get; private set; } = "";
/// <summary>Relay: 연결 방식이 정해짐 (백그라운드 스레드)</summary>
public event Action<RemoteClient> RelayModeChanged;
public RemoteState State { get; private set; } = RemoteState.Connecting; public RemoteState State { get; private set; } = RemoteState.Connecting;
public string StateMessage { get; private set; } = "연결 중…"; public string StateMessage { get; private set; } = "연결 중…";
public string Hostname { get; private set; } = ""; public string Hostname { get; private set; } = "";
@ -61,8 +71,9 @@ namespace JJServiceManager.Remote
/// <summary>원격 화면 이벤트 (frame / screenEnd)</summary> /// <summary>원격 화면 이벤트 (frame / screenEnd)</summary>
public event Action<JsonObject> ScreenEvent; public event Action<JsonObject> ScreenEvent;
public RemoteClient(RemoteTarget target, Func<string> passwordProvider = null) public RemoteClient(RemoteTarget target, Func<string> passwordProvider = null, RelayManager relay = null)
{ {
_relay = relay;
_target = target.Clone(); _target = target.Clone();
Hostname = target.Hostname ?? ""; Hostname = target.Hostname ?? "";
var prot = _target.ProtectedPassword; var prot = _target.ProtectedPassword;
@ -71,6 +82,9 @@ namespace JJServiceManager.Remote
public void Start() => _ = Task.Run(RunAsync); public void Start() => _ = Task.Run(RunAsync);
/// <summary>다시 시도 대기 중이면 바로 다시 시도 (대상 PC가 온라인이 됨 등)</summary>
public void Nudge() => _wake.TrySetResult(true);
public void Dispose() public void Dispose()
{ {
try { _cts.Cancel(); } catch { } try { _cts.Cancel(); } catch { }
@ -118,6 +132,10 @@ namespace JJServiceManager.Remote
SetState(RemoteState.Connecting, "대상 PC가 접속함, 인증 중…"); SetState(RemoteState.Connecting, "대상 PC가 접속함, 인증 중…");
hello = await HelloAsync(conn, _password(), sendPassword: true, ct).ConfigureAwait(false); hello = await HelloAsync(conn, _password(), sendPassword: true, ct).ConfigureAwait(false);
} }
else if (IsRelay)
{
(conn, hello) = await ConnectRelayAsync(ct).ConfigureAwait(false);
}
else else
{ {
SetState(RemoteState.Connecting, "연결 중…"); SetState(RemoteState.Connecting, "연결 중…");
@ -157,27 +175,129 @@ namespace JJServiceManager.Remote
{ {
SetState(RemoteState.AuthFailed, ex.Message); SetState(RemoteState.AuthFailed, ex.Message);
retry = IsPassive ? TimeSpan.FromSeconds(1) : TimeSpan.FromSeconds(30); retry = IsPassive ? TimeSpan.FromSeconds(1) : TimeSpan.FromSeconds(30);
// 중계: 비밀번호를 바꿀 때까지(Nudge) 다시 시도하지 않음
if (IsRelay) retry = TimeSpan.FromMinutes(10);
} }
catch (Exception ex) catch (Exception ex)
{ {
SetState(RemoteState.Disconnected, Friendly(ex)); SetState(RemoteState.Disconnected, Friendly(ex));
if (IsPassive) retry = TimeSpan.FromSeconds(1); if (IsPassive) retry = TimeSpan.FromSeconds(1);
if (IsRelay) retry = TimeSpan.FromSeconds(15);
} }
finally finally
{ {
var c = _conn; var c = _conn;
_conn = null; _conn = null;
// 직접 연결이었으면 끝 정보를 중계 서버에 보고 (중계는 서버가 직접 셈)
if (IsRelay && RelayMode == "direct" && _relaySession.Length > 0 && c != null)
_relay?.ReportStatus(_relaySession, "ended", c.BytesSent, c.BytesReceived, ct.IsCancellationRequested ? "관리 PC에서 닫음" : "연결 끊김");
_relaySession = "";
c?.Dispose(); c?.Dispose();
FailPending("연결이 끊겼습니다."); FailPending("연결이 끊겼습니다.");
_logs.Clear(); _logs.Clear();
ScreenEvent?.Invoke(new JsonObject { ["event"] = "screenEnd", ["sid"] = 0, ["reason"] = "대상 PC와 연결이 끊겼습니다." }); ScreenEvent?.Invoke(new JsonObject { ["event"] = "screenEnd", ["sid"] = 0, ["reason"] = "대상 PC와 연결이 끊겼습니다." });
} }
try { await Task.Delay(retry, ct).ConfigureAwait(false); } try
{
var wake = _wake.Task;
await Task.WhenAny(Task.Delay(retry, ct), wake).ConfigureAwait(false);
if (wake.IsCompleted) _wake = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
ct.ThrowIfCancellationRequested();
}
catch { break; } catch { break; }
} }
} }
// ───────────────────────── 중계 서버를 거친 연결 ─────────────────────────
/// <summary>중계 서버에 연결 요청 → 직접 연결 시도(최대 3초) → 안 되면 중계 통로. 둘 다 같은 TLS + hello(비밀번호 + 티켓)</summary>
private async Task<(JsonLineConnection conn, JsonObject hello)> ConnectRelayAsync(CancellationToken ct)
{
var relay = _relay ?? throw new RemoteException("중계 서버 설정이 없습니다.");
var deviceId = _target.RelayDeviceId;
if (!relay.IsLoggedIn)
{
SetState(RemoteState.Connecting, "중계 서버 로그인 기다리는 중…");
if (!await relay.WaitReadyAsync(TimeSpan.FromSeconds(30), ct).ConfigureAwait(false))
throw new RemoteException("중계 서버에 로그인되어 있지 않습니다.");
}
if (!relay.IsDeviceOnline(deviceId))
throw new RemoteException("대상 PC가 중계 서버에 접속해 있지 않습니다 (오프라인).");
var password = _password();
if (string.IsNullOrEmpty(password))
throw new RemoteAuthException("대상 PC 접속 비밀번호가 없습니다. 제목 줄의 [비밀번호]를 눌러 입력하세요.");
SetState(RemoteState.Connecting, "중계 서버에 연결 요청 중…");
var info = await relay.ConnectAsync(deviceId).ConfigureAwait(false);
_relaySession = info.SessionId;
// 1) 직접 연결 (대상 PC가 Active 수신 중일 때만 후보가 있음)
var started = DateTime.UtcNow;
foreach (var (host, port, mode) in info.Candidates)
{
var left = TimeSpan.FromSeconds(3) - (DateTime.UtcNow - started);
if (left <= TimeSpan.Zero) break;
SetState(RemoteState.Connecting, $"직접 연결 시도 중… ({host}:{port})");
JsonLineConnection conn = null;
try
{
using (var t = CancellationTokenSource.CreateLinkedTokenSource(ct))
{
t.CancelAfter(left);
conn = await RemoteDialer.DialAsync(mode, host, port, RemoteServer.SshUser, password, t.Token).ConfigureAwait(false);
}
var hello = await HelloAsync(conn, password, sendPassword: mode == RemoteMode.Port, ct, info.Ticket).ConfigureAwait(false);
relay.ReportStatus(info.SessionId, "direct");
SetRelayMode("direct");
return (conn, hello);
}
catch (RemoteAuthException ex)
{
// 비밀번호·티켓 문제는 중계로 바꿔도 같으므로 바로 실패
relay.ReportStatus(info.SessionId, "failed", reason: ex.Message);
throw;
}
catch (OperationCanceledException) when (ct.IsCancellationRequested)
{
throw;
}
catch
{
conn?.Dispose();
}
}
// 2) 중계 통로
SetState(RemoteState.Connecting, "중계 서버를 거쳐 연결 중…");
WebSocketStream stream = null;
try
{
stream = await RelayChannel.OpenRelayAsync(relay.Url, info.SessionId, "manager", info.RelayToken, ct).ConfigureAwait(false);
var ssl = await RemoteDialer.TlsClientAsync(stream, ct).ConfigureAwait(false);
var conn = new JsonLineConnection(ssl);
var hello = await HelloAsync(conn, password, sendPassword: true, ct, info.Ticket).ConfigureAwait(false);
SetRelayMode("relay");
return (conn, hello);
}
catch (RemoteAuthException)
{
throw;
}
catch (Exception ex) when (!ct.IsCancellationRequested)
{
try { stream?.Dispose(); } catch { }
throw new RemoteException("중계 연결 실패: " + RelayUrl.Friendly(ex));
}
}
private void SetRelayMode(string mode)
{
if (RelayMode == mode) return;
RelayMode = mode;
RelayModeChanged?.Invoke(this);
}
private async Task ReadLoopAsync(JsonLineConnection conn, CancellationToken ct) private async Task ReadLoopAsync(JsonLineConnection conn, CancellationToken ct)
{ {
while (!ct.IsCancellationRequested) while (!ct.IsCancellationRequested)
@ -332,7 +452,7 @@ namespace JJServiceManager.Remote
} }
/// <summary>연결 위에서 hello (비밀번호 확인). 실패하면 연결을 닫고 예외</summary> /// <summary>연결 위에서 hello (비밀번호 확인). 실패하면 연결을 닫고 예외</summary>
public static async Task<JsonObject> HelloAsync(JsonLineConnection conn, string password, bool sendPassword, CancellationToken ct) public static async Task<JsonObject> HelloAsync(JsonLineConnection conn, string password, bool sendPassword, CancellationToken ct, string ticket = null)
{ {
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(ct); using var timeout = CancellationTokenSource.CreateLinkedTokenSource(ct);
timeout.CancelAfter(ConnectTimeout); timeout.CancelAfter(ConnectTimeout);
@ -346,6 +466,7 @@ namespace JJServiceManager.Remote
["version"] = RemoteServer.Version ["version"] = RemoteServer.Version
}; };
if (sendPassword) hello["password"] = password; if (sendPassword) hello["password"] = password;
if (!string.IsNullOrEmpty(ticket)) hello["ticket"] = ticket;
await conn.SendAsync(hello, timeout.Token).ConfigureAwait(false); await conn.SendAsync(hello, timeout.Token).ConfigureAwait(false);
var reply = await conn.ReadAsync(timeout.Token).ConfigureAwait(false); var reply = await conn.ReadAsync(timeout.Token).ConfigureAwait(false);

View File

@ -89,6 +89,12 @@ namespace JJServiceManager.Remote
public bool IsDisposed => Volatile.Read(ref _disposed) == 1; public bool IsDisposed => Volatile.Read(ref _disposed) == 1;
private long _bytesSent, _charsReceived;
/// <summary>보낸 바이트 수 (연결 기록 통계용)</summary>
public long BytesSent => Interlocked.Read(ref _bytesSent);
/// <summary>받은 양 (문자 수 ≈ 바이트, 연결 기록 통계용)</summary>
public long BytesReceived => Interlocked.Read(ref _charsReceived);
private readonly char[] _buf = new char[64 * 1024]; private readonly char[] _buf = new char[64 * 1024];
private int _bufPos, _bufLen; private int _bufPos, _bufLen;
private readonly StringBuilder _line = new StringBuilder(); private readonly StringBuilder _line = new StringBuilder();
@ -104,6 +110,7 @@ namespace JJServiceManager.Remote
_bufLen = await _reader.ReadAsync(_buf.AsMemory(), ct).ConfigureAwait(false); _bufLen = await _reader.ReadAsync(_buf.AsMemory(), ct).ConfigureAwait(false);
_bufPos = 0; _bufPos = 0;
if (_bufLen <= 0) return null; if (_bufLen <= 0) return null;
Interlocked.Add(ref _charsReceived, _bufLen);
} }
int idx = Array.IndexOf(_buf, '\n', _bufPos, _bufLen - _bufPos); int idx = Array.IndexOf(_buf, '\n', _bufPos, _bufLen - _bufPos);
@ -139,6 +146,7 @@ namespace JJServiceManager.Remote
{ {
await _stream.WriteAsync(bytes, ct).ConfigureAwait(false); await _stream.WriteAsync(bytes, ct).ConfigureAwait(false);
await _stream.FlushAsync(ct).ConfigureAwait(false); await _stream.FlushAsync(ct).ConfigureAwait(false);
Interlocked.Add(ref _bytesSent, bytes.Length);
} }
finally finally
{ {

View File

@ -56,6 +56,28 @@ namespace JJServiceManager.Remote
/// <summary>화면을 보는 관리 PC 목록이 바뀜 (백그라운드 스레드)</summary> /// <summary>화면을 보는 관리 PC 목록이 바뀜 (백그라운드 스레드)</summary>
public event Action<IReadOnlyList<ScreenViewer>> ScreenViewersChanged; public event Action<IReadOnlyList<ScreenViewer>> ScreenViewersChanged;
/// <summary>중계 서버 권한 티켓으로 들어온 연결에서 실행한 작업 (티켓, 작업, 대상, 결과, 상세) → 서버 감사 로그</summary>
public event Action<RelayTicket, string, string, string, string> AuditRequested;
// 중계 서버 티켓 확인용
private string _relayPublicKey = "", _relayDeviceId = "";
/// <summary>중계 서버 공개키·이 PC의 기기 ID (중계 서버에 등록되어 있을 때)</summary>
public void SetRelayTrust(string serverPublicKey, string deviceId)
{
lock (_sync)
{
_relayPublicKey = serverPublicKey ?? "";
_relayDeviceId = deviceId ?? "";
}
}
private void Audit(RelayTicket t, string action, string target, string result, string detail = "")
{
if (t == null) return;
try { AuditRequested?.Invoke(t, action, target, result, detail); } catch { }
}
/// <param name="services">등록된 서비스 목록 (스레드 안전한 복사본을 돌려줘야 함)</param> /// <param name="services">등록된 서비스 목록 (스레드 안전한 복사본을 돌려줘야 함)</param>
/// <param name="log">작업 로그 기록</param> /// <param name="log">작업 로그 기록</param>
/// <param name="changed">원격에서 Start/Stop 했을 때 (상태 갱신용)</param> /// <param name="changed">원격에서 Start/Stop 했을 때 (상태 갱신용)</param>
@ -227,10 +249,15 @@ namespace JJServiceManager.Remote
// ───────────────────────── 요청 처리 ───────────────────────── // ───────────────────────── 요청 처리 ─────────────────────────
private async Task ServeAsync(JsonLineConnection conn, string peer, bool requireHelloPassword, CancellationToken ct) /// <summary>중계 통로로 들어온 연결 (권한 티켓 필수, 세션 ID 일치)</summary>
public Task ServeRelayAsync(JsonLineConnection conn, string peer, string sessionId, CancellationToken ct)
=> ServeAsync(conn, peer, requireHelloPassword: true, ct, relaySessionId: sessionId);
private async Task ServeAsync(JsonLineConnection conn, string peer, bool requireHelloPassword, CancellationToken ct, string relaySessionId = null)
{ {
bool authed = !requireHelloPassword; bool authed = !requireHelloPassword;
string who = peer; string who = peer;
RelayTicket ticket = null; // 중계 서버 권한 (없으면 비밀번호만으로 모든 권한 — 기존 Active/Passive)
var streams = new Dictionary<int, LogTailer>(); var streams = new Dictionary<int, LogTailer>();
int nextStream = 1; int nextStream = 1;
ScreenSession screen = null; ScreenSession screen = null;
@ -249,6 +276,25 @@ namespace JJServiceManager.Remote
{ {
var client = Json.Str(msg, "client"); var client = Json.Str(msg, "client");
who = string.IsNullOrEmpty(client) ? peer : $"{client}({peer})"; who = string.IsNullOrEmpty(client) ? peer : $"{client}({peer})";
// 중계 서버 권한 티켓: 중계 통로면 필수, 직접 연결이면 있을 때만 확인
var ticketText = Json.Str(msg, "ticket");
if (relaySessionId != null || ticketText.Length > 0)
{
string pub, dev;
lock (_sync) { pub = _relayPublicKey; dev = _relayDeviceId; }
var t = RelayTicket.Verify(ticketText, pub, dev, out var terr);
if (t == null || (relaySessionId != null && !string.Equals(t.SessionId, relaySessionId, StringComparison.OrdinalIgnoreCase)))
{
var why = t == null ? terr : "중계 세션이 맞지 않습니다.";
_log($"원격 접속 거부: {who} {why}", LogLevel.Warning);
await Reply(conn, id, error: why, extra: new JsonObject { ["authFailed"] = true });
break;
}
ticket = t;
who = $"{t.Username}@{(string.IsNullOrEmpty(client) ? "" : client)}({(relaySessionId != null ? "중계" : peer)})";
}
if (!authed) if (!authed)
{ {
if (IsThrottled()) if (IsThrottled())
@ -261,21 +307,33 @@ namespace JJServiceManager.Remote
RecordFailure(); RecordFailure();
await Task.Delay(1000, ct).ConfigureAwait(false); await Task.Delay(1000, ct).ConfigureAwait(false);
_log($"원격 접속 거부: {who} 비밀번호가 맞지 않습니다.", LogLevel.Warning); _log($"원격 접속 거부: {who} 비밀번호가 맞지 않습니다.", LogLevel.Warning);
Audit(ticket, "authFail", "", "denied", "대상 PC 접속 비밀번호가 맞지 않음");
await Reply(conn, id, error: "접속 비밀번호가 맞지 않습니다.", extra: new JsonObject { ["authFailed"] = true }); await Reply(conn, id, error: "접속 비밀번호가 맞지 않습니다.", extra: new JsonObject { ["authFailed"] = true });
break; break;
} }
authed = true; authed = true;
} }
_log($"원격 접속: {who}", LogLevel.Notice); _log($"원격 접속: {who}", LogLevel.Notice);
var pol = _policy(); var pol = Policy(ticket);
await Reply(conn, id, result: new JsonObject var helloResult = new JsonObject
{ {
["hostname"] = Environment.MachineName, ["hostname"] = Environment.MachineName,
["version"] = Version, ["version"] = Version,
["instance"] = AppInstance.Number, ["instance"] = AppInstance.Number,
["allowView"] = pol.AllowView, ["allowView"] = pol.AllowView,
["allowControl"] = pol.AllowView && pol.AllowControl ["allowControl"] = pol.AllowView && pol.AllowControl
}); };
if (ticket != null)
{
helloResult["perms"] = new JsonObject
{
["view"] = ticket.View,
["control"] = ticket.Control,
["screenView"] = ticket.ScreenView,
["screenControl"] = ticket.ScreenControl
};
}
await Reply(conn, id, result: helloResult);
continue; continue;
} }
@ -285,6 +343,16 @@ namespace JJServiceManager.Remote
break; break;
} }
// 중계 서버 권한 (티켓) 확인
var denied = Denied(ticket, op);
if (denied != null)
{
if (op == "start" || op == "stop" || op == "screenStart")
Audit(ticket, op, Json.Str(msg, "service"), "denied", denied);
if (op != "input") await Reply(conn, id, error: denied);
continue;
}
switch (op) switch (op)
{ {
case "ping": case "ping":
@ -292,13 +360,34 @@ namespace JJServiceManager.Remote
break; break;
case "list": case "list":
_ = Task.Run(() => SafeReply(conn, id, () => ListServices())); {
var tk = ticket;
_ = Task.Run(() => SafeReply(conn, id, () => ListServices(tk)));
break; break;
}
case "start": case "start":
case "stop": case "stop":
_ = Task.Run(() => SafeReply(conn, id, () => StartStop(op == "start", Json.Str(msg, "service"), who))); {
var tk = ticket;
var svc = Json.Str(msg, "service");
var whoNow = who;
_ = Task.Run(() => SafeReply(conn, id, () =>
{
try
{
var res = StartStop(op == "start", svc, whoNow);
Audit(tk, op, svc, "ok", $"{Json.Str(res, "seconds")}초");
return res;
}
catch (RemoteException ex)
{
Audit(tk, op, svc, ex.IsInfo ? "ok" : "fail", ex.Message);
throw;
}
}));
break; break;
}
case "details": case "details":
_ = Task.Run(() => SafeReply(conn, id, () => _ = Task.Run(() => SafeReply(conn, id, () =>
@ -363,7 +452,7 @@ namespace JJServiceManager.Remote
case "screenInfo": case "screenInfo":
{ {
var pol = _policy(); var pol = Policy(ticket);
var arr = new JsonArray(); var arr = new JsonArray();
foreach (var m in ScreenCapture.Monitors()) foreach (var m in ScreenCapture.Monitors())
arr.Add(new JsonObject { ["index"] = m.Index, ["width"] = m.Width, ["height"] = m.Height, ["primary"] = m.Primary }); arr.Add(new JsonObject { ["index"] = m.Index, ["width"] = m.Width, ["height"] = m.Height, ["primary"] = m.Primary });
@ -379,13 +468,14 @@ namespace JJServiceManager.Remote
case "screenStart": case "screenStart":
{ {
var whoNow = who; var whoNow = who;
var tk = ticket;
int monitor = Json.Int(msg, "monitor", 1); int monitor = Json.Int(msg, "monitor", 1);
// 승인 창을 기다리는 동안 다른 요청은 계속 처리되도록 따로 실행 // 승인 창을 기다리는 동안 다른 요청은 계속 처리되도록 따로 실행
_ = Task.Run(async () => _ = Task.Run(async () =>
{ {
try try
{ {
var pol = _policy(); var pol = Policy(tk);
if (!pol.AllowView) throw new RemoteException("대상 PC에서 화면 보기를 허용하지 않았습니다."); if (!pol.AllowView) throw new RemoteException("대상 PC에서 화면 보기를 허용하지 않았습니다.");
bool control = pol.AllowControl; bool control = pol.AllowControl;
if (pol.RequireApproval) if (pol.RequireApproval)
@ -395,12 +485,14 @@ namespace JJServiceManager.Remote
if (!ok) if (!ok)
{ {
_log($"[원격 화면] {whoNow} 연결 거부", LogLevel.Warning); _log($"[원격 화면] {whoNow} 연결 거부", LogLevel.Warning);
Audit(tk, "screenStart", "", "denied", "대상 PC에서 거부");
throw new RemoteException("대상 PC에서 연결을 거부했습니다."); throw new RemoteException("대상 PC에서 연결을 거부했습니다.");
} }
} }
var old = Interlocked.Exchange(ref screen, null); var old = Interlocked.Exchange(ref screen, null);
EndScreen(old); EndScreen(old);
var s = new ScreenSession(Interlocked.Increment(ref _nextScreenId), whoNow, control, monitor); var s = new ScreenSession(Interlocked.Increment(ref _nextScreenId), whoNow, control, monitor) { Ticket = tk };
Audit(tk, "screenStart", "", "ok", control ? "보기 + 제어" : "보기");
screen = s; screen = s;
_screens[s.Id] = s; _screens[s.Id] = s;
_log($"[원격 화면] {whoNow} {(control ? "보기 + 제어" : "보기")} 시작", LogLevel.Notice); _log($"[원격 화면] {whoNow} {(control ? "보기 + 제어" : "보기")} 시작", LogLevel.Notice);
@ -435,7 +527,7 @@ namespace JJServiceManager.Remote
// 응답 없이 바로 실행 (빠르게) // 응답 없이 바로 실행 (빠르게)
var s = screen; var s = screen;
if (s == null || s.Stopped || !s.Control) break; if (s == null || s.Stopped || !s.Control) break;
var pol = _policy(); var pol = Policy(ticket);
if (!pol.AllowView || !pol.AllowControl) break; if (!pol.AllowView || !pol.AllowControl) break;
try try
{ {
@ -487,6 +579,7 @@ namespace JJServiceManager.Remote
public int Id { get; } public int Id { get; }
public string Who { get; } public string Who { get; }
public bool Control { get; } public bool Control { get; }
public RelayTicket Ticket { get; init; }
public volatile int Monitor; public volatile int Monitor;
public bool Stopped => _cts.IsCancellationRequested; public bool Stopped => _cts.IsCancellationRequested;
public CancellationToken Token => _cts.Token; public CancellationToken Token => _cts.Token;
@ -517,7 +610,7 @@ namespace JJServiceManager.Remote
while (!s.Stopped && !ct.IsCancellationRequested && !conn.IsDisposed) while (!s.Stopped && !ct.IsCancellationRequested && !conn.IsDisposed)
{ {
var sw = Stopwatch.StartNew(); var sw = Stopwatch.StartNew();
var pol = _policy(); var pol = Policy(s.Ticket);
if (!pol.AllowView) if (!pol.AllowView)
{ {
endReason = "대상 PC에서 화면 보기 허용을 껐습니다."; endReason = "대상 PC에서 화면 보기 허용을 껐습니다.";
@ -572,6 +665,7 @@ namespace JJServiceManager.Remote
catch { } catch { }
} }
_log($"[원격 화면] {s.Who} 연결 끝", LogLevel.Info); _log($"[원격 화면] {s.Who} 연결 끝", LogLevel.Info);
Audit(s.Ticket, "screenEnd", "", "ok", endReason);
NotifyViewers(); NotifyViewers();
} }
} }
@ -596,6 +690,41 @@ namespace JJServiceManager.Remote
ScreenViewersChanged?.Invoke(list); ScreenViewersChanged?.Invoke(list);
} }
// ───────────────────────── 중계 서버 권한 ─────────────────────────
/// <summary>이 PC 설정 ∩ 중계 서버 권한</summary>
private ScreenPolicy Policy(RelayTicket t)
{
var p = _policy();
if (t == null) return p;
bool view = p.AllowView && t.ScreenView;
return new ScreenPolicy(view, view && p.AllowControl && t.ScreenControl, p.RequireApproval);
}
/// <summary>티켓 권한으로 할 수 없는 요청이면 사유</summary>
private static string Denied(RelayTicket t, string op)
{
if (t == null) return null;
switch (op)
{
case "list":
case "details":
case "logOpen":
return t.View ? null : "중계 서버에서 이 기기 [보기] 권한이 없습니다.";
case "start":
case "stop":
return t.Control ? null : "중계 서버에서 이 기기 [서비스 제어] 권한이 없습니다.";
case "screenInfo":
case "screenStart":
case "screenMonitor":
return t.ScreenView ? null : "중계 서버에서 이 기기 [화면 보기] 권한이 없습니다.";
case "input":
return t.ScreenControl ? null : "중계 서버에서 이 기기 [화면 제어] 권한이 없습니다.";
default:
return null;
}
}
// ───────────────────────── 서비스 ───────────────────────── // ───────────────────────── 서비스 ─────────────────────────
private ServiceEntry Registered(string serviceName) private ServiceEntry Registered(string serviceName)
@ -605,7 +734,7 @@ namespace JJServiceManager.Remote
return e; return e;
} }
private JsonObject ListServices() private JsonObject ListServices(RelayTicket ticket = null)
{ {
var arr = new JsonArray(); var arr = new JsonArray();
foreach (var e in _services()) foreach (var e in _services())
@ -620,7 +749,7 @@ namespace JJServiceManager.Remote
["logIsFolder"] = e.LogIsFolder ["logIsFolder"] = e.LogIsFolder
}); });
} }
var pol = _policy(); var pol = Policy(ticket);
return new JsonObject return new JsonObject
{ {
["services"] = arr, ["services"] = arr,

View File

@ -152,8 +152,22 @@ namespace JJServiceManager.Remote
try { _ssh?.Dispose(); } 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> /// <summary>TLS용 자체 서명 인증서 (실행할 때마다 새로 만듦, 지문 확인은 하지 않는 설정)</summary>
private static X509Certificate2 Certificate() internal static X509Certificate2 Certificate()
{ {
lock (CertLock) lock (CertLock)
{ {
@ -173,6 +187,20 @@ namespace JJServiceManager.Remote
/// <summary>접속하는 쪽 (지정 포트 TLS 또는 내장 SSH)</summary> /// <summary>접속하는 쪽 (지정 포트 TLS 또는 내장 SSH)</summary>
public static class RemoteDialer 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> /// <summary>
/// 접속. SSH면 sshUser/sshPassword로 인증까지 함 (실패 시 RemoteAuthException). /// 접속. SSH면 sshUser/sshPassword로 인증까지 함 (실패 시 RemoteAuthException).
/// TLS는 암호화만 하고 서버 지문은 확인하지 않음. /// TLS는 암호화만 하고 서버 지문은 확인하지 않음.

View File

@ -37,4 +37,8 @@
<EmbeddedResource Include="Views\*.xaml" /> <EmbeddedResource Include="Views\*.xaml" />
</ItemGroup> </ItemGroup>
<ItemGroup>
<Compile Remove="tests/**" />
<None Remove="tests/**" />
</ItemGroup>
</Project> </Project>

View File

@ -4,6 +4,7 @@ Windows 서비스 기동/종료 관리 프로그램 (C# WPF, .NET 8)
- v1.1부터 **다른 PC의 등록된 서비스를 원격으로 모니터링·기동·종료**할 수 있습니다. - v1.1부터 **다른 PC의 등록된 서비스를 원격으로 모니터링·기동·종료**할 수 있습니다.
- v2.0부터 **원격관리(화면 보기 / 키보드·마우스 제어)** 와 **Active / Passive 접속 방향 선택**이 추가되었습니다. - v2.0부터 **원격관리(화면 보기 / 키보드·마우스 제어)** 와 **Active / Passive 접속 방향 선택**이 추가되었습니다.
- (개발 중) **중계 서버** ([JJServiceManagerRelay](https://gitea.joynala.com/diadmin/JJServiceManagerRelay)): 모든 PC가 서버로만 접속하면 서로 연결 (먼저 직접, 안 되면 중계), 계정·권한·감사 로그.
## 실행 파일 ## 실행 파일
@ -102,6 +103,40 @@ Windows 서비스 기동/종료 관리 프로그램 (C# WPF, .NET 8)
설정 → 일반에서 **내 PC** / **원격** Start/Stop 각각 켜고 끔 (기본: 내 PC 끔, 원격 켬). 설정 → 일반에서 **내 PC** / **원격** Start/Stop 각각 켜고 끔 (기본: 내 PC 끔, 원격 켬).
여러 서비스를 한꺼번에 Start/Stop할 때는 한 번만 입력합니다. 여러 서비스를 한꺼번에 Start/Stop할 때는 한 번만 입력합니다.
## 중계 서버 (JJServiceManagerRelay)
NAS 등에 띄운 중계 서버(`https://jjsm.joynala.com`)를 거치면 대상 PC·관리 PC 모두 **포트를 열 필요가 없습니다**.
같은 내부망이거나 대상 PC의 Active 포트가 열려 있으면 **직접 연결**하고, 안 되면 **서버가 중계**합니다.
서버 설치: [JJServiceManagerRelay/docs/NAS설치.md](https://gitea.joynala.com/diadmin/JJServiceManagerRelay/src/branch/main/docs/NAS설치.md)
### 대상 PC
1. 설정 → 원격 접속 허용 → 켜기, 접속 비밀번호
2. **중계 서버 접속**: 서버 주소 확인 → **[등록 코드로 등록]** → 서버 관리자에게 받은 코드 입력 → 중계 서버 사용 켜기 → 확인
3. 내 PC 제목 줄에 `중계 서버 온라인` 표시
### 관리 PC
1. 설정 → 관리 대상 PC → 다른 PC 관리 켜기
2. **중계 서버 로그인**: 서버 주소, 아이디, 비밀번호 → **[로그인]** (첫 로그인이면 새 비밀번호를 정함) → 확인
3. 권한이 있는 대상 PC가 초기화면에 `이름 (중계)` / `이름 (직접)` 묶음(초록색)으로 자동으로 나타남
4. 묶음 제목 줄의 **[비밀번호]** 에 그 대상 PC의 접속 비밀번호를 입력 (기기마다 한 번, 암호화 저장)
### 권한과 보안
- 서버 권한 4가지: **보기 / 서비스 제어 / 화면 보기 / 화면 제어** (관리자는 모두)
- 서버는 연결마다 **권한 티켓**에 서명하고, **대상 PC가 직접 서명을 확인**해 그 권한을 넘는 요청은 거부
- 실제 권한 = 서버 권한 ∩ 대상 PC 설정 (화면 보기 허용 등)
- 중계 통로 안에서도 관리 PC ↔ 대상 PC가 TLS를 다시 맺으므로 **서버는 내용·비밀번호를 볼 수 없음**
- 대상 PC가 실제로 실행한 Start/Stop·원격 화면은 서버 **감사 로그**에 남음 (지우는 기능 없음)
### [서버 관리] 탭 (관리자 계정으로 로그인했을 때 설정에 나타남)
| 화면 | 기능 |
|---|---|
| 계정 | 추가 / 비밀번호 초기화 / 역할 / 사용·사용 안 함 / 메모 |
| 권한 | 사용자 선택 → 기기별 4가지 권한 체크 (바로 저장) |
| 기기 | 온라인·IP·Active 수신·버전, 이름 바꾸기, 사용 안 함, 삭제 |
| 등록 코드 | 새 코드 발급 (1시간 / 24시간 / 7일 / 30일, 메모), 취소 |
| 감사 로그 | 기간·사용자·기기·작업으로 조회, 더 보기, 복사 |
| 연결 기록 | 직접/중계, 시간, 트래픽 |
## 원격관리 (화면 보기 / 키보드·마우스 제어) ## 원격관리 (화면 보기 / 키보드·마우스 제어)
### 대상 PC 설정 (원격 접속 허용 탭 → 원격관리) ### 대상 PC 설정 (원격 접속 허용 탭 → 원격관리)

View File

@ -45,6 +45,9 @@
<Button Content="원격관리" Tag="remoteDesktop" Visibility="{Binding RemoteButtonVisibility}" <Button Content="원격관리" Tag="remoteDesktop" Visibility="{Binding RemoteButtonVisibility}"
Style="{DynamicResource RowButton}" Margin="14,0,0,0" MinHeight="22" Padding="8,0" Style="{DynamicResource RowButton}" Margin="14,0,0,0" MinHeight="22" Padding="8,0"
ToolTip="이 PC 화면 보기 / 키보드·마우스 제어 (대상 PC에서 허용해야 함)"/> ToolTip="이 PC 화면 보기 / 키보드·마우스 제어 (대상 PC에서 허용해야 함)"/>
<Button Content="비밀번호" Tag="relayPassword" Visibility="{Binding PasswordButtonVisibility}"
Style="{DynamicResource RowButton}" Margin="6,0,0,0" MinHeight="22" Padding="8,0"
ToolTip="중계 서버로 연결하는 이 PC의 접속 비밀번호 입력 (대상 PC의 [원격 접속 허용] 비밀번호)"/>
</StackPanel> </StackPanel>
</Border> </Border>
</Expander.Header> </Expander.Header>

View File

@ -172,6 +172,29 @@
</DockPanel> </DockPanel>
</Grid> </Grid>
<!-- 중계 서버 접속 -->
<TextBlock Text="중계 서버 접속 (이 PC → 중계 서버)" Style="{DynamicResource SectionTitle}" Margin="0,12,0,2"/>
<TextBlock Foreground="#78909C" TextWrapping="Wrap" Margin="0,0,0,6"
Text="공유기·방화벽 뒤에 있어도 관리 PC가 중계 서버를 거쳐 접속합니다 (같은 내부망이면 직접 연결). 서버 관리자에게 받은 등록 코드로 한 번 등록하세요. 관리 PC에서도 이 PC의 접속 비밀번호를 입력해야 합니다."/>
<DockPanel Height="40" LastChildFill="True">
<TextBlock Text="중계 서버 사용" Width="210" VerticalAlignment="Center" FontWeight="SemiBold"/>
<CheckBox x:Name="chkRelayAgent" DockPanel.Dock="Right" Style="{DynamicResource ToggleSwitch}"/>
<TextBlock Text="이 PC를 중계 서버에 접속해 두고 관리 PC의 연결 요청을 받음" Foreground="#78909C" VerticalAlignment="Center"/>
</DockPanel>
<DockPanel Height="40">
<TextBlock Text="서버 주소" Width="210" VerticalAlignment="Center" FontWeight="SemiBold"/>
<TextBox x:Name="txtRelayAgentUrl" Width="320" Height="28" VerticalContentAlignment="Center" HorizontalAlignment="Left"
ToolTip="예: https://jjsm.joynala.com"/>
</DockPanel>
<DockPanel Height="40">
<TextBlock Text="등록" Width="210" VerticalAlignment="Center" FontWeight="SemiBold"/>
<StackPanel DockPanel.Dock="Right" Orientation="Horizontal">
<Button x:Name="btnRelayEnroll" Content="등록 코드로 등록" FontWeight="Bold"/>
<Button x:Name="btnRelayUnenroll" Content="등록 해제"/>
</StackPanel>
<TextBlock x:Name="txtRelayAgentState" VerticalAlignment="Center" TextWrapping="Wrap"/>
</DockPanel>
<!-- 원격관리 (화면) --> <!-- 원격관리 (화면) -->
<TextBlock Text="원격관리 (화면 보기 / 제어)" Style="{DynamicResource SectionTitle}" Margin="0,12,0,2"/> <TextBlock Text="원격관리 (화면 보기 / 제어)" Style="{DynamicResource SectionTitle}" Margin="0,12,0,2"/>
<DockPanel Height="40" LastChildFill="True"> <DockPanel Height="40" LastChildFill="True">
@ -218,6 +241,26 @@
<TextBlock Foreground="#78909C" VerticalAlignment="Center" Margin="10,0,0,0" Text="Passive 대상이 있을 때만 열림"/> <TextBlock Foreground="#78909C" VerticalAlignment="Center" Margin="10,0,0,0" Text="Passive 대상이 있을 때만 열림"/>
</DockPanel> </DockPanel>
<!-- 중계 서버 로그인 -->
<TextBlock DockPanel.Dock="Top" Text="중계 서버 로그인 (권한 있는 PC를 자동으로 표시)" Style="{DynamicResource SectionTitle}" Margin="0,6,0,2"/>
<DockPanel DockPanel.Dock="Top" Height="38" LastChildFill="True">
<TextBlock Text="중계 서버 사용" Width="210" VerticalAlignment="Center" FontWeight="SemiBold"/>
<CheckBox x:Name="chkRelayManage" DockPanel.Dock="Right" Style="{DynamicResource ToggleSwitch}"/>
<TextBox x:Name="txtRelayManageUrl" Width="260" Height="28" VerticalContentAlignment="Center" HorizontalAlignment="Left"
ToolTip="중계 서버 주소. 예: https://jjsm.joynala.com"/>
</DockPanel>
<DockPanel DockPanel.Dock="Top" Height="38">
<TextBlock Text="아이디 / 비밀번호" Width="210" VerticalAlignment="Center" FontWeight="SemiBold"/>
<TextBox x:Name="txtRelayUser" Width="120" Height="28" VerticalContentAlignment="Center"/>
<PasswordBox x:Name="pwdRelay" Width="130" Height="28" VerticalContentAlignment="Center" Margin="6,0,0,0"/>
<CheckBox x:Name="chkRelaySavePw" Content="비밀번호 저장" VerticalAlignment="Center" Margin="8,0,0,0"
ToolTip="저장하면 로그인이 만료되어도 자동으로 다시 로그인합니다. (Windows 계정으로 암호화)"/>
<Button x:Name="btnRelayLogin" Content="로그인" FontWeight="Bold" Margin="8,0,0,0"/>
<Button x:Name="btnRelayLogout" Content="로그아웃"/>
</DockPanel>
<TextBlock DockPanel.Dock="Top" x:Name="txtRelayLoginState" Margin="210,0,0,4" TextWrapping="Wrap"/>
<TextBlock DockPanel.Dock="Top" Text="직접 등록한 PC (Active / Passive)" Style="{DynamicResource SectionTitle}" Margin="0,6,0,4"/>
<!-- 입력 --> <!-- 입력 -->
<Border DockPanel.Dock="Bottom" Background="#FAFAFA" BorderBrush="#CFD8DC" BorderThickness="1" Padding="10" Margin="0,8,0,0"> <Border DockPanel.Dock="Bottom" Background="#FAFAFA" BorderBrush="#CFD8DC" BorderThickness="1" Padding="10" Margin="0,8,0,0">
<Grid> <Grid>

View File

@ -5,6 +5,7 @@ using System.ComponentModel;
using System.Diagnostics; using System.Diagnostics;
using System.Linq; using System.Linq;
using System.Security.Principal; using System.Security.Principal;
using System.Text.Json.Nodes;
using System.ServiceProcess; using System.ServiceProcess;
using System.Threading.Tasks; using System.Threading.Tasks;
using System.Windows; using System.Windows;
@ -34,6 +35,14 @@ namespace JJServiceManager
private readonly DispatcherTimer _statusTimer; private readonly DispatcherTimer _statusTimer;
private readonly RemoteServer _remoteServer; private readonly RemoteServer _remoteServer;
private readonly PassiveHub _passiveHub; private readonly PassiveHub _passiveHub;
private readonly RelayAgent _relayAgent;
private readonly RelayManager _relayManager;
private string _relayAgentKey = "", _relayManageKey = "";
private string _relayLoginNotice;
/// <summary>중계 기기 그룹의 표시용 대상 정보 (그룹 Id → 대상)</summary>
private readonly Dictionary<string, RemoteTarget> _relayTargets = new Dictionary<string, RemoteTarget>(StringComparer.OrdinalIgnoreCase);
/// <summary>시작할 때 열려 있던 중계 기기 탭 (로그인 뒤 기기 목록을 받으면 다시 엶)</summary>
private readonly HashSet<string> _pendingRelayTabs = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
private RemoteIndicatorWindow _indicator; private RemoteIndicatorWindow _indicator;
private readonly Dictionary<string, RemoteViewerWindow> _viewers = new Dictionary<string, RemoteViewerWindow>(StringComparer.OrdinalIgnoreCase); private readonly Dictionary<string, RemoteViewerWindow> _viewers = new Dictionary<string, RemoteViewerWindow>(StringComparer.OrdinalIgnoreCase);
private string _passiveKey = ""; private string _passiveKey = "";
@ -170,6 +179,31 @@ namespace JJServiceManager
// 관리 PC: Passive 대상 PC의 접속을 받는 곳 // 관리 PC: Passive 대상 PC의 접속을 받는 곳
_passiveHub = new PassiveHub((m, l) => Log(m, l)); _passiveHub = new PassiveHub((m, l) => Log(m, l));
// 중계 서버: 대상 PC 역할 (이 PC를 서버에 등록해 두고 연결 요청을 받음)
_relayAgent = new RelayAgent(_remoteServer, (m, l) => Log(m, l));
_relayAgent.StateChanged += a => Dispatcher.BeginInvoke(new Action(UpdateLocalStatus));
_relayAgent.NameChanged += name => Dispatcher.BeginInvoke(new Action(() =>
{
if (_settings.RelayDeviceName != name) { _settings.RelayDeviceName = name; SaveSettings(); }
}));
_relayAgent.Revoked += why => Dispatcher.BeginInvoke(new Action(() =>
{
_relayAgentKey = "revoked";
UpdateLocalStatus();
}));
// 중계 서버: 관리 PC 역할 (로그인 → 권한 있는 기기를 자동으로 표시)
_relayManager = new RelayManager((m, l) => Log(m, l));
_relayManager.DevicesChanged += m => Dispatcher.BeginInvoke(new Action(SyncRelayGroups));
_relayManager.Presence += (id, online) => Dispatcher.BeginInvoke(new Action(() => OnRelayPresence(id, online)));
_relayManager.StateChanged += m => Dispatcher.BeginInvoke(new Action(SyncRelayGroups));
_relayManager.LoginRequired += msg => Dispatcher.BeginInvoke(new Action(() =>
{
if (_relayLoginNotice == msg) return;
_relayLoginNotice = msg;
Log(msg, LogLevel.Warning);
}));
CreateTray(); CreateTray();
Log("프로그램 시작" + (IsAdmin() ? " (관리자 권한)" : "") + AppInstance.TitleTag, LogLevel.Notice); Log("프로그램 시작" + (IsAdmin() ? " (관리자 권한)" : "") + AppInstance.TitleTag, LogLevel.Notice);
@ -283,15 +317,21 @@ namespace JJServiceManager
private void SaveOpenTabs() private void SaveOpenTabs()
{ {
_settings.OpenHostTabs = _hostTabs.Keys.ToList(); _settings.OpenHostTabs = _hostTabs.Keys.Concat(_pendingRelayTabs).Distinct().ToList();
SaveSettings(); SaveSettings();
} }
private void RestoreHostTabs() private void RestoreHostTabs()
{ {
foreach (var id in _settings.OpenHostTabs.ToList()) OpenHostTab(id, select: false); foreach (var id in _settings.OpenHostTabs.ToList())
// 없어진 PC 탭은 정리 {
_settings.OpenHostTabs = _hostTabs.Keys.ToList(); if (id.StartsWith(RelayGroupPrefix, StringComparison.OrdinalIgnoreCase) && !_groups.Any(g => g.Id == id))
_pendingRelayTabs.Add(id); // 로그인 뒤 기기 목록을 받으면 엶
else
OpenHostTab(id, select: false);
}
// 없어진 PC 탭은 정리 (중계 기기 탭은 유지)
_settings.OpenHostTabs = _hostTabs.Keys.Concat(_pendingRelayTabs).Distinct().ToList();
} }
// ───────────────────────── 내 PC 서비스 추가 / 삭제 ───────────────────────── // ───────────────────────── 내 PC 서비스 추가 / 삭제 ─────────────────────────
@ -397,6 +437,12 @@ namespace JJServiceManager
OpenRemoteDesktop(hg); OpenRemoteDesktop(hg);
return; return;
} }
if (e.OriginalSource is Button pb && pb.DataContext is HostGroup pg && pb.Tag as string == "relayPassword")
{
e.Handled = true;
AskRelayPassword(pg);
return;
}
if (e.OriginalSource is not Button b || b.DataContext is not ServiceRow row) return; if (e.OriginalSource is not Button b || b.DataContext is not ServiceRow row) return;
switch (b.Tag as string) switch (b.Tag as string)
{ {
@ -773,39 +819,19 @@ namespace JJServiceManager
// 기존 원격 PC 정리 // 기존 원격 PC 정리
_passiveHub.Clear(); _passiveHub.Clear();
foreach (var v in _viewers.Values.ToList()) v.Close(); foreach (var v in _viewers.Values.ToList()) v.Close();
foreach (var g in _groups.Where(g => !g.IsLocal).ToList()) foreach (var g in _groups.Where(g => !g.IsLocal && !IsRelayGroup(g)).ToList())
{ RemoveRemoteGroup(g);
g.Client?.Dispose();
_logWindows.Close($"remote|{g.Id}|", prefix: true);
_groups.Remove(g);
}
if (_settings.RemoteManageEnabled) if (_settings.RemoteManageEnabled)
{ {
// 중계 기기 그룹보다 앞에 넣음
int index = 1;
foreach (var t in _settings.Targets) foreach (var t in _settings.Targets)
{ {
var client = new RemoteClient(t); var client = new RemoteClient(t);
var g = new HostGroup var g = CreateRemoteGroup(t.Id, t.Label, client, index++);
{
Id = t.Id,
Client = client,
Title = t.Label,
StatusText = "연결 중…",
StatusBrush = Brushes2.Pending,
EmptyText = "연결 중…"
};
client.StateChanged += c =>
{
// 상태는 이벤트 시점 값으로 넘김 (UI에서 나중에 읽으면 연속 변경이 모두 같은 값으로 보임)
var st = c.State;
var stMsg = c.StateMessage;
Dispatcher.BeginInvoke(new Action(() => OnRemoteState(g, st, stMsg)));
};
client.ServicesChanged += c => Dispatcher.BeginInvoke(new Action(() => OnRemoteServices(g, c)));
client.HostnameDiscovered += (c, host) => Dispatcher.BeginInvoke(new Action(() => OnHostname(g, c, host)));
client.PeerAddressDiscovered += (c, addr) => Dispatcher.BeginInvoke(new Action(() => OnPeerAddress(g, addr))); client.PeerAddressDiscovered += (c, addr) => Dispatcher.BeginInvoke(new Action(() => OnPeerAddress(g, addr)));
if (t.Direction == ConnectDirection.Passive) _passiveHub.Register(client); if (t.Direction == ConnectDirection.Passive) _passiveHub.Register(client);
_groups.Add(g);
client.Start(); client.Start();
} }
} }
@ -820,6 +846,213 @@ namespace JJServiceManager
UpdateCounts(); UpdateCounts();
} }
/// <summary>원격 PC 그룹 만들기 (Active / Passive / 중계 공통)</summary>
private HostGroup CreateRemoteGroup(string id, string title, RemoteClient client, int index = -1)
{
var g = new HostGroup
{
Id = id,
Client = client,
Title = title,
StatusText = "연결 중…",
StatusBrush = Brushes2.Pending,
EmptyText = "연결 중…"
};
client.StateChanged += c =>
{
// 상태는 이벤트 시점 값으로 넘김 (UI에서 나중에 읽으면 연속 변경이 모두 같은 값으로 보임)
var st = c.State;
var stMsg = c.StateMessage;
Dispatcher.BeginInvoke(new Action(() => OnRemoteState(g, st, stMsg)));
};
client.ServicesChanged += c => Dispatcher.BeginInvoke(new Action(() => OnRemoteServices(g, c)));
client.HostnameDiscovered += (c, host) => Dispatcher.BeginInvoke(new Action(() => OnHostname(g, c, host)));
if (index >= 0 && index <= _groups.Count) _groups.Insert(index, g);
else _groups.Add(g);
return g;
}
private void RemoveRemoteGroup(HostGroup g)
{
if (_viewers.TryGetValue(g.Id, out var v)) v.Close();
g.Client?.Dispose();
_logWindows.Close($"remote|{g.Id}|", prefix: true);
_groups.Remove(g);
}
// ───────────────────────── 중계 서버 ─────────────────────────
private const string RelayGroupPrefix = "relay-";
private static bool IsRelayGroup(HostGroup g) => g.Id.StartsWith(RelayGroupPrefix, StringComparison.OrdinalIgnoreCase);
/// <summary>대상 PC 역할: 이 PC를 중계 서버에 등록해 두고 온라인 유지</summary>
private void ApplyRelayAgent()
{
var s = _settings;
bool hasPw = !string.IsNullOrEmpty(s.RemotePasswordHash);
bool need = s.RemoteServerEnabled && hasPw && s.RelayAgentEnabled && !string.IsNullOrEmpty(s.RelayDeviceId);
var key = need ? $"{s.RelayAgentUrl}|{s.RelayDeviceId}|{s.RelayDeviceKeyProtected}" : "off";
if (key != _relayAgentKey)
{
_relayAgentKey = key;
if (need)
{
_relayAgent.Start(s.RelayAgentUrl, s.RelayDeviceId, RemoteSecurity.Unprotect(s.RelayDeviceKeyProtected),
s.RelayServerPublicKey, RelayActiveListen);
}
else
{
_relayAgent.Stop();
}
}
else if (need)
{
_relayAgent.SendUpdate(); // Active 수신 정보가 바뀌었을 수 있음
}
if (!need && !string.IsNullOrEmpty(s.RelayServerPublicKey)) _remoteServer.SetRelayTrust(s.RelayServerPublicKey, s.RelayDeviceId);
}
/// <summary>중계 서버에 알릴 Active 수신 정보 (직접 연결 후보)</summary>
private JsonObject RelayActiveListen()
{
if (!_remoteServer.IsRunning) return null;
return new JsonObject { ["mode"] = _remoteServer.Mode == RemoteMode.Ssh ? "Ssh" : "Port", ["port"] = _remoteServer.Port };
}
/// <summary>관리 역할: 중계 서버 로그인 유지</summary>
private void ApplyRelayManage()
{
var s = _settings;
bool need = s.RemoteManageEnabled && s.RelayManageEnabled
&& (!string.IsNullOrEmpty(s.RelayTokenProtected) || !string.IsNullOrEmpty(s.RelayPasswordProtected));
var key = need ? $"{s.RelayManageUrl}|{s.RelayUsername}|{s.RelayTokenProtected}|{s.RelayPasswordProtected}" : "off";
if (key == _relayManageKey) return;
_relayManageKey = key;
_relayLoginNotice = null;
if (need)
{
_relayManager.Start(s.RelayManageUrl, s.RelayUsername,
() => RemoteSecurity.Unprotect(_settings.RelayTokenProtected),
() => RemoteSecurity.Unprotect(_settings.RelayPasswordProtected),
token => Dispatcher.BeginInvoke(new Action(() =>
{
_settings.RelayTokenProtected = RemoteSecurity.Protect(token);
_relayManageKey = $"{_settings.RelayManageUrl}|{_settings.RelayUsername}|{_settings.RelayTokenProtected}|{_settings.RelayPasswordProtected}";
SaveSettings();
})));
}
else
{
_relayManager.Stop();
}
SyncRelayGroups();
}
/// <summary>중계 서버의 기기 목록 → 초기화면 그룹 (권한 있는 기기만)</summary>
private void SyncRelayGroups()
{
bool active = _settings.RemoteManageEnabled && _settings.RelayManageEnabled && _relayManageKey != "off" && _relayManageKey != "";
var devices = active ? _relayManager.Devices : Array.Empty<RelayDevice>();
var wanted = devices.Where(d => d.Enabled).ToDictionary(d => RelayGroupPrefix + d.Id, StringComparer.OrdinalIgnoreCase);
// 없어진 기기
foreach (var g in _groups.Where(IsRelayGroup).ToList())
{
if (wanted.ContainsKey(g.Id)) continue;
// 서버 연결이 잠깐 끊긴 경우(목록이 비어 있음)에는 그대로 둠
if (active && devices.Count == 0 && !_relayManager.IsLoggedIn) continue;
RemoveRemoteGroup(g);
_relayTargets.Remove(g.Id);
if (_hostTabs.ContainsKey(g.Id)) CloseHostTab(g.Id, save: false);
}
foreach (var kv in wanted)
{
var d = kv.Value;
var gid = kv.Key;
var existing = _groups.FirstOrDefault(x => x.Id == gid);
if (existing != null)
{
// 이름이 바뀌었으면 제목 갱신
if (_relayTargets.TryGetValue(gid, out var t0) && (t0.RelayName != d.Name))
{
t0.RelayName = d.Name;
existing.Title = t0.Label;
existing.TitleChanged();
}
if (d.Online && existing.Client?.State != RemoteState.Connected) existing.Client?.Nudge();
continue;
}
var t = new RemoteTarget
{
Id = gid,
Direction = ConnectDirection.Relay,
RelayDeviceId = d.Id,
RelayName = d.Name,
Hostname = d.Hostname
};
_relayTargets[gid] = t;
var deviceId = d.Id;
var client = new RemoteClient(t, () =>
_settings.RelayDevicePasswords.TryGetValue(deviceId, out var p) ? RemoteSecurity.Unprotect(p) : "", _relayManager);
var g = CreateRemoteGroup(gid, t.Label, client);
client.RelayModeChanged += c => Dispatcher.BeginInvoke(new Action(() =>
{
t.RelayMode = c.RelayMode;
g.Title = t.Label;
g.TitleChanged();
_logWindows.ChoicesChanged();
}));
client.Start();
if (_pendingRelayTabs.Remove(gid)) OpenHostTab(gid, select: false);
}
_logWindows.ChoicesChanged();
UpdateCounts();
}
private void OnRelayPresence(string deviceId, bool online)
{
var g = _groups.FirstOrDefault(x => x.Id == RelayGroupPrefix + deviceId);
if (g == null) return;
if (online) g.Client?.Nudge();
}
/// <summary>중계 기기의 대상 PC 접속 비밀번호 입력 (기기마다 저장)</summary>
private void AskRelayPassword(HostGroup g)
{
if (!_relayTargets.TryGetValue(g.Id, out var t) || _modalOpen) return;
string pw;
_modalOpen = true;
try
{
pw = TextPrompt.Ask(this, "대상 PC 접속 비밀번호",
$"{g.Title}\n\n대상 PC의 [원격 접속 허용] 접속 비밀번호를 입력하세요.\n(이 PC에 Windows 계정으로 암호화해 저장합니다)", "", password: true);
}
finally { _modalOpen = false; }
if (string.IsNullOrEmpty(pw)) return;
_settings.RelayDevicePasswords[t.RelayDeviceId] = RemoteSecurity.Protect(pw);
SaveSettings();
Log($"[{g.Prefix}] 접속 비밀번호 저장");
g.Client?.Nudge();
}
/// <summary>내 PC 제목 줄의 원격 접속 허용 상태</summary>
private void UpdateLocalStatus()
{
var s = _settings;
var parts = new List<string>();
bool passive = s.RemoteServerEnabled && !string.IsNullOrEmpty(s.RemotePasswordHash) && s.PassiveManagers.Count > 0;
if (_remoteServer.IsRunning) parts.Add($"Active {(s.RemoteServerMode == RemoteMode.Ssh ? "SSH" : "지정 포트")} {s.RemoteServerPort}");
if (passive) parts.Add($"Passive {s.PassiveManagers.Count}곳");
if (_relayAgentKey == "revoked") parts.Add("중계 서버: 다시 등록 필요");
else if (_relayAgentKey != "off" && _relayAgentKey != "") parts.Add(_relayAgent.IsOnline ? "중계 서버 온라인" : "중계 서버 연결 안 됨");
if (s.RemoteServerEnabled && s.RemoteAllowView) parts.Add(s.RemoteAllowControl ? "화면 보기·제어 허용" : "화면 보기 허용");
_local.StatusText = parts.Count > 0 ? "원격 접속 허용 중 · " + string.Join(" · ", parts) : "";
_local.StatusBrush = _relayAgentKey == "revoked" ? Brushes2.Stopped : Brushes2.Running;
}
/// <summary>Passive 대상이 있으면 수신 포트를 엶 (방화벽 포함)</summary> /// <summary>Passive 대상이 있으면 수신 포트를 엶 (방화벽 포함)</summary>
private void ApplyPassiveListen(bool startup) private void ApplyPassiveListen(bool startup)
{ {
@ -934,6 +1167,14 @@ namespace JJServiceManager
/// <summary>hostname 자동 등록</summary> /// <summary>hostname 자동 등록</summary>
private void OnHostname(HostGroup g, RemoteClient c, string host) private void OnHostname(HostGroup g, RemoteClient c, string host)
{ {
if (_relayTargets.TryGetValue(g.Id, out var rt))
{
rt.Hostname = host;
g.Title = rt.Label;
g.TitleChanged();
_logWindows.ChoicesChanged();
return;
}
var t = _settings.Targets.FirstOrDefault(x => x.Id == g.Id); var t = _settings.Targets.FirstOrDefault(x => x.Id == g.Id);
if (t == null) return; if (t == null) return;
if (t.Hostname != host) if (t.Hostname != host)
@ -996,13 +1237,11 @@ namespace JJServiceManager
else _remoteServer.StopPassive(); else _remoteServer.StopPassive();
} }
// ── 중계 서버 (이 PC를 등록해 두고 연결 요청 받기)
ApplyRelayAgent();
// ── 원격관리 허용을 끄면 보고 있던 연결은 다음 장면에서 자동으로 끊김 // ── 원격관리 허용을 끄면 보고 있던 연결은 다음 장면에서 자동으로 끊김
var parts = new List<string>(); UpdateLocalStatus();
if (_remoteServer.IsRunning) parts.Add($"Active {(s.RemoteServerMode == RemoteMode.Ssh ? "SSH" : "지정 포트")} {s.RemoteServerPort}");
if (passive) parts.Add($"Passive {s.PassiveManagers.Count}곳");
if (s.RemoteServerEnabled && s.RemoteAllowView) parts.Add(s.RemoteAllowControl ? "화면 보기·제어 허용" : "화면 보기 허용");
_local.StatusText = parts.Count > 0 ? "원격 접속 허용 중 · " + string.Join(" · ", parts) : "";
_local.StatusBrush = Brushes2.Running;
} }
// ───────────────────────── Windows 종료 ───────────────────────── // ───────────────────────── Windows 종료 ─────────────────────────
@ -1043,7 +1282,7 @@ namespace JJServiceManager
ShowFromTray(); ShowFromTray();
if (_modalOpen) return; if (_modalOpen) return;
bool wasStartup = _settings.RunAtStartup; bool wasStartup = _settings.RunAtStartup;
var dlg = new SettingsWindow(_settings) { Owner = this }; var dlg = new SettingsWindow(_settings, _relayManager) { Owner = this };
// 설정 창에서 단축키를 누를 때 전역 단축키가 먼저 받지 않도록 잠시 해제 // 설정 창에서 단축키를 누를 때 전역 단축키가 먼저 받지 않도록 잠시 해제
_hotkey.Unregister(); _hotkey.Unregister();
@ -1081,6 +1320,7 @@ namespace JJServiceManager
ApplyHotkey(quiet: startup); ApplyHotkey(quiet: startup);
ApplyRemoteServer(startup); ApplyRemoteServer(startup);
ApplyRemoteManage(); ApplyRemoteManage();
ApplyRelayManage();
ApplyPassiveListen(startup); ApplyPassiveListen(startup);
} }
@ -1256,6 +1496,8 @@ namespace JJServiceManager
_logWindows.CloseAll(); _logWindows.CloseAll();
foreach (var v in _viewers.Values.ToList()) v.Close(); foreach (var v in _viewers.Values.ToList()) v.Close();
foreach (var g in _groups) g.Client?.Dispose(); foreach (var g in _groups) g.Client?.Dispose();
_relayManager.Dispose();
_relayAgent.Dispose();
_passiveHub.Dispose(); _passiveHub.Dispose();
_remoteServer.Dispose(); _remoteServer.Dispose();
_indicator?.Close(); _indicator?.Close();

145
Windows/RelayDialogs.cs Normal file
View File

@ -0,0 +1,145 @@
using System;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Input;
using System.Windows.Media;
namespace JJServiceManager
{
/// <summary>한 줄 입력 (글자 또는 비밀번호)</summary>
public sealed class TextPrompt : Window
{
private readonly TextBox _text;
private readonly PasswordBox _pw;
private readonly bool _password;
private TextPrompt(string title, string message, string initial, bool password, bool upper)
{
_password = password;
Title = title + AppInstance.TitleTag;
Width = 460;
SizeToContent = SizeToContent.Height;
ResizeMode = ResizeMode.NoResize;
WindowStartupLocation = WindowStartupLocation.CenterOwner;
ShowInTaskbar = false;
FontFamily = new FontFamily("Malgun Gothic");
FontSize = 13;
AppIcon.Apply(this);
var root = new StackPanel { Margin = new Thickness(18) };
root.Children.Add(new TextBlock { Text = message, TextWrapping = TextWrapping.Wrap, Margin = new Thickness(0, 0, 0, 10) });
if (password)
{
_pw = new PasswordBox { Height = 30, VerticalContentAlignment = VerticalAlignment.Center };
root.Children.Add(_pw);
}
else
{
_text = new TextBox
{
Height = 30,
VerticalContentAlignment = VerticalAlignment.Center,
Text = initial ?? "",
CharacterCasing = upper ? CharacterCasing.Upper : CharacterCasing.Normal,
FontFamily = upper ? new FontFamily("Consolas") : FontFamily,
FontWeight = upper ? FontWeights.Bold : FontWeights.Normal
};
root.Children.Add(_text);
}
var buttons = new StackPanel { Orientation = Orientation.Horizontal, HorizontalAlignment = HorizontalAlignment.Right, Margin = new Thickness(0, 14, 0, 0) };
var ok = new Button { Content = "확인", MinWidth = 80, IsDefault = true, FontWeight = FontWeights.Bold };
var cancel = new Button { Content = "취소", MinWidth = 80, IsCancel = true, Margin = new Thickness(6, 0, 0, 0) };
ok.Click += (s, e) => DialogResult = true;
buttons.Children.Add(ok);
buttons.Children.Add(cancel);
root.Children.Add(buttons);
Content = root;
Loaded += (s, e) =>
{
if (_pw != null) _pw.Focus();
else { _text.Focus(); _text.SelectAll(); }
};
}
private string Value => _password ? _pw.Password : _text.Text.Trim();
/// <summary>입력값 (취소하면 null)</summary>
public static string Ask(Window owner, string title, string message, string initial = "", bool password = false, bool upper = false)
{
var w = new TextPrompt(title, message, initial, password, upper);
if (owner != null && owner.IsVisible) w.Owner = owner;
return w.ShowDialog() == true ? w.Value : null;
}
}
/// <summary>중계 서버 비밀번호 변경 (지금 비밀번호 + 새 비밀번호 두 번)</summary>
public sealed class ChangePasswordWindow : Window
{
private readonly PasswordBox _old, _new1, _new2;
private readonly TextBlock _msg;
public string OldPassword => _old.Password;
public string NewPassword => _new1.Password;
private ChangePasswordWindow(string message, bool askOld)
{
Title = "중계 서버 비밀번호 변경" + AppInstance.TitleTag;
Width = 440;
SizeToContent = SizeToContent.Height;
ResizeMode = ResizeMode.NoResize;
WindowStartupLocation = WindowStartupLocation.CenterOwner;
ShowInTaskbar = false;
FontFamily = new FontFamily("Malgun Gothic");
FontSize = 13;
AppIcon.Apply(this);
var root = new StackPanel { Margin = new Thickness(18) };
root.Children.Add(new TextBlock { Text = message, TextWrapping = TextWrapping.Wrap, Margin = new Thickness(0, 0, 0, 10) });
var grid = new Grid();
grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(120) });
grid.ColumnDefinitions.Add(new ColumnDefinition());
_old = new PasswordBox { Height = 28, Margin = new Thickness(0, 3, 0, 3) };
_new1 = new PasswordBox { Height = 28, Margin = new Thickness(0, 3, 0, 3) };
_new2 = new PasswordBox { Height = 28, Margin = new Thickness(0, 3, 0, 3) };
int row = 0;
void Add(string label, Control c)
{
grid.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto });
var t = new TextBlock { Text = label, VerticalAlignment = VerticalAlignment.Center };
Grid.SetRow(t, row); Grid.SetRow(c, row); Grid.SetColumn(c, 1);
grid.Children.Add(t); grid.Children.Add(c);
row++;
}
if (askOld) Add("지금 비밀번호", _old);
Add("새 비밀번호", _new1);
Add("새 비밀번호 확인", _new2);
root.Children.Add(grid);
_msg = new TextBlock { Foreground = Brushes2.Stopped, Margin = new Thickness(0, 8, 0, 0), TextWrapping = TextWrapping.Wrap, Text = "8자 이상" };
root.Children.Add(_msg);
var buttons = new StackPanel { Orientation = Orientation.Horizontal, HorizontalAlignment = HorizontalAlignment.Right, Margin = new Thickness(0, 12, 0, 0) };
var ok = new Button { Content = "변경", MinWidth = 80, IsDefault = true, FontWeight = FontWeights.Bold };
var cancel = new Button { Content = "취소", MinWidth = 80, IsCancel = true, Margin = new Thickness(6, 0, 0, 0) };
ok.Click += (s, e) =>
{
if (askOld && _old.Password.Length == 0) { _msg.Text = "지금 비밀번호를 입력하세요."; return; }
if (_new1.Password.Length < 8) { _msg.Text = "새 비밀번호는 8자 이상이어야 합니다."; return; }
if (_new1.Password != _new2.Password) { _msg.Text = "새 비밀번호와 확인이 다릅니다."; return; }
if (askOld && _new1.Password == _old.Password) { _msg.Text = "지금과 다른 비밀번호를 입력하세요."; return; }
DialogResult = true;
};
buttons.Children.Add(ok);
buttons.Children.Add(cancel);
root.Children.Add(buttons);
Content = root;
Loaded += (s, e) => (askOld ? _old : _new1).Focus();
}
/// <summary>(지금 비밀번호, 새 비밀번호) 또는 취소하면 null. askOld=false면 지금 비밀번호는 빈 값</summary>
public static (string oldPw, string newPw)? Ask(Window owner, string message, bool askOld = true)
{
var w = new ChangePasswordWindow(message, askOld);
if (owner != null && owner.IsVisible) w.Owner = owner;
return w.ShowDialog() == true ? (w.OldPassword, w.NewPassword) : null;
}
}
}

890
Windows/ServerAdminPanel.cs Normal file
View File

@ -0,0 +1,890 @@
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Linq;
using System.Text;
using System.Text.Json.Nodes;
using System.Threading.Tasks;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Controls.Primitives;
using System.Windows.Data;
using System.Windows.Media;
using JJServiceManager.Remote;
namespace JJServiceManager
{
/// <summary>
/// 설정 → [서버 관리] 탭 (중계 서버 관리자 계정으로 로그인했을 때만).
/// 계정 / 권한 / 기기 / 등록 코드 / 감사 로그 / 연결 기록
/// </summary>
public sealed class ServerAdminPanel : DockPanel
{
private readonly RelayManager _relay;
private readonly Window _owner;
private readonly TextBlock _info, _status;
// 계정
private readonly ObservableCollection<UserRowItem> _users = new ObservableCollection<UserRowItem>();
private readonly ListView _userList;
private readonly TextBox _newUser, _newNote;
private readonly PasswordBox _newPw;
private readonly ComboBox _newRole;
// 권한
private readonly ComboBox _permUser;
private readonly ObservableCollection<PermRowItem> _perms = new ObservableCollection<PermRowItem>();
private readonly TextBlock _permNote;
private bool _permLoading;
// 기기
private readonly ObservableCollection<DeviceRowItem> _devices = new ObservableCollection<DeviceRowItem>();
private readonly ListView _deviceList;
// 등록 코드
private readonly ObservableCollection<CodeRowItem> _codes = new ObservableCollection<CodeRowItem>();
private readonly ListView _codeList;
private readonly ComboBox _codeHours;
private readonly TextBox _codeNote, _codeShown;
// 감사 로그 / 연결 기록
private readonly ObservableCollection<AuditRowItem> _audit = new ObservableCollection<AuditRowItem>();
private readonly ObservableCollection<ConnRowItem> _conns = new ObservableCollection<ConnRowItem>();
private readonly DatePicker _aFrom, _aTo, _cFrom, _cTo;
private readonly ComboBox _aUser, _aDevice, _aAction, _cUser, _cDevice;
private long? _auditLastId, _connLastId;
public ServerAdminPanel(RelayManager relay, Window owner)
{
_relay = relay;
_owner = owner;
Background = Brushes.White;
// 위: 서버 정보 + 상태
var top = new DockPanel { Margin = new Thickness(10, 8, 10, 4) };
var refreshAll = Btn("모두 새로고침", async () => await LoadAllAsync());
DockPanel.SetDock(refreshAll, Dock.Right);
top.Children.Add(refreshAll);
_info = new TextBlock { Foreground = Brushes2.Make("#37474F"), VerticalAlignment = VerticalAlignment.Center, TextWrapping = TextWrapping.Wrap };
top.Children.Add(_info);
DockPanel.SetDock(top, Dock.Top);
Children.Add(top);
_status = new TextBlock { Margin = new Thickness(10, 4, 10, 6), TextWrapping = TextWrapping.Wrap };
DockPanel.SetDock(_status, Dock.Bottom);
Children.Add(_status);
var tabs = new TabControl { Margin = new Thickness(6, 0, 6, 0) };
Children.Add(tabs);
// ───────── 계정 ─────────
{
var p = new DockPanel { Margin = new Thickness(8) };
var form = new WrapPanel { Margin = new Thickness(0, 8, 0, 0) };
_newUser = new TextBox { Width = 130, Height = 26, VerticalContentAlignment = VerticalAlignment.Center };
_newPw = new PasswordBox { Width = 130, Height = 26, VerticalContentAlignment = VerticalAlignment.Center };
_newRole = new ComboBox { Width = 100, Height = 26, ItemsSource = new[] { "일반 사용자", "관리자" }, SelectedIndex = 0 };
_newNote = new TextBox { Width = 140, Height = 26, VerticalContentAlignment = VerticalAlignment.Center };
form.Children.Add(Label("아이디")); form.Children.Add(_newUser);
form.Children.Add(Label("처음 비밀번호")); form.Children.Add(_newPw);
form.Children.Add(Label("역할")); form.Children.Add(_newRole);
form.Children.Add(Label("메모")); form.Children.Add(_newNote);
form.Children.Add(Btn("추가", AddUserAsync, bold: true));
DockPanel.SetDock(form, Dock.Bottom);
p.Children.Add(form);
var buttons = new WrapPanel { Margin = new Thickness(0, 6, 0, 0) };
buttons.Children.Add(Btn("새로고침", LoadUsersAsync));
buttons.Children.Add(Btn("비밀번호 초기화", ResetPasswordAsync));
buttons.Children.Add(Btn("역할 바꾸기", ToggleRoleAsync));
buttons.Children.Add(Btn("사용 / 사용 안 함", ToggleUserAsync));
buttons.Children.Add(Btn("메모", EditUserNoteAsync));
buttons.Children.Add(Note("새 계정은 첫 로그인 때 비밀번호를 바꿔야 합니다. 계정은 지우지 않고 '사용 안 함'으로 합니다 (감사 로그 보존)."));
DockPanel.SetDock(buttons, Dock.Bottom);
p.Children.Add(buttons);
_userList = Table(_users,
("아이디", nameof(UserRowItem.Username), 130),
("역할", nameof(UserRowItem.RoleText), 80),
("상태", nameof(UserRowItem.StateText), 110),
("마지막 로그인", nameof(UserRowItem.LastLogin), 140),
("만든 때", nameof(UserRowItem.Created), 140),
("메모", nameof(UserRowItem.Note), 160));
p.Children.Add(_userList);
tabs.Items.Add(new TabItem { Header = " 계정 ", Content = p });
}
// ───────── 권한 ─────────
{
var p = new DockPanel { Margin = new Thickness(8) };
var head = new DockPanel { Margin = new Thickness(0, 0, 0, 6) };
head.Children.Add(Label("사용자"));
_permUser = new ComboBox { Width = 200, Height = 26, DisplayMemberPath = nameof(UserRowItem.Username) };
_permUser.SelectionChanged += async (s, e) => await LoadPermsAsync();
head.Children.Add(_permUser);
_permNote = Note("체크하면 바로 저장됩니다. 서비스 제어·화면 권한을 주면 보기 권한은 자동으로 함께 켜집니다.");
_permNote.Margin = new Thickness(12, 0, 0, 0);
head.Children.Add(_permNote);
DockPanel.SetDock(head, Dock.Top);
p.Children.Add(head);
var list = new ListView { ItemsSource = _perms };
var gv = new GridView();
gv.Columns.Add(new GridViewColumn { Header = "기기", Width = 170, DisplayMemberBinding = new Binding(nameof(PermRowItem.Name)) });
gv.Columns.Add(new GridViewColumn { Header = "hostname", Width = 130, DisplayMemberBinding = new Binding(nameof(PermRowItem.Hostname)) });
gv.Columns.Add(CheckColumn("보기", nameof(PermRowItem.View)));
gv.Columns.Add(CheckColumn("서비스 제어", nameof(PermRowItem.Control)));
gv.Columns.Add(CheckColumn("화면 보기", nameof(PermRowItem.ScreenView)));
gv.Columns.Add(CheckColumn("화면 제어", nameof(PermRowItem.ScreenControl)));
list.View = gv;
list.AddHandler(ButtonBase.ClickEvent, new RoutedEventHandler(async (s, e) =>
{
if (e.OriginalSource is CheckBox cb && cb.DataContext is PermRowItem row) await SavePermAsync(row);
}));
p.Children.Add(list);
tabs.Items.Add(new TabItem { Header = " 권한 ", Content = p });
}
// ───────── 기기 ─────────
{
var p = new DockPanel { Margin = new Thickness(8) };
var buttons = new WrapPanel { Margin = new Thickness(0, 6, 0, 0) };
buttons.Children.Add(Btn("새로고침", LoadDevicesAsync));
buttons.Children.Add(Btn("이름 바꾸기", RenameDeviceAsync));
buttons.Children.Add(Btn("메모", EditDeviceNoteAsync));
buttons.Children.Add(Btn("사용 안 함", () => SetDeviceEnabledAsync(false)));
buttons.Children.Add(Btn("사용", () => SetDeviceEnabledAsync(true)));
buttons.Children.Add(Btn("삭제", DeleteDeviceAsync));
buttons.Children.Add(Note("'사용 안 함'은 바로 접속이 끊기고, 다시 '사용'하면 그대로 접속됩니다. '삭제'하면 그 PC는 새 등록 코드로 다시 등록해야 합니다."));
DockPanel.SetDock(buttons, Dock.Bottom);
p.Children.Add(buttons);
_deviceList = Table(_devices,
("이름", nameof(DeviceRowItem.Name), 140),
("hostname", nameof(DeviceRowItem.Hostname), 110),
("상태", nameof(DeviceRowItem.StateText), 90),
("외부 IP", nameof(DeviceRowItem.PublicIp), 110),
("내부 IP", nameof(DeviceRowItem.LanIps), 130),
("Active 수신", nameof(DeviceRowItem.ActiveText), 100),
("버전", nameof(DeviceRowItem.Version), 60),
("마지막 접속", nameof(DeviceRowItem.LastSeen), 140),
("등록", nameof(DeviceRowItem.Enrolled), 140),
("메모", nameof(DeviceRowItem.Note), 140));
p.Children.Add(_deviceList);
tabs.Items.Add(new TabItem { Header = " 기기 ", Content = p });
}
// ───────── 등록 코드 ─────────
{
var p = new DockPanel { Margin = new Thickness(8) };
var form = new WrapPanel { Margin = new Thickness(0, 0, 0, 6) };
_codeHours = new ComboBox { Width = 90, Height = 26, ItemsSource = new[] { "1시간", "24시간", "7일", "30일" }, SelectedIndex = 2 };
_codeNote = new TextBox { Width = 200, Height = 26, VerticalContentAlignment = VerticalAlignment.Center };
form.Children.Add(Label("유효 시간")); form.Children.Add(_codeHours);
form.Children.Add(Label("메모 (어느 PC용)")); form.Children.Add(_codeNote);
form.Children.Add(Btn("새 코드 발급", CreateCodeAsync, bold: true));
DockPanel.SetDock(form, Dock.Top);
p.Children.Add(form);
_codeShown = new TextBox
{
IsReadOnly = true, FontFamily = new FontFamily("Consolas"), FontSize = 22, FontWeight = FontWeights.Bold,
Height = 40, VerticalContentAlignment = VerticalAlignment.Center, HorizontalContentAlignment = HorizontalAlignment.Center,
Background = Brushes2.Make("#FFF8E1"), Visibility = Visibility.Collapsed, Margin = new Thickness(0, 0, 0, 6),
ToolTip = "대상 PC의 설정 → 원격 접속 허용 → 중계 서버 접속 → [등록 코드로 등록]에 입력하세요. 이 코드는 다시 볼 수 없습니다."
};
DockPanel.SetDock(_codeShown, Dock.Top);
p.Children.Add(_codeShown);
var buttons = new WrapPanel { Margin = new Thickness(0, 6, 0, 0) };
buttons.Children.Add(Btn("새로고침", LoadCodesAsync));
buttons.Children.Add(Btn("선택한 코드 취소", RevokeCodeAsync));
buttons.Children.Add(Note("코드는 한 번만 쓸 수 있고, 발급할 때만 보입니다."));
DockPanel.SetDock(buttons, Dock.Bottom);
p.Children.Add(buttons);
_codeList = Table(_codes,
("상태", nameof(CodeRowItem.StateText), 80),
("만든 사람", nameof(CodeRowItem.CreatedBy), 100),
("만든 때", nameof(CodeRowItem.Created), 140),
("만료", nameof(CodeRowItem.Expires), 140),
("사용한 기기", nameof(CodeRowItem.UsedDevice), 130),
("사용한 때", nameof(CodeRowItem.Used), 140),
("메모", nameof(CodeRowItem.Note), 160));
p.Children.Add(_codeList);
tabs.Items.Add(new TabItem { Header = " 등록 코드 ", Content = p });
}
// ───────── 감사 로그 ─────────
{
var p = new DockPanel { Margin = new Thickness(8) };
var f = new WrapPanel { Margin = new Thickness(0, 0, 0, 6) };
_aFrom = new DatePicker { Width = 120, SelectedDate = DateTime.Today.AddDays(-7) };
_aTo = new DatePicker { Width = 120, SelectedDate = DateTime.Today };
_aUser = new ComboBox { Width = 120, Height = 26, DisplayMemberPath = "Text" };
_aDevice = new ComboBox { Width = 140, Height = 26, DisplayMemberPath = "Text" };
_aAction = new ComboBox { Width = 130, Height = 26, DisplayMemberPath = "Text", ItemsSource = AuditActions(), SelectedIndex = 0 };
f.Children.Add(Label("기간")); f.Children.Add(_aFrom); f.Children.Add(Label("~")); f.Children.Add(_aTo);
f.Children.Add(Label("사용자")); f.Children.Add(_aUser);
f.Children.Add(Label("기기")); f.Children.Add(_aDevice);
f.Children.Add(Label("작업")); f.Children.Add(_aAction);
f.Children.Add(Btn("조회", () => QueryAuditAsync(false), bold: true));
f.Children.Add(Btn("더 보기", () => QueryAuditAsync(true)));
f.Children.Add(Btn("복사", () => { Copy(_audit.Select(a => a.ToLine())); return Task.CompletedTask; }));
DockPanel.SetDock(f, Dock.Top);
p.Children.Add(f);
p.Children.Add(Table(_audit,
("시각", nameof(AuditRowItem.At), 140),
("사용자", nameof(AuditRowItem.User), 90),
("기기", nameof(AuditRowItem.Device), 120),
("작업", nameof(AuditRowItem.ActionText), 110),
("대상", nameof(AuditRowItem.Target), 120),
("결과", nameof(AuditRowItem.ResultText), 60),
("내용", nameof(AuditRowItem.Detail), 220),
("IP", nameof(AuditRowItem.Ip), 110)));
tabs.Items.Add(new TabItem { Header = " 감사 로그 ", Content = p });
}
// ───────── 연결 기록 ─────────
{
var p = new DockPanel { Margin = new Thickness(8) };
var f = new WrapPanel { Margin = new Thickness(0, 0, 0, 6) };
_cFrom = new DatePicker { Width = 120, SelectedDate = DateTime.Today.AddDays(-7) };
_cTo = new DatePicker { Width = 120, SelectedDate = DateTime.Today };
_cUser = new ComboBox { Width = 120, Height = 26, DisplayMemberPath = "Text" };
_cDevice = new ComboBox { Width = 140, Height = 26, DisplayMemberPath = "Text" };
f.Children.Add(Label("기간")); f.Children.Add(_cFrom); f.Children.Add(Label("~")); f.Children.Add(_cTo);
f.Children.Add(Label("사용자")); f.Children.Add(_cUser);
f.Children.Add(Label("기기")); f.Children.Add(_cDevice);
f.Children.Add(Btn("조회", () => QueryConnsAsync(false), bold: true));
f.Children.Add(Btn("더 보기", () => QueryConnsAsync(true)));
f.Children.Add(Btn("복사", () => { Copy(_conns.Select(c => c.ToLine())); return Task.CompletedTask; }));
DockPanel.SetDock(f, Dock.Top);
p.Children.Add(f);
p.Children.Add(Table(_conns,
("시작", nameof(ConnRowItem.Started), 140),
("끝", nameof(ConnRowItem.Ended), 140),
("사용자", nameof(ConnRowItem.User), 90),
("기기", nameof(ConnRowItem.Device), 120),
("방식", nameof(ConnRowItem.ModeText), 60),
("보냄", nameof(ConnRowItem.ToDevice), 80),
("받음", nameof(ConnRowItem.FromDevice), 80),
("끝난 이유", nameof(ConnRowItem.Reason), 200),
("관리 PC IP", nameof(ConnRowItem.Ip), 110)));
tabs.Items.Add(new TabItem { Header = " 연결 기록 ", Content = p });
}
Loaded += async (s, e) => { if (_users.Count == 0) await LoadAllAsync(); };
}
// ───────────────────────── 불러오기 ─────────────────────────
private async Task LoadAllAsync()
{
await Run(async () =>
{
var i = await _relay.RequestAsync("admin.server.info");
_info.Text = $"중계 서버 {Json.Str(i, "version")} · DB {(Json.Bool(i, "db") ? "정상" : "오류")} · 접속 중: 기기 {Json.Int(i, "onlineDevices")}대, 관리 PC {Json.Int(i, "onlineManagers")}곳"
+ $" · 시작 {Local(Json.Str(i, "startedAt"))} · 로그인: {_relay.Username}";
});
await LoadUsersAsync();
await LoadDevicesAsync();
await LoadCodesAsync();
await QueryAuditAsync(false);
await QueryConnsAsync(false);
}
private async Task LoadUsersAsync()
{
await Run(async () =>
{
var r = await _relay.RequestAsync("admin.users.list");
var keep = (_permUser.SelectedItem as UserRowItem)?.Id;
_users.Clear();
foreach (var o in (r["users"] as JsonArray).OfType<JsonObject>()) _users.Add(UserRowItem.From(o));
_permUser.ItemsSource = _users.Where(u => !u.IsAdmin).ToList();
var sel = ((IEnumerable<UserRowItem>)_permUser.ItemsSource).FirstOrDefault(u => u.Id == keep)
?? ((IEnumerable<UserRowItem>)_permUser.ItemsSource).FirstOrDefault();
_permUser.SelectedItem = sel;
if (sel == null) { _perms.Clear(); _permNote.Text = "일반 사용자가 없습니다. [계정]에서 추가하세요. (관리자는 모든 기기에 모든 권한)"; }
FillFilters();
});
}
private async Task LoadDevicesAsync()
{
await Run(async () =>
{
var r = await _relay.RequestAsync("admin.devices.list");
_devices.Clear();
foreach (var o in (r["devices"] as JsonArray).OfType<JsonObject>()) _devices.Add(DeviceRowItem.From(o));
FillFilters();
});
}
private async Task LoadCodesAsync()
{
await Run(async () =>
{
var r = await _relay.RequestAsync("admin.codes.list");
_codes.Clear();
foreach (var o in (r["codes"] as JsonArray).OfType<JsonObject>()) _codes.Add(CodeRowItem.From(o));
});
}
private void FillFilters()
{
var users = new List<Pick> { new Pick("(전체)", null) };
users.AddRange(_users.Select(u => new Pick(u.Username, u.Id.ToString())));
var devices = new List<Pick> { new Pick("(전체)", null) };
devices.AddRange(_devices.Select(d => new Pick(d.Name, d.Id)));
foreach (var cb in new[] { _aUser, _cUser }) { var k = (cb.SelectedItem as Pick)?.Value; cb.ItemsSource = users; cb.SelectedItem = users.FirstOrDefault(x => x.Value == k) ?? users[0]; }
foreach (var cb in new[] { _aDevice, _cDevice }) { var k = (cb.SelectedItem as Pick)?.Value; cb.ItemsSource = devices; cb.SelectedItem = devices.FirstOrDefault(x => x.Value == k) ?? devices[0]; }
}
// ───────────────────────── 계정 ─────────────────────────
private UserRowItem SelectedUser()
{
if (_userList.SelectedItem is UserRowItem u) return u;
Say("목록에서 계정을 고르세요.", false);
return null;
}
private async Task AddUserAsync()
{
var name = _newUser.Text.Trim();
if (name.Length == 0) { Say("아이디를 입력하세요.", false); return; }
if (_newPw.Password.Length < 8) { Say("처음 비밀번호는 8자 이상이어야 합니다.", false); return; }
bool asAdmin = _newRole.SelectedIndex == 1;
await Run(async () =>
{
await _relay.RequestAsync("admin.users.create", new JsonObject
{
["username"] = name,
["password"] = _newPw.Password,
["role"] = asAdmin ? "admin" : "user",
["note"] = _newNote.Text.Trim(),
["mustChangePassword"] = true
});
_newUser.Text = ""; _newPw.Password = ""; _newNote.Text = ""; _newRole.SelectedIndex = 0;
Say($"계정 {name} 을(를) 만들었습니다. 첫 로그인 때 비밀번호를 바꾸게 됩니다." +
(asAdmin ? "" : " [권한] 탭에서 볼 수 있는 기기를 정하세요."), true);
await LoadUsersAsync();
});
}
private async Task ResetPasswordAsync()
{
var u = SelectedUser();
if (u == null) return;
var pw = TextPrompt.Ask(_owner, "비밀번호 초기화", $"{u.Username} 의 새 비밀번호 (8자 이상)\n다음 로그인 때 다시 바꾸게 되며, 접속 중인 연결은 끊깁니다.", "", password: true);
if (string.IsNullOrEmpty(pw)) return;
await Run(async () =>
{
await _relay.RequestAsync("admin.users.resetPassword", new JsonObject { ["userId"] = u.Id, ["password"] = pw });
Say($"{u.Username} 의 비밀번호를 초기화했습니다.", true);
await LoadUsersAsync();
});
}
private async Task ToggleRoleAsync()
{
var u = SelectedUser();
if (u == null) return;
var to = u.IsAdmin ? "user" : "admin";
if (!Dialogs.Confirm(_owner, $"{u.Username} 을(를) {(to == "admin" ? "관리자" : "일반 사용자")}로 바꿀까요?", "역할 바꾸기")) return;
await Run(async () =>
{
await _relay.RequestAsync("admin.users.update", new JsonObject { ["userId"] = u.Id, ["role"] = to });
Say("역할을 바꿨습니다.", true);
await LoadUsersAsync();
});
}
private async Task ToggleUserAsync()
{
var u = SelectedUser();
if (u == null) return;
bool to = !u.Enabled;
if (!to && !Dialogs.Confirm(_owner, $"{u.Username} 을(를) 사용 안 함으로 할까요?\n접속 중인 연결은 바로 끊깁니다.", "사용 안 함")) return;
await Run(async () =>
{
await _relay.RequestAsync("admin.users.update", new JsonObject { ["userId"] = u.Id, ["enabled"] = to });
Say(to ? "다시 사용하도록 했습니다." : "사용 안 함으로 했습니다.", true);
await LoadUsersAsync();
});
}
private async Task EditUserNoteAsync()
{
var u = SelectedUser();
if (u == null) return;
var note = TextPrompt.Ask(_owner, "메모", $"{u.Username} 메모", u.Note);
if (note == null) return;
await Run(async () =>
{
await _relay.RequestAsync("admin.users.update", new JsonObject { ["userId"] = u.Id, ["note"] = note });
await LoadUsersAsync();
});
}
// ───────────────────────── 권한 ─────────────────────────
private async Task LoadPermsAsync()
{
if (_permUser.SelectedItem is not UserRowItem u) { _perms.Clear(); return; }
await Run(async () =>
{
var r = await _relay.RequestAsync("admin.perms.get", new JsonObject { ["userId"] = u.Id });
_permLoading = true;
_perms.Clear();
foreach (var o in (r["devices"] as JsonArray).OfType<JsonObject>()) _perms.Add(PermRowItem.From(o));
_permLoading = false;
_permNote.Text = _perms.Count == 0
? "등록된 기기가 없습니다. [등록 코드]에서 코드를 발급해 대상 PC를 등록하세요."
: "체크하면 바로 저장됩니다. 서비스 제어·화면 권한을 주면 보기 권한은 자동으로 함께 켜집니다.";
});
}
private async Task SavePermAsync(PermRowItem row)
{
if (_permLoading || _permUser.SelectedItem is not UserRowItem u) return;
await Run(async () =>
{
var r = await _relay.RequestAsync("admin.perms.set", new JsonObject
{
["userId"] = u.Id,
["deviceId"] = row.DeviceId,
["view"] = row.View,
["control"] = row.Control,
["screenView"] = row.ScreenView,
["screenControl"] = row.ScreenControl
});
// 서버가 정리한 값으로 (예: 제어 → 보기 자동)
row.Set(Json.Bool(r, "view"), Json.Bool(r, "control"), Json.Bool(r, "screenView"), Json.Bool(r, "screenControl"));
Say($"{u.Username} → {row.Name}: {row.Summary}", true);
});
}
// ───────────────────────── 기기 ─────────────────────────
private DeviceRowItem SelectedDevice()
{
if (_deviceList.SelectedItem is DeviceRowItem d) return d;
Say("목록에서 기기를 고르세요.", false);
return null;
}
private async Task RenameDeviceAsync()
{
var d = SelectedDevice();
if (d == null) return;
var name = TextPrompt.Ask(_owner, "기기 이름 바꾸기", $"{d.Name} ({d.Hostname}) 의 새 이름", d.Name);
if (string.IsNullOrWhiteSpace(name)) return;
await Run(async () =>
{
await _relay.RequestAsync("admin.devices.update", new JsonObject { ["deviceId"] = d.Id, ["name"] = name.Trim() });
Say("이름을 바꿨습니다.", true);
await LoadDevicesAsync();
});
}
private async Task EditDeviceNoteAsync()
{
var d = SelectedDevice();
if (d == null) return;
var note = TextPrompt.Ask(_owner, "메모", $"{d.Name} 메모", d.Note);
if (note == null) return;
await Run(async () =>
{
await _relay.RequestAsync("admin.devices.update", new JsonObject { ["deviceId"] = d.Id, ["note"] = note });
await LoadDevicesAsync();
});
}
private async Task SetDeviceEnabledAsync(bool enable)
{
var d = SelectedDevice();
if (d == null) return;
if (!enable && !Dialogs.Confirm(_owner, $"{d.Name} 을(를) 사용 안 함으로 할까요?\n접속이 바로 끊기고, 다시 '사용'할 때까지 연결할 수 없습니다.", "사용 안 함")) return;
await Run(async () =>
{
await _relay.RequestAsync(enable ? "admin.devices.enable" : "admin.devices.disable", new JsonObject { ["deviceId"] = d.Id });
Say(enable ? "다시 사용하도록 했습니다." : "사용 안 함으로 했습니다.", true);
await LoadDevicesAsync();
});
}
private async Task DeleteDeviceAsync()
{
var d = SelectedDevice();
if (d == null) return;
if (!Dialogs.Confirm(_owner, $"{d.Name} 을(를) 서버에서 삭제할까요?\n\n그 PC는 새 등록 코드로 다시 등록해야 합니다.\n감사 로그·연결 기록은 그대로 남습니다.", "기기 삭제")) return;
await Run(async () =>
{
await _relay.RequestAsync("admin.devices.delete", new JsonObject { ["deviceId"] = d.Id });
Say("삭제했습니다.", true);
await LoadDevicesAsync();
});
}
// ───────────────────────── 등록 코드 ─────────────────────────
private async Task CreateCodeAsync()
{
int hours = _codeHours.SelectedIndex switch { 0 => 1, 1 => 24, 3 => 24 * 30, _ => 24 * 7 };
await Run(async () =>
{
var r = await _relay.RequestAsync("admin.codes.create", new JsonObject { ["validHours"] = hours, ["note"] = _codeNote.Text.Trim() });
_codeShown.Text = Json.Str(r, "code");
_codeShown.Visibility = Visibility.Visible;
_codeNote.Text = "";
Say($"등록 코드 {Json.Str(r, "code")} (만료 {Local(Json.Str(r, "expiresAt"))}). 대상 PC의 [원격 접속 허용 → 중계 서버 접속 → 등록 코드로 등록]에 입력하세요. 이 코드는 다시 볼 수 없습니다.", true);
await LoadCodesAsync();
});
}
private async Task RevokeCodeAsync()
{
if (_codeList.SelectedItem is not CodeRowItem c) { Say("목록에서 코드를 고르세요.", false); return; }
if (c.Status != "valid") { Say("사용 가능한 코드만 취소할 수 있습니다.", false); return; }
await Run(async () =>
{
await _relay.RequestAsync("admin.codes.revoke", new JsonObject { ["codeId"] = c.Id });
Say("코드를 취소했습니다.", true);
await LoadCodesAsync();
});
}
// ───────────────────────── 감사 로그 / 연결 기록 ─────────────────────────
private static List<Pick> AuditActions() => new List<Pick>
{
new Pick("(전체)", null),
new Pick("서비스 시작", "start"), new Pick("서비스 중지", "stop"),
new Pick("원격 화면 시작", "screenStart"), new Pick("원격 화면 끝", "screenEnd"),
new Pick("연결 요청", "connect"), new Pick("대상 PC 인증 실패", "authFail"),
new Pick("로그인", "login"), new Pick("로그인 실패", "loginFail"), new Pick("로그아웃", "logout"),
new Pick("비밀번호 변경", "passwordChange"), new Pick("비밀번호 초기화", "passwordReset"),
new Pick("계정 만들기", "userCreate"), new Pick("계정 변경", "userUpdate"), new Pick("권한 변경", "permChange"),
new Pick("등록 코드 발급", "codeCreate"), new Pick("등록 코드 취소", "codeRevoke"),
new Pick("기기 등록", "deviceEnroll"), new Pick("기기 변경", "deviceUpdate"),
new Pick("기기 사용 안 함", "deviceDisable"), new Pick("기기 사용", "deviceEnable"), new Pick("기기 삭제", "deviceDelete")
};
private static JsonObject Filter(DatePicker from, DatePicker to, ComboBox user, ComboBox device)
{
var o = new JsonObject { ["limit"] = 200 };
if (from.SelectedDate is DateTime f) o["from"] = f.Date.ToUniversalTime().ToString("o");
if (to.SelectedDate is DateTime t) o["to"] = t.Date.AddDays(1).ToUniversalTime().ToString("o");
if ((user.SelectedItem as Pick)?.Value is string u && long.TryParse(u, out var uid)) o["userId"] = uid;
if ((device.SelectedItem as Pick)?.Value is string d) o["deviceId"] = d;
return o;
}
private async Task QueryAuditAsync(bool more)
{
var q = Filter(_aFrom, _aTo, _aUser, _aDevice);
if ((_aAction.SelectedItem as Pick)?.Value is string a) q["action"] = a;
if (more)
{
if (_auditLastId == null) return;
q["beforeId"] = _auditLastId;
}
await Run(async () =>
{
var r = await _relay.RequestAsync("admin.audit.query", q);
if (!more) _audit.Clear();
var rows = (r["rows"] as JsonArray).OfType<JsonObject>().Select(AuditRowItem.From).ToList();
foreach (var x in rows) _audit.Add(x);
_auditLastId = rows.Count > 0 ? rows[^1].Id : _auditLastId;
if (more || rows.Count == 0) Say(rows.Count == 0 ? "더 없습니다." : $"{rows.Count}건 더 불러옴", true);
});
}
private async Task QueryConnsAsync(bool more)
{
var q = Filter(_cFrom, _cTo, _cUser, _cDevice);
if (more)
{
if (_connLastId == null) return;
q["beforeId"] = _connLastId;
}
await Run(async () =>
{
var r = await _relay.RequestAsync("admin.connections.query", q);
if (!more) _conns.Clear();
var rows = (r["rows"] as JsonArray).OfType<JsonObject>().Select(ConnRowItem.From).ToList();
foreach (var x in rows) _conns.Add(x);
_connLastId = rows.Count > 0 ? rows[^1].Id : _connLastId;
if (more) Say(rows.Count == 0 ? "더 없습니다." : $"{rows.Count}건 더 불러옴", true);
});
}
private void Copy(IEnumerable<string> lines)
{
var text = string.Join(Environment.NewLine, lines);
if (text.Length == 0) { Say("복사할 내용이 없습니다.", false); return; }
try { Clipboard.SetText(text); Say("복사했습니다.", true); }
catch (Exception ex) { Say("복사 실패: " + ex.Message, false); }
}
// ───────────────────────── 공통 ─────────────────────────
private async Task Run(Func<Task> work)
{
try
{
await work();
}
catch (Exception ex)
{
Say(RelayUrl.Friendly(ex), false);
}
}
private void Say(string msg, bool ok)
{
_status.Text = msg;
_status.Foreground = ok ? Brushes2.Running : Brushes2.Stopped;
}
public static string Local(string iso)
{
if (string.IsNullOrEmpty(iso)) return "";
return DateTime.TryParse(iso, null, System.Globalization.DateTimeStyles.RoundtripKind, out var t)
? t.ToLocalTime().ToString("yyyy-MM-dd HH:mm:ss") : iso;
}
public static string Bytes(long b) => b < 1024 ? $"{b}B" : b < 1024 * 1024 ? $"{b / 1024.0:0.0}KB" : b < 1024L * 1024 * 1024 ? $"{b / 1048576.0:0.0}MB" : $"{b / 1073741824.0:0.00}GB";
private static TextBlock Label(string text) => new TextBlock { Text = text, VerticalAlignment = VerticalAlignment.Center, Margin = new Thickness(8, 0, 4, 0) };
private static TextBlock Note(string text) => new TextBlock { Text = text, Foreground = Brushes2.Make("#78909C"), VerticalAlignment = VerticalAlignment.Center, TextWrapping = TextWrapping.Wrap, Margin = new Thickness(8, 0, 0, 0) };
private static Button Btn(string text, Func<Task> click, bool bold = false)
{
var b = new Button { Content = text, FontWeight = bold ? FontWeights.Bold : FontWeights.Normal, Margin = new Thickness(4, 2, 0, 2), Padding = new Thickness(10, 2, 10, 2) };
b.Click += async (s, e) =>
{
b.IsEnabled = false;
try { await click(); }
finally { b.IsEnabled = true; }
};
return b;
}
private static ListView Table<T>(ObservableCollection<T> items, params (string header, string path, double width)[] cols)
{
var gv = new GridView();
foreach (var (h, p, w) in cols)
gv.Columns.Add(new GridViewColumn { Header = h, Width = w, DisplayMemberBinding = new Binding(p) });
return new ListView { ItemsSource = items, View = gv, SelectionMode = SelectionMode.Single };
}
private static GridViewColumn CheckColumn(string header, string path)
{
var f = new FrameworkElementFactory(typeof(CheckBox));
f.SetBinding(ToggleButton.IsCheckedProperty, new Binding(path) { Mode = BindingMode.TwoWay, UpdateSourceTrigger = UpdateSourceTrigger.PropertyChanged });
f.SetValue(FrameworkElement.HorizontalAlignmentProperty, HorizontalAlignment.Center);
return new GridViewColumn { Header = header, Width = 90, CellTemplate = new DataTemplate { VisualTree = f } };
}
private sealed class Pick
{
public string Text { get; }
public string Value { get; }
public Pick(string text, string value) { Text = text; Value = value; }
}
// ───────────────────────── 표 한 줄 ─────────────────────────
private sealed class UserRowItem
{
public long Id { get; init; }
public string Username { get; init; }
public bool IsAdmin { get; init; }
public bool Enabled { get; init; }
public string RoleText => IsAdmin ? "관리자" : "일반";
public string StateText { get; init; }
public string LastLogin { get; init; }
public string Created { get; init; }
public string Note { get; init; }
public static UserRowItem From(JsonObject o)
{
bool enabled = Json.Bool(o, "enabled");
var state = !enabled ? "사용 안 함" : Json.Bool(o, "mustChangePassword") ? "비밀번호 변경 대기" : "사용";
if (Json.Bool(o, "online")) state += " · 접속 중";
return new UserRowItem
{
Id = o["id"]?.GetValue<long>() ?? 0,
Username = Json.Str(o, "username"),
IsAdmin = Json.Str(o, "role") == "admin",
Enabled = enabled,
StateText = state,
LastLogin = Local(Json.Str(o, "lastLoginAt")),
Created = Local(Json.Str(o, "createdAt")),
Note = Json.Str(o, "note")
};
}
}
private sealed class PermRowItem : ObservableObject
{
private bool _view, _control, _sv, _sc;
public string DeviceId { get; init; }
public string Name { get; init; }
public string Hostname { get; init; }
public bool View { get => _view; set => Set(ref _view, value); }
public bool Control { get => _control; set => Set(ref _control, value); }
public bool ScreenView { get => _sv; set => Set(ref _sv, value); }
public bool ScreenControl { get => _sc; set => Set(ref _sc, value); }
public void Set(bool v, bool c, bool sv, bool sc) { View = v; Control = c; ScreenView = sv; ScreenControl = sc; }
public string Summary
{
get
{
var p = new List<string>();
if (View) p.Add("보기");
if (Control) p.Add("서비스 제어");
if (ScreenView) p.Add("화면 보기");
if (ScreenControl) p.Add("화면 제어");
return p.Count == 0 ? "권한 없음 (목록에 안 보임)" : string.Join(", ", p);
}
}
public static PermRowItem From(JsonObject o) => new PermRowItem
{
DeviceId = Json.Str(o, "deviceId"),
Name = Json.Str(o, "name") + (Json.Bool(o, "enabled") ? "" : " (사용 안 함)"),
Hostname = Json.Str(o, "hostname"),
_view = Json.Bool(o, "view"),
_control = Json.Bool(o, "control"),
_sv = Json.Bool(o, "screenView"),
_sc = Json.Bool(o, "screenControl")
};
}
private sealed class DeviceRowItem
{
public string Id { get; init; }
public string Name { get; init; }
public string Hostname { get; init; }
public string StateText { get; init; }
public string PublicIp { get; init; }
public string LanIps { get; init; }
public string ActiveText { get; init; }
public string Version { get; init; }
public string LastSeen { get; init; }
public string Enrolled { get; init; }
public string Note { get; init; }
public static DeviceRowItem From(JsonObject o)
{
var al = o["activeListen"] as JsonObject;
var lan = (o["lanIps"] as JsonArray)?.Select(x => x?.ToString()).Where(x => !string.IsNullOrEmpty(x)) ?? Enumerable.Empty<string>();
return new DeviceRowItem
{
Id = Json.Str(o, "id"),
Name = Json.Str(o, "name"),
Hostname = Json.Str(o, "hostname"),
StateText = !Json.Bool(o, "enabled") ? "사용 안 함" : Json.Bool(o, "online") ? "● 온라인" : "오프라인",
PublicIp = Json.Str(o, "publicIp"),
LanIps = string.Join(", ", lan),
ActiveText = al == null ? "꺼짐" : $"{(Json.Str(al, "mode") == "Ssh" ? "SSH" : "TLS")} {Json.Int(al, "port")}",
Version = Json.Str(o, "appVersion"),
LastSeen = Local(Json.Str(o, "lastSeenAt")),
Enrolled = Local(Json.Str(o, "enrolledAt")),
Note = Json.Str(o, "note")
};
}
}
private sealed class CodeRowItem
{
public long Id { get; init; }
public string Status { get; init; }
public string StateText => Status switch { "valid" => "사용 가능", "used" => "사용됨", "revoked" => "취소됨", _ => "만료됨" };
public string CreatedBy { get; init; }
public string Created { get; init; }
public string Expires { get; init; }
public string Used { get; init; }
public string UsedDevice { get; init; }
public string Note { get; init; }
public static CodeRowItem From(JsonObject o) => new CodeRowItem
{
Id = o["id"]?.GetValue<long>() ?? 0,
Status = Json.Str(o, "status"),
CreatedBy = Json.Str(o, "createdBy"),
Created = Local(Json.Str(o, "createdAt")),
Expires = Local(Json.Str(o, "expiresAt")),
Used = Local(Json.Str(o, "usedAt")),
UsedDevice = Json.Str(o, "usedDevice"),
Note = Json.Str(o, "note")
};
}
private sealed class AuditRowItem
{
public long Id { get; init; }
public string At { get; init; }
public string User { get; init; }
public string Device { get; init; }
public string Action { get; init; }
public string ActionText => AuditActions().FirstOrDefault(a => a.Value == Action)?.Text ?? Action;
public string Target { get; init; }
public string Result { get; init; }
public string ResultText => Result switch { "ok" => "성공", "fail" => "실패", "denied" => "거부", _ => Result };
public string Detail { get; init; }
public string Ip { get; init; }
public string ToLine() => $"{At}\t{User}\t{Device}\t{ActionText}\t{Target}\t{ResultText}\t{Detail}\t{Ip}";
public static AuditRowItem From(JsonObject o) => new AuditRowItem
{
Id = o["id"]?.GetValue<long>() ?? 0,
At = Local(Json.Str(o, "at")),
User = Json.Str(o, "username"),
Device = Json.Str(o, "deviceName"),
Action = Json.Str(o, "action"),
Target = Json.Str(o, "target"),
Result = Json.Str(o, "result"),
Detail = Json.Str(o, "detail"),
Ip = Json.Str(o, "ip")
};
}
private sealed class ConnRowItem
{
public long Id { get; init; }
public string Started { get; init; }
public string Ended { get; init; }
public string User { get; init; }
public string Device { get; init; }
public string Mode { get; init; }
public string ModeText => Mode switch { "direct" => "직접", "relay" => "중계", "failed" => "실패", "" => "진행 중", _ => Mode };
public string ToDevice { get; init; }
public string FromDevice { get; init; }
public string Reason { get; init; }
public string Ip { get; init; }
public string ToLine() => $"{Started}\t{Ended}\t{User}\t{Device}\t{ModeText}\t{ToDevice}\t{FromDevice}\t{Reason}\t{Ip}";
public static ConnRowItem From(JsonObject o) => new ConnRowItem
{
Id = o["id"]?.GetValue<long>() ?? 0,
Started = Local(Json.Str(o, "startedAt")),
Ended = Local(Json.Str(o, "endedAt")),
User = Json.Str(o, "username"),
Device = Json.Str(o, "deviceName"),
Mode = Json.Str(o, "mode"),
ToDevice = Bytes(o["bytesToDevice"]?.GetValue<long>() ?? 0),
FromDevice = Bytes(o["bytesFromDevice"]?.GetValue<long>() ?? 0),
Reason = Json.Str(o, "endReason"),
Ip = Json.Str(o, "managerIp")
};
}
}
}

View File

@ -52,7 +52,11 @@ namespace JJServiceManager
/// <summary>원격 PC 제목 줄의 [원격관리] 버튼</summary> /// <summary>원격 PC 제목 줄의 [원격관리] 버튼</summary>
public Visibility RemoteButtonVisibility => IsLocal ? Visibility.Collapsed : Visibility.Visible; public Visibility RemoteButtonVisibility => IsLocal ? Visibility.Collapsed : Visibility.Visible;
public Brush HeaderBrush => IsLocal ? Brushes2.Make("#E3F2FD") : Brushes2.Make("#F3E5F5"); /// <summary>중계 기기 제목 줄의 [비밀번호] 버튼 (대상 PC 접속 비밀번호는 기기마다 입력)</summary>
public bool IsRelay => Id.StartsWith("relay-", System.StringComparison.OrdinalIgnoreCase);
public Visibility PasswordButtonVisibility => IsRelay ? Visibility.Visible : Visibility.Collapsed;
public Brush HeaderBrush => IsLocal ? Brushes2.Make("#E3F2FD") : IsRelay ? Brushes2.Make("#E8F5E9") : Brushes2.Make("#F3E5F5");
public HostGroup() public HostGroup()
{ {

View File

@ -52,6 +52,22 @@ namespace JJServiceManager
private readonly Button _btnTest; private readonly Button _btnTest;
private readonly ObservableCollection<TargetItem> _items = new ObservableCollection<TargetItem>(); private readonly ObservableCollection<TargetItem> _items = new ObservableCollection<TargetItem>();
// 중계 서버 (대상 PC: 등록)
private readonly CheckBox _relayAgent;
private readonly TextBox _relayAgentUrl;
private readonly TextBlock _relayAgentState;
private string _relayDeviceId, _relayKeyProt, _relayPub, _relayDeviceName;
// 중계 서버 (관리 PC: 로그인)
private readonly RelayManager _relay;
private readonly CheckBox _relayManage, _relaySavePw;
private readonly TextBox _relayManageUrl, _relayUser;
private readonly PasswordBox _relayPw;
private readonly TextBlock _relayLoginState;
private readonly Button _btnRelayLogin;
private string _relayTokenProt, _relayPwProt;
private string _loggedUrl, _loggedUser; // 이 토큰을 받은 주소·아이디
/// <summary>목록 표시용</summary> /// <summary>목록 표시용</summary>
private sealed class TargetItem : ObservableObject private sealed class TargetItem : ObservableObject
{ {
@ -68,9 +84,10 @@ namespace JJServiceManager
} }
} }
public SettingsWindow(AppSettings current) public SettingsWindow(AppSettings current, RelayManager relay = null)
{ {
_s = current; _s = current;
_relay = relay;
Title = "설정" + AppInstance.TitleTag; Title = "설정" + AppInstance.TitleTag;
Width = 800; Width = 800;
@ -223,6 +240,48 @@ namespace JJServiceManager
root.Find<Button>("btnDeleteTarget").Click += (s, e) => DeleteTarget(); root.Find<Button>("btnDeleteTarget").Click += (s, e) => DeleteTarget();
_btnTest.Click += async (s, e) => await TestTarget(); _btnTest.Click += async (s, e) => await TestTarget();
// ───────── 중계 서버 (대상 PC: 등록) ─────────
_relayAgent = root.Find<CheckBox>("chkRelayAgent");
_relayAgentUrl = root.Find<TextBox>("txtRelayAgentUrl");
_relayAgentState = root.Find<TextBlock>("txtRelayAgentState");
_relayAgent.IsChecked = current.RelayAgentEnabled;
_relayAgentUrl.Text = current.RelayAgentUrl;
_relayDeviceId = current.RelayDeviceId;
_relayKeyProt = current.RelayDeviceKeyProtected;
_relayPub = current.RelayServerPublicKey;
_relayDeviceName = current.RelayDeviceName;
ShowRelayAgentState(null, true);
root.Find<Button>("btnRelayEnroll").Click += async (s, e) => await EnrollAsync((Button)s);
root.Find<Button>("btnRelayUnenroll").Click += (s, e) => Unenroll();
// ───────── 중계 서버 (관리 PC: 로그인) ─────────
_relayManage = root.Find<CheckBox>("chkRelayManage");
_relayManageUrl = root.Find<TextBox>("txtRelayManageUrl");
_relayUser = root.Find<TextBox>("txtRelayUser");
_relayPw = root.Find<PasswordBox>("pwdRelay");
_relaySavePw = root.Find<CheckBox>("chkRelaySavePw");
_relayLoginState = root.Find<TextBlock>("txtRelayLoginState");
_btnRelayLogin = root.Find<Button>("btnRelayLogin");
_relayManage.IsChecked = current.RelayManageEnabled;
_relayManageUrl.Text = current.RelayManageUrl;
_relayUser.Text = current.RelayUsername;
_relayTokenProt = current.RelayTokenProtected;
_relayPwProt = current.RelayPasswordProtected;
_relaySavePw.IsChecked = !string.IsNullOrEmpty(current.RelayPasswordProtected) || string.IsNullOrEmpty(current.RelayTokenProtected);
_loggedUrl = current.RelayManageUrl;
_loggedUser = current.RelayUsername;
ShowRelayLoginState();
_btnRelayLogin.Click += async (s, e) => await RelayLoginAsync();
root.Find<Button>("btnRelayLogout").Click += async (s, e) => await RelayLogoutAsync();
// [서버 관리] 탭: 중계 서버에 관리자 계정으로 로그인되어 있을 때만
if (relay != null && relay.IsLoggedIn && relay.IsAdmin)
{
var tabs = root.Find<TabControl>("tabs");
tabs.Items.Add(new TabItem { Header = " 서버 관리 ", Content = new ServerAdminPanel(relay, this) });
Width = 980;
}
// ───────── 확인 / 취소 ───────── // ───────── 확인 / 취소 ─────────
root.Find<Button>("btnOk").Click += (s, e) => Save(); root.Find<Button>("btnOk").Click += (s, e) => Save();
root.Find<Button>("btnCancel").Click += (s, e) => Close(); root.Find<Button>("btnCancel").Click += (s, e) => Close();
@ -529,6 +588,132 @@ namespace JJServiceManager
tb.Foreground = ok ? Brushes2.Running : Brushes2.Stopped; tb.Foreground = ok ? Brushes2.Running : Brushes2.Stopped;
} }
// ───────────────────────── 중계 서버: 대상 PC 등록 ─────────────────────────
private void ShowRelayAgentState(string msg, bool ok)
{
if (msg != null) { ShowResult(_relayAgentState, msg, ok); return; }
_relayAgentState.Foreground = Brushes2.Make("#37474F");
_relayAgentState.Text = string.IsNullOrEmpty(_relayDeviceId)
? "등록되지 않음"
: $"등록됨: {(_relayDeviceName.Length > 0 ? _relayDeviceName : Environment.MachineName)} (기기 ID {_relayDeviceId.Substring(0, Math.Min(8, _relayDeviceId.Length))}…)";
}
private async System.Threading.Tasks.Task EnrollAsync(Button btn)
{
var url = _relayAgentUrl.Text.Trim();
if (url.Length == 0) { ShowRelayAgentState("서버 주소를 입력하세요.", false); return; }
if (!string.IsNullOrEmpty(_relayDeviceId) &&
!Dialogs.Confirm(this, "이미 등록되어 있습니다. 새 등록 코드로 다시 등록할까요?\n(서버의 이전 기기 항목은 관리자가 정리해야 합니다)", "다시 등록"))
return;
var code = TextPrompt.Ask(this, "등록 코드", "중계 서버 관리자에게 받은 등록 코드를 입력하세요. (예: AB12-CD34)", "", upper: true);
if (string.IsNullOrWhiteSpace(code)) return;
// 지금 입력 중인 Active 수신 정보 (직접 연결 후보)
System.Text.Json.Nodes.JsonObject active = null;
if (_server.IsChecked == true && _activeListen.IsChecked == true && int.TryParse(_serverPort.Text.Trim(), out var port))
active = new System.Text.Json.Nodes.JsonObject { ["mode"] = _serverMode.SelectedIndex == 0 ? "Ssh" : "Port", ["port"] = port };
btn.IsEnabled = false;
ShowRelayAgentState("등록 중…", true);
try
{
var r = await RelayAgent.EnrollAsync(url, code.Trim(), active, System.Threading.CancellationToken.None);
_relayDeviceId = r.DeviceId;
_relayKeyProt = RemoteSecurity.Protect(r.DeviceKey);
_relayPub = r.ServerPublicKey;
_relayDeviceName = r.Name ?? "";
_relayAgent.IsChecked = true;
ShowRelayAgentState($"등록했습니다: {_relayDeviceName}. [확인]을 누르면 중계 서버에 접속합니다.", true);
}
catch (Exception ex)
{
ShowRelayAgentState("등록 실패: " + RelayUrl.Friendly(ex), false);
}
finally
{
btn.IsEnabled = true;
}
}
private void Unenroll()
{
if (string.IsNullOrEmpty(_relayDeviceId)) { ShowRelayAgentState("등록되어 있지 않습니다.", false); return; }
if (!Dialogs.Confirm(this, "이 PC의 중계 서버 등록 정보를 지울까요?\n다시 쓰려면 새 등록 코드가 필요합니다.\n(서버의 기기 항목은 서버 관리자가 '삭제'로 정리합니다)", "등록 해제")) return;
_relayDeviceId = "";
_relayKeyProt = "";
_relayPub = "";
_relayDeviceName = "";
_relayAgent.IsChecked = false;
ShowRelayAgentState("등록 정보를 지웠습니다. [확인]을 누르면 저장됩니다.", true);
}
// ───────────────────────── 중계 서버: 관리 PC 로그인 ─────────────────────────
private void ShowRelayLoginState(string msg = null, bool ok = true)
{
if (msg != null) { ShowResult(_relayLoginState, msg, ok); return; }
_relayLoginState.Foreground = Brushes2.Make("#37474F");
if (_relay != null && _relay.IsLoggedIn)
_relayLoginState.Text = _relay.StatusText + $" · 기기 {_relay.Devices.Count}대";
else if (!string.IsNullOrEmpty(_relayTokenProt) || !string.IsNullOrEmpty(_relayPwProt))
_relayLoginState.Text = _relay != null && _relay.StatusText.Length > 0 ? _relay.StatusText : "로그인 정보 저장됨";
else
_relayLoginState.Text = "로그인하지 않음";
}
private async System.Threading.Tasks.Task RelayLoginAsync()
{
var url = _relayManageUrl.Text.Trim();
var user = _relayUser.Text.Trim();
var pw = _relayPw.Password;
if (url.Length == 0) { ShowRelayLoginState("서버 주소를 입력하세요.", false); return; }
if (user.Length == 0 || pw.Length == 0) { ShowRelayLoginState("아이디와 비밀번호를 입력하세요.", false); return; }
_btnRelayLogin.IsEnabled = false;
ShowRelayLoginState("로그인 중…");
try
{
var ct = System.Threading.CancellationToken.None;
var (token, mustChange, isAdmin) = await RelayManager.LoginAsync(url, user, pw, ct);
if (mustChange)
{
// 첫 로그인 / 비밀번호 초기화 후: 새 비밀번호로 바꿔야 함
var np = ChangePasswordWindow.Ask(this, $"{user}: 처음 로그인했거나 비밀번호가 초기화되었습니다.\n새 비밀번호를 정하세요.", askOld: false);
if (np == null) { ShowRelayLoginState("비밀번호를 바꿔야 로그인할 수 있습니다.", false); return; }
if (np.Value.newPw == pw) { ShowRelayLoginState("지금과 다른 비밀번호를 정하세요.", false); return; }
await RelayManager.ChangePasswordAsync(url, token, pw, np.Value.newPw, ct);
pw = np.Value.newPw;
(token, _, isAdmin) = await RelayManager.LoginAsync(url, user, pw, ct);
}
_relayTokenProt = RemoteSecurity.Protect(token);
_relayPwProt = _relaySavePw.IsChecked == true ? RemoteSecurity.Protect(pw) : "";
_loggedUrl = url;
_loggedUser = user;
_relayPw.Password = "";
_relayManage.IsChecked = true;
ShowRelayLoginState($"로그인 성공: {user}{(isAdmin ? " (관리자)" : "")}. [확인]을 누르면 권한 있는 PC가 초기화면에 나타납니다."
+ (isAdmin && (_relay == null || !_relay.IsAdmin) ? " [서버 관리] 탭은 다음에 설정을 열 때 보입니다." : ""), true);
}
catch (Exception ex)
{
ShowRelayLoginState("로그인 실패: " + RelayUrl.Friendly(ex), false);
}
finally
{
_btnRelayLogin.IsEnabled = true;
}
}
private async System.Threading.Tasks.Task RelayLogoutAsync()
{
if (string.IsNullOrEmpty(_relayTokenProt) && string.IsNullOrEmpty(_relayPwProt)) { ShowRelayLoginState("로그인되어 있지 않습니다.", false); return; }
if (_relay != null) await _relay.LogoutAsync();
_relayTokenProt = "";
_relayPwProt = "";
ShowRelayLoginState("로그아웃했습니다. [확인]을 누르면 저장됩니다.", true);
}
// ───────────────────────── 저장 ───────────────────────── // ───────────────────────── 저장 ─────────────────────────
private void Save() private void Save()
@ -561,9 +746,38 @@ namespace JJServiceManager
Fail(1, "원격 접속을 허용하려면 접속 비밀번호를 정하세요."); Fail(1, "원격 접속을 허용하려면 접속 비밀번호를 정하세요.");
return; return;
} }
if (_server.IsChecked == true && _activeListen.IsChecked != true && _pms.Count == 0) bool relayAgentOn = _relayAgent.IsChecked == true;
if (relayAgentOn && string.IsNullOrEmpty(_relayDeviceId))
{ {
Fail(1, "원격 접속 허용을 켜려면 Active 수신을 켜거나 Passive 접속을 하나 이상 추가하세요."); Fail(1, "중계 서버를 사용하려면 먼저 [등록 코드로 등록]을 하세요.");
return;
}
if (relayAgentOn && _server.IsChecked != true)
{
Fail(1, "중계 서버 접속은 [원격 접속 허용]이 켜져 있어야 동작합니다.");
return;
}
if (_server.IsChecked == true && _activeListen.IsChecked != true && _pms.Count == 0 && !relayAgentOn)
{
Fail(1, "원격 접속 허용을 켜려면 Active 수신을 켜거나, Passive 접속을 추가하거나, 중계 서버를 사용하세요.");
return;
}
bool relayManageOn = _relayManage.IsChecked == true;
if (relayManageOn && string.IsNullOrEmpty(_relayTokenProt) && string.IsNullOrEmpty(_relayPwProt))
{
Fail(2, "중계 서버를 사용하려면 [로그인]을 먼저 하세요.");
return;
}
if (relayManageOn && (!string.Equals(_relayManageUrl.Text.Trim(), _loggedUrl, StringComparison.OrdinalIgnoreCase)
|| !string.Equals(_relayUser.Text.Trim(), _loggedUser, StringComparison.OrdinalIgnoreCase)))
{
Fail(2, "중계 서버 주소나 아이디를 바꿨으면 [로그인]을 다시 누르세요.");
return;
}
if (relayManageOn && _manage.IsChecked != true)
{
Fail(2, "중계 서버 로그인은 위의 [다른 PC 관리]가 켜져 있어야 동작합니다.");
return; return;
} }
@ -602,6 +816,19 @@ namespace JJServiceManager
_s.PassiveListenTcpPort = _passiveTcpPort; _s.PassiveListenTcpPort = _passiveTcpPort;
_s.Targets = _items.Select(i => i.T.Clone()).ToList(); _s.Targets = _items.Select(i => i.T.Clone()).ToList();
// 중계 서버
_s.RelayAgentEnabled = relayAgentOn;
_s.RelayAgentUrl = _relayAgentUrl.Text.Trim();
_s.RelayDeviceId = _relayDeviceId ?? "";
_s.RelayDeviceKeyProtected = _relayKeyProt ?? "";
_s.RelayServerPublicKey = _relayPub ?? "";
_s.RelayDeviceName = _relayDeviceName ?? "";
_s.RelayManageEnabled = relayManageOn;
_s.RelayManageUrl = _relayManageUrl.Text.Trim();
_s.RelayUsername = _relayUser.Text.Trim();
_s.RelayTokenProtected = _relayTokenProt ?? "";
_s.RelayPasswordProtected = _relaySavePw.IsChecked == true ? (_relayPwProt ?? "") : "";
DialogResult = true; DialogResult = true;
} }

View File

@ -0,0 +1,160 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Text.Json.Nodes;
using System.Threading;
using System.Threading.Tasks;
using JJServiceManager.Remote;
namespace JJServiceManager
{
static class M
{
static int pass, fail;
static void Check(bool ok, string name, string extra = "")
{
if (ok) { pass++; Console.WriteLine($" [OK] {name}"); }
else { fail++; Console.WriteLine($" [FAIL] {name} {extra}"); }
}
static async Task<bool> Until(Func<bool> cond, int seconds = 10)
{
for (int i = 0; i < seconds * 10; i++) { if (cond()) return true; await Task.Delay(100); }
return cond();
}
static async Task<int> Main()
{
Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);
const string url = "http://127.0.0.1:21900";
var log = new List<string>();
Action<string, LogLevel> L(string who) => (m, l) => { lock (log) log.Add($"[{who} {l}] {m}"); };
Console.WriteLine("== 1. 관리자 첫 로그인 + 비밀번호 변경 (설정 화면 흐름)");
var (tok0, must, isAdmin) = await RelayManager.LoginAsync(url, "admin", "firstpass1", CancellationToken.None);
Check(must && isAdmin && tok0.Length > 10, "첫 로그인: 관리자, 비밀번호 변경 필요");
await RelayManager.ChangePasswordAsync(url, tok0, "firstpass1", "AdminPass#2026", CancellationToken.None);
var (adminToken, must2, _) = await RelayManager.LoginAsync(url, "admin", "AdminPass#2026", CancellationToken.None);
Check(!must2, "새 비밀번호로 로그인");
var admin = new RelayManager(L("admin"));
string savedToken = adminToken;
admin.Start(url, "admin", () => savedToken, () => "", t => savedToken = t);
Check(await admin.WaitReadyAsync(TimeSpan.FromSeconds(10), CancellationToken.None) && admin.IsAdmin, "관리 PC 상시 연결 (토큰)", admin.StatusText);
Console.WriteLine("== 2. 대상 PC 등록 + 온라인");
var code = Json.Str(await admin.RequestAsync("admin.codes.create", new JsonObject { ["note"] = "통합 시험" }), "code");
var services = new List<ServiceEntry> { new ServiceEntry { ServiceName = "Spooler", DisplayName = "Print Spooler" } };
bool allowView = true, allowControl = true;
var srv = new RemoteServer(() => services.ToList(), L("target"), () => { }, () => new ScreenPolicy(allowView, allowView && allowControl, false),
(w, c) => Task.FromResult(true));
var (h, s) = RemoteSecurity.HashPassword("target-pw");
srv.Start(RemoteMode.Port, 19988, h, s);
JsonObject activeInfo = new JsonObject { ["mode"] = "Port", ["port"] = 19988 };
var enr = await RelayAgent.EnrollAsync(url, code, activeInfo, CancellationToken.None);
Check(enr.DeviceId.Length > 0 && enr.DeviceKey.Length > 0 && enr.ServerPublicKey.Length > 0, "등록 코드로 등록 → 기기 ID·키·서버 공개키");
try { await RelayAgent.EnrollAsync(url, code, null, CancellationToken.None); Check(false, "같은 코드 재사용 거부"); }
catch (RemoteException ex) { Check(ex.Message.Contains("이미"), "같은 코드 재사용 거부", ex.Message); }
var agent = new RelayAgent(srv, L("agent"));
agent.Start(url, enr.DeviceId, enr.DeviceKey, enr.ServerPublicKey, () => activeInfo?.DeepClone() as JsonObject);
Check(await Until(() => agent.IsOnline), "대상 PC 중계 서버 온라인", agent.StatusText);
Check(await Until(() => admin.IsDeviceOnline(enr.DeviceId)), "관리 PC 기기 목록에 온라인으로 보임");
Console.WriteLine("== 3. 직접 연결 (대상 PC Active 수신 중)");
var target = new RemoteTarget { Id = "relay-" + enr.DeviceId, Direction = ConnectDirection.Relay, RelayDeviceId = enr.DeviceId };
var c1 = new RemoteClient(target, () => "target-pw", admin);
c1.Start();
Check(await Until(() => c1.State == RemoteState.Connected, 15), "연결됨", c1.StateMessage);
Check(c1.RelayMode == "direct", "직접 연결로 연결", c1.RelayMode);
await Until(() => c1.Services.Count > 0);
Check(c1.Services.Any(x => x.ServiceName == "Spooler"), "서비스 목록 받음");
try { await c1.StartServiceAsync("Spooler"); } catch { /* Linux에는 서비스가 없음 → 실패, 감사 로그엔 남음 */ }
c1.Dispose();
await Task.Delay(800);
Console.WriteLine("== 4. 중계 연결 (대상 PC Active 수신 끔)");
srv.Stop();
activeInfo = null;
agent.SendUpdate();
await Task.Delay(500);
var c2 = new RemoteClient(target, () => "target-pw", admin);
int frames = 0;
c2.ScreenEvent += e => { if (Json.Str(e, "event") == "frame") Interlocked.Increment(ref frames); };
c2.Start();
Check(await Until(() => c2.State == RemoteState.Connected, 20), "연결됨", c2.StateMessage);
Check(c2.RelayMode == "relay", "중계로 연결", c2.RelayMode);
await Until(() => c2.Services.Count > 0);
Check(c2.Services.Count == 1, "중계로 서비스 목록 받음");
var st = await c2.RequestAsync("screenStart", new JsonObject { ["monitor"] = 1 }, TimeSpan.FromSeconds(10));
await Task.Delay(1500);
Check(frames > 0, $"중계로 원격 화면 프레임 받음 ({frames}장)");
c2.SendNoReply(new JsonObject { ["op"] = "input", ["kind"] = "key", ["vk"] = 65, ["down"] = true });
await Task.Delay(400);
Check(InputInjector.Got.Any(x => x.StartsWith("key 65")), "중계로 키 입력 전달 (관리자 = 화면 제어 권한)");
await c2.RequestAsync("screenStop", null, TimeSpan.FromSeconds(5));
c2.Dispose();
await Task.Delay(800);
Console.WriteLine("== 5. 일반 사용자 권한 (보기만) → 티켓으로 대상 PC가 거부");
await admin.RequestAsync("admin.users.create", new JsonObject { ["username"] = "bob", ["password"] = "BobPass#123", ["mustChangePassword"] = false });
var users = (await admin.RequestAsync("admin.users.list"))["users"].AsArray();
long bobId = users.First(u => Json.Str((JsonObject)u, "username") == "bob")["id"].GetValue<long>();
await admin.RequestAsync("admin.perms.set", new JsonObject { ["userId"] = bobId, ["deviceId"] = enr.DeviceId, ["view"] = true });
var (bobTok, _, _) = await RelayManager.LoginAsync(url, "bob", "BobPass#123", CancellationToken.None);
var bob = new RelayManager(L("bob"));
bob.Start(url, "bob", () => bobTok, () => "", t => bobTok = t);
Check(await bob.WaitReadyAsync(TimeSpan.FromSeconds(10), CancellationToken.None) && !bob.IsAdmin, "bob 로그인 (일반 사용자)");
Check(bob.Devices.Count == 1 && bob.Devices[0].View && !bob.Devices[0].Control, "bob 기기 목록: 보기 권한만");
var c3 = new RemoteClient(target, () => "target-pw", bob);
c3.Start();
Check(await Until(() => c3.State == RemoteState.Connected, 20), "bob 연결됨 (보기)", c3.StateMessage);
await Until(() => c3.Services.Count > 0);
Check(c3.Services.Count == 1, "bob 서비스 목록 보기 가능");
Check(!c3.AllowView, "bob에게는 화면 보기 허용 안 됨 (서버 권한 ∩ 대상 PC 설정)");
try { await c3.StopServiceAsync("Spooler"); Check(false, "bob Stop 거부"); }
catch (RemoteException ex) { Check(ex.Message.Contains("서비스 제어"), "bob Stop 거부 (대상 PC가 티켓 권한 확인)", ex.Message); }
try { await c3.RequestAsync("screenStart", new JsonObject { ["monitor"] = 1 }, TimeSpan.FromSeconds(5)); Check(false, "bob 화면 거부"); }
catch (RemoteException ex) { Check(ex.Message.Contains("화면 보기"), "bob 원격 화면 거부", ex.Message); }
c3.Dispose();
Console.WriteLine("== 6. 대상 PC 접속 비밀번호가 틀리면 (서버 권한이 있어도) 거부");
var c4 = new RemoteClient(target, () => "wrong-pw", admin);
c4.Start();
Check(await Until(() => c4.State == RemoteState.AuthFailed, 20), "인증 실패", c4.StateMessage);
c4.Dispose();
Console.WriteLine("== 7. 감사 로그 / 연결 기록 (대상 PC가 보고)");
await Task.Delay(1500);
var audit = (await admin.RequestAsync("admin.audit.query", new JsonObject { ["deviceId"] = enr.DeviceId, ["limit"] = 100 }))["rows"].AsArray()
.Select(x => (JsonObject)x).ToList();
string Row(JsonObject r) => $"{Json.Str(r, "username")}:{Json.Str(r, "action")}:{Json.Str(r, "target")}:{Json.Str(r, "result")}";
var rows = audit.Select(Row).ToList();
Check(rows.Any(r => r.StartsWith("admin:start:Spooler:")), "admin Start 기록 (대상 PC 보고)", string.Join(" | ", rows));
Check(rows.Contains("admin:screenStart::ok") && rows.Contains("admin:screenEnd::ok"), "원격 화면 시작·끝 기록");
Check(rows.Contains("bob:stop:Spooler:denied") && rows.Contains("bob:screenStart::denied"), "bob 거부 기록");
Check(rows.Contains("admin:authFail::denied"), "대상 PC 비밀번호 틀림 기록");
var conns = (await admin.RequestAsync("admin.connections.query", new JsonObject { ["deviceId"] = enr.DeviceId }))["rows"].AsArray()
.Select(x => (JsonObject)x).ToList();
var modes = conns.Select(x => Json.Str(x, "mode")).ToList();
Check(modes.Contains("direct") && modes.Contains("relay"), "연결 기록: 직접 / 중계", string.Join(",", modes));
var relayRow = conns.FirstOrDefault(x => Json.Str(x, "mode") == "relay" && Json.Str(x, "username") == "admin");
Check(relayRow != null && relayRow["bytesFromDevice"].GetValue<long>() > 1000, "중계 트래픽 기록", relayRow?.ToJsonString());
var directRow = conns.FirstOrDefault(x => Json.Str(x, "mode") == "direct");
Check(directRow != null && directRow["bytesFromDevice"].GetValue<long>() > 0 && Json.Str(directRow, "endedAt").Length > 0, "직접 연결 끝·트래픽 보고", directRow?.ToJsonString());
Console.WriteLine("== 8. 기기 사용 안 함 → 대상 PC에 알림");
string revoked = null;
agent.Revoked += r => revoked = r;
await admin.RequestAsync("admin.devices.disable", new JsonObject { ["deviceId"] = enr.DeviceId });
Check(await Until(() => revoked != null), "대상 PC가 사용 안 함 알림 받음", revoked);
Check(await Until(() => !admin.IsDeviceOnline(enr.DeviceId)), "관리 PC에서 오프라인");
agent.Dispose(); admin.Dispose(); bob.Dispose(); srv.Dispose();
Console.WriteLine($"\n결과: 통과 {pass} / 실패 {fail}");
if (fail > 0) { Console.WriteLine("--- 로그"); lock (log) foreach (var l in log) Console.WriteLine(l); }
return fail == 0 ? 0 : 1;
}
}
}

View File

@ -0,0 +1,7 @@
# RelayIntegration
중계 서버(JJServiceManagerRelay)와 이 프로그램의 원격 코어(대상 PC·관리 PC)를 함께 띄워 확인하는 통합 점검 (32개 항목, Linux에서도 실행).
화면 캡처·입력은 가짜(Stubs.cs)로 대신합니다.
1. PostgreSQL + 중계 서버를 `http://127.0.0.1:21900`에 띄움 (첫 관리자 `admin` / `firstpass1`, 빈 DB)
2. `dotnet run -c Release`

View File

@ -0,0 +1,19 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup><OutputType>Exe</OutputType><TargetFramework>net8.0</TargetFramework><Nullable>disable</Nullable><NoWarn>CA1416</NoWarn><ImplicitUsings>disable</ImplicitUsings></PropertyGroup>
<ItemGroup>
<PackageReference Include="System.ServiceProcess.ServiceController" Version="8.0.1" />
<PackageReference Include="Microsoft.DevTunnels.Ssh" Version="3.12.42" />
<PackageReference Include="Microsoft.DevTunnels.Ssh.Tcp" Version="3.12.42" />
<PackageReference Include="System.Security.Cryptography.ProtectedData" Version="8.0.0" />
<PackageReference Include="System.Text.Encoding.CodePages" Version="8.0.0" />
<Compile Include="../../Core/Models.cs" />
<Compile Include="../../Core/LogTailer.cs" />
<Compile Include="../../Core/Services.cs" />
<Compile Include="../../Core/ConsoleRunner.cs" />
<Compile Include="../../Core/Remote/RemoteCommon.cs" />
<Compile Include="../../Core/Remote/RemoteClient.cs" />
<Compile Include="../../Core/Remote/RemoteServer.cs" />
<Compile Include="../../Core/Remote/RemoteTransport.cs" />
<Compile Include="../../Core/Remote/Relay.cs" />
</ItemGroup>
</Project>

View File

@ -0,0 +1,14 @@
using System; using System.IO; using System.Linq; using System.Text; using System.Threading; using System.Threading.Tasks; using System.Collections.Generic; using System.Drawing;
using System.Text.Json.Nodes;
using JJServiceManager.Remote;
namespace JJServiceManager { public enum LogLevel { Info, Success, Warning, Error, Notice } }
namespace JJServiceManager.Remote {
// 테스트용 가짜 화면 (Linux)
public sealed class MonitorInfo { public int Index{get;set;} public string Name{get;set;}=""; public int X{get;set;} public int Y{get;set;} public int Width{get;set;} public int Height{get;set;} public bool Primary{get;set;} }
public static class ScreenCapture {
public static List<MonitorInfo> Monitors()=> new(){ new MonitorInfo{Index=1,Width=1920,Height=1080,Primary=true}, new MonitorInfo{Index=2,Width=1280,Height=1024} };
public static Rectangle Area(int m)=> m==2? new Rectangle(1920,0,1280,1024) : new Rectangle(0,0,1920,1080);
static int n; public static byte[] CaptureJpeg(Rectangle a,int mw,long q,out int w,out int h,out ulong hash){ w=a.Width; h=a.Height; hash=(ulong)(Interlocked.Increment(ref n)/5); return new byte[]{1,2,3,(byte)a.Width}; }
}
public static class InputInjector { public static List<string> Got=new(); public static void Mouse(Rectangle a,double x,double y,string act,string b,int d){ lock(Got) Got.Add($"mouse {act} {b} {x:0.00},{y:0.00} area={a.Width}"); } public static void Key(int vk,bool down){ lock(Got) Got.Add($"key {vk} {(down?"down":"up")}"); } }
}