Compare commits
1 Commits
main
...
feature/re
| Author | SHA1 | Date |
|---|---|---|
|
|
d2cd87526f |
|
|
@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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는 암호화만 하고 서버 지문은 확인하지 않음.
|
||||||
|
|
|
||||||
|
|
@ -37,4 +37,8 @@
|
||||||
<EmbeddedResource Include="Views\*.xaml" />
|
<EmbeddedResource Include="Views\*.xaml" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<Compile Remove="tests/**" />
|
||||||
|
<None Remove="tests/**" />
|
||||||
|
</ItemGroup>
|
||||||
</Project>
|
</Project>
|
||||||
|
|
|
||||||
35
README.md
35
README.md
|
|
@ -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 설정 (원격 접속 허용 탭 → 원격관리)
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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,23 +819,41 @@ 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 = CreateRemoteGroup(t.Id, t.Label, client, index++);
|
||||||
|
client.PeerAddressDiscovered += (c, addr) => Dispatcher.BeginInvoke(new Action(() => OnPeerAddress(g, addr)));
|
||||||
|
if (t.Direction == ConnectDirection.Passive) _passiveHub.Register(client);
|
||||||
|
client.Start();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 열어둔 PC 탭: 없어진 PC는 닫고, 남은 PC는 새 목록으로 다시 엶
|
||||||
|
var open = _hostTabs.Keys.ToList();
|
||||||
|
foreach (var id in open) CloseHostTab(id, save: false);
|
||||||
|
foreach (var id in open) OpenHostTab(id, select: false);
|
||||||
|
SaveOpenTabs();
|
||||||
|
|
||||||
|
_logWindows.ChoicesChanged();
|
||||||
|
UpdateCounts();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>원격 PC 그룹 만들기 (Active / Passive / 중계 공통)</summary>
|
||||||
|
private HostGroup CreateRemoteGroup(string id, string title, RemoteClient client, int index = -1)
|
||||||
|
{
|
||||||
var g = new HostGroup
|
var g = new HostGroup
|
||||||
{
|
{
|
||||||
Id = t.Id,
|
Id = id,
|
||||||
Client = client,
|
Client = client,
|
||||||
Title = t.Label,
|
Title = title,
|
||||||
StatusText = "연결 중…",
|
StatusText = "연결 중…",
|
||||||
StatusBrush = Brushes2.Pending,
|
StatusBrush = Brushes2.Pending,
|
||||||
EmptyText = "연결 중…"
|
EmptyText = "연결 중…"
|
||||||
|
|
@ -803,23 +867,192 @@ namespace JJServiceManager
|
||||||
};
|
};
|
||||||
client.ServicesChanged += c => Dispatcher.BeginInvoke(new Action(() => OnRemoteServices(g, c)));
|
client.ServicesChanged += c => Dispatcher.BeginInvoke(new Action(() => OnRemoteServices(g, c)));
|
||||||
client.HostnameDiscovered += (c, host) => Dispatcher.BeginInvoke(new Action(() => OnHostname(g, c, host)));
|
client.HostnameDiscovered += (c, host) => Dispatcher.BeginInvoke(new Action(() => OnHostname(g, c, host)));
|
||||||
client.PeerAddressDiscovered += (c, addr) => Dispatcher.BeginInvoke(new Action(() => OnPeerAddress(g, addr)));
|
if (index >= 0 && index <= _groups.Count) _groups.Insert(index, g);
|
||||||
if (t.Direction == ConnectDirection.Passive) _passiveHub.Register(client);
|
else _groups.Add(g);
|
||||||
_groups.Add(g);
|
return g;
|
||||||
client.Start();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 열어둔 PC 탭: 없어진 PC는 닫고, 남은 PC는 새 목록으로 다시 엶
|
private void RemoveRemoteGroup(HostGroup g)
|
||||||
var open = _hostTabs.Keys.ToList();
|
{
|
||||||
foreach (var id in open) CloseHostTab(id, save: false);
|
if (_viewers.TryGetValue(g.Id, out var v)) v.Close();
|
||||||
foreach (var id in open) OpenHostTab(id, select: false);
|
g.Client?.Dispose();
|
||||||
SaveOpenTabs();
|
_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();
|
_logWindows.ChoicesChanged();
|
||||||
UpdateCounts();
|
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();
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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")
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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`
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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")}"); } }
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue