diff --git a/Core/ConsoleRunner.cs b/Core/ConsoleRunner.cs
new file mode 100644
index 0000000..5405cfe
--- /dev/null
+++ b/Core/ConsoleRunner.cs
@@ -0,0 +1,78 @@
+using System;
+using System.Diagnostics;
+using System.IO;
+using System.Runtime.InteropServices;
+using System.Text;
+using System.Threading.Tasks;
+
+namespace JJServiceManager
+{
+ ///
+ /// Windows 명령(schtasks, netsh 등)을 실행하고 출력을 읽음.
+ /// 시스템 설정에 따라 출력이 UTF-8 또는 CP949(OEM)일 수 있어,
+ /// 바이트로 받은 뒤 UTF-8로 올바르게 읽히면 UTF-8, 아니면 시스템 OEM 코드페이지로 해석함.
+ ///
+ public static class ConsoleRunner
+ {
+ [DllImport("kernel32.dll")]
+ private static extern uint GetOEMCP();
+
+ public static (int exitCode, string output) Run(string file, string args, int timeoutMs = 15000)
+ {
+ try
+ {
+ var psi = new ProcessStartInfo(file, args)
+ {
+ UseShellExecute = false,
+ CreateNoWindow = true,
+ RedirectStandardOutput = true,
+ RedirectStandardError = true
+ };
+ using var p = Process.Start(psi);
+ var outTask = ReadAllAsync(p.StandardOutput.BaseStream);
+ var errTask = ReadAllAsync(p.StandardError.BaseStream);
+ if (!p.WaitForExit(timeoutMs))
+ {
+ try { p.Kill(); } catch { }
+ return (-1, "시간 초과");
+ }
+ var bytes = Combine(outTask.Result, errTask.Result);
+ return (p.ExitCode, Decode(bytes).Trim());
+ }
+ catch (Exception ex)
+ {
+ return (-1, ex.Message);
+ }
+ }
+
+ private static async Task ReadAllAsync(Stream s)
+ {
+ using var ms = new MemoryStream();
+ await s.CopyToAsync(ms).ConfigureAwait(false);
+ return ms.ToArray();
+ }
+
+ private static byte[] Combine(byte[] a, byte[] b)
+ {
+ var r = new byte[a.Length + b.Length];
+ Buffer.BlockCopy(a, 0, r, 0, a.Length);
+ Buffer.BlockCopy(b, 0, r, a.Length, b.Length);
+ return r;
+ }
+
+ public static string Decode(byte[] bytes)
+ {
+ if (bytes.Length == 0) return "";
+ try
+ {
+ var strict = new UTF8Encoding(false, throwOnInvalidBytes: true);
+ return strict.GetString(bytes);
+ }
+ catch { }
+ try { return Encoding.GetEncoding((int)GetOEMCP()).GetString(bytes); }
+ catch { }
+ try { return Encoding.GetEncoding(949).GetString(bytes); }
+ catch { return Encoding.UTF8.GetString(bytes); }
+ }
+ }
+}
diff --git a/Core/Models.cs b/Core/Models.cs
index bd9abb6..f83cc44 100644
--- a/Core/Models.cs
+++ b/Core/Models.cs
@@ -61,10 +61,24 @@ namespace JJServiceManager
Port = 1 // 지정 포트 + TLS (기본 9988)
}
+ /// 접속 방향
+ public enum ConnectDirection
+ {
+ Active = 0, // 관리 PC → 대상 PC 로 접속 (대상 PC가 포트를 엶)
+ Passive = 1 // 대상 PC → 관리 PC 로 접속 (관리 PC가 포트를 엶)
+ }
+
/// 관리 대상 PC
public class RemoteTarget
{
public string Id { get; set; } = Guid.NewGuid().ToString("N");
+
+ /// Active: 관리 PC가 접속 / Passive: 대상 PC가 접속해 옴
+ public ConnectDirection Direction { get; set; } = ConnectDirection.Active;
+
+ /// Passive일 때 대상 PC가 알려주는 접속 코드 (예: AB12-CD34)
+ public string AccessCode { get; set; } = "";
+
public string Address { get; set; } = "";
/// 접속할 때 자동으로 채워짐
public string Hostname { get; set; } = "";
@@ -73,10 +87,50 @@ namespace JJServiceManager
/// 접속 비밀번호 (Windows 사용자 계정으로 암호화, Base64)
public string ProtectedPassword { get; set; } = "";
+ /// Passive로 접속해 온 대상 PC의 IP (표시용, 자동)
+ public string LastAddress { get; set; } = "";
+
[JsonIgnore]
- public string Label => string.IsNullOrWhiteSpace(Hostname) ? Address : $"{Address} / {Hostname}";
+ public string Label
+ {
+ get
+ {
+ var addr = Direction == ConnectDirection.Passive
+ ? (string.IsNullOrWhiteSpace(LastAddress) ? "Passive " + AccessCode : LastAddress)
+ : Address;
+ return string.IsNullOrWhiteSpace(Hostname) ? addr : $"{addr} / {Hostname}";
+ }
+ }
public RemoteTarget Clone() => (RemoteTarget)MemberwiseClone();
+
+ /// 새 접속 코드 (헷갈리는 글자 0/O, 1/I 제외)
+ public static string NewAccessCode()
+ {
+ const string chars = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
+ var sb = new System.Text.StringBuilder();
+ for (int i = 0; i < 8; i++)
+ {
+ if (i == 4) sb.Append('-');
+ sb.Append(chars[System.Security.Cryptography.RandomNumberGenerator.GetInt32(chars.Length)]);
+ }
+ return sb.ToString();
+ }
+ }
+
+ /// 대상 PC 쪽: Passive로 접속할 관리 PC
+ public class PassiveManager
+ {
+ public string Id { get; set; } = Guid.NewGuid().ToString("N");
+ public string Address { get; set; } = "";
+ public RemoteMode Mode { get; set; } = RemoteMode.Port;
+ public int Port { get; set; } = 9990;
+ public string AccessCode { get; set; } = "";
+
+ [JsonIgnore]
+ public string Label => $"{Address} ({(Mode == RemoteMode.Ssh ? "SSH" : "지정 포트")} {Port}) · 코드 {AccessCode}";
+
+ public PassiveManager Clone() => (PassiveManager)MemberwiseClone();
}
/// 로그창 띄우기 방식
@@ -160,11 +214,36 @@ namespace JJServiceManager
[JsonIgnore]
public int RemoteServerPort => RemoteServerMode == RemoteMode.Ssh ? RemoteServerSshPort : RemoteServerTcpPort;
+ /// Active 수신: 이 PC가 포트를 열고 관리 PC의 접속을 받음
+ public bool RemoteServerActiveListen { get; set; } = true;
+
+ /// Passive 접속: 이 PC가 관리 PC로 접속
+ public List PassiveManagers { get; set; } = new List();
+
+ // ───────── 원격관리 (화면 보기 / 제어) ─────────
+
+ /// 원격에서 이 PC 화면 보기 허용
+ public bool RemoteAllowView { get; set; }
+
+ /// 원격에서 이 PC 키보드·마우스 제어 허용 (화면 보기가 켜져 있어야 함)
+ public bool RemoteAllowControl { get; set; }
+
+ /// 원격 화면 연결 시 이 PC에서 승인해야 함
+ public bool RemoteRequireApproval { get; set; } = true;
+
// ───────── 다른 PC 관리 (관리 역할) ─────────
public bool RemoteManageEnabled { get; set; }
public List Targets { get; set; } = new List();
+ /// Passive 대상 PC의 접속을 기다리는 방식/포트
+ public RemoteMode PassiveListenMode { get; set; } = RemoteMode.Port;
+ public int PassiveListenTcpPort { get; set; } = 9990 + AppInstance.Number - 1;
+ public int PassiveListenSshPort { get; set; } = 2224 + AppInstance.Number - 1;
+
+ [JsonIgnore]
+ public int PassiveListenPort => PassiveListenMode == RemoteMode.Ssh ? PassiveListenSshPort : PassiveListenTcpPort;
+
/// 초기화면에 열어둔 PC 탭 ("local" 또는 대상 Id)
public List OpenHostTabs { get; set; } = new List();
@@ -203,6 +282,7 @@ namespace JJServiceManager
{
s.Services ??= new List();
s.Targets ??= new List();
+ s.PassiveManagers ??= new List();
s.OpenHostTabs ??= new List();
return s;
}
diff --git a/Core/Remote/RemoteClient.cs b/Core/Remote/RemoteClient.cs
index e82d893..03c1e28 100644
--- a/Core/Remote/RemoteClient.cs
+++ b/Core/Remote/RemoteClient.cs
@@ -1,24 +1,22 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
-using System.Diagnostics;
using System.Linq;
-using System.Net.Security;
using System.Net.Sockets;
-using System.Security.Claims;
using System.Text.Json.Nodes;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.DevTunnels.Ssh;
-using SshClientTcp = Microsoft.DevTunnels.Ssh.Tcp.SshClient;
namespace JJServiceManager.Remote
{
public enum RemoteState { Connecting, Connected, Disconnected, AuthFailed }
///
- /// 관리 역할: 대상 PC 하나에 접속을 유지하며 서비스 목록을 주기적으로 받아옴.
- /// 끊기면 자동으로 다시 접속함.
+ /// 관리 역할: 대상 PC 하나와 연결을 유지하며 서비스 목록을 주기적으로 받아옴.
+ /// - Active: 이쪽에서 대상 PC로 접속 (끊기면 자동 재접속)
+ /// - Passive: 대상 PC가 접속해 오기를 기다림 (PassiveHub가 연결을 넘겨줌)
+ /// 연결 하나로 목록 / Start·Stop / 로그 / 원격 화면을 모두 주고받음.
///
public sealed class RemoteClient : IDisposable
{
@@ -26,9 +24,12 @@ namespace JJServiceManager.Remote
private static readonly TimeSpan PollInterval = TimeSpan.FromSeconds(2);
private readonly RemoteTarget _target;
+ private readonly Func _password;
private readonly CancellationTokenSource _cts = new CancellationTokenSource();
private readonly ConcurrentDictionary> _pending = new ConcurrentDictionary>();
private readonly ConcurrentDictionary _logs = new ConcurrentDictionary();
+ private readonly object _incomingLock = new object();
+ private TaskCompletionSource<(JsonLineConnection conn, string peer)> _incoming;
private JsonLineConnection _conn;
private int _nextId;
@@ -39,19 +40,26 @@ namespace JJServiceManager.Remote
}
public RemoteTarget Target => _target;
+ public bool IsPassive => _target.Direction == ConnectDirection.Passive;
public RemoteState State { get; private set; } = RemoteState.Connecting;
public string StateMessage { get; private set; } = "연결 중…";
public string Hostname { get; private set; } = "";
public List Services { get; private set; } = new List();
+ /// 대상 PC가 화면 보기 / 제어를 허용했는지 (목록 받을 때 갱신)
+ public bool AllowView { get; private set; }
+ public bool AllowControl { get; private set; }
+
/// 연결 상태가 바뀜 (백그라운드 스레드에서 발생)
public event Action StateChanged;
/// 서비스 목록을 새로 받음 (백그라운드 스레드에서 발생)
public event Action ServicesChanged;
/// 대상 PC의 hostname을 처음 알게 되었거나 바뀜
public event Action HostnameDiscovered;
-
- private readonly Func _password;
+ /// Passive: 대상 PC가 접속해 온 주소
+ public event Action PeerAddressDiscovered;
+ /// 원격 화면 이벤트 (frame / screenEnd)
+ public event Action ScreenEvent;
public RemoteClient(RemoteTarget target, Func passwordProvider = null)
{
@@ -67,21 +75,54 @@ namespace JJServiceManager.Remote
{
try { _cts.Cancel(); } catch { }
_conn?.Dispose();
+ lock (_incomingLock) _incoming?.TrySetCanceled();
}
- // ───────────────────────── 접속 유지 ─────────────────────────
+ /// Passive: 대상 PC가 접속해 온 연결을 넘겨받음. 받아들였으면 true
+ public bool Offer(JsonLineConnection conn, string peer)
+ {
+ lock (_incomingLock)
+ {
+ if (_incoming == null || _incoming.Task.IsCompleted) return false;
+ return _incoming.TrySetResult((conn, peer));
+ }
+ }
+
+ // ───────────────────────── 연결 유지 ─────────────────────────
private async Task RunAsync()
{
var ct = _cts.Token;
while (!ct.IsCancellationRequested)
{
- SetState(RemoteState.Connecting, "연결 중…");
TimeSpan retry = TimeSpan.FromSeconds(5);
try
{
- var password = _password();
- var (conn, hello) = await ConnectAsync(_target, password, ct).ConfigureAwait(false);
+ JsonLineConnection conn;
+ JsonObject hello;
+ if (IsPassive)
+ {
+ SetState(RemoteState.Connecting, $"대상 PC의 접속을 기다리는 중 (코드 {_target.AccessCode})");
+ TaskCompletionSource<(JsonLineConnection, string)> tcs;
+ lock (_incomingLock)
+ {
+ _incoming = new TaskCompletionSource<(JsonLineConnection, string)>(TaskCreationOptions.RunContinuationsAsynchronously);
+ tcs = _incoming;
+ }
+ using (ct.Register(() => tcs.TrySetCanceled()))
+ {
+ var (c, peer) = await tcs.Task.ConfigureAwait(false);
+ conn = c;
+ if (!string.IsNullOrEmpty(peer)) PeerAddressDiscovered?.Invoke(this, peer);
+ }
+ SetState(RemoteState.Connecting, "대상 PC가 접속함, 인증 중…");
+ hello = await HelloAsync(conn, _password(), sendPassword: true, ct).ConfigureAwait(false);
+ }
+ else
+ {
+ SetState(RemoteState.Connecting, "연결 중…");
+ (conn, hello) = await ConnectAsync(_target, _password(), ct).ConfigureAwait(false);
+ }
_conn = conn;
var host = Json.Str(hello, "hostname");
@@ -90,14 +131,18 @@ namespace JJServiceManager.Remote
Hostname = host;
HostnameDiscovered?.Invoke(this, host);
}
+ AllowView = Json.Bool(hello, "allowView");
+ AllowControl = Json.Bool(hello, "allowControl");
SetState(RemoteState.Connected, "연결됨");
var reader = Task.Run(() => ReadLoopAsync(conn, ct));
// 목록 주기적으로 받기
while (!ct.IsCancellationRequested && !reader.IsCompleted)
{
- var res = await RequestAsync("list", null, TimeSpan.FromSeconds(15)).ConfigureAwait(false);
+ var res = await RequestAsync("list", null, TimeSpan.FromSeconds(20)).ConfigureAwait(false);
Services = ParseServices(res);
+ AllowView = Json.Bool(res, "allowView");
+ AllowControl = Json.Bool(res, "allowControl");
ServicesChanged?.Invoke(this);
await Task.WhenAny(reader, Task.Delay(PollInterval, ct)).ConfigureAwait(false);
}
@@ -111,11 +156,12 @@ namespace JJServiceManager.Remote
catch (RemoteAuthException ex)
{
SetState(RemoteState.AuthFailed, ex.Message);
- retry = TimeSpan.FromSeconds(30);
+ retry = IsPassive ? TimeSpan.FromSeconds(1) : TimeSpan.FromSeconds(30);
}
catch (Exception ex)
{
SetState(RemoteState.Disconnected, Friendly(ex));
+ if (IsPassive) retry = TimeSpan.FromSeconds(1);
}
finally
{
@@ -124,6 +170,7 @@ namespace JJServiceManager.Remote
c?.Dispose();
FailPending("연결이 끊겼습니다.");
_logs.Clear();
+ ScreenEvent?.Invoke(new JsonObject { ["event"] = "screenEnd", ["sid"] = 0, ["reason"] = "대상 PC와 연결이 끊겼습니다." });
}
try { await Task.Delay(retry, ct).ConfigureAwait(false); }
@@ -141,6 +188,11 @@ namespace JJServiceManager.Remote
if (msg.ContainsKey("event"))
{
var ev = Json.Str(msg, "event");
+ if (ev == "frame" || ev == "screenEnd")
+ {
+ ScreenEvent?.Invoke(msg);
+ continue;
+ }
int sid = Json.Int(msg, "stream");
if (!_logs.TryGetValue(sid, out var sub)) continue;
if (ev == "log")
@@ -215,6 +267,15 @@ namespace JJServiceManager.Remote
return reply["result"] as JsonObject ?? new JsonObject();
}
+ /// 응답을 기다리지 않고 보냄 (원격 화면 입력)
+ public void SendNoReply(JsonObject msg)
+ {
+ var conn = _conn;
+ if (conn == null || State != RemoteState.Connected) return;
+ msg["id"] = 0;
+ _ = conn.SendAsync(msg).ContinueWith(t => { _ = t.Exception; }, TaskScheduler.Default);
+ }
+
public Task StartServiceAsync(string service)
=> RequestAsync("start", new JsonObject { ["service"] = service }, TimeSpan.FromSeconds(90));
@@ -246,72 +307,35 @@ namespace JJServiceManager.Remote
// ───────────────────────── 연결 ─────────────────────────
- /// 접속 + 인증 + hello. 성공하면 연결과 hello 결과
+ /// Active: 접속 + 인증 + hello. 성공하면 연결과 hello 결과
public static async Task<(JsonLineConnection conn, JsonObject hello)> ConnectAsync(RemoteTarget t, string password, CancellationToken ct)
{
if (string.IsNullOrWhiteSpace(t.Address)) throw new RemoteException("대상 IP가 비어 있습니다.");
if (string.IsNullOrEmpty(password)) throw new RemoteAuthException("접속 비밀번호가 저장되어 있지 않습니다.");
+ JsonLineConnection conn;
+ using (var timeout = CancellationTokenSource.CreateLinkedTokenSource(ct))
+ {
+ timeout.CancelAfter(ConnectTimeout);
+ try
+ {
+ conn = await RemoteDialer.DialAsync(t.Mode, t.Address, t.Port, RemoteServer.SshUser, password, timeout.Token).ConfigureAwait(false);
+ }
+ catch (OperationCanceledException) when (!ct.IsCancellationRequested)
+ {
+ throw new RemoteException("연결 시간이 초과되었습니다. (IP, 포트, 방화벽, 원격 접속 허용 여부 확인)");
+ }
+ }
+ // SSH는 SSH 인증에서 비밀번호를 확인했으므로 hello에는 보내지 않음
+ var hello = await HelloAsync(conn, password, sendPassword: t.Mode == RemoteMode.Port, ct).ConfigureAwait(false);
+ return (conn, hello);
+ }
+
+ /// 연결 위에서 hello (비밀번호 확인). 실패하면 연결을 닫고 예외
+ public static async Task HelloAsync(JsonLineConnection conn, string password, bool sendPassword, CancellationToken ct)
+ {
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(ct);
timeout.CancelAfter(ConnectTimeout);
- var tok = timeout.Token;
-
- JsonLineConnection conn;
- bool sendPassword;
-
- try
- {
- if (t.Mode == RemoteMode.Port)
- {
- var tcp = new TcpClient { NoDelay = true };
- try
- {
- await tcp.ConnectAsync(t.Address.Trim(), t.Port, tok).ConfigureAwait(false);
- var ssl = new SslStream(tcp.GetStream(), false); // 암호화만, 서버 지문 확인 안 함 (아래 옵션)
- await ssl.AuthenticateAsClientAsync(new SslClientAuthenticationOptions
- {
- TargetHost = "JJServiceManager",
- RemoteCertificateValidationCallback = (a, b, c, d) => true
- }, tok).ConfigureAwait(false);
- conn = new JsonLineConnection(ssl, tcp);
- }
- catch
- {
- tcp.Dispose();
- throw;
- }
- sendPassword = true;
- }
- else
- {
- var client = new SshClientTcp(SshSessionConfiguration.Default, new TraceSource("JJServiceManager.SshClient"));
- try
- {
- var session = await client.OpenSessionAsync(t.Address.Trim(), t.Port, tok).ConfigureAwait(false);
- // 서버 지문 확인 안 함 (설정: 확인 없이 암호화만)
- session.Authenticating += (s, e) => e.AuthenticationTask = Task.FromResult(new ClaimsPrincipal());
- bool ok = await session.AuthenticateAsync(new SshClientCredentials(RemoteServer.SshUser, password), tok).ConfigureAwait(false);
- if (!ok)
- {
- session.Dispose();
- throw new RemoteAuthException("접속 비밀번호가 맞지 않습니다.");
- }
- var channel = await session.OpenChannelAsync(tok).ConfigureAwait(false);
- conn = new JsonLineConnection(new SshStream(channel), session, client);
- }
- catch
- {
- client.Dispose();
- throw;
- }
- sendPassword = false;
- }
- }
- catch (OperationCanceledException) when (!ct.IsCancellationRequested)
- {
- throw new RemoteException("연결 시간이 초과되었습니다. (IP, 포트, 방화벽, 원격 접속 허용 여부 확인)");
- }
-
try
{
var hello = new JsonObject
@@ -322,9 +346,9 @@ namespace JJServiceManager.Remote
["version"] = RemoteServer.Version
};
if (sendPassword) hello["password"] = password;
- await conn.SendAsync(hello, tok).ConfigureAwait(false);
+ await conn.SendAsync(hello, timeout.Token).ConfigureAwait(false);
- var reply = await conn.ReadAsync(tok).ConfigureAwait(false);
+ var reply = await conn.ReadAsync(timeout.Token).ConfigureAwait(false);
if (reply == null) throw new RemoteException("대상 PC가 연결을 끊었습니다.");
if (!Json.Bool(reply, "ok"))
{
@@ -332,7 +356,7 @@ namespace JJServiceManager.Remote
if (Json.Bool(reply, "authFailed")) throw new RemoteAuthException(err);
throw new RemoteException(err);
}
- return (conn, reply["result"] as JsonObject ?? new JsonObject());
+ return reply["result"] as JsonObject ?? new JsonObject();
}
catch (OperationCanceledException) when (!ct.IsCancellationRequested)
{
@@ -380,12 +404,14 @@ namespace JJServiceManager.Remote
return ex switch
{
RemoteException re => re.Message,
+ RemoteAuthException ra => ra.Message,
SocketException se when se.SocketErrorCode == SocketError.ConnectionRefused
- => "연결이 거부되었습니다. (대상 PC에서 원격 접속 허용이 꺼져 있거나 포트가 다름)",
+ => "연결이 거부되었습니다. (상대 PC에서 기능이 꺼져 있거나 포트가 다름)",
SocketException se when se.SocketErrorCode == SocketError.HostNotFound
- => "대상 PC를 찾을 수 없습니다. (IP 확인)",
+ => "상대 PC를 찾을 수 없습니다. (주소 확인)",
SocketException se when se.SocketErrorCode == SocketError.TimedOut || se.SocketErrorCode == SocketError.HostUnreachable || se.SocketErrorCode == SocketError.NetworkUnreachable
- => "대상 PC에 닿지 않습니다. (네트워크, 방화벽 확인)",
+ => "상대 PC에 닿지 않습니다. (네트워크, 방화벽 확인)",
+ OperationCanceledException => "연결 시간이 초과되었습니다. (주소, 포트, 방화벽 확인)",
System.Security.Authentication.AuthenticationException
=> "암호화 연결에 실패했습니다. (방식(SSH/지정 포트)이 맞는지 확인)",
SshConnectionException
@@ -402,6 +428,104 @@ namespace JJServiceManager.Remote
public RemoteAuthException(string message) : base(message) { }
}
+ ///
+ /// 관리 PC: Passive 대상 PC들의 접속을 받는 곳.
+ /// 접속해 온 연결의 첫 메시지(announce)의 접속 코드로 어느 대상인지 찾아 RemoteClient에 넘김.
+ ///
+ public sealed class PassiveHub : IDisposable
+ {
+ private readonly ConcurrentDictionary _byCode = new ConcurrentDictionary(StringComparer.OrdinalIgnoreCase);
+ private readonly Action _log;
+ private RemoteListener _listener;
+ private string _key = "";
+
+ public PassiveHub(Action log) => _log = log;
+
+ public bool IsRunning => _listener != null;
+
+ public void Register(RemoteClient c)
+ {
+ if (!string.IsNullOrWhiteSpace(c.Target.AccessCode)) _byCode[c.Target.AccessCode.Trim()] = c;
+ }
+
+ public void Clear() => _byCode.Clear();
+
+ /// 수신 시작/중지 (방식·포트가 같으면 그대로)
+ public void Apply(bool enabled, RemoteMode mode, int port)
+ {
+ var key = enabled ? $"{mode}|{port}" : "off";
+ if (key == _key) return;
+ _key = key;
+ _listener?.Dispose();
+ _listener = null;
+ if (!enabled)
+ {
+ _log("Passive 수신 중지", LogLevel.Info);
+ return;
+ }
+ try
+ {
+ _listener = RemoteListener.Start(mode, port, CheckSshAsync, OnConnectionAsync);
+ _listener.Failed += msg => _log("Passive 수신 중단: " + msg, LogLevel.Error);
+ _log($"Passive 수신 시작: {(mode == RemoteMode.Ssh ? "SSH" : "지정 포트(TLS)")} {port}", LogLevel.Success);
+ }
+ catch (Exception ex)
+ {
+ _key = "";
+ var msg = ex is SocketException se && se.SocketErrorCode == SocketError.AddressAlreadyInUse
+ ? $"포트 {port}를 다른 프로그램이 사용 중입니다."
+ : ex.Message;
+ _log($"Passive 수신 시작 실패 (포트 {port}): {msg}", LogLevel.Error);
+ }
+ }
+
+ private Task CheckSshAsync(string user, string password)
+ => Task.FromResult(user == RemoteServer.PassiveSshUser && !string.IsNullOrEmpty(password) && _byCode.ContainsKey(password.Trim()));
+
+ private async Task OnConnectionAsync(JsonLineConnection conn, string peer, CancellationToken ct)
+ {
+ try
+ {
+ JsonObject first;
+ using (var timeout = CancellationTokenSource.CreateLinkedTokenSource(ct))
+ {
+ timeout.CancelAfter(TimeSpan.FromSeconds(10));
+ first = await conn.ReadAsync(timeout.Token).ConfigureAwait(false);
+ }
+ if (first == null || Json.Str(first, "op") != "announce")
+ {
+ conn.Dispose();
+ return;
+ }
+ var code = Json.Str(first, "code").Trim();
+ var host = Json.Str(first, "hostname");
+ if (!_byCode.TryGetValue(code, out var client))
+ {
+ _log($"Passive 접속 거부: 등록되지 않은 접속 코드 ({host} {peer})", LogLevel.Warning);
+ conn.Dispose();
+ return;
+ }
+ var addr = peer == "SSH" ? host : peer;
+ if (!client.Offer(conn, addr))
+ {
+ // 이미 연결되어 있음
+ conn.Dispose();
+ }
+ }
+ catch
+ {
+ conn.Dispose();
+ }
+ }
+
+ public void Dispose()
+ {
+ _listener?.Dispose();
+ _listener = null;
+ _key = "";
+ }
+ }
+
/// 원격 서비스 로그 (LogTailView에서 로컬 파일과 같은 방식으로 사용)
public sealed class RemoteLogSource : ILogSource
{
diff --git a/Core/Remote/RemoteCommon.cs b/Core/Remote/RemoteCommon.cs
index 997ac22..3094922 100644
--- a/Core/Remote/RemoteCommon.cs
+++ b/Core/Remote/RemoteCommon.cs
@@ -72,7 +72,7 @@ namespace JJServiceManager.Remote
///
public sealed class JsonLineConnection : IDisposable
{
- private const int MaxLineChars = 4 * 1024 * 1024;
+ private const int MaxLineChars = 16 * 1024 * 1024;
private readonly Stream _stream;
private readonly StreamReader _reader;
@@ -89,32 +89,45 @@ namespace JJServiceManager.Remote
public bool IsDisposed => Volatile.Read(ref _disposed) == 1;
- /// 다음 메시지. 연결이 끊기면 null
+ private readonly char[] _buf = new char[64 * 1024];
+ private int _bufPos, _bufLen;
+ private readonly StringBuilder _line = new StringBuilder();
+
+ /// 다음 메시지. 연결이 끊기면 null (한 번에 한 곳에서만 호출)
public async Task ReadAsync(CancellationToken ct)
{
- var sb = new StringBuilder();
- var buf = new char[1];
+ _line.Clear();
while (true)
{
- int n = await _reader.ReadAsync(buf.AsMemory(0, 1), ct).ConfigureAwait(false);
- if (n == 0) return null;
- char c = buf[0];
- if (c == '\n')
+ if (_bufPos >= _bufLen)
{
- var line = sb.ToString().Trim();
- sb.Clear();
- if (line.Length == 0) continue;
- try
- {
- return JsonNode.Parse(line) as JsonObject ?? new JsonObject();
- }
- catch (JsonException)
- {
- throw new IOException("잘못된 메시지를 받았습니다.");
- }
+ _bufLen = await _reader.ReadAsync(_buf.AsMemory(), ct).ConfigureAwait(false);
+ _bufPos = 0;
+ if (_bufLen <= 0) return null;
+ }
+
+ int idx = Array.IndexOf(_buf, '\n', _bufPos, _bufLen - _bufPos);
+ if (idx < 0)
+ {
+ _line.Append(_buf, _bufPos, _bufLen - _bufPos);
+ _bufPos = _bufLen;
+ if (_line.Length > MaxLineChars) throw new IOException("메시지가 너무 큽니다.");
+ continue;
+ }
+
+ _line.Append(_buf, _bufPos, idx - _bufPos);
+ _bufPos = idx + 1;
+ var text = _line.ToString().Trim();
+ _line.Clear();
+ if (text.Length == 0) continue;
+ try
+ {
+ return JsonNode.Parse(text) as JsonObject ?? new JsonObject();
+ }
+ catch (JsonException)
+ {
+ throw new IOException("잘못된 메시지를 받았습니다.");
}
- sb.Append(c);
- if (sb.Length > MaxLineChars) throw new IOException("메시지가 너무 큽니다.");
}
}
@@ -187,54 +200,28 @@ namespace JJServiceManager.Remote
}
///
- /// Windows 방화벽 규칙 (원격 접속 허용 포트)
- /// 규칙 이름: "JJServiceManager Remote" (+ 테스트 인스턴스 꼬리표)
+ /// Windows 방화벽 규칙
+ /// - "JJServiceManager Remote" : 대상 PC의 Active 수신 포트
+ /// - "JJServiceManager Passive" : 관리 PC의 Passive 수신 포트
+ /// (+ 테스트 인스턴스 꼬리표)
///
public static class FirewallManager
{
- private static string RuleName => "JJServiceManager Remote" + AppInstance.Suffix;
+ public const string ActiveRule = "JJServiceManager Remote";
+ public const string PassiveRule = "JJServiceManager Passive";
/// enabled면 해당 포트를 열고, 아니면 규칙을 지움
- public static (bool ok, string message) Apply(bool enabled, int port)
+ public static (bool ok, string message) Apply(string rule, bool enabled, int port)
{
+ var name = rule + AppInstance.Suffix;
// 기존 규칙은 지우고 다시 만듦 (포트가 바뀌었을 수 있음)
- Run($"advfirewall firewall delete rule name=\"{RuleName}\"");
- if (!enabled) return (true, "방화벽 규칙 삭제");
+ ConsoleRunner.Run("netsh.exe", $"advfirewall firewall delete rule name=\"{name}\"");
+ if (!enabled) return (true, $"방화벽 규칙 삭제 ({name})");
- var r = Run($"advfirewall firewall add rule name=\"{RuleName}\" dir=in action=allow protocol=TCP localport={port} profile=any");
- return r.exit == 0
- ? (true, $"방화벽 TCP {port} 허용")
- : (false, "방화벽 규칙 추가 실패: " + r.output);
- }
-
- private static (int exit, string output) Run(string args)
- {
- try
- {
- var psi = new ProcessStartInfo("netsh.exe", args)
- {
- UseShellExecute = false,
- CreateNoWindow = true,
- RedirectStandardOutput = true,
- RedirectStandardError = true,
- StandardOutputEncoding = OemEncoding(),
- StandardErrorEncoding = OemEncoding()
- };
- using var p = Process.Start(psi);
- var o = p.StandardOutput.ReadToEnd() + p.StandardError.ReadToEnd();
- p.WaitForExit(15000);
- return (p.ExitCode, o.Trim());
- }
- catch (Exception ex)
- {
- return (-1, ex.Message);
- }
- }
-
- private static Encoding OemEncoding()
- {
- try { return Encoding.GetEncoding(System.Globalization.CultureInfo.CurrentCulture.TextInfo.OEMCodePage); }
- catch { return Encoding.UTF8; }
+ var r = ConsoleRunner.Run("netsh.exe", $"advfirewall firewall add rule name=\"{name}\" dir=in action=allow protocol=TCP localport={port} profile=any");
+ return r.exitCode == 0
+ ? (true, $"방화벽 TCP {port} 허용 ({name})")
+ : (false, $"방화벽 규칙 추가 실패 ({name}): {r.output}");
}
}
diff --git a/Core/Remote/RemoteServer.cs b/Core/Remote/RemoteServer.cs
index 376c6ed..22ef654 100644
--- a/Core/Remote/RemoteServer.cs
+++ b/Core/Remote/RemoteServer.cs
@@ -1,60 +1,74 @@
using System;
+using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
-using System.Net;
-using System.Net.Security;
-using System.Net.Sockets;
-using System.Security.Claims;
-using System.Security.Cryptography;
-using System.Security.Cryptography.X509Certificates;
using System.Text.Json.Nodes;
using System.Threading;
using System.Threading.Tasks;
-using Microsoft.DevTunnels.Ssh;
-using Microsoft.DevTunnels.Ssh.Algorithms;
-using Microsoft.DevTunnels.Ssh.Events;
-using Microsoft.DevTunnels.Ssh.Messages;
-using SshServerTcp = Microsoft.DevTunnels.Ssh.Tcp.SshServer;
namespace JJServiceManager.Remote
{
+ /// 원격관리(화면) 허용 설정
+ public readonly record struct ScreenPolicy(bool AllowView, bool AllowControl, bool RequireApproval);
+
+ /// 지금 이 PC 화면을 보고 있는 관리 PC
+ public readonly record struct ScreenViewer(int Id, string Who, bool Control);
+
///
/// 원격 접속 허용 (대상 역할).
- /// 관리 PC의 접속을 받아, 이 PC에 "등록된 서비스"에 한해서만
- /// 목록 / 상태 / Start / Stop / 상세 / 로그 보기를 처리함.
+ /// - Active: 포트를 열고 관리 PC의 접속을 받음
+ /// - Passive: 관리 PC로 접속해 감 (접속 코드)
+ /// 연결된 뒤에는 똑같이, 이 PC에 "등록된 서비스"에 한해서만
+ /// 목록 / 상태 / Start / Stop / 상세 / 로그 보기를 처리하고,
+ /// 허용된 경우 화면 보기 / 키보드·마우스 제어를 처리함.
///
public sealed class RemoteServer : IDisposable
{
- public const string Version = "1.1";
+ public const string Version = "1.2";
public const string SshUser = "jjsm";
+ public const string PassiveSshUser = "jjsm-passive";
private static readonly TimeSpan OpTimeout = TimeSpan.FromSeconds(60);
private readonly Func> _services;
private readonly Action _log;
private readonly Action _changed;
+ private readonly Func _policy;
+ private readonly Func> _approve;
private readonly object _sync = new object();
- private CancellationTokenSource _cts;
- private TcpListener _listener;
- private X509Certificate2 _cert;
- private SshServerTcp _ssh;
+ private RemoteListener _listener;
private string _hash = "", _salt = "";
private readonly Queue _failures = new Queue();
- public bool IsRunning { get; private set; }
+ // Passive
+ private CancellationTokenSource _passiveCts;
+
+ // 화면 보기 중인 세션
+ private readonly ConcurrentDictionary _screens = new ConcurrentDictionary();
+ private int _nextScreenId;
+
+ public bool IsRunning => _listener != null;
public RemoteMode Mode { get; private set; }
public int Port { get; private set; }
+ /// 화면을 보는 관리 PC 목록이 바뀜 (백그라운드 스레드)
+ public event Action> ScreenViewersChanged;
+
/// 등록된 서비스 목록 (스레드 안전한 복사본을 돌려줘야 함)
/// 작업 로그 기록
/// 원격에서 Start/Stop 했을 때 (상태 갱신용)
- public RemoteServer(Func> services, Action log, Action changed)
+ /// 원격관리 허용 설정
+ /// 화면 연결 승인 요청 (누가, 제어 여부) → 허용 여부
+ public RemoteServer(Func> services, Action log, Action changed,
+ Func policy, Func> approve)
{
_services = services;
_log = log;
_changed = changed;
+ _policy = policy;
+ _approve = approve;
}
/// 비밀번호만 바뀐 경우 (재시작 없이)
@@ -67,162 +81,128 @@ namespace JJServiceManager.Remote
}
}
+ // ───────────────────────── Active: 접속 받기 ─────────────────────────
+
public void Start(RemoteMode mode, int port, string hash, string salt)
{
Stop();
UpdatePassword(hash, salt);
Mode = mode;
Port = port;
- _cts = new CancellationTokenSource();
-
try
{
- if (mode == RemoteMode.Port) StartTls(port, _cts.Token);
- else StartSsh(port);
- IsRunning = true;
- _log($"원격 접속 허용 시작: {(mode == RemoteMode.Ssh ? "SSH" : "지정 포트(TLS)")} {port}", LogLevel.Success);
+ _listener = RemoteListener.Start(mode, port, CheckSshPasswordAsync,
+ (conn, peer, ct) => ServeAsync(conn, peer, requireHelloPassword: mode == RemoteMode.Port, ct));
+ _listener.Failed += msg =>
+ {
+ _log($"원격 접속 허용(SSH) 중단: {msg}", LogLevel.Error);
+ _listener?.Dispose();
+ _listener = null;
+ };
+ _log($"원격 접속 허용 시작 (Active 수신): {(mode == RemoteMode.Ssh ? "SSH" : "지정 포트(TLS)")} {port}", LogLevel.Success);
}
catch (Exception ex)
{
- IsRunning = false;
- _log($"원격 접속 허용 시작 실패 (포트 {port}): {ex.Message}", LogLevel.Error);
- Stop();
+ _listener = null;
+ var msg = ex is System.Net.Sockets.SocketException se && se.SocketErrorCode == System.Net.Sockets.SocketError.AddressAlreadyInUse
+ ? $"포트 {port}를 다른 프로그램이 사용 중입니다."
+ : ex.Message;
+ _log($"원격 접속 허용 시작 실패 (포트 {port}): {msg}", LogLevel.Error);
}
}
public void Stop()
{
- bool wasRunning = IsRunning;
- IsRunning = false;
- try { _cts?.Cancel(); } catch { }
- try { _listener?.Stop(); } catch { }
- try { _ssh?.Dispose(); } catch { }
+ if (_listener == null) return;
+ _listener.Dispose();
_listener = null;
- _ssh = null;
- _cts = null;
- if (wasRunning) _log("원격 접속 허용 중지", LogLevel.Info);
+ _log("원격 접속 허용 (Active 수신) 중지", LogLevel.Info);
}
- public void Dispose() => Stop();
-
- // ───────────────────────── 지정 포트 + TLS ─────────────────────────
-
- private void StartTls(int port, CancellationToken ct)
+ public void Dispose()
{
- _cert ??= CreateCertificate();
- _listener = new TcpListener(IPAddress.Any, port);
- _listener.Start();
- var listener = _listener;
- _ = Task.Run(async () =>
- {
- while (!ct.IsCancellationRequested)
- {
- TcpClient tcp;
- try { tcp = await listener.AcceptTcpClientAsync(ct).ConfigureAwait(false); }
- catch { break; }
- _ = Task.Run(() => HandleTcpAsync(tcp, ct));
- }
- });
+ Stop();
+ StopPassive();
+ StopAllScreens();
}
- private async Task HandleTcpAsync(TcpClient tcp, CancellationToken ct)
+ // ───────────────────────── Passive: 관리 PC로 접속 ─────────────────────────
+
+ /// Passive 접속 목록 적용 (빈 목록이면 중지)
+ public void StartPassive(IReadOnlyList managers)
{
- string peer = (tcp.Client.RemoteEndPoint as IPEndPoint)?.Address.ToString() ?? "?";
- try
+ StopPassive();
+ if (managers == null || managers.Count == 0) return;
+ _passiveCts = new CancellationTokenSource();
+ foreach (var m in managers) _ = Task.Run(() => PassiveLoopAsync(m.Clone(), _passiveCts.Token));
+ }
+
+ public void StopPassive()
+ {
+ try { _passiveCts?.Cancel(); } catch { }
+ _passiveCts = null;
+ }
+
+ private async Task PassiveLoopAsync(PassiveManager m, CancellationToken ct)
+ {
+ string where = $"{m.Address}:{m.Port}";
+ string lastProblem = null;
+ while (!ct.IsCancellationRequested)
{
- tcp.NoDelay = true;
- var ssl = new SslStream(tcp.GetStream(), false);
- using (var hs = CancellationTokenSource.CreateLinkedTokenSource(ct))
+ var retry = TimeSpan.FromSeconds(5);
+ try
{
- hs.CancelAfter(TimeSpan.FromSeconds(15));
- await ssl.AuthenticateAsServerAsync(new SslServerAuthenticationOptions
+ JsonLineConnection conn;
+ using (var timeout = CancellationTokenSource.CreateLinkedTokenSource(ct))
{
- ServerCertificate = _cert,
- ClientCertificateRequired = false
- }, hs.Token).ConfigureAwait(false);
- }
- var conn = new JsonLineConnection(ssl, tcp);
- await ServeAsync(conn, peer, requireHelloPassword: true, ct).ConfigureAwait(false);
- }
- catch
- {
- try { tcp.Dispose(); } catch { }
- }
- }
-
- /// TLS용 자체 서명 인증서 (실행할 때마다 새로 만듦, 지문 확인은 하지 않는 설정)
- private static X509Certificate2 CreateCertificate()
- {
- using var rsa = RSA.Create(2048);
- var req = new CertificateRequest("CN=JJServiceManager", rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
- req.CertificateExtensions.Add(new X509KeyUsageExtension(X509KeyUsageFlags.DigitalSignature | X509KeyUsageFlags.KeyEncipherment, false));
- req.CertificateExtensions.Add(new X509EnhancedKeyUsageExtension(new OidCollection { new Oid("1.3.6.1.5.5.7.3.1") }, false));
- using var cert = req.CreateSelfSigned(DateTimeOffset.UtcNow.AddDays(-1), DateTimeOffset.UtcNow.AddYears(10));
- // Windows(SChannel)에서 개인 키를 쓰려면 PFX로 한 번 내보냈다가 다시 읽어야 함
- return new X509Certificate2(cert.Export(X509ContentType.Pfx), (string)null, X509KeyStorageFlags.Exportable);
- }
-
- // ───────────────────────── 내장 SSH ─────────────────────────
-
- private void StartSsh(int port)
- {
- var hostKey = SshAlgorithms.PublicKey.ECDsaSha2Nistp384.GenerateKeyPair();
- var ssh = new SshServerTcp(SshSessionConfiguration.Default, new TraceSource("JJServiceManager.SshServer"));
- ssh.Credentials = new SshServerCredentials(new[] { hostKey });
-
- ssh.SessionAuthenticating += (s, e) =>
- {
- if (e.AuthenticationType == SshAuthenticationType.ClientPassword)
- e.AuthenticationTask = CheckSshPasswordAsync(e.Username, e.Password);
- // 그 밖의 인증 방식(공개 키 등)은 허용하지 않음
- };
-
- ssh.SessionOpened += (s, session) =>
- {
- session.ChannelOpening += (s2, e) =>
- {
- if (!e.IsRemoteRequest) return;
- if (session.Principal == null)
- {
- e.FailureReason = SshChannelOpenFailureReason.AdministrativelyProhibited;
- e.FailureDescription = "인증되지 않았습니다.";
- return;
+ timeout.CancelAfter(TimeSpan.FromSeconds(10));
+ conn = await RemoteDialer.DialAsync(m.Mode, m.Address, m.Port, PassiveSshUser, m.AccessCode, timeout.Token).ConfigureAwait(false);
+ await conn.SendAsync(new JsonObject
+ {
+ ["op"] = "announce",
+ ["code"] = m.AccessCode,
+ ["hostname"] = Environment.MachineName,
+ ["version"] = Version
+ }, timeout.Token).ConfigureAwait(false);
}
- var channel = e.Channel;
- var token = _cts?.Token ?? CancellationToken.None;
- _ = Task.Run(() => ServeAsync(new JsonLineConnection(new SshStream(channel)), "SSH", requireHelloPassword: false, token));
- };
- };
-
- ssh.ExceptionRaised += (s, ex) =>
- {
- if (ex is SocketException se && se.SocketErrorCode == SocketError.AddressAlreadyInUse)
- _log($"원격 접속 허용 실패: 포트 {port}를 다른 프로그램이 사용 중입니다.", LogLevel.Error);
- };
-
- _ssh = ssh;
- _ = ssh.AcceptSessionsAsync(port, IPAddress.Any).ContinueWith(t =>
- {
- if (t.IsFaulted && IsRunning)
- {
- IsRunning = false;
- _log($"원격 접속 허용(SSH) 중단: {t.Exception?.GetBaseException().Message}", LogLevel.Error);
+ _log($"Passive 연결됨: 관리 PC {where}", LogLevel.Success);
+ lastProblem = null;
+ await ServeAsync(conn, $"{m.Address}(Passive)", requireHelloPassword: true, ct).ConfigureAwait(false);
+ if (!ct.IsCancellationRequested) _log($"Passive 연결 끊김: 관리 PC {where}", LogLevel.Warning);
+ }
+ catch (Exception ex) when (!ct.IsCancellationRequested)
+ {
+ if (ex is RemoteAuthException) retry = TimeSpan.FromSeconds(30);
+ var msg = RemoteClient.Friendly(ex);
+ if (msg != lastProblem)
+ {
+ _log($"Passive 접속 실패 (관리 PC {where}): {msg}", LogLevel.Warning);
+ lastProblem = msg;
+ }
+ }
+ catch
+ {
+ break;
}
- }, TaskScheduler.Default);
- }
- private async Task CheckSshPasswordAsync(string user, string password)
- {
- if (IsThrottled()) return null;
- if (CheckPassword(password)) return new ClaimsPrincipal(new ClaimsIdentity("password"));
- RecordFailure();
- await Task.Delay(1000).ConfigureAwait(false);
- _log("원격 접속 거부 (SSH): 비밀번호가 맞지 않습니다.", LogLevel.Warning);
- return null;
+ try { await Task.Delay(retry, ct).ConfigureAwait(false); }
+ catch { break; }
+ }
}
// ───────────────────────── 인증 ─────────────────────────
+ private async Task CheckSshPasswordAsync(string user, string password)
+ {
+ if (user != SshUser) return false;
+ if (IsThrottled()) return false;
+ if (CheckPassword(password)) return true;
+ RecordFailure();
+ await Task.Delay(1000).ConfigureAwait(false);
+ _log("원격 접속 거부 (SSH): 비밀번호가 맞지 않습니다.", LogLevel.Warning);
+ return false;
+ }
+
private bool CheckPassword(string password)
{
string h, s;
@@ -253,6 +233,7 @@ namespace JJServiceManager.Remote
string who = peer;
var streams = new Dictionary();
int nextStream = 1;
+ ScreenSession screen = null;
try
{
@@ -286,11 +267,14 @@ namespace JJServiceManager.Remote
authed = true;
}
_log($"원격 접속: {who}", LogLevel.Notice);
+ var pol = _policy();
await Reply(conn, id, result: new JsonObject
{
["hostname"] = Environment.MachineName,
["version"] = Version,
- ["instance"] = AppInstance.Number
+ ["instance"] = AppInstance.Number,
+ ["allowView"] = pol.AllowView,
+ ["allowControl"] = pol.AllowView && pol.AllowControl
});
continue;
}
@@ -375,6 +359,102 @@ namespace JJServiceManager.Remote
break;
}
+ // ───── 원격관리: 화면 ─────
+
+ case "screenInfo":
+ {
+ var pol = _policy();
+ var arr = new JsonArray();
+ foreach (var m in ScreenCapture.Monitors())
+ arr.Add(new JsonObject { ["index"] = m.Index, ["width"] = m.Width, ["height"] = m.Height, ["primary"] = m.Primary });
+ await Reply(conn, id, result: new JsonObject
+ {
+ ["allowView"] = pol.AllowView,
+ ["allowControl"] = pol.AllowView && pol.AllowControl,
+ ["monitors"] = arr
+ });
+ break;
+ }
+
+ case "screenStart":
+ {
+ var whoNow = who;
+ int monitor = Json.Int(msg, "monitor", 1);
+ // 승인 창을 기다리는 동안 다른 요청은 계속 처리되도록 따로 실행
+ _ = Task.Run(async () =>
+ {
+ try
+ {
+ var pol = _policy();
+ if (!pol.AllowView) throw new RemoteException("대상 PC에서 화면 보기를 허용하지 않았습니다.");
+ bool control = pol.AllowControl;
+ if (pol.RequireApproval)
+ {
+ _log($"[원격 화면] {whoNow} 연결 승인 요청", LogLevel.Notice);
+ bool ok = await _approve(whoNow, control).ConfigureAwait(false);
+ if (!ok)
+ {
+ _log($"[원격 화면] {whoNow} 연결 거부", LogLevel.Warning);
+ throw new RemoteException("대상 PC에서 연결을 거부했습니다.");
+ }
+ }
+ var old = Interlocked.Exchange(ref screen, null);
+ old?.Stop();
+ var s = new ScreenSession(Interlocked.Increment(ref _nextScreenId), whoNow, control, monitor);
+ screen = s;
+ _screens[s.Id] = s;
+ _log($"[원격 화면] {whoNow} {(control ? "보기 + 제어" : "보기")} 시작", LogLevel.Notice);
+ NotifyViewers();
+ await Reply(conn, id, result: new JsonObject { ["sid"] = s.Id, ["control"] = control });
+ _ = Task.Run(() => ScreenLoopAsync(conn, s, ct));
+ }
+ catch (RemoteException ex) { await Reply(conn, id, error: ex.Message); }
+ catch (Exception ex) { await Reply(conn, id, error: ex.Message); }
+ });
+ break;
+ }
+
+ case "screenMonitor":
+ {
+ var s = screen;
+ if (s != null) s.Monitor = Json.Int(msg, "monitor", 1);
+ await Reply(conn, id, result: new JsonObject());
+ break;
+ }
+
+ case "screenStop":
+ {
+ var s = Interlocked.Exchange(ref screen, null);
+ s?.Stop();
+ await Reply(conn, id, result: new JsonObject());
+ break;
+ }
+
+ case "input":
+ {
+ // 응답 없이 바로 실행 (빠르게)
+ var s = screen;
+ if (s == null || s.Stopped || !s.Control) break;
+ var pol = _policy();
+ if (!pol.AllowView || !pol.AllowControl) break;
+ try
+ {
+ if (Json.Str(msg, "kind") == "key")
+ {
+ InputInjector.Key(Json.Int(msg, "vk"), Json.Bool(msg, "down"));
+ }
+ else
+ {
+ double nx = 0, ny = 0;
+ try { nx = msg["x"]?.GetValue() ?? 0; ny = msg["y"]?.GetValue() ?? 0; } catch { }
+ InputInjector.Mouse(ScreenCapture.Area(s.Monitor), nx, ny,
+ Json.Str(msg, "action"), Json.Str(msg, "button"), Json.Int(msg, "delta"));
+ }
+ }
+ catch { }
+ break;
+ }
+
default:
await Reply(conn, id, error: "알 수 없는 요청: " + op);
break;
@@ -392,11 +472,120 @@ namespace JJServiceManager.Remote
foreach (var t in streams.Values) t.Dispose();
streams.Clear();
}
+ var s = Interlocked.Exchange(ref screen, null);
+ s?.Stop();
conn.Dispose();
if (authed && who != peer) _log($"원격 연결 종료: {who}", LogLevel.Info);
}
}
+ // ───────────────────────── 원격관리: 화면 보내기 ─────────────────────────
+
+ private sealed class ScreenSession
+ {
+ private readonly CancellationTokenSource _cts = new CancellationTokenSource();
+ public int Id { get; }
+ public string Who { get; }
+ public bool Control { get; }
+ public volatile int Monitor;
+ public bool Stopped => _cts.IsCancellationRequested;
+ public CancellationToken Token => _cts.Token;
+
+ public ScreenSession(int id, string who, bool control, int monitor)
+ {
+ Id = id;
+ Who = who;
+ Control = control;
+ Monitor = monitor;
+ }
+
+ public void Stop()
+ {
+ try { _cts.Cancel(); } catch { }
+ }
+ }
+
+ private async Task ScreenLoopAsync(JsonLineConnection conn, ScreenSession s, CancellationToken ct)
+ {
+ const int FrameMs = 120; // 최대 약 8장/초
+ ulong lastHash = 0;
+ var lastSent = DateTime.MinValue;
+ int lastMonitor = -1;
+ string endReason = "종료";
+ try
+ {
+ while (!s.Stopped && !ct.IsCancellationRequested && !conn.IsDisposed)
+ {
+ var sw = Stopwatch.StartNew();
+ var pol = _policy();
+ if (!pol.AllowView)
+ {
+ endReason = "대상 PC에서 화면 보기 허용을 껐습니다.";
+ break;
+ }
+
+ int monitor = s.Monitor;
+ var area = ScreenCapture.Area(monitor);
+ int maxWidth = monitor == 0 ? 2560 : 1920;
+ byte[] jpeg = null;
+ int w = 0, h = 0;
+ ulong hash = 0;
+ try { jpeg = ScreenCapture.CaptureJpeg(area, maxWidth, 60L, out w, out h, out hash); }
+ catch { /* 잠금 화면 등 캡처할 수 없는 순간 */ }
+
+ bool changed = hash != lastHash || monitor != lastMonitor;
+ if (jpeg != null && (changed || DateTime.UtcNow - lastSent > TimeSpan.FromSeconds(2)))
+ {
+ await conn.SendAsync(new JsonObject
+ {
+ ["event"] = "frame",
+ ["sid"] = s.Id,
+ ["monitor"] = monitor,
+ ["w"] = w,
+ ["h"] = h,
+ ["data"] = Convert.ToBase64String(jpeg)
+ }, s.Token).ConfigureAwait(false);
+ lastHash = hash;
+ lastMonitor = monitor;
+ lastSent = DateTime.UtcNow;
+ }
+
+ int wait = FrameMs - (int)sw.ElapsedMilliseconds;
+ if (wait > 0) await Task.Delay(wait, s.Token).ConfigureAwait(false);
+ }
+ }
+ catch
+ {
+ // 연결 끊김 또는 중지
+ }
+ finally
+ {
+ s.Stop();
+ _screens.TryRemove(s.Id, out _);
+ if (!conn.IsDisposed)
+ {
+ try { await conn.SendAsync(new JsonObject { ["event"] = "screenEnd", ["sid"] = s.Id, ["reason"] = endReason }).ConfigureAwait(false); }
+ catch { }
+ }
+ _log($"[원격 화면] {s.Who} 연결 끝", LogLevel.Info);
+ NotifyViewers();
+ }
+ }
+
+ /// 이 PC에서 [연결 끊기]를 눌렀을 때 모든 화면 연결 종료
+ public void StopAllScreens()
+ {
+ foreach (var s in _screens.Values) s.Stop();
+ }
+
+ private void NotifyViewers()
+ {
+ var list = _screens.Values.Where(s => !s.Stopped).Select(s => new ScreenViewer(s.Id, s.Who, s.Control)).ToList();
+ ScreenViewersChanged?.Invoke(list);
+ }
+
+ // ───────────────────────── 서비스 ─────────────────────────
+
private ServiceEntry Registered(string serviceName)
{
var e = _services().FirstOrDefault(x => string.Equals(x.ServiceName, serviceName, StringComparison.OrdinalIgnoreCase));
@@ -419,7 +608,13 @@ namespace JJServiceManager.Remote
["logIsFolder"] = e.LogIsFolder
});
}
- return new JsonObject { ["services"] = arr };
+ var pol = _policy();
+ return new JsonObject
+ {
+ ["services"] = arr,
+ ["allowView"] = pol.AllowView,
+ ["allowControl"] = pol.AllowView && pol.AllowControl
+ };
}
private JsonObject StartStop(bool start, string serviceName, string who)
@@ -452,6 +647,8 @@ namespace JJServiceManager.Remote
}
}
+ // ───────────────────────── 보내기 ─────────────────────────
+
private static async Task SafeReply(JsonLineConnection conn, int id, Func work)
{
try
diff --git a/Core/Remote/RemoteTransport.cs b/Core/Remote/RemoteTransport.cs
new file mode 100644
index 0000000..c1861d8
--- /dev/null
+++ b/Core/Remote/RemoteTransport.cs
@@ -0,0 +1,229 @@
+using System;
+using System.Diagnostics;
+using System.Net;
+using System.Net.Security;
+using System.Net.Sockets;
+using System.Security.Claims;
+using System.Security.Cryptography;
+using System.Security.Cryptography.X509Certificates;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.DevTunnels.Ssh;
+using Microsoft.DevTunnels.Ssh.Algorithms;
+using Microsoft.DevTunnels.Ssh.Events;
+using Microsoft.DevTunnels.Ssh.Messages;
+using SshClientTcp = Microsoft.DevTunnels.Ssh.Tcp.SshClient;
+using SshServerTcp = Microsoft.DevTunnels.Ssh.Tcp.SshServer;
+
+namespace JJServiceManager.Remote
+{
+ ///
+ /// 접속을 받는 쪽 (지정 포트 TLS 또는 내장 SSH).
+ /// Active: 대상 PC가 사용 / Passive: 관리 PC가 사용
+ ///
+ public sealed class RemoteListener : IDisposable
+ {
+ private static X509Certificate2 _cert;
+ private static readonly object CertLock = new object();
+
+ private readonly CancellationTokenSource _cts = new CancellationTokenSource();
+ private TcpListener _tcp;
+ private SshServerTcp _ssh;
+
+ public RemoteMode Mode { get; }
+ public int Port { get; }
+
+ /// SSH가 비정상 종료되었을 때
+ public event Action Failed;
+
+ private RemoteListener(RemoteMode mode, int port)
+ {
+ Mode = mode;
+ Port = port;
+ }
+
+ /// SSH 비밀번호 확인 (user, password) → 허용 여부
+ /// 연결 하나가 생길 때 (연결, 상대 표시, 취소)
+ public static RemoteListener Start(RemoteMode mode, int port,
+ Func> sshAuth,
+ Func onConnection)
+ {
+ var l = new RemoteListener(mode, port);
+ if (mode == RemoteMode.Port) l.StartTls(onConnection);
+ else l.StartSsh(sshAuth, onConnection);
+ return l;
+ }
+
+ private void StartTls(Func onConnection)
+ {
+ var cert = Certificate();
+ _tcp = new TcpListener(IPAddress.Any, Port);
+ _tcp.Start();
+ var listener = _tcp;
+ var ct = _cts.Token;
+ _ = Task.Run(async () =>
+ {
+ while (!ct.IsCancellationRequested)
+ {
+ TcpClient tcp;
+ try { tcp = await listener.AcceptTcpClientAsync(ct).ConfigureAwait(false); }
+ catch { break; }
+ _ = Task.Run(async () =>
+ {
+ string peer = (tcp.Client.RemoteEndPoint as IPEndPoint)?.Address.ToString() ?? "?";
+ if (peer.StartsWith("::ffff:")) peer = peer.Substring(7);
+ try
+ {
+ tcp.NoDelay = true;
+ var ssl = new SslStream(tcp.GetStream(), false);
+ using (var hs = CancellationTokenSource.CreateLinkedTokenSource(ct))
+ {
+ hs.CancelAfter(TimeSpan.FromSeconds(15));
+ await ssl.AuthenticateAsServerAsync(new SslServerAuthenticationOptions
+ {
+ ServerCertificate = cert,
+ ClientCertificateRequired = false
+ }, hs.Token).ConfigureAwait(false);
+ }
+ await onConnection(new JsonLineConnection(ssl, tcp), peer, ct).ConfigureAwait(false);
+ }
+ catch
+ {
+ try { tcp.Dispose(); } catch { }
+ }
+ });
+ }
+ });
+ }
+
+ private void StartSsh(Func> sshAuth, Func onConnection)
+ {
+ var hostKey = SshAlgorithms.PublicKey.ECDsaSha2Nistp384.GenerateKeyPair();
+ var ssh = new SshServerTcp(SshSessionConfiguration.Default, new TraceSource("JJServiceManager.SshServer"));
+ ssh.Credentials = new SshServerCredentials(new[] { hostKey });
+ var ct = _cts.Token;
+
+ ssh.SessionAuthenticating += (s, e) =>
+ {
+ if (e.AuthenticationType == SshAuthenticationType.ClientPassword)
+ {
+ var user = e.Username;
+ var pw = e.Password;
+ e.AuthenticationTask = Task.Run(async () =>
+ await sshAuth(user, pw).ConfigureAwait(false) ? new ClaimsPrincipal(new ClaimsIdentity("password")) : null);
+ }
+ // 그 밖의 인증 방식(공개 키 등)은 허용하지 않음
+ };
+
+ ssh.SessionOpened += (s, session) =>
+ {
+ session.ChannelOpening += (s2, e) =>
+ {
+ if (!e.IsRemoteRequest) return;
+ if (session.Principal == null)
+ {
+ e.FailureReason = SshChannelOpenFailureReason.AdministrativelyProhibited;
+ e.FailureDescription = "인증되지 않았습니다.";
+ return;
+ }
+ var channel = e.Channel;
+ _ = Task.Run(() => onConnection(new JsonLineConnection(new SshStream(channel)), "SSH", ct));
+ };
+ };
+
+ _ssh = ssh;
+ _ = ssh.AcceptSessionsAsync(Port, IPAddress.Any).ContinueWith(t =>
+ {
+ if (t.IsFaulted && !ct.IsCancellationRequested)
+ {
+ var ex = t.Exception?.GetBaseException();
+ var msg = ex is SocketException se && se.SocketErrorCode == SocketError.AddressAlreadyInUse
+ ? $"포트 {Port}를 다른 프로그램이 사용 중입니다."
+ : ex?.Message ?? "알 수 없는 오류";
+ Failed?.Invoke(msg);
+ }
+ }, TaskScheduler.Default);
+ }
+
+ public void Dispose()
+ {
+ try { _cts.Cancel(); } catch { }
+ try { _tcp?.Stop(); } catch { }
+ try { _ssh?.Dispose(); } catch { }
+ }
+
+ /// TLS용 자체 서명 인증서 (실행할 때마다 새로 만듦, 지문 확인은 하지 않는 설정)
+ private static X509Certificate2 Certificate()
+ {
+ lock (CertLock)
+ {
+ if (_cert != null) return _cert;
+ using var rsa = RSA.Create(2048);
+ var req = new CertificateRequest("CN=JJServiceManager", rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
+ req.CertificateExtensions.Add(new X509KeyUsageExtension(X509KeyUsageFlags.DigitalSignature | X509KeyUsageFlags.KeyEncipherment, false));
+ req.CertificateExtensions.Add(new X509EnhancedKeyUsageExtension(new OidCollection { new Oid("1.3.6.1.5.5.7.3.1") }, false));
+ using var cert = req.CreateSelfSigned(DateTimeOffset.UtcNow.AddDays(-1), DateTimeOffset.UtcNow.AddYears(10));
+ // Windows(SChannel)에서 개인 키를 쓰려면 PFX로 한 번 내보냈다가 다시 읽어야 함
+ _cert = new X509Certificate2(cert.Export(X509ContentType.Pfx), (string)null, X509KeyStorageFlags.Exportable);
+ return _cert;
+ }
+ }
+ }
+
+ /// 접속하는 쪽 (지정 포트 TLS 또는 내장 SSH)
+ public static class RemoteDialer
+ {
+ ///
+ /// 접속. SSH면 sshUser/sshPassword로 인증까지 함 (실패 시 RemoteAuthException).
+ /// TLS는 암호화만 하고 서버 지문은 확인하지 않음.
+ ///
+ public static async Task DialAsync(RemoteMode mode, string host, int port,
+ string sshUser, string sshPassword, CancellationToken ct)
+ {
+ host = (host ?? "").Trim();
+ if (mode == RemoteMode.Port)
+ {
+ var tcp = new TcpClient { NoDelay = true };
+ try
+ {
+ await tcp.ConnectAsync(host, port, ct).ConfigureAwait(false);
+ var ssl = new SslStream(tcp.GetStream(), false);
+ await ssl.AuthenticateAsClientAsync(new SslClientAuthenticationOptions
+ {
+ TargetHost = "JJServiceManager",
+ RemoteCertificateValidationCallback = (a, b, c, d) => true
+ }, ct).ConfigureAwait(false);
+ return new JsonLineConnection(ssl, tcp);
+ }
+ catch
+ {
+ tcp.Dispose();
+ throw;
+ }
+ }
+
+ var client = new SshClientTcp(SshSessionConfiguration.Default, new TraceSource("JJServiceManager.SshClient"));
+ try
+ {
+ var session = await client.OpenSessionAsync(host, port, ct).ConfigureAwait(false);
+ // 서버 지문 확인 안 함 (설정: 확인 없이 암호화만)
+ session.Authenticating += (s, e) => e.AuthenticationTask = Task.FromResult(new ClaimsPrincipal());
+ bool ok = await session.AuthenticateAsync(new SshClientCredentials(sshUser, sshPassword), ct).ConfigureAwait(false);
+ if (!ok)
+ {
+ session.Dispose();
+ throw new RemoteAuthException(sshUser == RemoteServer.PassiveSshUser
+ ? "관리 PC가 접속 코드를 받아들이지 않았습니다."
+ : "접속 비밀번호가 맞지 않습니다.");
+ }
+ var channel = await session.OpenChannelAsync(ct).ConfigureAwait(false);
+ return new JsonLineConnection(new SshStream(channel), session, client);
+ }
+ catch
+ {
+ client.Dispose();
+ throw;
+ }
+ }
+ }
+}
diff --git a/Core/Remote/ScreenCapture.cs b/Core/Remote/ScreenCapture.cs
new file mode 100644
index 0000000..0474de2
--- /dev/null
+++ b/Core/Remote/ScreenCapture.cs
@@ -0,0 +1,282 @@
+using System;
+using System.Collections.Generic;
+using System.Drawing;
+using System.Drawing.Drawing2D;
+using System.Drawing.Imaging;
+using System.IO;
+using System.Linq;
+using System.Runtime.InteropServices;
+using Forms = System.Windows.Forms;
+
+namespace JJServiceManager.Remote
+{
+ /// 모니터 정보 (실제 픽셀 좌표)
+ 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; }
+ }
+
+ /// 대상 PC: 화면 캡처
+ public static class ScreenCapture
+ {
+ /// 모니터 목록 (1부터, 주 모니터 우선이 아니라 Windows 순서)
+ public static List Monitors()
+ {
+ var list = new List();
+ int i = 1;
+ foreach (var s in Forms.Screen.AllScreens.OrderBy(s => s.Bounds.X).ThenBy(s => s.Bounds.Y))
+ {
+ list.Add(new MonitorInfo
+ {
+ Index = i++,
+ Name = s.DeviceName,
+ X = s.Bounds.X,
+ Y = s.Bounds.Y,
+ Width = s.Bounds.Width,
+ Height = s.Bounds.Height,
+ Primary = s.Primary
+ });
+ }
+ return list;
+ }
+
+ /// monitor 0 = 전체 화면, 1~ = 해당 모니터
+ public static Rectangle Area(int monitor)
+ {
+ if (monitor > 0)
+ {
+ var m = Monitors().FirstOrDefault(x => x.Index == monitor);
+ if (m != null) return new Rectangle(m.X, m.Y, m.Width, m.Height);
+ }
+ return Forms.SystemInformation.VirtualScreen;
+ }
+
+ [StructLayout(LayoutKind.Sequential)]
+ private struct CURSORINFO
+ {
+ public int cbSize;
+ public int flags;
+ public IntPtr hCursor;
+ public POINT ptScreenPos;
+ }
+
+ [StructLayout(LayoutKind.Sequential)]
+ private struct POINT { public int X; public int Y; }
+
+ [StructLayout(LayoutKind.Sequential)]
+ private struct ICONINFO
+ {
+ public bool fIcon;
+ public int xHotspot;
+ public int yHotspot;
+ public IntPtr hbmMask;
+ public IntPtr hbmColor;
+ }
+
+ [DllImport("user32.dll")] private static extern bool GetCursorInfo(ref CURSORINFO pci);
+ [DllImport("user32.dll")] private static extern bool GetIconInfo(IntPtr hIcon, out ICONINFO piconinfo);
+ [DllImport("user32.dll")] private static extern bool DrawIconEx(IntPtr hdc, int x, int y, IntPtr hIcon, int cx, int cy, int step, IntPtr brush, int flags);
+ [DllImport("gdi32.dll")] private static extern bool DeleteObject(IntPtr h);
+
+ ///
+ /// 영역을 캡처해 JPEG로. maxWidth보다 넓으면 줄임.
+ /// hash: 변화 확인용 (같으면 보내지 않음)
+ ///
+ public static byte[] CaptureJpeg(Rectangle area, int maxWidth, long quality, out int width, out int height, out ulong hash)
+ {
+ using var bmp = new Bitmap(area.Width, area.Height, PixelFormat.Format32bppRgb);
+ using (var g = Graphics.FromImage(bmp))
+ {
+ g.CopyFromScreen(area.X, area.Y, 0, 0, area.Size, CopyPixelOperation.SourceCopy);
+ DrawCursor(g, area);
+ }
+
+ hash = Hash(bmp);
+
+ Bitmap output = bmp;
+ Bitmap scaled = null;
+ if (area.Width > maxWidth)
+ {
+ double r = (double)maxWidth / area.Width;
+ int w = maxWidth, h = Math.Max(1, (int)(area.Height * r));
+ scaled = new Bitmap(w, h, PixelFormat.Format24bppRgb);
+ using var g2 = Graphics.FromImage(scaled);
+ g2.InterpolationMode = InterpolationMode.HighQualityBilinear;
+ g2.DrawImage(bmp, 0, 0, w, h);
+ output = scaled;
+ }
+
+ try
+ {
+ width = output.Width;
+ height = output.Height;
+ using var ms = new MemoryStream();
+ var codec = ImageCodecInfo.GetImageEncoders().First(c => c.FormatID == ImageFormat.Jpeg.Guid);
+ using var ep = new EncoderParameters(1);
+ ep.Param[0] = new EncoderParameter(Encoder.Quality, quality);
+ output.Save(ms, codec, ep);
+ return ms.ToArray();
+ }
+ finally
+ {
+ scaled?.Dispose();
+ }
+ }
+
+ private static void DrawCursor(Graphics g, Rectangle area)
+ {
+ try
+ {
+ var ci = new CURSORINFO { cbSize = Marshal.SizeOf() };
+ if (!GetCursorInfo(ref ci) || ci.flags != 1 /* CURSOR_SHOWING */) return;
+ int hx = 0, hy = 0;
+ if (GetIconInfo(ci.hCursor, out var ii))
+ {
+ hx = ii.xHotspot;
+ hy = ii.yHotspot;
+ if (ii.hbmMask != IntPtr.Zero) DeleteObject(ii.hbmMask);
+ if (ii.hbmColor != IntPtr.Zero) DeleteObject(ii.hbmColor);
+ }
+ int x = ci.ptScreenPos.X - area.X - hx;
+ int y = ci.ptScreenPos.Y - area.Y - hy;
+ if (x < -64 || y < -64 || x > area.Width || y > area.Height) return;
+ var hdc = g.GetHdc();
+ try { DrawIconEx(hdc, x, y, ci.hCursor, 0, 0, 0, IntPtr.Zero, 3 /* DI_NORMAL */); }
+ finally { g.ReleaseHdc(hdc); }
+ }
+ catch { }
+ }
+
+ /// 화면이 바뀌었는지 빠르게 확인하기 위한 값 (일부 픽셀만 사용)
+ private static unsafe ulong Hash(Bitmap bmp)
+ {
+ var data = bmp.LockBits(new Rectangle(0, 0, bmp.Width, bmp.Height), ImageLockMode.ReadOnly, PixelFormat.Format32bppRgb);
+ try
+ {
+ ulong h = 1469598103934665603UL;
+ byte* p = (byte*)data.Scan0;
+ for (int y = 0; y < data.Height; y += 4)
+ {
+ uint* row = (uint*)(p + (long)y * data.Stride);
+ for (int x = 0; x < data.Width; x += 4)
+ {
+ h ^= row[x];
+ h *= 1099511628211UL;
+ }
+ }
+ return h;
+ }
+ finally
+ {
+ bmp.UnlockBits(data);
+ }
+ }
+ }
+
+ /// 대상 PC: 원격에서 받은 마우스/키보드 입력을 실행
+ public static class InputInjector
+ {
+ private const int INPUT_MOUSE = 0;
+ private const int INPUT_KEYBOARD = 1;
+ private const uint MOUSEEVENTF_MOVE = 0x0001, MOUSEEVENTF_LEFTDOWN = 0x0002, MOUSEEVENTF_LEFTUP = 0x0004,
+ MOUSEEVENTF_RIGHTDOWN = 0x0008, MOUSEEVENTF_RIGHTUP = 0x0010, MOUSEEVENTF_MIDDLEDOWN = 0x0020, MOUSEEVENTF_MIDDLEUP = 0x0040,
+ MOUSEEVENTF_WHEEL = 0x0800, MOUSEEVENTF_HWHEEL = 0x1000, MOUSEEVENTF_VIRTUALDESK = 0x4000, MOUSEEVENTF_ABSOLUTE = 0x8000;
+ private const uint KEYEVENTF_EXTENDEDKEY = 0x0001, KEYEVENTF_KEYUP = 0x0002;
+
+ [StructLayout(LayoutKind.Sequential)]
+ private struct MOUSEINPUT { public int dx; public int dy; public int mouseData; public uint dwFlags; public uint time; public IntPtr dwExtraInfo; }
+
+ [StructLayout(LayoutKind.Sequential)]
+ private struct KEYBDINPUT { public ushort wVk; public ushort wScan; public uint dwFlags; public uint time; public IntPtr dwExtraInfo; }
+
+ [StructLayout(LayoutKind.Explicit)]
+ private struct InputUnion
+ {
+ [FieldOffset(0)] public MOUSEINPUT mi;
+ [FieldOffset(0)] public KEYBDINPUT ki;
+ }
+
+ [StructLayout(LayoutKind.Sequential)]
+ private struct INPUT { public int type; public InputUnion U; }
+
+ [DllImport("user32.dll", SetLastError = true)]
+ private static extern uint SendInput(uint nInputs, INPUT[] pInputs, int cbSize);
+
+ [DllImport("user32.dll")]
+ private static extern uint MapVirtualKey(uint uCode, uint uMapType);
+
+ ///
+ /// 마우스. nx, ny = 캡처 영역 안의 비율(0~1).
+ /// action: move / down / up / wheel / hwheel, button: left / right / middle
+ ///
+ public static void Mouse(Rectangle area, double nx, double ny, string action, string button, int delta)
+ {
+ var vs = Forms.SystemInformation.VirtualScreen;
+ nx = Math.Clamp(nx, 0, 1);
+ ny = Math.Clamp(ny, 0, 1);
+ double px = area.X + nx * (area.Width - 1);
+ double py = area.Y + ny * (area.Height - 1);
+ int ax = (int)Math.Round((px - vs.X) * 65535.0 / Math.Max(1, vs.Width - 1));
+ int ay = (int)Math.Round((py - vs.Y) * 65535.0 / Math.Max(1, vs.Height - 1));
+
+ uint flags = MOUSEEVENTF_MOVE | MOUSEEVENTF_ABSOLUTE | MOUSEEVENTF_VIRTUALDESK;
+ int data = 0;
+ switch (action)
+ {
+ case "down":
+ flags |= button switch { "right" => MOUSEEVENTF_RIGHTDOWN, "middle" => MOUSEEVENTF_MIDDLEDOWN, _ => MOUSEEVENTF_LEFTDOWN };
+ break;
+ case "up":
+ flags |= button switch { "right" => MOUSEEVENTF_RIGHTUP, "middle" => MOUSEEVENTF_MIDDLEUP, _ => MOUSEEVENTF_LEFTUP };
+ break;
+ case "wheel":
+ flags |= MOUSEEVENTF_WHEEL;
+ data = delta;
+ break;
+ case "hwheel":
+ flags |= MOUSEEVENTF_HWHEEL;
+ data = delta;
+ break;
+ }
+
+ var input = new INPUT { type = INPUT_MOUSE };
+ input.U.mi = new MOUSEINPUT { dx = ax, dy = ay, mouseData = data, dwFlags = flags };
+ SendInput(1, new[] { input }, Marshal.SizeOf());
+ }
+
+ /// 키보드 (가상 키 코드)
+ public static void Key(int vk, bool down)
+ {
+ if (vk <= 0 || vk > 254) return;
+ uint flags = down ? 0 : KEYEVENTF_KEYUP;
+ if (IsExtended(vk)) flags |= KEYEVENTF_EXTENDEDKEY;
+ var input = new INPUT { type = INPUT_KEYBOARD };
+ input.U.ki = new KEYBDINPUT
+ {
+ wVk = (ushort)vk,
+ wScan = (ushort)MapVirtualKey((uint)vk, 0),
+ dwFlags = flags
+ };
+ SendInput(1, new[] { input }, Marshal.SizeOf());
+ }
+
+ private static bool IsExtended(int vk) => vk switch
+ {
+ 0x21 or 0x22 or 0x23 or 0x24 => true, // PgUp PgDn End Home
+ 0x25 or 0x26 or 0x27 or 0x28 => true, // 방향키
+ 0x2D or 0x2E => true, // Insert Delete
+ 0x5B or 0x5C or 0x5D => true, // Win Apps
+ 0x6F => true, // Num /
+ 0x90 => true, // NumLock
+ 0xA3 or 0xA5 => true, // 오른쪽 Ctrl, Alt
+ 0x2C => true, // PrintScreen
+ _ => false
+ };
+ }
+}
diff --git a/Core/SystemIntegration.cs b/Core/SystemIntegration.cs
index b09d263..ffa6a99 100644
--- a/Core/SystemIntegration.cs
+++ b/Core/SystemIntegration.cs
@@ -95,36 +95,8 @@ $@"
return (r.ExitCode == 0, r.Output);
}
- /// 명령 프롬프트 출력 인코딩 (한국어 Windows = CP949)
- private static Encoding OemEncoding()
- {
- try { return Encoding.GetEncoding(System.Globalization.CultureInfo.CurrentCulture.TextInfo.OEMCodePage); }
- catch { return Encoding.UTF8; }
- }
-
private static (int ExitCode, string Output) RunSchtasks(string args)
- {
- try
- {
- var psi = new ProcessStartInfo("schtasks.exe", args)
- {
- UseShellExecute = false,
- CreateNoWindow = true,
- RedirectStandardOutput = true,
- RedirectStandardError = true,
- StandardOutputEncoding = OemEncoding(),
- StandardErrorEncoding = OemEncoding()
- };
- using var p = Process.Start(psi);
- var output = p.StandardOutput.ReadToEnd() + p.StandardError.ReadToEnd();
- p.WaitForExit(15000);
- return (p.ExitCode, output.Trim());
- }
- catch (Exception ex)
- {
- return (-1, ex.Message);
- }
- }
+ => ConsoleRunner.Run("schtasks.exe", args);
}
/// 전역 단축키 (어느 프로그램을 쓰고 있어도 동작)
diff --git a/JJServiceManager.csproj b/JJServiceManager.csproj
index 08176e3..93c1118 100644
--- a/JJServiceManager.csproj
+++ b/JJServiceManager.csproj
@@ -14,6 +14,7 @@
disable
disable
latest
+ true
1.1.0
JJServiceManagerEnt
Windows 서비스 기동/종료 관리
diff --git a/Views/MainWindow.xaml b/Views/MainWindow.xaml
index 9e27be9..d8c3f78 100644
--- a/Views/MainWindow.xaml
+++ b/Views/MainWindow.xaml
@@ -42,6 +42,9 @@
+
diff --git a/Views/Settings.xaml b/Views/Settings.xaml
index df1514b..16cf9dc 100644
--- a/Views/Settings.xaml
+++ b/Views/Settings.xaml
@@ -98,63 +98,124 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
-
-
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
-
-
+
+
+
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
+
+
-
+
-
+
+
+
+
+
+
+
+
+
+
@@ -170,30 +231,43 @@
+
-
-
+
+
+
+
+
-
-
+
+
+
+
+
+
+
+
+
+
-
-
+
+
-
-
+
-
+
-
+
@@ -207,11 +281,11 @@
-
+
-
+
diff --git a/Windows/MainWindow.cs b/Windows/MainWindow.cs
index e4893d6..af377ba 100644
--- a/Windows/MainWindow.cs
+++ b/Windows/MainWindow.cs
@@ -33,6 +33,11 @@ namespace JJServiceManager
private readonly LogWindowManager _logWindows;
private readonly DispatcherTimer _statusTimer;
private readonly RemoteServer _remoteServer;
+ private readonly PassiveHub _passiveHub;
+ private RemoteIndicatorWindow _indicator;
+ private readonly Dictionary _viewers = new Dictionary(StringComparer.OrdinalIgnoreCase);
+ private string _passiveKey = "";
+ private string _passiveListenKey = "";
private readonly TabControl _tabs;
private readonly TabItem _tabAll;
@@ -124,6 +129,14 @@ namespace JJServiceManager
PreviewKeyDown += OnPreviewKeyDown;
+ // 처음 뜰 때 다른 창 뒤에 가려지지 않도록 맨 앞으로
+ ContentRendered += (s, e) =>
+ {
+ Activate();
+ Topmost = true;
+ Topmost = false;
+ };
+
// 내 PC 상태 자동 갱신 (2초)
_statusTimer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(2) };
_statusTimer.Tick += async (s, e) => await RefreshStatuses(false);
@@ -137,7 +150,25 @@ namespace JJServiceManager
_remoteServer = new RemoteServer(
() => _servicesSnapshot,
(m, l) => Log(m, l),
- () => Dispatcher.BeginInvoke(new Action(() => _ = RefreshStatuses(true))));
+ () => Dispatcher.BeginInvoke(new Action(() => _ = RefreshStatuses(true))),
+ () => new ScreenPolicy(_settings.RemoteAllowView, _settings.RemoteAllowView && _settings.RemoteAllowControl, _settings.RemoteRequireApproval),
+ (who, control) => Dispatcher.InvokeAsync(() => ApprovalWindow.AskAsync(who, control)).Task.Unwrap());
+ _remoteServer.ScreenViewersChanged += list => Dispatcher.BeginInvoke(new Action(() =>
+ {
+ if (_indicator == null)
+ {
+ _indicator = new RemoteIndicatorWindow();
+ _indicator.DisconnectRequested += () =>
+ {
+ Log("[원격 화면] 이 PC에서 연결 끊기", LogLevel.Warning);
+ _remoteServer.StopAllScreens();
+ };
+ }
+ _indicator.Update(list);
+ }));
+
+ // 관리 PC: Passive 대상 PC의 접속을 받는 곳
+ _passiveHub = new PassiveHub((m, l) => Log(m, l));
CreateTray();
@@ -360,6 +391,12 @@ namespace JJServiceManager
private async void OnRowButton(object sender, RoutedEventArgs e)
{
+ if (e.OriginalSource is Button hb && hb.DataContext is HostGroup hg && hb.Tag as string == "remoteDesktop")
+ {
+ e.Handled = true;
+ OpenRemoteDesktop(hg);
+ return;
+ }
if (e.OriginalSource is not Button b || b.DataContext is not ServiceRow row) return;
switch (b.Tag as string)
{
@@ -499,6 +536,56 @@ namespace JJServiceManager
};
}
+ // ───────────────────────── 원격관리 (화면 보기 / 제어) ─────────────────────────
+
+ private void OpenRemoteDesktop(HostGroup g)
+ {
+ if (g.IsLocal || g.Client == null) return;
+ if (_viewers.TryGetValue(g.Id, out var existing))
+ {
+ if (existing.WindowState == WindowState.Minimized) existing.WindowState = WindowState.Normal;
+ existing.Activate();
+ return;
+ }
+ if (g.Client.State != RemoteState.Connected)
+ {
+ Dialogs.Info(this, $"{g.Title} 에 연결되어 있지 않습니다.\n({g.StatusText})");
+ return;
+ }
+ if (!g.Client.AllowView)
+ {
+ Dialogs.Info(this, $"{g.Title} 에서 화면 보기를 허용하지 않았습니다.\n대상 PC 설정 → 원격 접속 허용 → 원격관리에서 '화면 보기 허용'을 켜야 합니다.");
+ return;
+ }
+
+ // 원격관리는 항상 숫자 6자리 확인
+ if (_modalOpen) return;
+ bool ok;
+ _modalOpen = true;
+ try
+ {
+ ok = CodeConfirmWindow.Ask(this, "원격관리",
+ $"{g.Title} 의 화면을 {(g.Client.AllowControl ? "보고 키보드·마우스로 제어" : "봅")}니다.",
+ "원격관리 시작");
+ }
+ finally { _modalOpen = false; }
+ if (!ok)
+ {
+ Log($"[{g.Prefix}] 원격관리 취소");
+ return;
+ }
+
+ Log($"[{g.Prefix}] 원격관리 시작 ({(g.Client.AllowControl ? "보기 + 제어" : "보기")})", LogLevel.Notice);
+ var v = new RemoteViewerWindow(g.Id, g.Client, g.Title);
+ v.Closed += (s, e) =>
+ {
+ _viewers.Remove(g.Id);
+ Log($"[{g.Prefix}] 원격관리 끝");
+ };
+ _viewers[g.Id] = v;
+ v.Show();
+ }
+
/// 좌우 분할 창에서 고를 수 있는 로그 (내 PC + 원격)
private List LogChoices()
{
@@ -674,7 +761,7 @@ namespace JJServiceManager
private static string TargetsKey(AppSettings s)
=> (s.RemoteManageEnabled ? "on;" : "off;")
- + string.Join(";", s.Targets.Select(t => $"{t.Id}|{t.Address}|{t.Mode}|{t.Port}|{t.ProtectedPassword}"));
+ + string.Join(";", s.Targets.Select(t => $"{t.Id}|{t.Direction}|{t.AccessCode}|{t.Address}|{t.Mode}|{t.Port}|{t.ProtectedPassword}"));
/// 관리 대상 PC 목록이 바뀌면 접속을 새로 만듦
private void ApplyRemoteManage()
@@ -684,6 +771,8 @@ namespace JJServiceManager
_targetsKey = key;
// 기존 원격 PC 정리
+ _passiveHub.Clear();
+ foreach (var v in _viewers.Values.ToList()) v.Close();
foreach (var g in _groups.Where(g => !g.IsLocal).ToList())
{
g.Client?.Dispose();
@@ -708,6 +797,8 @@ namespace JJServiceManager
client.StateChanged += c => Dispatcher.BeginInvoke(new Action(() => OnRemoteState(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.PeerAddressDiscovered += (c, addr) => Dispatcher.BeginInvoke(new Action(() => OnPeerAddress(g, addr)));
+ if (t.Direction == ConnectDirection.Passive) _passiveHub.Register(client);
_groups.Add(g);
client.Start();
}
@@ -723,6 +814,43 @@ namespace JJServiceManager
UpdateCounts();
}
+ /// Passive 대상이 있으면 수신 포트를 엶 (방화벽 포함)
+ private void ApplyPassiveListen(bool startup)
+ {
+ var s = _settings;
+ bool need = s.RemoteManageEnabled && s.Targets.Any(t => t.Direction == ConnectDirection.Passive);
+ var key = need ? $"{s.PassiveListenMode}|{s.PassiveListenPort}" : "off";
+ if (key == _passiveListenKey) return;
+ bool wasOn = _passiveListenKey != "" && _passiveListenKey != "off";
+ _passiveListenKey = key;
+ _passiveHub.Apply(need, s.PassiveListenMode, s.PassiveListenPort);
+ if (need || wasOn)
+ {
+ bool on = need && _passiveHub.IsRunning;
+ int port = s.PassiveListenPort;
+ Task.Run(() =>
+ {
+ var (ok, msg) = FirewallManager.Apply(FirewallManager.PassiveRule, on, port);
+ if (!startup || !ok) Log(msg, ok ? LogLevel.Info : LogLevel.Warning);
+ });
+ }
+ }
+
+ /// Passive: 대상 PC가 접속해 온 IP 표시
+ private void OnPeerAddress(HostGroup g, string addr)
+ {
+ var t = _settings.Targets.FirstOrDefault(x => x.Id == g.Id);
+ if (t == null || string.IsNullOrEmpty(addr)) return;
+ if (t.LastAddress != addr)
+ {
+ t.LastAddress = addr;
+ SaveSettings();
+ }
+ g.Title = t.Label;
+ g.TitleChanged();
+ _logWindows.ChoicesChanged();
+ }
+
private void OnRemoteState(HostGroup g, RemoteClient c)
{
if (!_groups.Contains(g)) return;
@@ -818,48 +946,56 @@ namespace JJServiceManager
private void ApplyRemoteServer(bool startup)
{
var s = _settings;
- var key = s.RemoteServerEnabled ? $"{s.RemoteServerMode}|{s.RemoteServerPort}" : "off";
+ bool hasPw = !string.IsNullOrEmpty(s.RemotePasswordHash);
+ // 비밀번호는 Active / Passive 모두 사용하므로 항상 반영
+ _remoteServer.UpdatePassword(s.RemotePasswordHash, s.RemotePasswordSalt);
+
+ if (s.RemoteServerEnabled && !hasPw)
+ Log("원격 접속 허용: 접속 비밀번호가 없어 시작하지 않았습니다. (설정 → 원격 접속 허용)", LogLevel.Warning);
+
+ // ── Active 수신
+ bool active = s.RemoteServerEnabled && hasPw && s.RemoteServerActiveListen;
+ var key = active ? $"{s.RemoteServerMode}|{s.RemoteServerPort}" : "off";
if (key != _serverKey)
{
bool wasOn = _serverKey != "" && _serverKey != "off";
_serverKey = key;
- if (s.RemoteServerEnabled)
- {
- if (string.IsNullOrEmpty(s.RemotePasswordHash))
- {
- Log("원격 접속 허용: 접속 비밀번호가 없어 시작하지 않았습니다. (설정 → 원격 접속 허용)", LogLevel.Warning);
- _serverKey = "nopw";
- }
- else
- {
- _remoteServer.Start(s.RemoteServerMode, s.RemoteServerPort, s.RemotePasswordHash, s.RemotePasswordSalt);
- }
- }
- else
- {
- _remoteServer.Stop();
- }
+ if (active) _remoteServer.Start(s.RemoteServerMode, s.RemoteServerPort, s.RemotePasswordHash, s.RemotePasswordSalt);
+ else _remoteServer.Stop();
// 방화벽: 켤 때 포트 열기, 끌 때 규칙 삭제 (처음 실행 시 꺼져 있으면 건드리지 않음)
- if (s.RemoteServerEnabled || wasOn || !startup)
+ if (active || wasOn || !startup)
{
- bool on = s.RemoteServerEnabled && _remoteServer.IsRunning;
+ bool on = active && _remoteServer.IsRunning;
int port = s.RemoteServerPort;
Task.Run(() =>
{
- var (ok, msg) = FirewallManager.Apply(on, port);
+ var (ok, msg) = FirewallManager.Apply(FirewallManager.ActiveRule, on, port);
if (!startup || !ok) Log(msg, ok ? LogLevel.Info : LogLevel.Warning);
});
}
}
- else
+
+ // ── Passive 접속 (이 PC → 관리 PC)
+ bool passive = s.RemoteServerEnabled && hasPw && s.PassiveManagers.Count > 0;
+ var pkey = passive ? string.Join(";", s.PassiveManagers.Select(m => $"{m.Address}|{m.Mode}|{m.Port}|{m.AccessCode}")) : "off";
+ if (pkey != _passiveKey)
{
- _remoteServer.UpdatePassword(s.RemotePasswordHash, s.RemotePasswordSalt);
+ _passiveKey = pkey;
+ if (passive)
+ {
+ _remoteServer.StartPassive(s.PassiveManagers);
+ Log($"Passive 접속 시작: 관리 PC {s.PassiveManagers.Count}곳", LogLevel.Info);
+ }
+ else _remoteServer.StopPassive();
}
- _local.StatusText = _remoteServer.IsRunning
- ? $"원격 접속 허용 중 · {(s.RemoteServerMode == RemoteMode.Ssh ? "SSH" : "지정 포트")} {s.RemoteServerPort}"
- : "";
+ // ── 원격관리 허용을 끄면 보고 있던 연결은 다음 장면에서 자동으로 끊김
+ var parts = new List();
+ 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;
}
@@ -939,6 +1075,7 @@ namespace JJServiceManager
ApplyHotkey(quiet: startup);
ApplyRemoteServer(startup);
ApplyRemoteManage();
+ ApplyPassiveListen(startup);
}
private void ApplyHotkey(bool quiet)
@@ -1111,8 +1248,11 @@ namespace JJServiceManager
_statusTimer.Stop();
SaveSettings();
_logWindows.CloseAll();
+ foreach (var v in _viewers.Values.ToList()) v.Close();
foreach (var g in _groups) g.Client?.Dispose();
+ _passiveHub.Dispose();
_remoteServer.Dispose();
+ _indicator?.Close();
_hotkey?.Dispose();
if (_tray != null)
{
diff --git a/Windows/RemoteScreenWindows.cs b/Windows/RemoteScreenWindows.cs
new file mode 100644
index 0000000..65fb230
--- /dev/null
+++ b/Windows/RemoteScreenWindows.cs
@@ -0,0 +1,483 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Text.Json.Nodes;
+using System.Threading;
+using System.Threading.Tasks;
+using System.Windows;
+using System.Windows.Controls;
+using System.Windows.Input;
+using System.Windows.Media;
+using System.Windows.Media.Imaging;
+using System.Windows.Threading;
+using JJServiceManager.Remote;
+
+namespace JJServiceManager
+{
+ /// 대상 PC: 원격 화면 연결 승인 창 (30초 안에 답하지 않으면 거부)
+ public sealed class ApprovalWindow : Window
+ {
+ private readonly TaskCompletionSource _result = new TaskCompletionSource();
+ private readonly DispatcherTimer _timer;
+ private readonly TextBlock _countdown;
+ private int _left = 30;
+
+ private ApprovalWindow(string who, bool control)
+ {
+ Title = "원격관리 연결 요청" + AppInstance.TitleTag;
+ Width = 460;
+ SizeToContent = SizeToContent.Height;
+ ResizeMode = ResizeMode.NoResize;
+ WindowStartupLocation = WindowStartupLocation.CenterScreen;
+ Topmost = true;
+ FontFamily = new FontFamily("Malgun Gothic");
+ FontSize = 13;
+ Background = Brushes2.Make("#FAFAFA");
+ AppIcon.Apply(this);
+
+ var root = new StackPanel { Margin = new Thickness(20, 18, 20, 16) };
+ root.Children.Add(new TextBlock
+ {
+ Text = control ? "원격 화면 보기 + 키보드·마우스 제어 요청" : "원격 화면 보기 요청",
+ FontSize = 15,
+ FontWeight = FontWeights.Bold,
+ Foreground = Brushes2.Make(control ? "#B71C1C" : "#155E75"),
+ Margin = new Thickness(0, 0, 0, 10)
+ });
+ root.Children.Add(new TextBlock
+ {
+ Text = $"관리 PC [{who}] 가 이 PC 화면을 {(control ? "보고 조작" : "보")}려고 합니다.\n허용할까요?",
+ TextWrapping = TextWrapping.Wrap,
+ Margin = new Thickness(0, 0, 0, 10)
+ });
+ _countdown = new TextBlock { Foreground = Brushes.Gray };
+ root.Children.Add(_countdown);
+
+ var buttons = new StackPanel { Orientation = Orientation.Horizontal, HorizontalAlignment = HorizontalAlignment.Right, Margin = new Thickness(0, 12, 0, 0) };
+ var allow = new Button { Content = "허용", MinWidth = 90, FontWeight = FontWeights.Bold };
+ var deny = new Button { Content = "거부", MinWidth = 90, IsCancel = true };
+ allow.Click += (s, e) => Finish(true);
+ deny.Click += (s, e) => Finish(false);
+ buttons.Children.Add(allow);
+ buttons.Children.Add(deny);
+ root.Children.Add(buttons);
+ Content = root;
+
+ _timer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(1) };
+ _timer.Tick += (s, e) =>
+ {
+ _left--;
+ UpdateCountdown();
+ if (_left <= 0) Finish(false);
+ };
+ UpdateCountdown();
+ Closed += (s, e) => { _timer.Stop(); _result.TrySetResult(false); };
+ Loaded += (s, e) => { _timer.Start(); Activate(); };
+ }
+
+ private void UpdateCountdown() => _countdown.Text = $"{_left}초 뒤 자동으로 거부됩니다.";
+
+ private void Finish(bool ok)
+ {
+ _result.TrySetResult(ok);
+ Close();
+ }
+
+ /// UI 스레드에서 호출
+ public static Task AskAsync(string who, bool control)
+ {
+ var w = new ApprovalWindow(who, control);
+ w.Show();
+ return w._result.Task;
+ }
+ }
+
+ /// 대상 PC: 원격 화면 연결 중 화면 위쪽 표시줄 (항상 표시)
+ public sealed class RemoteIndicatorWindow : Window
+ {
+ private readonly TextBlock _text;
+ private readonly Border _bar;
+
+ public event Action DisconnectRequested;
+
+ public RemoteIndicatorWindow()
+ {
+ WindowStyle = WindowStyle.None;
+ ResizeMode = ResizeMode.NoResize;
+ AllowsTransparency = true;
+ Background = Brushes.Transparent;
+ Topmost = true;
+ ShowInTaskbar = false;
+ ShowActivated = false;
+ SizeToContent = SizeToContent.WidthAndHeight;
+ FontFamily = new FontFamily("Malgun Gothic");
+ FontSize = 13;
+
+ _bar = new Border
+ {
+ CornerRadius = new CornerRadius(0, 0, 8, 8),
+ Padding = new Thickness(14, 6, 8, 6),
+ Background = Brushes2.Make("#E65100")
+ };
+ var panel = new StackPanel { Orientation = Orientation.Horizontal };
+ _text = new TextBlock { Foreground = Brushes.White, FontWeight = FontWeights.Bold, VerticalAlignment = VerticalAlignment.Center };
+ var stop = new Button
+ {
+ Content = "연결 끊기",
+ Margin = new Thickness(12, 0, 0, 0),
+ MinWidth = 0,
+ MinHeight = 0,
+ Padding = new Thickness(8, 2, 8, 2),
+ Background = Brushes.White
+ };
+ stop.Click += (s, e) => DisconnectRequested?.Invoke();
+ panel.Children.Add(_text);
+ panel.Children.Add(stop);
+ _bar.Child = panel;
+ Content = _bar;
+
+ SizeChanged += (s, e) => Reposition();
+ }
+
+ public void Update(IReadOnlyList viewers)
+ {
+ if (viewers.Count == 0)
+ {
+ Hide();
+ return;
+ }
+ bool control = viewers.Any(v => v.Control);
+ _bar.Background = Brushes2.Make(control ? "#C62828" : "#E65100");
+ _text.Text = $"● 원격 {(control ? "제어" : "보기")} 중: {string.Join(", ", viewers.Select(v => v.Who).Distinct())}";
+ if (!IsVisible) Show();
+ Reposition();
+ }
+
+ private void Reposition()
+ {
+ var wa = SystemParameters.WorkArea;
+ Left = wa.Left + (wa.Width - ActualWidth) / 2;
+ Top = wa.Top;
+ }
+ }
+
+ /// 관리 PC: 원격 화면 창 (보기 / 제어)
+ public sealed class RemoteViewerWindow : Window
+ {
+ private readonly RemoteClient _client;
+ private readonly string _label;
+ private readonly Image _image;
+ private readonly TextBlock _status, _overlay;
+ private readonly StackPanel _monitorButtons;
+ private readonly HashSet _pressedKeys = new HashSet();
+ private int _sid;
+ private bool _control;
+ private bool _ended;
+ private int _monitor = 1;
+ private int _frames;
+ private DateTime _fpsStart = DateTime.UtcNow;
+ private int _decoding;
+ private DateTime _lastMove = DateTime.MinValue;
+
+ public string ViewerKey { get; }
+
+ public RemoteViewerWindow(string key, RemoteClient client, string label)
+ {
+ ViewerKey = key;
+ _client = client;
+ _label = label;
+
+ Title = $"원격관리 - {label}" + AppInstance.TitleTag;
+ Width = 1280;
+ Height = 800;
+ WindowStartupLocation = WindowStartupLocation.CenterScreen;
+ FontFamily = new FontFamily("Malgun Gothic");
+ FontSize = 13;
+ Background = Brushes2.Make("#263238");
+ AppIcon.Apply(this);
+ // 한글 입력기는 대상 PC에서 처리하도록 이 창에서는 끔
+ InputMethod.SetIsInputMethodEnabled(this, false);
+
+ var root = new DockPanel();
+
+ // ── 도구 모음
+ var bar = new DockPanel { Background = Brushes2.Make("#ECEFF1") };
+ DockPanel.SetDock(bar, Dock.Top);
+ var close = new Button { Content = "연결 끊기" };
+ close.Click += (s, e) => Close();
+ DockPanel.SetDock(close, Dock.Right);
+ bar.Children.Add(close);
+ _status = new TextBlock { VerticalAlignment = VerticalAlignment.Center, Margin = new Thickness(12, 0, 12, 0), Foreground = Brushes2.Make("#37474F") };
+ DockPanel.SetDock(_status, Dock.Right);
+ bar.Children.Add(_status);
+ _monitorButtons = new StackPanel { Orientation = Orientation.Horizontal, Margin = new Thickness(4) };
+ bar.Children.Add(_monitorButtons);
+ root.Children.Add(bar);
+
+ // ── 화면
+ var stage = new Grid { Background = Brushes.Black, ClipToBounds = true };
+ _image = new Image
+ {
+ Stretch = Stretch.Uniform,
+ HorizontalAlignment = HorizontalAlignment.Center,
+ VerticalAlignment = VerticalAlignment.Center,
+ Focusable = true,
+ Cursor = Cursors.Arrow
+ };
+ RenderOptions.SetBitmapScalingMode(_image, BitmapScalingMode.HighQuality);
+ _overlay = new TextBlock
+ {
+ Text = "연결 중…",
+ Foreground = Brushes.White,
+ FontSize = 16,
+ HorizontalAlignment = HorizontalAlignment.Center,
+ VerticalAlignment = VerticalAlignment.Center,
+ TextAlignment = TextAlignment.Center,
+ TextWrapping = TextWrapping.Wrap
+ };
+ stage.Children.Add(_image);
+ stage.Children.Add(_overlay);
+ root.Children.Add(stage);
+ Content = root;
+
+ _image.MouseMove += OnMouseMove;
+ _image.MouseDown += OnMouseDown;
+ _image.MouseUp += OnMouseUp;
+ _image.MouseWheel += OnMouseWheel;
+ PreviewKeyDown += OnKey;
+ PreviewKeyUp += OnKey;
+ Deactivated += (s, e) => ReleaseKeys();
+
+ _client.ScreenEvent += OnScreenEvent;
+ Loaded += async (s, e) => await StartAsync();
+ Closed += (s, e) => Stop();
+ }
+
+ // ───────────────────────── 시작 / 끝 ─────────────────────────
+
+ private async Task StartAsync()
+ {
+ try
+ {
+ _overlay.Text = "대상 PC 정보를 확인하는 중…";
+ var info = await _client.RequestAsync("screenInfo", null, TimeSpan.FromSeconds(15));
+ if (!Json.Bool(info, "allowView"))
+ {
+ End("대상 PC에서 화면 보기를 허용하지 않았습니다.\n(대상 PC 설정 → 원격 접속 허용 → 화면 보기 허용)");
+ return;
+ }
+
+ var monitors = (info["monitors"] as JsonArray)?.OfType().ToList() ?? new List();
+ var primary = monitors.FirstOrDefault(m => Json.Bool(m, "primary"));
+ _monitor = primary != null ? Json.Int(primary, "index", 1) : 1;
+ foreach (var m in monitors)
+ {
+ int idx = Json.Int(m, "index", 1);
+ AddMonitorButton($"모니터 {idx}" + (Json.Bool(m, "primary") ? " (주)" : ""), idx,
+ $"{Json.Int(m, "width")} × {Json.Int(m, "height")}");
+ }
+ if (monitors.Count > 1) AddMonitorButton("전체", 0, "모든 모니터를 한 화면에");
+ HighlightMonitor();
+
+ _overlay.Text = "대상 PC의 승인을 기다리는 중…";
+ var r = await _client.RequestAsync("screenStart", new JsonObject { ["monitor"] = _monitor }, TimeSpan.FromSeconds(45));
+ _sid = Json.Int(r, "sid");
+ _control = Json.Bool(r, "control");
+ _overlay.Text = "";
+ UpdateStatus();
+ _image.Cursor = _control ? Cursors.Cross : Cursors.Arrow;
+ _image.Focus();
+ }
+ catch (Exception ex)
+ {
+ End(RemoteClient.Friendly(ex));
+ }
+ }
+
+ private void AddMonitorButton(string text, int index, string tip)
+ {
+ var b = new Button { Content = text, Tag = index, ToolTip = tip, MinWidth = 70 };
+ b.Click += async (s, e) =>
+ {
+ _monitor = index;
+ HighlightMonitor();
+ try { await _client.RequestAsync("screenMonitor", new JsonObject { ["monitor"] = index }, TimeSpan.FromSeconds(10)); }
+ catch { }
+ _image.Focus();
+ };
+ _monitorButtons.Children.Add(b);
+ }
+
+ private void HighlightMonitor()
+ {
+ foreach (Button b in _monitorButtons.Children)
+ {
+ bool on = (int)b.Tag == _monitor;
+ b.FontWeight = on ? FontWeights.Bold : FontWeights.Normal;
+ b.Background = on ? Brushes2.Make("#BBDEFB") : Brushes.White;
+ }
+ }
+
+ private void End(string reason)
+ {
+ _ended = true;
+ _overlay.Text = reason;
+ _status.Text = "연결 끝";
+ _image.Cursor = Cursors.Arrow;
+ }
+
+ private void Stop()
+ {
+ _client.ScreenEvent -= OnScreenEvent;
+ ReleaseKeys();
+ if (_sid > 0 && !_ended)
+ _ = _client.RequestAsync("screenStop", null, TimeSpan.FromSeconds(5)).ContinueWith(t => { _ = t.Exception; });
+ }
+
+ private void UpdateStatus()
+ {
+ var secs = (DateTime.UtcNow - _fpsStart).TotalSeconds;
+ double fps = secs > 0 ? _frames / secs : 0;
+ if (secs > 3) { _frames = 0; _fpsStart = DateTime.UtcNow; }
+ _status.Text = $"{(_control ? "● 제어 중" : "보기 전용")} · {fps:0.0}장/초";
+ _status.Foreground = _control ? Brushes2.Stopped : Brushes2.Make("#37474F");
+ }
+
+ // ───────────────────────── 화면 받기 ─────────────────────────
+
+ private void OnScreenEvent(JsonObject msg)
+ {
+ var ev = Json.Str(msg, "event");
+ int sid = Json.Int(msg, "sid");
+ if (ev == "screenEnd")
+ {
+ if (sid == 0 || sid == _sid)
+ {
+ var reason = Json.Str(msg, "reason");
+ Dispatcher.BeginInvoke(new Action(() => End("원격 화면 연결이 끝났습니다.\n" + reason)));
+ }
+ return;
+ }
+ if (ev != "frame" || sid != _sid || _ended) return;
+
+ // 화면 그리기가 밀리면 이전 장면은 건너뜀
+ if (Interlocked.Exchange(ref _decoding, 1) == 1) return;
+ try
+ {
+ var bytes = Convert.FromBase64String(Json.Str(msg, "data"));
+ var bmp = new BitmapImage();
+ bmp.BeginInit();
+ bmp.CacheOption = BitmapCacheOption.OnLoad;
+ bmp.StreamSource = new MemoryStream(bytes);
+ bmp.EndInit();
+ bmp.Freeze();
+ Dispatcher.BeginInvoke(new Action(() =>
+ {
+ _image.Source = bmp;
+ _frames++;
+ UpdateStatus();
+ Interlocked.Exchange(ref _decoding, 0);
+ }), DispatcherPriority.Render);
+ }
+ catch
+ {
+ Interlocked.Exchange(ref _decoding, 0);
+ }
+ }
+
+ // ───────────────────────── 입력 보내기 ─────────────────────────
+
+ private bool CanControl => _control && !_ended && _sid > 0;
+
+ private (double x, double y) Norm(MouseEventArgs e)
+ {
+ var p = e.GetPosition(_image);
+ double w = Math.Max(1, _image.ActualWidth), h = Math.Max(1, _image.ActualHeight);
+ return (Math.Clamp(p.X / w, 0, 1), Math.Clamp(p.Y / h, 0, 1));
+ }
+
+ private void SendMouse(MouseEventArgs e, string action, string button = "", int delta = 0)
+ {
+ var (x, y) = Norm(e);
+ _client.SendNoReply(new JsonObject
+ {
+ ["op"] = "input",
+ ["kind"] = "mouse",
+ ["action"] = action,
+ ["button"] = button,
+ ["x"] = Math.Round(x, 5),
+ ["y"] = Math.Round(y, 5),
+ ["delta"] = delta
+ });
+ }
+
+ private static string ButtonName(MouseButton b) => b switch
+ {
+ MouseButton.Right => "right",
+ MouseButton.Middle => "middle",
+ _ => "left"
+ };
+
+ private void OnMouseMove(object sender, MouseEventArgs e)
+ {
+ if (!CanControl) return;
+ // 너무 자주 보내지 않도록 (약 30회/초)
+ if ((DateTime.UtcNow - _lastMove).TotalMilliseconds < 33) return;
+ _lastMove = DateTime.UtcNow;
+ SendMouse(e, "move");
+ }
+
+ private void OnMouseDown(object sender, MouseButtonEventArgs e)
+ {
+ _image.Focus();
+ if (!CanControl) return;
+ _image.CaptureMouse();
+ SendMouse(e, "down", ButtonName(e.ChangedButton));
+ e.Handled = true;
+ }
+
+ private void OnMouseUp(object sender, MouseButtonEventArgs e)
+ {
+ if (!CanControl) return;
+ SendMouse(e, "up", ButtonName(e.ChangedButton));
+ if (Mouse.LeftButton == MouseButtonState.Released && Mouse.RightButton == MouseButtonState.Released && Mouse.MiddleButton == MouseButtonState.Released)
+ _image.ReleaseMouseCapture();
+ e.Handled = true;
+ }
+
+ private void OnMouseWheel(object sender, MouseWheelEventArgs e)
+ {
+ if (!CanControl) return;
+ SendMouse(e, "wheel", "", e.Delta);
+ e.Handled = true;
+ }
+
+ private void OnKey(object sender, KeyEventArgs e)
+ {
+ if (!CanControl) return;
+ var key = e.Key switch
+ {
+ Key.System => e.SystemKey,
+ Key.ImeProcessed => e.ImeProcessedKey,
+ Key.DeadCharProcessed => e.DeadCharProcessedKey,
+ _ => e.Key
+ };
+ int vk = KeyInterop.VirtualKeyFromKey(key);
+ if (vk <= 0) return;
+ bool down = e.RoutedEvent == Keyboard.PreviewKeyDownEvent;
+ if (down) _pressedKeys.Add(vk); else _pressedKeys.Remove(vk);
+ _client.SendNoReply(new JsonObject { ["op"] = "input", ["kind"] = "key", ["vk"] = vk, ["down"] = down });
+ e.Handled = true;
+ }
+
+ /// 창을 벗어나면 눌린 키가 대상 PC에 남지 않도록 모두 뗌
+ private void ReleaseKeys()
+ {
+ if (_pressedKeys.Count == 0) return;
+ foreach (var vk in _pressedKeys.ToList())
+ _client.SendNoReply(new JsonObject { ["op"] = "input", ["kind"] = "key", ["vk"] = vk, ["down"] = false });
+ _pressedKeys.Clear();
+ }
+ }
+}
diff --git a/Windows/ServiceRow.cs b/Windows/ServiceRow.cs
index 24bbc29..1d52230 100644
--- a/Windows/ServiceRow.cs
+++ b/Windows/ServiceRow.cs
@@ -49,6 +49,9 @@ namespace JJServiceManager
/// 마지막으로 기록한 연결 실패 메시지 (같은 메시지 반복 기록 방지)
public string Tag { get; set; }
+ /// 원격 PC 제목 줄의 [원격관리] 버튼
+ public Visibility RemoteButtonVisibility => IsLocal ? Visibility.Collapsed : Visibility.Visible;
+
public Brush HeaderBrush => IsLocal ? Brushes2.Make("#E3F2FD") : Brushes2.Make("#F3E5F5");
public HostGroup()
diff --git a/Windows/SettingsWindow.cs b/Windows/SettingsWindow.cs
index 0a7b684..7a167ec 100644
--- a/Windows/SettingsWindow.cs
+++ b/Windows/SettingsWindow.cs
@@ -22,7 +22,7 @@ namespace JJServiceManager
private Key _key;
// 원격 접속 허용
- private readonly CheckBox _server;
+ private readonly CheckBox _server, _activeListen, _allowView, _allowControl, _requireApproval;
private readonly ComboBox _serverMode;
private readonly TextBox _serverPort;
private readonly TextBlock _serverPortHint, _pwState, _serverInfo;
@@ -30,13 +30,25 @@ namespace JJServiceManager
private int _sshPort, _tcpPort;
private int _shownMode = -1;
+ // Passive 접속 (대상 PC 쪽)
+ private readonly ListBox _pmList;
+ private readonly TextBox _pmAddr, _pmCode, _pmPort;
+ private readonly ComboBox _pmMode;
+ private readonly TextBlock _pmResult;
+ private readonly ObservableCollection _pms = new ObservableCollection();
+
// 관리 대상 PC
private readonly CheckBox _manage;
+ private readonly ComboBox _passiveMode;
+ private readonly TextBox _passivePort;
+ private int _passiveSshPort, _passiveTcpPort;
+ private int _shownPassiveMode = -1;
private readonly ListBox _targets;
- private readonly TextBox _addr, _hostname, _port;
- private readonly ComboBox _mode;
+ private readonly TextBox _addr, _hostname, _port, _code;
+ private readonly ComboBox _mode, _direction;
private readonly PasswordBox _pwdTarget;
- private readonly TextBlock _pwTargetState, _test;
+ private readonly TextBlock _pwTargetState, _test, _lblAddr;
+ private readonly FrameworkElement _lblMode, _lblPort, _lblCode, _pnlCode;
private readonly Button _btnTest;
private readonly ObservableCollection _items = new ObservableCollection();
@@ -46,7 +58,9 @@ namespace JJServiceManager
public RemoteTarget T { get; }
public TargetItem(RemoteTarget t) => T = t;
public string Label => T.Label;
- public string ModeText => (T.Mode == RemoteMode.Ssh ? "SSH" : "지정 포트") + " " + T.Port;
+ public string ModeText => T.Direction == ConnectDirection.Passive
+ ? "Passive · 코드 " + T.AccessCode
+ : "Active · " + (T.Mode == RemoteMode.Ssh ? "SSH" : "지정 포트") + " " + T.Port;
public void Changed()
{
OnPropertyChanged(nameof(Label));
@@ -59,8 +73,8 @@ namespace JJServiceManager
_s = current;
Title = "설정" + AppInstance.TitleTag;
- Width = 780;
- Height = 640;
+ Width = 800;
+ Height = 720;
MinHeight = 560;
ResizeMode = ResizeMode.CanResize;
FontFamily = new FontFamily("Malgun Gothic");
@@ -105,6 +119,10 @@ namespace JJServiceManager
// ───────── 원격 접속 허용 ─────────
_server = root.Find("chkServer");
+ _activeListen = root.Find("chkActiveListen");
+ _allowView = root.Find("chkAllowView");
+ _allowControl = root.Find("chkAllowControl");
+ _requireApproval = root.Find("chkRequireApproval");
_serverMode = root.Find("cmbServerMode");
_serverPort = root.Find("txtServerPort");
_serverPortHint = root.Find("txtServerPortHint");
@@ -116,26 +134,76 @@ namespace JJServiceManager
_sshPort = current.RemoteServerSshPort;
_tcpPort = current.RemoteServerTcpPort;
_server.IsChecked = current.RemoteServerEnabled;
+ _activeListen.IsChecked = current.RemoteServerActiveListen;
+ _allowView.IsChecked = current.RemoteAllowView;
+ _allowControl.IsChecked = current.RemoteAllowControl;
+ _requireApproval.IsChecked = current.RemoteRequireApproval;
_serverMode.SelectionChanged += (s, e) => OnServerModeChanged();
_serverMode.SelectedIndex = (int)current.RemoteServerMode;
_pwState.Text = string.IsNullOrEmpty(current.RemotePasswordHash)
? "비밀번호가 설정되어 있지 않습니다. 원격 접속을 허용하려면 입력하세요."
: "비밀번호가 설정되어 있습니다. 바꿀 때만 입력하세요.";
+
+ // 제어 허용은 화면 보기가 켜져 있어야 함
+ _allowView.Checked += (s, e) => _allowControl.IsEnabled = true;
+ _allowView.Unchecked += (s, e) => { _allowControl.IsChecked = false; _allowControl.IsEnabled = false; };
+ _allowControl.IsEnabled = _allowView.IsChecked == true;
UpdateServerInfo();
+ // Passive 접속 (이 PC → 관리 PC)
+ _pmList = root.Find("lstPassive");
+ _pmAddr = root.Find("txtPmAddr");
+ _pmCode = root.Find("txtPmCode");
+ _pmPort = root.Find("txtPmPort");
+ _pmMode = root.Find("cmbPmMode");
+ _pmResult = root.Find("txtPmResult");
+ foreach (var m in current.PassiveManagers) _pms.Add(m.Clone());
+ _pmList.ItemsSource = _pms;
+ _pmList.SelectionChanged += (s, e) => ShowPm(_pmList.SelectedItem as PassiveManager);
+ _pmMode.SelectionChanged += (s, e) =>
+ {
+ if (_pmPort.Text == "" || _pmPort.Text == "9990" || _pmPort.Text == "2224")
+ _pmPort.Text = _pmMode.SelectedIndex == 0 ? "2224" : "9990";
+ };
+ ClearPmForm();
+ root.Find