Compare commits
No commits in common. "feature/relay-server" and "main" have entirely different histories.
feature/re
...
main
|
|
@ -65,8 +65,7 @@ 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>
|
||||||
|
|
@ -91,28 +90,11 @@ 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)
|
||||||
{
|
{
|
||||||
// 아직 한 번도 접속하지 않았으면 코드로 표시
|
// 아직 한 번도 접속하지 않았으면 코드로 표시
|
||||||
|
|
@ -266,31 +248,6 @@ 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>();
|
||||||
|
|
||||||
|
|
@ -331,9 +288,6 @@ 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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
1037
Core/Remote/Relay.cs
1037
Core/Remote/Relay.cs
File diff suppressed because it is too large
Load Diff
|
|
@ -32,9 +32,6 @@ 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
|
||||||
{
|
{
|
||||||
|
|
@ -44,16 +41,6 @@ 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: 중계 서버가 준 이 기기 권한 (연결 hello에서 받음, 없으면 null)</summary>
|
|
||||||
public (bool view, bool control, bool screenView, bool screenControl)? RelayPerms { get; private set; }
|
|
||||||
|
|
||||||
/// <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; } = "";
|
||||||
|
|
@ -74,9 +61,8 @@ 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, RelayManager relay = null)
|
public RemoteClient(RemoteTarget target, Func<string> passwordProvider = null)
|
||||||
{
|
{
|
||||||
_relay = relay;
|
|
||||||
_target = target.Clone();
|
_target = target.Clone();
|
||||||
Hostname = target.Hostname ?? "";
|
Hostname = target.Hostname ?? "";
|
||||||
var prot = _target.ProtectedPassword;
|
var prot = _target.ProtectedPassword;
|
||||||
|
|
@ -85,9 +71,6 @@ 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 { }
|
||||||
|
|
@ -135,10 +118,6 @@ 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, "연결 중…");
|
||||||
|
|
@ -154,9 +133,6 @@ namespace JJServiceManager.Remote
|
||||||
}
|
}
|
||||||
AllowView = Json.Bool(hello, "allowView");
|
AllowView = Json.Bool(hello, "allowView");
|
||||||
AllowControl = Json.Bool(hello, "allowControl");
|
AllowControl = Json.Bool(hello, "allowControl");
|
||||||
RelayPerms = hello["perms"] is JsonObject rp
|
|
||||||
? (Json.Bool(rp, "view"), Json.Bool(rp, "control"), Json.Bool(rp, "screenView"), Json.Bool(rp, "screenControl"))
|
|
||||||
: null;
|
|
||||||
SetState(RemoteState.Connected, "연결됨");
|
SetState(RemoteState.Connected, "연결됨");
|
||||||
|
|
||||||
var reader = Task.Run(() => ReadLoopAsync(conn, ct));
|
var reader = Task.Run(() => ReadLoopAsync(conn, ct));
|
||||||
|
|
@ -181,129 +157,27 @@ 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
|
try { await Task.Delay(retry, ct).ConfigureAwait(false); }
|
||||||
{
|
|
||||||
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)
|
||||||
|
|
@ -458,7 +332,7 @@ namespace JJServiceManager.Remote
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>연결 위에서 hello (비밀번호 확인). 실패하면 연결을 닫고 예외</summary>
|
/// <summary>연결 위에서 hello (비밀번호 확인). 실패하면 연결을 닫고 예외</summary>
|
||||||
public static async Task<JsonObject> HelloAsync(JsonLineConnection conn, string password, bool sendPassword, CancellationToken ct, string ticket = null)
|
public static async Task<JsonObject> HelloAsync(JsonLineConnection conn, string password, bool sendPassword, CancellationToken ct)
|
||||||
{
|
{
|
||||||
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||||
timeout.CancelAfter(ConnectTimeout);
|
timeout.CancelAfter(ConnectTimeout);
|
||||||
|
|
@ -472,7 +346,6 @@ 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,12 +89,6 @@ 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();
|
||||||
|
|
@ -110,7 +104,6 @@ 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);
|
||||||
|
|
@ -146,7 +139,6 @@ 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
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -47,8 +47,6 @@ namespace JJServiceManager.Remote
|
||||||
|
|
||||||
// 화면 보기 중인 세션
|
// 화면 보기 중인 세션
|
||||||
private readonly ConcurrentDictionary<int, ScreenSession> _screens = new ConcurrentDictionary<int, ScreenSession>();
|
private readonly ConcurrentDictionary<int, ScreenSession> _screens = new ConcurrentDictionary<int, ScreenSession>();
|
||||||
/// <summary>중계 서버 권한 티켓으로 열린 연결 (권한이 바뀌면 바로 끊기 위해)</summary>
|
|
||||||
private readonly ConcurrentDictionary<JsonLineConnection, RelayTicket> _ticketConns = new ConcurrentDictionary<JsonLineConnection, RelayTicket>();
|
|
||||||
private int _nextScreenId;
|
private int _nextScreenId;
|
||||||
|
|
||||||
public bool IsRunning => _listener != null;
|
public bool IsRunning => _listener != null;
|
||||||
|
|
@ -58,28 +56,6 @@ 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>
|
||||||
|
|
@ -249,34 +225,12 @@ namespace JJServiceManager.Remote
|
||||||
lock (_failures) _failures.Enqueue(DateTime.UtcNow);
|
lock (_failures) _failures.Enqueue(DateTime.UtcNow);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>중계 서버가 이 사용자의 권한·계정을 바꿈 → 옛 티켓으로 열린 연결을 바로 끊음 (관리 PC는 새 티켓으로 다시 연결)</summary>
|
|
||||||
public int DropTicketUser(long userId)
|
|
||||||
{
|
|
||||||
int n = 0;
|
|
||||||
foreach (var kv in _ticketConns)
|
|
||||||
{
|
|
||||||
if (kv.Value.UserId != userId) continue;
|
|
||||||
if (_ticketConns.TryRemove(kv.Key, out _))
|
|
||||||
{
|
|
||||||
n++;
|
|
||||||
try { kv.Key.Dispose(); } catch { }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (n > 0) _log($"중계 서버에서 사용자 권한이 바뀌어 원격 연결 {n}개를 끊었습니다.", LogLevel.Notice);
|
|
||||||
return n;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ───────────────────────── 요청 처리 ─────────────────────────
|
// ───────────────────────── 요청 처리 ─────────────────────────
|
||||||
|
|
||||||
/// <summary>중계 통로로 들어온 연결 (권한 티켓 필수, 세션 ID 일치)</summary>
|
private async Task ServeAsync(JsonLineConnection conn, string peer, bool requireHelloPassword, CancellationToken ct)
|
||||||
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;
|
||||||
|
|
@ -295,25 +249,6 @@ 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())
|
||||||
|
|
@ -326,34 +261,21 @@ 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);
|
||||||
if (ticket != null) _ticketConns[conn] = ticket;
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -363,16 +285,6 @@ 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":
|
||||||
|
|
@ -380,34 +292,13 @@ 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, () =>
|
||||||
|
|
@ -472,7 +363,7 @@ namespace JJServiceManager.Remote
|
||||||
|
|
||||||
case "screenInfo":
|
case "screenInfo":
|
||||||
{
|
{
|
||||||
var pol = Policy(ticket);
|
var pol = _policy();
|
||||||
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 });
|
||||||
|
|
@ -488,14 +379,13 @@ 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(tk);
|
var pol = _policy();
|
||||||
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)
|
||||||
|
|
@ -505,14 +395,12 @@ 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) { Ticket = tk };
|
var s = new ScreenSession(Interlocked.Increment(ref _nextScreenId), whoNow, control, monitor);
|
||||||
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);
|
||||||
|
|
@ -547,7 +435,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(ticket);
|
var pol = _policy();
|
||||||
if (!pol.AllowView || !pol.AllowControl) break;
|
if (!pol.AllowView || !pol.AllowControl) break;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
|
@ -584,7 +472,6 @@ namespace JJServiceManager.Remote
|
||||||
foreach (var t in streams.Values) t.Dispose();
|
foreach (var t in streams.Values) t.Dispose();
|
||||||
streams.Clear();
|
streams.Clear();
|
||||||
}
|
}
|
||||||
_ticketConns.TryRemove(conn, out _);
|
|
||||||
var s = Interlocked.Exchange(ref screen, null);
|
var s = Interlocked.Exchange(ref screen, null);
|
||||||
EndScreen(s);
|
EndScreen(s);
|
||||||
conn.Dispose();
|
conn.Dispose();
|
||||||
|
|
@ -600,7 +487,6 @@ 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;
|
||||||
|
|
@ -631,7 +517,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(s.Ticket);
|
var pol = _policy();
|
||||||
if (!pol.AllowView)
|
if (!pol.AllowView)
|
||||||
{
|
{
|
||||||
endReason = "대상 PC에서 화면 보기 허용을 껐습니다.";
|
endReason = "대상 PC에서 화면 보기 허용을 껐습니다.";
|
||||||
|
|
@ -686,7 +572,6 @@ namespace JJServiceManager.Remote
|
||||||
catch { }
|
catch { }
|
||||||
}
|
}
|
||||||
_log($"[원격 화면] {s.Who} 연결 끝", LogLevel.Info);
|
_log($"[원격 화면] {s.Who} 연결 끝", LogLevel.Info);
|
||||||
Audit(s.Ticket, "screenEnd", "", "ok", endReason);
|
|
||||||
NotifyViewers();
|
NotifyViewers();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -711,41 +596,6 @@ 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)
|
||||||
|
|
@ -755,7 +605,7 @@ namespace JJServiceManager.Remote
|
||||||
return e;
|
return e;
|
||||||
}
|
}
|
||||||
|
|
||||||
private JsonObject ListServices(RelayTicket ticket = null)
|
private JsonObject ListServices()
|
||||||
{
|
{
|
||||||
var arr = new JsonArray();
|
var arr = new JsonArray();
|
||||||
foreach (var e in _services())
|
foreach (var e in _services())
|
||||||
|
|
@ -770,7 +620,7 @@ namespace JJServiceManager.Remote
|
||||||
["logIsFolder"] = e.LogIsFolder
|
["logIsFolder"] = e.LogIsFolder
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
var pol = Policy(ticket);
|
var pol = _policy();
|
||||||
return new JsonObject
|
return new JsonObject
|
||||||
{
|
{
|
||||||
["services"] = arr,
|
["services"] = arr,
|
||||||
|
|
|
||||||
|
|
@ -152,22 +152,8 @@ 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>
|
||||||
internal static X509Certificate2 Certificate()
|
private static X509Certificate2 Certificate()
|
||||||
{
|
{
|
||||||
lock (CertLock)
|
lock (CertLock)
|
||||||
{
|
{
|
||||||
|
|
@ -187,20 +173,6 @@ 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,8 +37,4 @@
|
||||||
<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,7 +4,6 @@ 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가 서버로만 접속하면 서로 연결 (먼저 직접, 안 되면 중계), 계정·권한·감사 로그.
|
|
||||||
|
|
||||||
## 실행 파일
|
## 실행 파일
|
||||||
|
|
||||||
|
|
@ -103,40 +102,6 @@ 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,9 +45,6 @@
|
||||||
<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,29 +172,6 @@
|
||||||
</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">
|
||||||
|
|
@ -241,26 +218,6 @@
|
||||||
<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,7 +5,6 @@ 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;
|
||||||
|
|
@ -35,14 +34,6 @@ 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 = "";
|
||||||
|
|
@ -179,31 +170,6 @@ 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);
|
||||||
|
|
@ -317,21 +283,15 @@ namespace JJServiceManager
|
||||||
|
|
||||||
private void SaveOpenTabs()
|
private void SaveOpenTabs()
|
||||||
{
|
{
|
||||||
_settings.OpenHostTabs = _hostTabs.Keys.Concat(_pendingRelayTabs).Distinct().ToList();
|
_settings.OpenHostTabs = _hostTabs.Keys.ToList();
|
||||||
SaveSettings();
|
SaveSettings();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void RestoreHostTabs()
|
private void RestoreHostTabs()
|
||||||
{
|
{
|
||||||
foreach (var id in _settings.OpenHostTabs.ToList())
|
foreach (var id in _settings.OpenHostTabs.ToList()) OpenHostTab(id, select: false);
|
||||||
{
|
// 없어진 PC 탭은 정리
|
||||||
if (id.StartsWith(RelayGroupPrefix, StringComparison.OrdinalIgnoreCase) && !_groups.Any(g => g.Id == id))
|
_settings.OpenHostTabs = _hostTabs.Keys.ToList();
|
||||||
_pendingRelayTabs.Add(id); // 로그인 뒤 기기 목록을 받으면 엶
|
|
||||||
else
|
|
||||||
OpenHostTab(id, select: false);
|
|
||||||
}
|
|
||||||
// 없어진 PC 탭은 정리 (중계 기기 탭은 유지)
|
|
||||||
_settings.OpenHostTabs = _hostTabs.Keys.Concat(_pendingRelayTabs).Distinct().ToList();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ───────────────────────── 내 PC 서비스 추가 / 삭제 ─────────────────────────
|
// ───────────────────────── 내 PC 서비스 추가 / 삭제 ─────────────────────────
|
||||||
|
|
@ -437,12 +397,6 @@ 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)
|
||||||
{
|
{
|
||||||
|
|
@ -600,10 +554,6 @@ namespace JJServiceManager
|
||||||
}
|
}
|
||||||
if (!g.Client.AllowView)
|
if (!g.Client.AllowView)
|
||||||
{
|
{
|
||||||
// 중계 서버 권한이 없어서인지, 대상 PC 설정이 꺼져 있어서인지 구분
|
|
||||||
if (g.Client.RelayPerms is { } rp && !rp.screenView)
|
|
||||||
Dialogs.Info(this, $"{g.Title} 의 [화면 보기] 권한이 중계 서버에서 주어지지 않았습니다.\n중계 서버 관리자에게 [서버 관리 → 권한]에서 '화면 보기'를 요청하세요.");
|
|
||||||
else
|
|
||||||
Dialogs.Info(this, $"{g.Title} 에서 화면 보기를 허용하지 않았습니다.\n대상 PC 설정 → 원격 접속 허용 → 원격관리에서 '화면 보기 허용'을 켜야 합니다.");
|
Dialogs.Info(this, $"{g.Title} 에서 화면 보기를 허용하지 않았습니다.\n대상 PC 설정 → 원격 접속 허용 → 원격관리에서 '화면 보기 허용'을 켜야 합니다.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -823,41 +773,23 @@ 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 && !IsRelayGroup(g)).ToList())
|
foreach (var g in _groups.Where(g => !g.IsLocal).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 = id,
|
Id = t.Id,
|
||||||
Client = client,
|
Client = client,
|
||||||
Title = title,
|
Title = t.Label,
|
||||||
StatusText = "연결 중…",
|
StatusText = "연결 중…",
|
||||||
StatusBrush = Brushes2.Pending,
|
StatusBrush = Brushes2.Pending,
|
||||||
EmptyText = "연결 중…"
|
EmptyText = "연결 중…"
|
||||||
|
|
@ -871,200 +803,23 @@ 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)));
|
||||||
if (index >= 0 && index <= _groups.Count) _groups.Insert(index, g);
|
client.PeerAddressDiscovered += (c, addr) => Dispatcher.BeginInvoke(new Action(() => OnPeerAddress(g, addr)));
|
||||||
else _groups.Add(g);
|
if (t.Direction == ConnectDirection.Passive) _passiveHub.Register(client);
|
||||||
return g;
|
_groups.Add(g);
|
||||||
}
|
|
||||||
|
|
||||||
private void RemoveRemoteGroup(HostGroup g)
|
|
||||||
{
|
|
||||||
if (_viewers.TryGetValue(g.Id, out var v)) v.Close();
|
|
||||||
g.Client?.Dispose();
|
|
||||||
_logWindows.Close($"remote|{g.Id}|", prefix: true);
|
|
||||||
_groups.Remove(g);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ───────────────────────── 중계 서버 ─────────────────────────
|
|
||||||
|
|
||||||
private const string RelayGroupPrefix = "relay-";
|
|
||||||
private static bool IsRelayGroup(HostGroup g) => g.Id.StartsWith(RelayGroupPrefix, StringComparison.OrdinalIgnoreCase);
|
|
||||||
|
|
||||||
/// <summary>대상 PC 역할: 이 PC를 중계 서버에 등록해 두고 온라인 유지</summary>
|
|
||||||
private void ApplyRelayAgent()
|
|
||||||
{
|
|
||||||
var s = _settings;
|
|
||||||
bool hasPw = !string.IsNullOrEmpty(s.RemotePasswordHash);
|
|
||||||
bool need = s.RemoteServerEnabled && hasPw && s.RelayAgentEnabled && !string.IsNullOrEmpty(s.RelayDeviceId);
|
|
||||||
var key = need ? $"{s.RelayAgentUrl}|{s.RelayDeviceId}|{s.RelayDeviceKeyProtected}" : "off";
|
|
||||||
if (key != _relayAgentKey)
|
|
||||||
{
|
|
||||||
_relayAgentKey = key;
|
|
||||||
if (need)
|
|
||||||
{
|
|
||||||
_relayAgent.Start(s.RelayAgentUrl, s.RelayDeviceId, RemoteSecurity.Unprotect(s.RelayDeviceKeyProtected),
|
|
||||||
s.RelayServerPublicKey, RelayActiveListen);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
_relayAgent.Stop();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else if (need)
|
|
||||||
{
|
|
||||||
_relayAgent.SendUpdate(); // Active 수신 정보가 바뀌었을 수 있음
|
|
||||||
}
|
|
||||||
if (!need && !string.IsNullOrEmpty(s.RelayServerPublicKey)) _remoteServer.SetRelayTrust(s.RelayServerPublicKey, s.RelayDeviceId);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>중계 서버에 알릴 Active 수신 정보 (직접 연결 후보)</summary>
|
|
||||||
private JsonObject RelayActiveListen()
|
|
||||||
{
|
|
||||||
if (!_remoteServer.IsRunning) return null;
|
|
||||||
return new JsonObject { ["mode"] = _remoteServer.Mode == RemoteMode.Ssh ? "Ssh" : "Port", ["port"] = _remoteServer.Port };
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>관리 역할: 중계 서버 로그인 유지</summary>
|
|
||||||
private void ApplyRelayManage()
|
|
||||||
{
|
|
||||||
var s = _settings;
|
|
||||||
bool need = s.RemoteManageEnabled && s.RelayManageEnabled
|
|
||||||
&& (!string.IsNullOrEmpty(s.RelayTokenProtected) || !string.IsNullOrEmpty(s.RelayPasswordProtected));
|
|
||||||
var key = need ? $"{s.RelayManageUrl}|{s.RelayUsername}|{s.RelayTokenProtected}|{s.RelayPasswordProtected}" : "off";
|
|
||||||
if (key == _relayManageKey) return;
|
|
||||||
_relayManageKey = key;
|
|
||||||
_relayLoginNotice = null;
|
|
||||||
if (need)
|
|
||||||
{
|
|
||||||
_relayManager.Start(s.RelayManageUrl, s.RelayUsername,
|
|
||||||
() => RemoteSecurity.Unprotect(_settings.RelayTokenProtected),
|
|
||||||
() => RemoteSecurity.Unprotect(_settings.RelayPasswordProtected),
|
|
||||||
token => Dispatcher.BeginInvoke(new Action(() =>
|
|
||||||
{
|
|
||||||
_settings.RelayTokenProtected = RemoteSecurity.Protect(token);
|
|
||||||
_relayManageKey = $"{_settings.RelayManageUrl}|{_settings.RelayUsername}|{_settings.RelayTokenProtected}|{_settings.RelayPasswordProtected}";
|
|
||||||
SaveSettings();
|
|
||||||
})));
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
_relayManager.Stop();
|
|
||||||
}
|
|
||||||
SyncRelayGroups();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>중계 서버의 기기 목록 → 초기화면 그룹 (권한 있는 기기만)</summary>
|
|
||||||
private readonly Dictionary<string, string> _relayPermSig = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
|
||||||
private static string PermSig(RelayDevice d) => $"{d.View}{d.Control}{d.ScreenView}{d.ScreenControl}";
|
|
||||||
|
|
||||||
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);
|
|
||||||
_relayPermSig.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();
|
|
||||||
}
|
|
||||||
// 권한이 바뀌었으면: 대상 PC가 옛 티켓 연결을 끊음 → 기다리지 않고 새 티켓으로 바로 다시 연결
|
|
||||||
bool permChanged = _relayPermSig.TryGetValue(gid, out var sig0) && sig0 != PermSig(d);
|
|
||||||
_relayPermSig[gid] = PermSig(d);
|
|
||||||
if (d.Online && (permChanged || 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;
|
|
||||||
_relayPermSig[gid] = PermSig(d);
|
|
||||||
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();
|
client.Start();
|
||||||
if (_pendingRelayTabs.Remove(gid)) OpenHostTab(gid, select: false);
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 열어둔 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();
|
_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)
|
||||||
{
|
{
|
||||||
|
|
@ -1179,14 +934,6 @@ 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)
|
||||||
|
|
@ -1249,11 +996,13 @@ namespace JJServiceManager
|
||||||
else _remoteServer.StopPassive();
|
else _remoteServer.StopPassive();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 중계 서버 (이 PC를 등록해 두고 연결 요청 받기)
|
|
||||||
ApplyRelayAgent();
|
|
||||||
|
|
||||||
// ── 원격관리 허용을 끄면 보고 있던 연결은 다음 장면에서 자동으로 끊김
|
// ── 원격관리 허용을 끄면 보고 있던 연결은 다음 장면에서 자동으로 끊김
|
||||||
UpdateLocalStatus();
|
var parts = new List<string>();
|
||||||
|
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 종료 ─────────────────────────
|
||||||
|
|
@ -1294,7 +1043,7 @@ namespace JJServiceManager
|
||||||
ShowFromTray();
|
ShowFromTray();
|
||||||
if (_modalOpen) return;
|
if (_modalOpen) return;
|
||||||
bool wasStartup = _settings.RunAtStartup;
|
bool wasStartup = _settings.RunAtStartup;
|
||||||
var dlg = new SettingsWindow(_settings, _relayManager) { Owner = this };
|
var dlg = new SettingsWindow(_settings) { Owner = this };
|
||||||
|
|
||||||
// 설정 창에서 단축키를 누를 때 전역 단축키가 먼저 받지 않도록 잠시 해제
|
// 설정 창에서 단축키를 누를 때 전역 단축키가 먼저 받지 않도록 잠시 해제
|
||||||
_hotkey.Unregister();
|
_hotkey.Unregister();
|
||||||
|
|
@ -1332,7 +1081,6 @@ namespace JJServiceManager
|
||||||
ApplyHotkey(quiet: startup);
|
ApplyHotkey(quiet: startup);
|
||||||
ApplyRemoteServer(startup);
|
ApplyRemoteServer(startup);
|
||||||
ApplyRemoteManage();
|
ApplyRemoteManage();
|
||||||
ApplyRelayManage();
|
|
||||||
ApplyPassiveListen(startup);
|
ApplyPassiveListen(startup);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1508,8 +1256,6 @@ 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();
|
||||||
|
|
|
||||||
|
|
@ -1,145 +0,0 @@
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,890 +0,0 @@
|
||||||
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,11 +52,7 @@ namespace JJServiceManager
|
||||||
/// <summary>원격 PC 제목 줄의 [원격관리] 버튼</summary>
|
/// <summary>원격 PC 제목 줄의 [원격관리] 버튼</summary>
|
||||||
public Visibility RemoteButtonVisibility => IsLocal ? Visibility.Collapsed : Visibility.Visible;
|
public Visibility RemoteButtonVisibility => IsLocal ? Visibility.Collapsed : Visibility.Visible;
|
||||||
|
|
||||||
/// <summary>중계 기기 제목 줄의 [비밀번호] 버튼 (대상 PC 접속 비밀번호는 기기마다 입력)</summary>
|
public Brush HeaderBrush => IsLocal ? Brushes2.Make("#E3F2FD") : Brushes2.Make("#F3E5F5");
|
||||||
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,22 +52,6 @@ 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
|
||||||
{
|
{
|
||||||
|
|
@ -84,10 +68,9 @@ namespace JJServiceManager
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public SettingsWindow(AppSettings current, RelayManager relay = null)
|
public SettingsWindow(AppSettings current)
|
||||||
{
|
{
|
||||||
_s = current;
|
_s = current;
|
||||||
_relay = relay;
|
|
||||||
|
|
||||||
Title = "설정" + AppInstance.TitleTag;
|
Title = "설정" + AppInstance.TitleTag;
|
||||||
Width = 800;
|
Width = 800;
|
||||||
|
|
@ -240,48 +223,6 @@ 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();
|
||||||
|
|
@ -588,132 +529,6 @@ 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()
|
||||||
|
|
@ -746,38 +561,9 @@ namespace JJServiceManager
|
||||||
Fail(1, "원격 접속을 허용하려면 접속 비밀번호를 정하세요.");
|
Fail(1, "원격 접속을 허용하려면 접속 비밀번호를 정하세요.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
bool relayAgentOn = _relayAgent.IsChecked == true;
|
if (_server.IsChecked == true && _activeListen.IsChecked != true && _pms.Count == 0)
|
||||||
if (relayAgentOn && string.IsNullOrEmpty(_relayDeviceId))
|
|
||||||
{
|
{
|
||||||
Fail(1, "중계 서버를 사용하려면 먼저 [등록 코드로 등록]을 하세요.");
|
Fail(1, "원격 접속 허용을 켜려면 Active 수신을 켜거나 Passive 접속을 하나 이상 추가하세요.");
|
||||||
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -816,19 +602,6 @@ 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,175 +0,0 @@
|
||||||
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); }
|
|
||||||
|
|
||||||
Console.WriteLine("== 5-1. 연결 중에 권한을 바꾸면 → 대상 PC가 옛 티켓 연결을 바로 끊고, 새 권한으로 다시 연결");
|
|
||||||
bool sawDrop = false;
|
|
||||||
c3.StateChanged += c => { if (c.State != RemoteState.Connected) sawDrop = true; };
|
|
||||||
await admin.RequestAsync("admin.perms.set", new JsonObject { ["userId"] = bobId, ["deviceId"] = enr.DeviceId, ["view"] = true, ["control"] = true });
|
|
||||||
Check(await Until(() => sawDrop, 10), "권한 변경 → 기존 연결 끊김");
|
|
||||||
Check(await Until(() => bob.Devices.Count == 1 && bob.Devices[0].Control, 10), "bob 기기 목록에 새 권한 반영");
|
|
||||||
c3.Nudge(); // 화면(MainWindow)은 권한이 바뀌면 Nudge → 바로 다시 연결
|
|
||||||
Check(await Until(() => c3.State == RemoteState.Connected && c3.RelayPerms?.control == true, 20), "새 티켓으로 다시 연결 (서비스 제어 권한)", c3.StateMessage);
|
|
||||||
try { await c3.StopServiceAsync("Spooler"); Check(true, "bob Stop 이제 허용 (리눅스라 실행 자체는 실패해도 됨)"); }
|
|
||||||
catch (RemoteException ex) { Check(!ex.Message.Contains("서비스 제어"), "bob Stop 이제 허용", ex.Message); }
|
|
||||||
|
|
||||||
sawDrop = false;
|
|
||||||
await admin.RequestAsync("admin.users.update", new JsonObject { ["userId"] = bobId, ["enabled"] = false });
|
|
||||||
Check(await Until(() => sawDrop, 10), "계정 사용 안 함 → 대상 PC 연결도 바로 끊김");
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,7 +0,0 @@
|
||||||
# RelayIntegration
|
|
||||||
|
|
||||||
중계 서버(JJServiceManagerRelay)와 이 프로그램의 원격 코어(대상 PC·관리 PC)를 함께 띄워 확인하는 통합 점검 (37개 항목, Linux에서도 실행).
|
|
||||||
화면 캡처·입력은 가짜(Stubs.cs)로 대신합니다.
|
|
||||||
|
|
||||||
1. PostgreSQL + 중계 서버를 `http://127.0.0.1:21900`에 띄움 (첫 관리자 `admin` / `firstpass1`, 빈 DB)
|
|
||||||
2. `dotnet run -c Release`
|
|
||||||
|
|
@ -1,19 +0,0 @@
|
||||||
<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>
|
|
||||||
|
|
@ -1,14 +0,0 @@
|
||||||
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