원격관리(화면 보기/제어)와 Active/Passive 연결 방향 추가
- 원격관리: PC 제목 줄 [원격관리] → 숫자 6자리 → 원격 화면 창 (모니터 1/2/…/전체, 제어 허용 시 마우스·키보드 바로 전달) - 대상 PC 설정: 화면 보기 허용 / 제어 허용 / 연결 시 승인 필요, 연결 중 상단 표시줄 + [연결 끊기] - Passive: 대상 PC가 관리 PC로 접속 (접속 코드로 구분, 끊기면 자동 재접속) - 관리 대상 PC마다 Active/Passive 선택, Passive 수신 방식·포트, 방화벽 규칙 자동 - 수정: Windows 명령 출력 글자 깨짐(UTF-8/OEM 자동 판별), 대상 목록 표시, 창을 처음 뜰 때 맨 앞으로 - 수정: Passive만 쓸 때 대상 PC 비밀번호가 반영되지 않던 문제 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013U497kUNL8ArhV7jkzCpKamain
parent
091881a5c4
commit
e7f7bcf1f2
|
|
@ -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
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Windows 명령(schtasks, netsh 등)을 실행하고 출력을 읽음.
|
||||||
|
/// 시스템 설정에 따라 출력이 UTF-8 또는 CP949(OEM)일 수 있어,
|
||||||
|
/// 바이트로 받은 뒤 UTF-8로 올바르게 읽히면 UTF-8, 아니면 시스템 OEM 코드페이지로 해석함.
|
||||||
|
/// </summary>
|
||||||
|
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<byte[]> 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); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -61,10 +61,24 @@ namespace JJServiceManager
|
||||||
Port = 1 // 지정 포트 + TLS (기본 9988)
|
Port = 1 // 지정 포트 + TLS (기본 9988)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>접속 방향</summary>
|
||||||
|
public enum ConnectDirection
|
||||||
|
{
|
||||||
|
Active = 0, // 관리 PC → 대상 PC 로 접속 (대상 PC가 포트를 엶)
|
||||||
|
Passive = 1 // 대상 PC → 관리 PC 로 접속 (관리 PC가 포트를 엶)
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>관리 대상 PC</summary>
|
/// <summary>관리 대상 PC</summary>
|
||||||
public class RemoteTarget
|
public class RemoteTarget
|
||||||
{
|
{
|
||||||
public string Id { get; set; } = Guid.NewGuid().ToString("N");
|
public string Id { get; set; } = Guid.NewGuid().ToString("N");
|
||||||
|
|
||||||
|
/// <summary>Active: 관리 PC가 접속 / Passive: 대상 PC가 접속해 옴</summary>
|
||||||
|
public ConnectDirection Direction { get; set; } = ConnectDirection.Active;
|
||||||
|
|
||||||
|
/// <summary>Passive일 때 대상 PC가 알려주는 접속 코드 (예: AB12-CD34)</summary>
|
||||||
|
public string AccessCode { get; set; } = "";
|
||||||
|
|
||||||
public string Address { get; set; } = "";
|
public string Address { get; set; } = "";
|
||||||
/// <summary>접속할 때 자동으로 채워짐</summary>
|
/// <summary>접속할 때 자동으로 채워짐</summary>
|
||||||
public string Hostname { get; set; } = "";
|
public string Hostname { get; set; } = "";
|
||||||
|
|
@ -73,10 +87,50 @@ namespace JJServiceManager
|
||||||
/// <summary>접속 비밀번호 (Windows 사용자 계정으로 암호화, Base64)</summary>
|
/// <summary>접속 비밀번호 (Windows 사용자 계정으로 암호화, Base64)</summary>
|
||||||
public string ProtectedPassword { get; set; } = "";
|
public string ProtectedPassword { get; set; } = "";
|
||||||
|
|
||||||
|
/// <summary>Passive로 접속해 온 대상 PC의 IP (표시용, 자동)</summary>
|
||||||
|
public string LastAddress { get; set; } = "";
|
||||||
|
|
||||||
[JsonIgnore]
|
[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();
|
public RemoteTarget Clone() => (RemoteTarget)MemberwiseClone();
|
||||||
|
|
||||||
|
/// <summary>새 접속 코드 (헷갈리는 글자 0/O, 1/I 제외)</summary>
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>대상 PC 쪽: Passive로 접속할 관리 PC</summary>
|
||||||
|
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();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>로그창 띄우기 방식</summary>
|
/// <summary>로그창 띄우기 방식</summary>
|
||||||
|
|
@ -160,11 +214,36 @@ namespace JJServiceManager
|
||||||
[JsonIgnore]
|
[JsonIgnore]
|
||||||
public int RemoteServerPort => RemoteServerMode == RemoteMode.Ssh ? RemoteServerSshPort : RemoteServerTcpPort;
|
public int RemoteServerPort => RemoteServerMode == RemoteMode.Ssh ? RemoteServerSshPort : RemoteServerTcpPort;
|
||||||
|
|
||||||
|
/// <summary>Active 수신: 이 PC가 포트를 열고 관리 PC의 접속을 받음</summary>
|
||||||
|
public bool RemoteServerActiveListen { get; set; } = true;
|
||||||
|
|
||||||
|
/// <summary>Passive 접속: 이 PC가 관리 PC로 접속</summary>
|
||||||
|
public List<PassiveManager> PassiveManagers { get; set; } = new List<PassiveManager>();
|
||||||
|
|
||||||
|
// ───────── 원격관리 (화면 보기 / 제어) ─────────
|
||||||
|
|
||||||
|
/// <summary>원격에서 이 PC 화면 보기 허용</summary>
|
||||||
|
public bool RemoteAllowView { get; set; }
|
||||||
|
|
||||||
|
/// <summary>원격에서 이 PC 키보드·마우스 제어 허용 (화면 보기가 켜져 있어야 함)</summary>
|
||||||
|
public bool RemoteAllowControl { get; set; }
|
||||||
|
|
||||||
|
/// <summary>원격 화면 연결 시 이 PC에서 승인해야 함</summary>
|
||||||
|
public bool RemoteRequireApproval { get; set; } = true;
|
||||||
|
|
||||||
// ───────── 다른 PC 관리 (관리 역할) ─────────
|
// ───────── 다른 PC 관리 (관리 역할) ─────────
|
||||||
|
|
||||||
public bool RemoteManageEnabled { get; set; }
|
public bool RemoteManageEnabled { get; set; }
|
||||||
public List<RemoteTarget> Targets { get; set; } = new List<RemoteTarget>();
|
public List<RemoteTarget> Targets { get; set; } = new List<RemoteTarget>();
|
||||||
|
|
||||||
|
/// <summary>Passive 대상 PC의 접속을 기다리는 방식/포트</summary>
|
||||||
|
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;
|
||||||
|
|
||||||
/// <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>();
|
||||||
|
|
||||||
|
|
@ -203,6 +282,7 @@ namespace JJServiceManager
|
||||||
{
|
{
|
||||||
s.Services ??= new List<ServiceEntry>();
|
s.Services ??= new List<ServiceEntry>();
|
||||||
s.Targets ??= new List<RemoteTarget>();
|
s.Targets ??= new List<RemoteTarget>();
|
||||||
|
s.PassiveManagers ??= new List<PassiveManager>();
|
||||||
s.OpenHostTabs ??= new List<string>();
|
s.OpenHostTabs ??= new List<string>();
|
||||||
return s;
|
return s;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,24 +1,22 @@
|
||||||
using System;
|
using System;
|
||||||
using System.Collections.Concurrent;
|
using System.Collections.Concurrent;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Diagnostics;
|
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Net.Security;
|
|
||||||
using System.Net.Sockets;
|
using System.Net.Sockets;
|
||||||
using System.Security.Claims;
|
|
||||||
using System.Text.Json.Nodes;
|
using System.Text.Json.Nodes;
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using Microsoft.DevTunnels.Ssh;
|
using Microsoft.DevTunnels.Ssh;
|
||||||
using SshClientTcp = Microsoft.DevTunnels.Ssh.Tcp.SshClient;
|
|
||||||
|
|
||||||
namespace JJServiceManager.Remote
|
namespace JJServiceManager.Remote
|
||||||
{
|
{
|
||||||
public enum RemoteState { Connecting, Connected, Disconnected, AuthFailed }
|
public enum RemoteState { Connecting, Connected, Disconnected, AuthFailed }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 관리 역할: 대상 PC 하나에 접속을 유지하며 서비스 목록을 주기적으로 받아옴.
|
/// 관리 역할: 대상 PC 하나와 연결을 유지하며 서비스 목록을 주기적으로 받아옴.
|
||||||
/// 끊기면 자동으로 다시 접속함.
|
/// - Active: 이쪽에서 대상 PC로 접속 (끊기면 자동 재접속)
|
||||||
|
/// - Passive: 대상 PC가 접속해 오기를 기다림 (PassiveHub가 연결을 넘겨줌)
|
||||||
|
/// 연결 하나로 목록 / Start·Stop / 로그 / 원격 화면을 모두 주고받음.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class RemoteClient : IDisposable
|
public sealed class RemoteClient : IDisposable
|
||||||
{
|
{
|
||||||
|
|
@ -26,9 +24,12 @@ namespace JJServiceManager.Remote
|
||||||
private static readonly TimeSpan PollInterval = TimeSpan.FromSeconds(2);
|
private static readonly TimeSpan PollInterval = TimeSpan.FromSeconds(2);
|
||||||
|
|
||||||
private readonly RemoteTarget _target;
|
private readonly RemoteTarget _target;
|
||||||
|
private readonly Func<string> _password;
|
||||||
private readonly CancellationTokenSource _cts = new CancellationTokenSource();
|
private readonly CancellationTokenSource _cts = new CancellationTokenSource();
|
||||||
private readonly ConcurrentDictionary<int, TaskCompletionSource<JsonObject>> _pending = new ConcurrentDictionary<int, TaskCompletionSource<JsonObject>>();
|
private readonly ConcurrentDictionary<int, TaskCompletionSource<JsonObject>> _pending = new ConcurrentDictionary<int, TaskCompletionSource<JsonObject>>();
|
||||||
private readonly ConcurrentDictionary<int, LogSubscriber> _logs = new ConcurrentDictionary<int, LogSubscriber>();
|
private readonly ConcurrentDictionary<int, LogSubscriber> _logs = new ConcurrentDictionary<int, LogSubscriber>();
|
||||||
|
private readonly object _incomingLock = new object();
|
||||||
|
private TaskCompletionSource<(JsonLineConnection conn, string peer)> _incoming;
|
||||||
private JsonLineConnection _conn;
|
private JsonLineConnection _conn;
|
||||||
private int _nextId;
|
private int _nextId;
|
||||||
|
|
||||||
|
|
@ -39,19 +40,26 @@ namespace JJServiceManager.Remote
|
||||||
}
|
}
|
||||||
|
|
||||||
public RemoteTarget Target => _target;
|
public RemoteTarget Target => _target;
|
||||||
|
public bool IsPassive => _target.Direction == ConnectDirection.Passive;
|
||||||
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; } = "";
|
||||||
public List<RemoteServiceInfo> Services { get; private set; } = new List<RemoteServiceInfo>();
|
public List<RemoteServiceInfo> Services { get; private set; } = new List<RemoteServiceInfo>();
|
||||||
|
|
||||||
|
/// <summary>대상 PC가 화면 보기 / 제어를 허용했는지 (목록 받을 때 갱신)</summary>
|
||||||
|
public bool AllowView { get; private set; }
|
||||||
|
public bool AllowControl { get; private set; }
|
||||||
|
|
||||||
/// <summary>연결 상태가 바뀜 (백그라운드 스레드에서 발생)</summary>
|
/// <summary>연결 상태가 바뀜 (백그라운드 스레드에서 발생)</summary>
|
||||||
public event Action<RemoteClient> StateChanged;
|
public event Action<RemoteClient> StateChanged;
|
||||||
/// <summary>서비스 목록을 새로 받음 (백그라운드 스레드에서 발생)</summary>
|
/// <summary>서비스 목록을 새로 받음 (백그라운드 스레드에서 발생)</summary>
|
||||||
public event Action<RemoteClient> ServicesChanged;
|
public event Action<RemoteClient> ServicesChanged;
|
||||||
/// <summary>대상 PC의 hostname을 처음 알게 되었거나 바뀜</summary>
|
/// <summary>대상 PC의 hostname을 처음 알게 되었거나 바뀜</summary>
|
||||||
public event Action<RemoteClient, string> HostnameDiscovered;
|
public event Action<RemoteClient, string> HostnameDiscovered;
|
||||||
|
/// <summary>Passive: 대상 PC가 접속해 온 주소</summary>
|
||||||
private readonly Func<string> _password;
|
public event Action<RemoteClient, string> PeerAddressDiscovered;
|
||||||
|
/// <summary>원격 화면 이벤트 (frame / screenEnd)</summary>
|
||||||
|
public event Action<JsonObject> ScreenEvent;
|
||||||
|
|
||||||
public RemoteClient(RemoteTarget target, Func<string> passwordProvider = null)
|
public RemoteClient(RemoteTarget target, Func<string> passwordProvider = null)
|
||||||
{
|
{
|
||||||
|
|
@ -67,21 +75,54 @@ namespace JJServiceManager.Remote
|
||||||
{
|
{
|
||||||
try { _cts.Cancel(); } catch { }
|
try { _cts.Cancel(); } catch { }
|
||||||
_conn?.Dispose();
|
_conn?.Dispose();
|
||||||
|
lock (_incomingLock) _incoming?.TrySetCanceled();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ───────────────────────── 접속 유지 ─────────────────────────
|
/// <summary>Passive: 대상 PC가 접속해 온 연결을 넘겨받음. 받아들였으면 true</summary>
|
||||||
|
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()
|
private async Task RunAsync()
|
||||||
{
|
{
|
||||||
var ct = _cts.Token;
|
var ct = _cts.Token;
|
||||||
while (!ct.IsCancellationRequested)
|
while (!ct.IsCancellationRequested)
|
||||||
{
|
{
|
||||||
SetState(RemoteState.Connecting, "연결 중…");
|
|
||||||
TimeSpan retry = TimeSpan.FromSeconds(5);
|
TimeSpan retry = TimeSpan.FromSeconds(5);
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var password = _password();
|
JsonLineConnection conn;
|
||||||
var (conn, hello) = await ConnectAsync(_target, password, ct).ConfigureAwait(false);
|
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;
|
_conn = conn;
|
||||||
|
|
||||||
var host = Json.Str(hello, "hostname");
|
var host = Json.Str(hello, "hostname");
|
||||||
|
|
@ -90,14 +131,18 @@ namespace JJServiceManager.Remote
|
||||||
Hostname = host;
|
Hostname = host;
|
||||||
HostnameDiscovered?.Invoke(this, host);
|
HostnameDiscovered?.Invoke(this, host);
|
||||||
}
|
}
|
||||||
|
AllowView = Json.Bool(hello, "allowView");
|
||||||
|
AllowControl = Json.Bool(hello, "allowControl");
|
||||||
SetState(RemoteState.Connected, "연결됨");
|
SetState(RemoteState.Connected, "연결됨");
|
||||||
|
|
||||||
var reader = Task.Run(() => ReadLoopAsync(conn, ct));
|
var reader = Task.Run(() => ReadLoopAsync(conn, ct));
|
||||||
// 목록 주기적으로 받기
|
// 목록 주기적으로 받기
|
||||||
while (!ct.IsCancellationRequested && !reader.IsCompleted)
|
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);
|
Services = ParseServices(res);
|
||||||
|
AllowView = Json.Bool(res, "allowView");
|
||||||
|
AllowControl = Json.Bool(res, "allowControl");
|
||||||
ServicesChanged?.Invoke(this);
|
ServicesChanged?.Invoke(this);
|
||||||
await Task.WhenAny(reader, Task.Delay(PollInterval, ct)).ConfigureAwait(false);
|
await Task.WhenAny(reader, Task.Delay(PollInterval, ct)).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
|
|
@ -111,11 +156,12 @@ namespace JJServiceManager.Remote
|
||||||
catch (RemoteAuthException ex)
|
catch (RemoteAuthException ex)
|
||||||
{
|
{
|
||||||
SetState(RemoteState.AuthFailed, ex.Message);
|
SetState(RemoteState.AuthFailed, ex.Message);
|
||||||
retry = TimeSpan.FromSeconds(30);
|
retry = IsPassive ? TimeSpan.FromSeconds(1) : TimeSpan.FromSeconds(30);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
SetState(RemoteState.Disconnected, Friendly(ex));
|
SetState(RemoteState.Disconnected, Friendly(ex));
|
||||||
|
if (IsPassive) retry = TimeSpan.FromSeconds(1);
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
|
|
@ -124,6 +170,7 @@ namespace JJServiceManager.Remote
|
||||||
c?.Dispose();
|
c?.Dispose();
|
||||||
FailPending("연결이 끊겼습니다.");
|
FailPending("연결이 끊겼습니다.");
|
||||||
_logs.Clear();
|
_logs.Clear();
|
||||||
|
ScreenEvent?.Invoke(new JsonObject { ["event"] = "screenEnd", ["sid"] = 0, ["reason"] = "대상 PC와 연결이 끊겼습니다." });
|
||||||
}
|
}
|
||||||
|
|
||||||
try { await Task.Delay(retry, ct).ConfigureAwait(false); }
|
try { await Task.Delay(retry, ct).ConfigureAwait(false); }
|
||||||
|
|
@ -141,6 +188,11 @@ namespace JJServiceManager.Remote
|
||||||
if (msg.ContainsKey("event"))
|
if (msg.ContainsKey("event"))
|
||||||
{
|
{
|
||||||
var ev = Json.Str(msg, "event");
|
var ev = Json.Str(msg, "event");
|
||||||
|
if (ev == "frame" || ev == "screenEnd")
|
||||||
|
{
|
||||||
|
ScreenEvent?.Invoke(msg);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
int sid = Json.Int(msg, "stream");
|
int sid = Json.Int(msg, "stream");
|
||||||
if (!_logs.TryGetValue(sid, out var sub)) continue;
|
if (!_logs.TryGetValue(sid, out var sub)) continue;
|
||||||
if (ev == "log")
|
if (ev == "log")
|
||||||
|
|
@ -215,6 +267,15 @@ namespace JJServiceManager.Remote
|
||||||
return reply["result"] as JsonObject ?? new JsonObject();
|
return reply["result"] as JsonObject ?? new JsonObject();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>응답을 기다리지 않고 보냄 (원격 화면 입력)</summary>
|
||||||
|
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<JsonObject> StartServiceAsync(string service)
|
public Task<JsonObject> StartServiceAsync(string service)
|
||||||
=> RequestAsync("start", new JsonObject { ["service"] = service }, TimeSpan.FromSeconds(90));
|
=> RequestAsync("start", new JsonObject { ["service"] = service }, TimeSpan.FromSeconds(90));
|
||||||
|
|
||||||
|
|
@ -246,72 +307,35 @@ namespace JJServiceManager.Remote
|
||||||
|
|
||||||
// ───────────────────────── 연결 ─────────────────────────
|
// ───────────────────────── 연결 ─────────────────────────
|
||||||
|
|
||||||
/// <summary>접속 + 인증 + hello. 성공하면 연결과 hello 결과</summary>
|
/// <summary>Active: 접속 + 인증 + hello. 성공하면 연결과 hello 결과</summary>
|
||||||
public static async Task<(JsonLineConnection conn, JsonObject hello)> ConnectAsync(RemoteTarget t, string password, CancellationToken ct)
|
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.IsNullOrWhiteSpace(t.Address)) throw new RemoteException("대상 IP가 비어 있습니다.");
|
||||||
if (string.IsNullOrEmpty(password)) throw new RemoteAuthException("접속 비밀번호가 저장되어 있지 않습니다.");
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>연결 위에서 hello (비밀번호 확인). 실패하면 연결을 닫고 예외</summary>
|
||||||
|
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);
|
||||||
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
|
try
|
||||||
{
|
{
|
||||||
var hello = new JsonObject
|
var hello = new JsonObject
|
||||||
|
|
@ -322,9 +346,9 @@ namespace JJServiceManager.Remote
|
||||||
["version"] = RemoteServer.Version
|
["version"] = RemoteServer.Version
|
||||||
};
|
};
|
||||||
if (sendPassword) hello["password"] = password;
|
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 (reply == null) throw new RemoteException("대상 PC가 연결을 끊었습니다.");
|
||||||
if (!Json.Bool(reply, "ok"))
|
if (!Json.Bool(reply, "ok"))
|
||||||
{
|
{
|
||||||
|
|
@ -332,7 +356,7 @@ namespace JJServiceManager.Remote
|
||||||
if (Json.Bool(reply, "authFailed")) throw new RemoteAuthException(err);
|
if (Json.Bool(reply, "authFailed")) throw new RemoteAuthException(err);
|
||||||
throw new RemoteException(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)
|
catch (OperationCanceledException) when (!ct.IsCancellationRequested)
|
||||||
{
|
{
|
||||||
|
|
@ -380,12 +404,14 @@ namespace JJServiceManager.Remote
|
||||||
return ex switch
|
return ex switch
|
||||||
{
|
{
|
||||||
RemoteException re => re.Message,
|
RemoteException re => re.Message,
|
||||||
|
RemoteAuthException ra => ra.Message,
|
||||||
SocketException se when se.SocketErrorCode == SocketError.ConnectionRefused
|
SocketException se when se.SocketErrorCode == SocketError.ConnectionRefused
|
||||||
=> "연결이 거부되었습니다. (대상 PC에서 원격 접속 허용이 꺼져 있거나 포트가 다름)",
|
=> "연결이 거부되었습니다. (상대 PC에서 기능이 꺼져 있거나 포트가 다름)",
|
||||||
SocketException se when se.SocketErrorCode == SocketError.HostNotFound
|
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
|
SocketException se when se.SocketErrorCode == SocketError.TimedOut || se.SocketErrorCode == SocketError.HostUnreachable || se.SocketErrorCode == SocketError.NetworkUnreachable
|
||||||
=> "대상 PC에 닿지 않습니다. (네트워크, 방화벽 확인)",
|
=> "상대 PC에 닿지 않습니다. (네트워크, 방화벽 확인)",
|
||||||
|
OperationCanceledException => "연결 시간이 초과되었습니다. (주소, 포트, 방화벽 확인)",
|
||||||
System.Security.Authentication.AuthenticationException
|
System.Security.Authentication.AuthenticationException
|
||||||
=> "암호화 연결에 실패했습니다. (방식(SSH/지정 포트)이 맞는지 확인)",
|
=> "암호화 연결에 실패했습니다. (방식(SSH/지정 포트)이 맞는지 확인)",
|
||||||
SshConnectionException
|
SshConnectionException
|
||||||
|
|
@ -402,6 +428,104 @@ namespace JJServiceManager.Remote
|
||||||
public RemoteAuthException(string message) : base(message) { }
|
public RemoteAuthException(string message) : base(message) { }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 관리 PC: Passive 대상 PC들의 접속을 받는 곳.
|
||||||
|
/// 접속해 온 연결의 첫 메시지(announce)의 접속 코드로 어느 대상인지 찾아 RemoteClient에 넘김.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class PassiveHub : IDisposable
|
||||||
|
{
|
||||||
|
private readonly ConcurrentDictionary<string, RemoteClient> _byCode = new ConcurrentDictionary<string, RemoteClient>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
private readonly Action<string, LogLevel> _log;
|
||||||
|
private RemoteListener _listener;
|
||||||
|
private string _key = "";
|
||||||
|
|
||||||
|
public PassiveHub(Action<string, LogLevel> 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();
|
||||||
|
|
||||||
|
/// <summary>수신 시작/중지 (방식·포트가 같으면 그대로)</summary>
|
||||||
|
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<bool> 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 = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>원격 서비스 로그 (LogTailView에서 로컬 파일과 같은 방식으로 사용)</summary>
|
/// <summary>원격 서비스 로그 (LogTailView에서 로컬 파일과 같은 방식으로 사용)</summary>
|
||||||
public sealed class RemoteLogSource : ILogSource
|
public sealed class RemoteLogSource : ILogSource
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -72,7 +72,7 @@ namespace JJServiceManager.Remote
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class JsonLineConnection : IDisposable
|
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 Stream _stream;
|
||||||
private readonly StreamReader _reader;
|
private readonly StreamReader _reader;
|
||||||
|
|
@ -89,32 +89,45 @@ namespace JJServiceManager.Remote
|
||||||
|
|
||||||
public bool IsDisposed => Volatile.Read(ref _disposed) == 1;
|
public bool IsDisposed => Volatile.Read(ref _disposed) == 1;
|
||||||
|
|
||||||
/// <summary>다음 메시지. 연결이 끊기면 null</summary>
|
private readonly char[] _buf = new char[64 * 1024];
|
||||||
|
private int _bufPos, _bufLen;
|
||||||
|
private readonly StringBuilder _line = new StringBuilder();
|
||||||
|
|
||||||
|
/// <summary>다음 메시지. 연결이 끊기면 null (한 번에 한 곳에서만 호출)</summary>
|
||||||
public async Task<JsonObject> ReadAsync(CancellationToken ct)
|
public async Task<JsonObject> ReadAsync(CancellationToken ct)
|
||||||
{
|
{
|
||||||
var sb = new StringBuilder();
|
_line.Clear();
|
||||||
var buf = new char[1];
|
|
||||||
while (true)
|
while (true)
|
||||||
{
|
{
|
||||||
int n = await _reader.ReadAsync(buf.AsMemory(0, 1), ct).ConfigureAwait(false);
|
if (_bufPos >= _bufLen)
|
||||||
if (n == 0) return null;
|
|
||||||
char c = buf[0];
|
|
||||||
if (c == '\n')
|
|
||||||
{
|
{
|
||||||
var line = sb.ToString().Trim();
|
_bufLen = await _reader.ReadAsync(_buf.AsMemory(), ct).ConfigureAwait(false);
|
||||||
sb.Clear();
|
_bufPos = 0;
|
||||||
if (line.Length == 0) continue;
|
if (_bufLen <= 0) return null;
|
||||||
try
|
}
|
||||||
{
|
|
||||||
return JsonNode.Parse(line) as JsonObject ?? new JsonObject();
|
int idx = Array.IndexOf(_buf, '\n', _bufPos, _bufLen - _bufPos);
|
||||||
}
|
if (idx < 0)
|
||||||
catch (JsonException)
|
{
|
||||||
{
|
_line.Append(_buf, _bufPos, _bufLen - _bufPos);
|
||||||
throw new IOException("잘못된 메시지를 받았습니다.");
|
_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
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Windows 방화벽 규칙 (원격 접속 허용 포트)
|
/// Windows 방화벽 규칙
|
||||||
/// 규칙 이름: "JJServiceManager Remote" (+ 테스트 인스턴스 꼬리표)
|
/// - "JJServiceManager Remote" : 대상 PC의 Active 수신 포트
|
||||||
|
/// - "JJServiceManager Passive" : 관리 PC의 Passive 수신 포트
|
||||||
|
/// (+ 테스트 인스턴스 꼬리표)
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static class FirewallManager
|
public static class FirewallManager
|
||||||
{
|
{
|
||||||
private static string RuleName => "JJServiceManager Remote" + AppInstance.Suffix;
|
public const string ActiveRule = "JJServiceManager Remote";
|
||||||
|
public const string PassiveRule = "JJServiceManager Passive";
|
||||||
|
|
||||||
/// <summary>enabled면 해당 포트를 열고, 아니면 규칙을 지움</summary>
|
/// <summary>enabled면 해당 포트를 열고, 아니면 규칙을 지움</summary>
|
||||||
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}\"");
|
ConsoleRunner.Run("netsh.exe", $"advfirewall firewall delete rule name=\"{name}\"");
|
||||||
if (!enabled) return (true, "방화벽 규칙 삭제");
|
if (!enabled) return (true, $"방화벽 규칙 삭제 ({name})");
|
||||||
|
|
||||||
var r = Run($"advfirewall firewall add rule name=\"{RuleName}\" dir=in action=allow protocol=TCP localport={port} profile=any");
|
var r = ConsoleRunner.Run("netsh.exe", $"advfirewall firewall add rule name=\"{name}\" dir=in action=allow protocol=TCP localport={port} profile=any");
|
||||||
return r.exit == 0
|
return r.exitCode == 0
|
||||||
? (true, $"방화벽 TCP {port} 허용")
|
? (true, $"방화벽 TCP {port} 허용 ({name})")
|
||||||
: (false, "방화벽 규칙 추가 실패: " + r.output);
|
: (false, $"방화벽 규칙 추가 실패 ({name}): {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; }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,60 +1,74 @@
|
||||||
using System;
|
using System;
|
||||||
|
using System.Collections.Concurrent;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Diagnostics;
|
using System.Diagnostics;
|
||||||
using System.Linq;
|
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.Text.Json.Nodes;
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
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
|
namespace JJServiceManager.Remote
|
||||||
{
|
{
|
||||||
|
/// <summary>원격관리(화면) 허용 설정</summary>
|
||||||
|
public readonly record struct ScreenPolicy(bool AllowView, bool AllowControl, bool RequireApproval);
|
||||||
|
|
||||||
|
/// <summary>지금 이 PC 화면을 보고 있는 관리 PC</summary>
|
||||||
|
public readonly record struct ScreenViewer(int Id, string Who, bool Control);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 원격 접속 허용 (대상 역할).
|
/// 원격 접속 허용 (대상 역할).
|
||||||
/// 관리 PC의 접속을 받아, 이 PC에 "등록된 서비스"에 한해서만
|
/// - Active: 포트를 열고 관리 PC의 접속을 받음
|
||||||
/// 목록 / 상태 / Start / Stop / 상세 / 로그 보기를 처리함.
|
/// - Passive: 관리 PC로 접속해 감 (접속 코드)
|
||||||
|
/// 연결된 뒤에는 똑같이, 이 PC에 "등록된 서비스"에 한해서만
|
||||||
|
/// 목록 / 상태 / Start / Stop / 상세 / 로그 보기를 처리하고,
|
||||||
|
/// 허용된 경우 화면 보기 / 키보드·마우스 제어를 처리함.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class RemoteServer : IDisposable
|
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 SshUser = "jjsm";
|
||||||
|
public const string PassiveSshUser = "jjsm-passive";
|
||||||
|
|
||||||
private static readonly TimeSpan OpTimeout = TimeSpan.FromSeconds(60);
|
private static readonly TimeSpan OpTimeout = TimeSpan.FromSeconds(60);
|
||||||
|
|
||||||
private readonly Func<IReadOnlyList<ServiceEntry>> _services;
|
private readonly Func<IReadOnlyList<ServiceEntry>> _services;
|
||||||
private readonly Action<string, LogLevel> _log;
|
private readonly Action<string, LogLevel> _log;
|
||||||
private readonly Action _changed;
|
private readonly Action _changed;
|
||||||
|
private readonly Func<ScreenPolicy> _policy;
|
||||||
|
private readonly Func<string, bool, Task<bool>> _approve;
|
||||||
private readonly object _sync = new object();
|
private readonly object _sync = new object();
|
||||||
|
|
||||||
private CancellationTokenSource _cts;
|
private RemoteListener _listener;
|
||||||
private TcpListener _listener;
|
|
||||||
private X509Certificate2 _cert;
|
|
||||||
private SshServerTcp _ssh;
|
|
||||||
private string _hash = "", _salt = "";
|
private string _hash = "", _salt = "";
|
||||||
private readonly Queue<DateTime> _failures = new Queue<DateTime>();
|
private readonly Queue<DateTime> _failures = new Queue<DateTime>();
|
||||||
|
|
||||||
public bool IsRunning { get; private set; }
|
// Passive
|
||||||
|
private CancellationTokenSource _passiveCts;
|
||||||
|
|
||||||
|
// 화면 보기 중인 세션
|
||||||
|
private readonly ConcurrentDictionary<int, ScreenSession> _screens = new ConcurrentDictionary<int, ScreenSession>();
|
||||||
|
private int _nextScreenId;
|
||||||
|
|
||||||
|
public bool IsRunning => _listener != null;
|
||||||
public RemoteMode Mode { get; private set; }
|
public RemoteMode Mode { get; private set; }
|
||||||
public int Port { get; private set; }
|
public int Port { get; private set; }
|
||||||
|
|
||||||
|
/// <summary>화면을 보는 관리 PC 목록이 바뀜 (백그라운드 스레드)</summary>
|
||||||
|
public event Action<IReadOnlyList<ScreenViewer>> ScreenViewersChanged;
|
||||||
|
|
||||||
/// <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>
|
||||||
public RemoteServer(Func<IReadOnlyList<ServiceEntry>> services, Action<string, LogLevel> log, Action changed)
|
/// <param name="policy">원격관리 허용 설정</param>
|
||||||
|
/// <param name="approve">화면 연결 승인 요청 (누가, 제어 여부) → 허용 여부</param>
|
||||||
|
public RemoteServer(Func<IReadOnlyList<ServiceEntry>> services, Action<string, LogLevel> log, Action changed,
|
||||||
|
Func<ScreenPolicy> policy, Func<string, bool, Task<bool>> approve)
|
||||||
{
|
{
|
||||||
_services = services;
|
_services = services;
|
||||||
_log = log;
|
_log = log;
|
||||||
_changed = changed;
|
_changed = changed;
|
||||||
|
_policy = policy;
|
||||||
|
_approve = approve;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>비밀번호만 바뀐 경우 (재시작 없이)</summary>
|
/// <summary>비밀번호만 바뀐 경우 (재시작 없이)</summary>
|
||||||
|
|
@ -67,162 +81,128 @@ namespace JJServiceManager.Remote
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ───────────────────────── Active: 접속 받기 ─────────────────────────
|
||||||
|
|
||||||
public void Start(RemoteMode mode, int port, string hash, string salt)
|
public void Start(RemoteMode mode, int port, string hash, string salt)
|
||||||
{
|
{
|
||||||
Stop();
|
Stop();
|
||||||
UpdatePassword(hash, salt);
|
UpdatePassword(hash, salt);
|
||||||
Mode = mode;
|
Mode = mode;
|
||||||
Port = port;
|
Port = port;
|
||||||
_cts = new CancellationTokenSource();
|
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
if (mode == RemoteMode.Port) StartTls(port, _cts.Token);
|
_listener = RemoteListener.Start(mode, port, CheckSshPasswordAsync,
|
||||||
else StartSsh(port);
|
(conn, peer, ct) => ServeAsync(conn, peer, requireHelloPassword: mode == RemoteMode.Port, ct));
|
||||||
IsRunning = true;
|
_listener.Failed += msg =>
|
||||||
_log($"원격 접속 허용 시작: {(mode == RemoteMode.Ssh ? "SSH" : "지정 포트(TLS)")} {port}", LogLevel.Success);
|
{
|
||||||
|
_log($"원격 접속 허용(SSH) 중단: {msg}", LogLevel.Error);
|
||||||
|
_listener?.Dispose();
|
||||||
|
_listener = null;
|
||||||
|
};
|
||||||
|
_log($"원격 접속 허용 시작 (Active 수신): {(mode == RemoteMode.Ssh ? "SSH" : "지정 포트(TLS)")} {port}", LogLevel.Success);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
IsRunning = false;
|
_listener = null;
|
||||||
_log($"원격 접속 허용 시작 실패 (포트 {port}): {ex.Message}", LogLevel.Error);
|
var msg = ex is System.Net.Sockets.SocketException se && se.SocketErrorCode == System.Net.Sockets.SocketError.AddressAlreadyInUse
|
||||||
Stop();
|
? $"포트 {port}를 다른 프로그램이 사용 중입니다."
|
||||||
|
: ex.Message;
|
||||||
|
_log($"원격 접속 허용 시작 실패 (포트 {port}): {msg}", LogLevel.Error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Stop()
|
public void Stop()
|
||||||
{
|
{
|
||||||
bool wasRunning = IsRunning;
|
if (_listener == null) return;
|
||||||
IsRunning = false;
|
_listener.Dispose();
|
||||||
try { _cts?.Cancel(); } catch { }
|
|
||||||
try { _listener?.Stop(); } catch { }
|
|
||||||
try { _ssh?.Dispose(); } catch { }
|
|
||||||
_listener = null;
|
_listener = null;
|
||||||
_ssh = null;
|
_log("원격 접속 허용 (Active 수신) 중지", LogLevel.Info);
|
||||||
_cts = null;
|
|
||||||
if (wasRunning) _log("원격 접속 허용 중지", LogLevel.Info);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Dispose() => Stop();
|
public void Dispose()
|
||||||
|
|
||||||
// ───────────────────────── 지정 포트 + TLS ─────────────────────────
|
|
||||||
|
|
||||||
private void StartTls(int port, CancellationToken ct)
|
|
||||||
{
|
{
|
||||||
_cert ??= CreateCertificate();
|
Stop();
|
||||||
_listener = new TcpListener(IPAddress.Any, port);
|
StopPassive();
|
||||||
_listener.Start();
|
StopAllScreens();
|
||||||
var listener = _listener;
|
|
||||||
_ = Task.Run(async () =>
|
|
||||||
{
|
|
||||||
while (!ct.IsCancellationRequested)
|
|
||||||
{
|
|
||||||
TcpClient tcp;
|
|
||||||
try { tcp = await listener.AcceptTcpClientAsync(ct).ConfigureAwait(false); }
|
|
||||||
catch { break; }
|
|
||||||
_ = Task.Run(() => HandleTcpAsync(tcp, ct));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task HandleTcpAsync(TcpClient tcp, CancellationToken ct)
|
// ───────────────────────── Passive: 관리 PC로 접속 ─────────────────────────
|
||||||
|
|
||||||
|
/// <summary>Passive 접속 목록 적용 (빈 목록이면 중지)</summary>
|
||||||
|
public void StartPassive(IReadOnlyList<PassiveManager> managers)
|
||||||
{
|
{
|
||||||
string peer = (tcp.Client.RemoteEndPoint as IPEndPoint)?.Address.ToString() ?? "?";
|
StopPassive();
|
||||||
try
|
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 retry = TimeSpan.FromSeconds(5);
|
||||||
var ssl = new SslStream(tcp.GetStream(), false);
|
try
|
||||||
using (var hs = CancellationTokenSource.CreateLinkedTokenSource(ct))
|
|
||||||
{
|
{
|
||||||
hs.CancelAfter(TimeSpan.FromSeconds(15));
|
JsonLineConnection conn;
|
||||||
await ssl.AuthenticateAsServerAsync(new SslServerAuthenticationOptions
|
using (var timeout = CancellationTokenSource.CreateLinkedTokenSource(ct))
|
||||||
{
|
{
|
||||||
ServerCertificate = _cert,
|
timeout.CancelAfter(TimeSpan.FromSeconds(10));
|
||||||
ClientCertificateRequired = false
|
conn = await RemoteDialer.DialAsync(m.Mode, m.Address, m.Port, PassiveSshUser, m.AccessCode, timeout.Token).ConfigureAwait(false);
|
||||||
}, hs.Token).ConfigureAwait(false);
|
await conn.SendAsync(new JsonObject
|
||||||
}
|
{
|
||||||
var conn = new JsonLineConnection(ssl, tcp);
|
["op"] = "announce",
|
||||||
await ServeAsync(conn, peer, requireHelloPassword: true, ct).ConfigureAwait(false);
|
["code"] = m.AccessCode,
|
||||||
}
|
["hostname"] = Environment.MachineName,
|
||||||
catch
|
["version"] = Version
|
||||||
{
|
}, timeout.Token).ConfigureAwait(false);
|
||||||
try { tcp.Dispose(); } catch { }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>TLS용 자체 서명 인증서 (실행할 때마다 새로 만듦, 지문 확인은 하지 않는 설정)</summary>
|
|
||||||
private static X509Certificate2 CreateCertificate()
|
|
||||||
{
|
|
||||||
using var rsa = RSA.Create(2048);
|
|
||||||
var req = new CertificateRequest("CN=JJServiceManager", rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
|
|
||||||
req.CertificateExtensions.Add(new X509KeyUsageExtension(X509KeyUsageFlags.DigitalSignature | X509KeyUsageFlags.KeyEncipherment, false));
|
|
||||||
req.CertificateExtensions.Add(new X509EnhancedKeyUsageExtension(new OidCollection { new Oid("1.3.6.1.5.5.7.3.1") }, false));
|
|
||||||
using var cert = req.CreateSelfSigned(DateTimeOffset.UtcNow.AddDays(-1), DateTimeOffset.UtcNow.AddYears(10));
|
|
||||||
// Windows(SChannel)에서 개인 키를 쓰려면 PFX로 한 번 내보냈다가 다시 읽어야 함
|
|
||||||
return new X509Certificate2(cert.Export(X509ContentType.Pfx), (string)null, X509KeyStorageFlags.Exportable);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ───────────────────────── 내장 SSH ─────────────────────────
|
|
||||||
|
|
||||||
private void StartSsh(int port)
|
|
||||||
{
|
|
||||||
var hostKey = SshAlgorithms.PublicKey.ECDsaSha2Nistp384.GenerateKeyPair();
|
|
||||||
var ssh = new SshServerTcp(SshSessionConfiguration.Default, new TraceSource("JJServiceManager.SshServer"));
|
|
||||||
ssh.Credentials = new SshServerCredentials(new[] { hostKey });
|
|
||||||
|
|
||||||
ssh.SessionAuthenticating += (s, e) =>
|
|
||||||
{
|
|
||||||
if (e.AuthenticationType == SshAuthenticationType.ClientPassword)
|
|
||||||
e.AuthenticationTask = CheckSshPasswordAsync(e.Username, e.Password);
|
|
||||||
// 그 밖의 인증 방식(공개 키 등)은 허용하지 않음
|
|
||||||
};
|
|
||||||
|
|
||||||
ssh.SessionOpened += (s, session) =>
|
|
||||||
{
|
|
||||||
session.ChannelOpening += (s2, e) =>
|
|
||||||
{
|
|
||||||
if (!e.IsRemoteRequest) return;
|
|
||||||
if (session.Principal == null)
|
|
||||||
{
|
|
||||||
e.FailureReason = SshChannelOpenFailureReason.AdministrativelyProhibited;
|
|
||||||
e.FailureDescription = "인증되지 않았습니다.";
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
var channel = e.Channel;
|
_log($"Passive 연결됨: 관리 PC {where}", LogLevel.Success);
|
||||||
var token = _cts?.Token ?? CancellationToken.None;
|
lastProblem = null;
|
||||||
_ = Task.Run(() => ServeAsync(new JsonLineConnection(new SshStream(channel)), "SSH", requireHelloPassword: false, token));
|
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)
|
||||||
ssh.ExceptionRaised += (s, ex) =>
|
{
|
||||||
{
|
if (ex is RemoteAuthException) retry = TimeSpan.FromSeconds(30);
|
||||||
if (ex is SocketException se && se.SocketErrorCode == SocketError.AddressAlreadyInUse)
|
var msg = RemoteClient.Friendly(ex);
|
||||||
_log($"원격 접속 허용 실패: 포트 {port}를 다른 프로그램이 사용 중입니다.", LogLevel.Error);
|
if (msg != lastProblem)
|
||||||
};
|
{
|
||||||
|
_log($"Passive 접속 실패 (관리 PC {where}): {msg}", LogLevel.Warning);
|
||||||
_ssh = ssh;
|
lastProblem = msg;
|
||||||
_ = ssh.AcceptSessionsAsync(port, IPAddress.Any).ContinueWith(t =>
|
}
|
||||||
{
|
}
|
||||||
if (t.IsFaulted && IsRunning)
|
catch
|
||||||
{
|
{
|
||||||
IsRunning = false;
|
break;
|
||||||
_log($"원격 접속 허용(SSH) 중단: {t.Exception?.GetBaseException().Message}", LogLevel.Error);
|
|
||||||
}
|
}
|
||||||
}, TaskScheduler.Default);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task<ClaimsPrincipal> CheckSshPasswordAsync(string user, string password)
|
try { await Task.Delay(retry, ct).ConfigureAwait(false); }
|
||||||
{
|
catch { break; }
|
||||||
if (IsThrottled()) return null;
|
}
|
||||||
if (CheckPassword(password)) return new ClaimsPrincipal(new ClaimsIdentity("password"));
|
|
||||||
RecordFailure();
|
|
||||||
await Task.Delay(1000).ConfigureAwait(false);
|
|
||||||
_log("원격 접속 거부 (SSH): 비밀번호가 맞지 않습니다.", LogLevel.Warning);
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ───────────────────────── 인증 ─────────────────────────
|
// ───────────────────────── 인증 ─────────────────────────
|
||||||
|
|
||||||
|
private async Task<bool> 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)
|
private bool CheckPassword(string password)
|
||||||
{
|
{
|
||||||
string h, s;
|
string h, s;
|
||||||
|
|
@ -253,6 +233,7 @@ namespace JJServiceManager.Remote
|
||||||
string who = peer;
|
string who = peer;
|
||||||
var streams = new Dictionary<int, LogTailer>();
|
var streams = new Dictionary<int, LogTailer>();
|
||||||
int nextStream = 1;
|
int nextStream = 1;
|
||||||
|
ScreenSession screen = null;
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
|
@ -286,11 +267,14 @@ namespace JJServiceManager.Remote
|
||||||
authed = true;
|
authed = true;
|
||||||
}
|
}
|
||||||
_log($"원격 접속: {who}", LogLevel.Notice);
|
_log($"원격 접속: {who}", LogLevel.Notice);
|
||||||
|
var pol = _policy();
|
||||||
await Reply(conn, id, result: new JsonObject
|
await Reply(conn, id, result: new JsonObject
|
||||||
{
|
{
|
||||||
["hostname"] = Environment.MachineName,
|
["hostname"] = Environment.MachineName,
|
||||||
["version"] = Version,
|
["version"] = Version,
|
||||||
["instance"] = AppInstance.Number
|
["instance"] = AppInstance.Number,
|
||||||
|
["allowView"] = pol.AllowView,
|
||||||
|
["allowControl"] = pol.AllowView && pol.AllowControl
|
||||||
});
|
});
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
@ -375,6 +359,102 @@ namespace JJServiceManager.Remote
|
||||||
break;
|
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<double>() ?? 0; ny = msg["y"]?.GetValue<double>() ?? 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:
|
default:
|
||||||
await Reply(conn, id, error: "알 수 없는 요청: " + op);
|
await Reply(conn, id, error: "알 수 없는 요청: " + op);
|
||||||
break;
|
break;
|
||||||
|
|
@ -392,11 +472,120 @@ namespace JJServiceManager.Remote
|
||||||
foreach (var t in streams.Values) t.Dispose();
|
foreach (var t in streams.Values) t.Dispose();
|
||||||
streams.Clear();
|
streams.Clear();
|
||||||
}
|
}
|
||||||
|
var s = Interlocked.Exchange(ref screen, null);
|
||||||
|
s?.Stop();
|
||||||
conn.Dispose();
|
conn.Dispose();
|
||||||
if (authed && who != peer) _log($"원격 연결 종료: {who}", LogLevel.Info);
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>이 PC에서 [연결 끊기]를 눌렀을 때 모든 화면 연결 종료</summary>
|
||||||
|
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)
|
private ServiceEntry Registered(string serviceName)
|
||||||
{
|
{
|
||||||
var e = _services().FirstOrDefault(x => string.Equals(x.ServiceName, serviceName, StringComparison.OrdinalIgnoreCase));
|
var e = _services().FirstOrDefault(x => string.Equals(x.ServiceName, serviceName, StringComparison.OrdinalIgnoreCase));
|
||||||
|
|
@ -419,7 +608,13 @@ namespace JJServiceManager.Remote
|
||||||
["logIsFolder"] = e.LogIsFolder
|
["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)
|
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<JsonObject> work)
|
private static async Task SafeReply(JsonLineConnection conn, int id, Func<JsonObject> work)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 접속을 받는 쪽 (지정 포트 TLS 또는 내장 SSH).
|
||||||
|
/// Active: 대상 PC가 사용 / Passive: 관리 PC가 사용
|
||||||
|
/// </summary>
|
||||||
|
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; }
|
||||||
|
|
||||||
|
/// <summary>SSH가 비정상 종료되었을 때</summary>
|
||||||
|
public event Action<string> Failed;
|
||||||
|
|
||||||
|
private RemoteListener(RemoteMode mode, int port)
|
||||||
|
{
|
||||||
|
Mode = mode;
|
||||||
|
Port = port;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <param name="sshAuth">SSH 비밀번호 확인 (user, password) → 허용 여부</param>
|
||||||
|
/// <param name="onConnection">연결 하나가 생길 때 (연결, 상대 표시, 취소)</param>
|
||||||
|
public static RemoteListener Start(RemoteMode mode, int port,
|
||||||
|
Func<string, string, Task<bool>> sshAuth,
|
||||||
|
Func<JsonLineConnection, string, CancellationToken, Task> 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<JsonLineConnection, string, CancellationToken, Task> 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<string, string, Task<bool>> sshAuth, Func<JsonLineConnection, string, CancellationToken, Task> 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 { }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>TLS용 자체 서명 인증서 (실행할 때마다 새로 만듦, 지문 확인은 하지 않는 설정)</summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>접속하는 쪽 (지정 포트 TLS 또는 내장 SSH)</summary>
|
||||||
|
public static class RemoteDialer
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 접속. SSH면 sshUser/sshPassword로 인증까지 함 (실패 시 RemoteAuthException).
|
||||||
|
/// TLS는 암호화만 하고 서버 지문은 확인하지 않음.
|
||||||
|
/// </summary>
|
||||||
|
public static async Task<JsonLineConnection> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
{
|
||||||
|
/// <summary>모니터 정보 (실제 픽셀 좌표)</summary>
|
||||||
|
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; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>대상 PC: 화면 캡처</summary>
|
||||||
|
public static class ScreenCapture
|
||||||
|
{
|
||||||
|
/// <summary>모니터 목록 (1부터, 주 모니터 우선이 아니라 Windows 순서)</summary>
|
||||||
|
public static List<MonitorInfo> Monitors()
|
||||||
|
{
|
||||||
|
var list = new List<MonitorInfo>();
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>monitor 0 = 전체 화면, 1~ = 해당 모니터</summary>
|
||||||
|
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);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 영역을 캡처해 JPEG로. maxWidth보다 넓으면 줄임.
|
||||||
|
/// hash: 변화 확인용 (같으면 보내지 않음)
|
||||||
|
/// </summary>
|
||||||
|
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<CURSORINFO>() };
|
||||||
|
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 { }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>화면이 바뀌었는지 빠르게 확인하기 위한 값 (일부 픽셀만 사용)</summary>
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>대상 PC: 원격에서 받은 마우스/키보드 입력을 실행</summary>
|
||||||
|
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);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 마우스. nx, ny = 캡처 영역 안의 비율(0~1).
|
||||||
|
/// action: move / down / up / wheel / hwheel, button: left / right / middle
|
||||||
|
/// </summary>
|
||||||
|
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<INPUT>());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>키보드 (가상 키 코드)</summary>
|
||||||
|
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<INPUT>());
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -95,36 +95,8 @@ $@"<?xml version=""1.0"" encoding=""UTF-16""?>
|
||||||
return (r.ExitCode == 0, r.Output);
|
return (r.ExitCode == 0, r.Output);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>명령 프롬프트 출력 인코딩 (한국어 Windows = CP949)</summary>
|
|
||||||
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)
|
private static (int ExitCode, string Output) RunSchtasks(string args)
|
||||||
{
|
=> ConsoleRunner.Run("schtasks.exe", 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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>전역 단축키 (어느 프로그램을 쓰고 있어도 동작)</summary>
|
/// <summary>전역 단축키 (어느 프로그램을 쓰고 있어도 동작)</summary>
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@
|
||||||
<Nullable>disable</Nullable>
|
<Nullable>disable</Nullable>
|
||||||
<ImplicitUsings>disable</ImplicitUsings>
|
<ImplicitUsings>disable</ImplicitUsings>
|
||||||
<LangVersion>latest</LangVersion>
|
<LangVersion>latest</LangVersion>
|
||||||
|
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
|
||||||
<Version>1.1.0</Version>
|
<Version>1.1.0</Version>
|
||||||
<Product>JJServiceManagerEnt</Product>
|
<Product>JJServiceManagerEnt</Product>
|
||||||
<Description>Windows 서비스 기동/종료 관리</Description>
|
<Description>Windows 서비스 기동/종료 관리</Description>
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,9 @@
|
||||||
<TextBlock Text="{Binding Title}" FontWeight="Bold" FontSize="13.5" VerticalAlignment="Center"/>
|
<TextBlock Text="{Binding Title}" FontWeight="Bold" FontSize="13.5" VerticalAlignment="Center"/>
|
||||||
<Ellipse Width="8" Height="8" Fill="{Binding StatusBrush}" Margin="12,0,5,0" VerticalAlignment="Center"/>
|
<Ellipse Width="8" Height="8" Fill="{Binding StatusBrush}" Margin="12,0,5,0" VerticalAlignment="Center"/>
|
||||||
<TextBlock Text="{Binding StatusText}" Foreground="{Binding StatusBrush}" VerticalAlignment="Center"/>
|
<TextBlock Text="{Binding StatusText}" Foreground="{Binding StatusBrush}" VerticalAlignment="Center"/>
|
||||||
|
<Button Content="원격관리" Tag="remoteDesktop" Visibility="{Binding RemoteButtonVisibility}"
|
||||||
|
Style="{DynamicResource RowButton}" Margin="14,0,0,0" MinHeight="22" Padding="8,0"
|
||||||
|
ToolTip="이 PC 화면 보기 / 키보드·마우스 제어 (대상 PC에서 허용해야 함)"/>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Border>
|
</Border>
|
||||||
</Expander.Header>
|
</Expander.Header>
|
||||||
|
|
|
||||||
|
|
@ -98,63 +98,124 @@
|
||||||
|
|
||||||
<!-- ═════════════ 2. 원격 접속 허용 (대상 역할) ═════════════ -->
|
<!-- ═════════════ 2. 원격 접속 허용 (대상 역할) ═════════════ -->
|
||||||
<TabItem Header=" 원격 접속 허용 ">
|
<TabItem Header=" 원격 접속 허용 ">
|
||||||
<Border Background="White" Padding="14,10">
|
<ScrollViewer VerticalScrollBarVisibility="Auto" Background="White">
|
||||||
<Grid>
|
<StackPanel Margin="14,10">
|
||||||
<Grid.ColumnDefinitions>
|
|
||||||
<ColumnDefinition Width="210"/>
|
|
||||||
<ColumnDefinition Width="*"/>
|
|
||||||
</Grid.ColumnDefinitions>
|
|
||||||
<Grid.RowDefinitions>
|
|
||||||
<RowDefinition Height="44"/>
|
|
||||||
<RowDefinition Height="44"/>
|
|
||||||
<RowDefinition Height="44"/>
|
|
||||||
<RowDefinition Height="44"/>
|
|
||||||
<RowDefinition Height="44"/>
|
|
||||||
<RowDefinition Height="Auto"/>
|
|
||||||
</Grid.RowDefinitions>
|
|
||||||
|
|
||||||
<TextBlock Grid.Row="0" Text="원격 접속 허용" VerticalAlignment="Center" FontWeight="SemiBold"/>
|
<DockPanel Height="40" LastChildFill="True">
|
||||||
<DockPanel Grid.Row="0" Grid.Column="1" VerticalAlignment="Center" LastChildFill="True">
|
<TextBlock Text="원격 접속 허용" Width="210" VerticalAlignment="Center" FontWeight="SemiBold"/>
|
||||||
<CheckBox x:Name="chkServer" DockPanel.Dock="Right" Style="{DynamicResource ToggleSwitch}"/>
|
<CheckBox x:Name="chkServer" DockPanel.Dock="Right" Style="{DynamicResource ToggleSwitch}"/>
|
||||||
<TextBlock Text="다른 PC가 이 PC의 등록된 서비스를 보고 Start / Stop 할 수 있게 함" Foreground="#78909C" VerticalAlignment="Center" TextWrapping="Wrap"/>
|
<TextBlock Text="다른 PC가 이 PC의 등록된 서비스를 보고 Start / Stop 할 수 있게 함" Foreground="#78909C" VerticalAlignment="Center" TextWrapping="Wrap"/>
|
||||||
</DockPanel>
|
</DockPanel>
|
||||||
|
|
||||||
<TextBlock Grid.Row="1" Text="통신 방식" VerticalAlignment="Center" FontWeight="SemiBold"/>
|
<DockPanel Height="40">
|
||||||
<ComboBox x:Name="cmbServerMode" Grid.Row="1" Grid.Column="1" Width="260" Height="28" HorizontalAlignment="Left" VerticalContentAlignment="Center">
|
<TextBlock Text="접속 비밀번호" Width="210" VerticalAlignment="Center" FontWeight="SemiBold"/>
|
||||||
<ComboBoxItem Content="SSH (프로그램 내장)"/>
|
<PasswordBox x:Name="pwdServer" Width="170" Height="28" VerticalContentAlignment="Center"/>
|
||||||
<ComboBoxItem Content="지정 포트 (TLS 암호화)"/>
|
|
||||||
</ComboBox>
|
|
||||||
|
|
||||||
<TextBlock Grid.Row="2" Text="포트" VerticalAlignment="Center" FontWeight="SemiBold"/>
|
|
||||||
<StackPanel Grid.Row="2" Grid.Column="1" Orientation="Horizontal" VerticalAlignment="Center">
|
|
||||||
<TextBox x:Name="txtServerPort" Width="100" Height="28" VerticalContentAlignment="Center" MaxLength="5"/>
|
|
||||||
<TextBlock x:Name="txtServerPortHint" Foreground="#78909C" VerticalAlignment="Center" Margin="10,0,0,0"/>
|
|
||||||
</StackPanel>
|
|
||||||
|
|
||||||
<TextBlock Grid.Row="3" Text="접속 비밀번호" VerticalAlignment="Center" FontWeight="SemiBold"/>
|
|
||||||
<StackPanel Grid.Row="3" Grid.Column="1" Orientation="Horizontal" VerticalAlignment="Center">
|
|
||||||
<PasswordBox x:Name="pwdServer" Width="180" Height="28" VerticalContentAlignment="Center"/>
|
|
||||||
<TextBlock Text="확인" VerticalAlignment="Center" Margin="10,0,6,0"/>
|
<TextBlock Text="확인" VerticalAlignment="Center" Margin="10,0,6,0"/>
|
||||||
<PasswordBox x:Name="pwdServer2" Width="180" Height="28" VerticalContentAlignment="Center"/>
|
<PasswordBox x:Name="pwdServer2" Width="170" Height="28" VerticalContentAlignment="Center" HorizontalAlignment="Left"/>
|
||||||
</StackPanel>
|
</DockPanel>
|
||||||
|
<TextBlock x:Name="txtPwState" Margin="210,0,0,6" Foreground="#78909C"/>
|
||||||
|
|
||||||
<TextBlock Grid.Row="4" Grid.Column="1" x:Name="txtPwState" VerticalAlignment="Center" Foreground="#78909C"/>
|
<!-- Active 수신 -->
|
||||||
|
<TextBlock Text="Active 수신 (관리 PC → 이 PC로 접속)" Style="{DynamicResource SectionTitle}" Margin="0,10,0,2"/>
|
||||||
|
<DockPanel Height="40" LastChildFill="True">
|
||||||
|
<TextBlock Text="Active 수신" Width="210" VerticalAlignment="Center" FontWeight="SemiBold"/>
|
||||||
|
<CheckBox x:Name="chkActiveListen" 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"/>
|
||||||
|
<ComboBox x:Name="cmbServerMode" Width="220" Height="28" VerticalContentAlignment="Center">
|
||||||
|
<ComboBoxItem Content="SSH (프로그램 내장)"/>
|
||||||
|
<ComboBoxItem Content="지정 포트 (TLS 암호화)"/>
|
||||||
|
</ComboBox>
|
||||||
|
<TextBox x:Name="txtServerPort" Width="80" Height="28" VerticalContentAlignment="Center" MaxLength="5" Margin="10,0,0,0"/>
|
||||||
|
<TextBlock x:Name="txtServerPortHint" Foreground="#78909C" VerticalAlignment="Center" Margin="10,0,0,0"/>
|
||||||
|
</DockPanel>
|
||||||
|
|
||||||
<Border Grid.Row="5" Grid.ColumnSpan="2" Background="#F1F8FB" BorderBrush="#B0BEC5" BorderThickness="1" Padding="10" Margin="0,10,0,0">
|
<!-- Passive 접속 -->
|
||||||
|
<TextBlock Text="Passive 접속 (이 PC → 관리 PC로 접속)" Style="{DynamicResource SectionTitle}" Margin="0,12,0,2"/>
|
||||||
|
<TextBlock Foreground="#78909C" TextWrapping="Wrap" Margin="0,0,0,6"
|
||||||
|
Text="이 PC가 방화벽·공유기 뒤에 있어도 됩니다. 관리 PC의 [관리 대상 PC]에서 이 PC를 Passive로 등록하면 나오는 접속 코드를 입력하세요."/>
|
||||||
|
<ListBox x:Name="lstPassive" Height="70" BorderBrush="#B0BEC5" HorizontalContentAlignment="Stretch" DisplayMemberPath="Label"/>
|
||||||
|
<Grid Margin="0,6,0,0">
|
||||||
|
<Grid.ColumnDefinitions>
|
||||||
|
<ColumnDefinition Width="90"/>
|
||||||
|
<ColumnDefinition Width="*"/>
|
||||||
|
<ColumnDefinition Width="70"/>
|
||||||
|
<ColumnDefinition Width="Auto"/>
|
||||||
|
</Grid.ColumnDefinitions>
|
||||||
|
<Grid.RowDefinitions>
|
||||||
|
<RowDefinition Height="34"/>
|
||||||
|
<RowDefinition Height="34"/>
|
||||||
|
<RowDefinition Height="Auto"/>
|
||||||
|
</Grid.RowDefinitions>
|
||||||
|
<TextBlock Text="관리 PC 주소" VerticalAlignment="Center"/>
|
||||||
|
<TextBox x:Name="txtPmAddr" Grid.Column="1" Height="28" VerticalContentAlignment="Center" Margin="0,0,12,0" ToolTip="관리 PC의 IP 또는 도메인"/>
|
||||||
|
<TextBlock Grid.Column="2" Text="접속 코드" VerticalAlignment="Center"/>
|
||||||
|
<TextBox x:Name="txtPmCode" Grid.Column="3" Width="130" Height="28" VerticalContentAlignment="Center" CharacterCasing="Upper" FontFamily="Consolas" FontWeight="Bold"/>
|
||||||
|
<TextBlock Grid.Row="1" Text="방식 / 포트" VerticalAlignment="Center"/>
|
||||||
|
<StackPanel Grid.Row="1" Grid.Column="1" Grid.ColumnSpan="3" Orientation="Horizontal">
|
||||||
|
<ComboBox x:Name="cmbPmMode" Width="220" Height="28" VerticalContentAlignment="Center">
|
||||||
|
<ComboBoxItem Content="SSH (프로그램 내장)"/>
|
||||||
|
<ComboBoxItem Content="지정 포트 (TLS 암호화)"/>
|
||||||
|
</ComboBox>
|
||||||
|
<TextBox x:Name="txtPmPort" Width="80" Height="28" VerticalContentAlignment="Center" MaxLength="5" Margin="10,0,0,0"/>
|
||||||
|
</StackPanel>
|
||||||
|
<DockPanel Grid.Row="2" Grid.ColumnSpan="4" Margin="0,4,0,0">
|
||||||
|
<StackPanel DockPanel.Dock="Right" Orientation="Horizontal">
|
||||||
|
<Button x:Name="btnPmNew" Content="새로 입력"/>
|
||||||
|
<Button x:Name="btnPmAdd" Content="추가" FontWeight="Bold"/>
|
||||||
|
<Button x:Name="btnPmUpdate" Content="수정"/>
|
||||||
|
<Button x:Name="btnPmDelete" Content="삭제"/>
|
||||||
|
</StackPanel>
|
||||||
|
<TextBlock x:Name="txtPmResult" VerticalAlignment="Center" TextWrapping="Wrap"/>
|
||||||
|
</DockPanel>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<!-- 원격관리 (화면) -->
|
||||||
|
<TextBlock Text="원격관리 (화면 보기 / 제어)" Style="{DynamicResource SectionTitle}" Margin="0,12,0,2"/>
|
||||||
|
<DockPanel Height="40" LastChildFill="True">
|
||||||
|
<TextBlock Text="화면 보기 허용" Width="210" VerticalAlignment="Center" FontWeight="SemiBold"/>
|
||||||
|
<CheckBox x:Name="chkAllowView" DockPanel.Dock="Right" Style="{DynamicResource ToggleSwitch}"/>
|
||||||
|
<TextBlock Text="관리 PC에서 이 PC 화면을 볼 수 있게 함" Foreground="#78909C" VerticalAlignment="Center"/>
|
||||||
|
</DockPanel>
|
||||||
|
<DockPanel Height="40" LastChildFill="True">
|
||||||
|
<TextBlock Text="화면 및 키보드·마우스 제어 허용" Width="210" VerticalAlignment="Center" FontWeight="SemiBold"/>
|
||||||
|
<CheckBox x:Name="chkAllowControl" DockPanel.Dock="Right" Style="{DynamicResource ToggleSwitch}"/>
|
||||||
|
<TextBlock Text="관리 PC에서 이 PC를 마우스·키보드로 조작할 수 있게 함" Foreground="#78909C" VerticalAlignment="Center"/>
|
||||||
|
</DockPanel>
|
||||||
|
<DockPanel Height="40" LastChildFill="True">
|
||||||
|
<TextBlock Text="연결 시 승인 필요" Width="210" VerticalAlignment="Center" FontWeight="SemiBold"/>
|
||||||
|
<CheckBox x:Name="chkRequireApproval" DockPanel.Dock="Right" Style="{DynamicResource ToggleSwitch}"/>
|
||||||
|
<TextBlock Text="원격 화면 연결 때마다 이 PC에서 허용 / 거부 (30초 안에 답이 없으면 거부)" Foreground="#78909C" VerticalAlignment="Center" TextWrapping="Wrap"/>
|
||||||
|
</DockPanel>
|
||||||
|
<TextBlock Foreground="#78909C" Margin="0,0,0,8" TextWrapping="Wrap"
|
||||||
|
Text="원격 화면 연결 중에는 이 PC 화면 위쪽에 항상 표시줄이 나타나며 [연결 끊기]로 바로 끊을 수 있습니다. 잠금 화면·로그인 화면·UAC 창은 보거나 조작할 수 없습니다."/>
|
||||||
|
|
||||||
|
<Border Background="#F1F8FB" BorderBrush="#B0BEC5" BorderThickness="1" Padding="10" Margin="0,4,0,0">
|
||||||
<TextBlock x:Name="txtServerInfo" TextWrapping="Wrap" Foreground="#37474F" LineHeight="20"/>
|
<TextBlock x:Name="txtServerInfo" TextWrapping="Wrap" Foreground="#37474F" LineHeight="20"/>
|
||||||
</Border>
|
</Border>
|
||||||
</Grid>
|
</StackPanel>
|
||||||
</Border>
|
</ScrollViewer>
|
||||||
</TabItem>
|
</TabItem>
|
||||||
|
|
||||||
<!-- ═════════════ 3. 관리 대상 PC (관리 역할) ═════════════ -->
|
<!-- ═════════════ 3. 관리 대상 PC (관리 역할) ═════════════ -->
|
||||||
<TabItem Header=" 관리 대상 PC ">
|
<TabItem Header=" 관리 대상 PC ">
|
||||||
<Border Background="White" Padding="14,10">
|
<Border Background="White" Padding="14,10">
|
||||||
<DockPanel>
|
<DockPanel>
|
||||||
<DockPanel DockPanel.Dock="Top" Height="44" LastChildFill="True">
|
<DockPanel DockPanel.Dock="Top" Height="40" LastChildFill="True">
|
||||||
<TextBlock Text="다른 PC 관리" Width="210" VerticalAlignment="Center" FontWeight="SemiBold"/>
|
<TextBlock Text="다른 PC 관리" Width="210" VerticalAlignment="Center" FontWeight="SemiBold"/>
|
||||||
<CheckBox x:Name="chkManage" DockPanel.Dock="Right" Style="{DynamicResource ToggleSwitch}"/>
|
<CheckBox x:Name="chkManage" DockPanel.Dock="Right" Style="{DynamicResource ToggleSwitch}"/>
|
||||||
<TextBlock Text="아래 PC에 접속해 등록된 서비스를 초기화면에 표시" Foreground="#78909C" VerticalAlignment="Center"/>
|
<TextBlock Text="아래 PC의 등록된 서비스를 초기화면에 표시" Foreground="#78909C" VerticalAlignment="Center"/>
|
||||||
|
</DockPanel>
|
||||||
|
<DockPanel DockPanel.Dock="Top" Height="40">
|
||||||
|
<TextBlock Text="Passive 수신 방식 / 포트" Width="210" VerticalAlignment="Center" FontWeight="SemiBold"/>
|
||||||
|
<ComboBox x:Name="cmbPassiveMode" Width="220" Height="28" VerticalContentAlignment="Center">
|
||||||
|
<ComboBoxItem Content="SSH (프로그램 내장)"/>
|
||||||
|
<ComboBoxItem Content="지정 포트 (TLS 암호화)"/>
|
||||||
|
</ComboBox>
|
||||||
|
<TextBox x:Name="txtPassivePort" Width="80" Height="28" VerticalContentAlignment="Center" MaxLength="5" Margin="10,0,0,0"/>
|
||||||
|
<TextBlock Foreground="#78909C" VerticalAlignment="Center" Margin="10,0,0,0" Text="Passive 대상이 있을 때만 열림"/>
|
||||||
</DockPanel>
|
</DockPanel>
|
||||||
|
|
||||||
<!-- 입력 -->
|
<!-- 입력 -->
|
||||||
|
|
@ -170,30 +231,43 @@
|
||||||
<RowDefinition Height="36"/>
|
<RowDefinition Height="36"/>
|
||||||
<RowDefinition Height="36"/>
|
<RowDefinition Height="36"/>
|
||||||
<RowDefinition Height="36"/>
|
<RowDefinition Height="36"/>
|
||||||
|
<RowDefinition Height="36"/>
|
||||||
<RowDefinition Height="Auto"/>
|
<RowDefinition Height="Auto"/>
|
||||||
</Grid.RowDefinitions>
|
</Grid.RowDefinitions>
|
||||||
|
|
||||||
<TextBlock Text="대상 IP" VerticalAlignment="Center"/>
|
<TextBlock Text="연결 방향" VerticalAlignment="Center"/>
|
||||||
<TextBox x:Name="txtAddr" Grid.Column="1" Height="28" VerticalContentAlignment="Center" Margin="0,0,12,0"
|
<ComboBox x:Name="cmbDirection" Grid.Column="1" Height="28" VerticalContentAlignment="Center" Margin="0,0,12,0">
|
||||||
ToolTip="예: 192.168.0.10 (컴퓨터 이름도 가능)"/>
|
<ComboBoxItem Content="Active (이 PC → 대상 PC로 접속)"/>
|
||||||
|
<ComboBoxItem Content="Passive (대상 PC → 이 PC로 접속)"/>
|
||||||
|
</ComboBox>
|
||||||
<TextBlock Grid.Column="2" Text="hostname" VerticalAlignment="Center"/>
|
<TextBlock Grid.Column="2" Text="hostname" VerticalAlignment="Center"/>
|
||||||
<TextBox x:Name="txtHostname" Grid.Column="3" Height="28" VerticalContentAlignment="Center" IsReadOnly="True"
|
<TextBox x:Name="txtHostname" Grid.Column="3" Height="28" VerticalContentAlignment="Center" IsReadOnly="True"
|
||||||
Background="#ECEFF1" ToolTip="연결 테스트 또는 접속할 때 자동으로 채워집니다."/>
|
Background="#ECEFF1" ToolTip="연결 테스트 또는 접속할 때 자동으로 채워집니다."/>
|
||||||
|
|
||||||
<TextBlock Grid.Row="1" Text="통신 방식" VerticalAlignment="Center"/>
|
<TextBlock Grid.Row="1" x:Name="lblAddr" Text="대상 IP" VerticalAlignment="Center"/>
|
||||||
<ComboBox x:Name="cmbMode" Grid.Row="1" Grid.Column="1" Height="28" VerticalContentAlignment="Center" Margin="0,0,12,0">
|
<TextBox x:Name="txtAddr" Grid.Row="1" Grid.Column="1" Height="28" VerticalContentAlignment="Center" Margin="0,0,12,0"
|
||||||
|
ToolTip="예: 192.168.0.10 (컴퓨터 이름도 가능)"/>
|
||||||
|
<TextBlock Grid.Row="1" Grid.Column="2" x:Name="lblCode" Text="접속 코드" VerticalAlignment="Center"/>
|
||||||
|
<DockPanel Grid.Row="1" Grid.Column="3" x:Name="pnlCode">
|
||||||
|
<Button x:Name="btnNewCode" DockPanel.Dock="Right" Content="새 코드" MinWidth="0" Padding="8,2"/>
|
||||||
|
<TextBox x:Name="txtCode" Height="28" VerticalContentAlignment="Center" IsReadOnly="True"
|
||||||
|
FontFamily="Consolas" FontWeight="Bold" Background="#FFF8E1" ToolTip="대상 PC의 [원격 접속 허용 → Passive 접속]에 이 코드를 입력하세요."/>
|
||||||
|
</DockPanel>
|
||||||
|
|
||||||
|
<TextBlock Grid.Row="2" x:Name="lblMode" Text="통신 방식" VerticalAlignment="Center"/>
|
||||||
|
<ComboBox x:Name="cmbMode" Grid.Row="2" Grid.Column="1" Height="28" VerticalContentAlignment="Center" Margin="0,0,12,0">
|
||||||
<ComboBoxItem Content="SSH (프로그램 내장)"/>
|
<ComboBoxItem Content="SSH (프로그램 내장)"/>
|
||||||
<ComboBoxItem Content="지정 포트 (TLS 암호화)"/>
|
<ComboBoxItem Content="지정 포트 (TLS 암호화)"/>
|
||||||
</ComboBox>
|
</ComboBox>
|
||||||
<TextBlock Grid.Row="1" Grid.Column="2" Text="포트" VerticalAlignment="Center"/>
|
<TextBlock Grid.Row="2" Grid.Column="2" x:Name="lblPort" Text="포트" VerticalAlignment="Center"/>
|
||||||
<TextBox x:Name="txtPort" Grid.Row="1" Grid.Column="3" Width="100" HorizontalAlignment="Left" Height="28" VerticalContentAlignment="Center" MaxLength="5"/>
|
<TextBox x:Name="txtPort" Grid.Row="2" Grid.Column="3" Width="100" HorizontalAlignment="Left" Height="28" VerticalContentAlignment="Center" MaxLength="5"/>
|
||||||
|
|
||||||
<TextBlock Grid.Row="2" Text="비밀번호" VerticalAlignment="Center"/>
|
<TextBlock Grid.Row="3" Text="비밀번호" VerticalAlignment="Center"/>
|
||||||
<PasswordBox x:Name="pwdTarget" Grid.Row="2" Grid.Column="1" Height="28" VerticalContentAlignment="Center" Margin="0,0,12,0"
|
<PasswordBox x:Name="pwdTarget" Grid.Row="3" Grid.Column="1" Height="28" VerticalContentAlignment="Center" Margin="0,0,12,0"
|
||||||
ToolTip="대상 PC의 [원격 접속 허용] 접속 비밀번호"/>
|
ToolTip="대상 PC의 [원격 접속 허용] 접속 비밀번호"/>
|
||||||
<TextBlock Grid.Row="2" Grid.Column="2" Grid.ColumnSpan="2" x:Name="txtPwTarget" VerticalAlignment="Center" Foreground="#78909C"/>
|
<TextBlock Grid.Row="3" Grid.Column="2" Grid.ColumnSpan="2" x:Name="txtPwTarget" VerticalAlignment="Center" Foreground="#78909C"/>
|
||||||
|
|
||||||
<DockPanel Grid.Row="3" Grid.ColumnSpan="4" Margin="0,6,0,0">
|
<DockPanel Grid.Row="4" Grid.ColumnSpan="4" Margin="0,6,0,0">
|
||||||
<StackPanel DockPanel.Dock="Right" Orientation="Horizontal">
|
<StackPanel DockPanel.Dock="Right" Orientation="Horizontal">
|
||||||
<Button x:Name="btnTest" Content="연결 테스트"/>
|
<Button x:Name="btnTest" Content="연결 테스트"/>
|
||||||
<Button x:Name="btnNew" Content="새로 입력"/>
|
<Button x:Name="btnNew" Content="새로 입력"/>
|
||||||
|
|
@ -207,11 +281,11 @@
|
||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
<!-- 목록 -->
|
<!-- 목록 -->
|
||||||
<ListBox x:Name="lstTargets" BorderBrush="#B0BEC5" MinHeight="120">
|
<ListBox x:Name="lstTargets" BorderBrush="#B0BEC5" MinHeight="90" HorizontalContentAlignment="Stretch">
|
||||||
<ListBox.ItemTemplate>
|
<ListBox.ItemTemplate>
|
||||||
<DataTemplate>
|
<DataTemplate>
|
||||||
<DockPanel Margin="4,3">
|
<DockPanel Margin="4,3">
|
||||||
<TextBlock DockPanel.Dock="Right" Text="{Binding ModeText}" Foreground="#607D8B"/>
|
<TextBlock DockPanel.Dock="Right" Text="{Binding ModeText}" Foreground="#607D8B" Margin="16,0,0,0"/>
|
||||||
<TextBlock Text="{Binding Label}" FontWeight="SemiBold"/>
|
<TextBlock Text="{Binding Label}" FontWeight="SemiBold"/>
|
||||||
</DockPanel>
|
</DockPanel>
|
||||||
</DataTemplate>
|
</DataTemplate>
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,11 @@ namespace JJServiceManager
|
||||||
private readonly LogWindowManager _logWindows;
|
private readonly LogWindowManager _logWindows;
|
||||||
private readonly DispatcherTimer _statusTimer;
|
private readonly DispatcherTimer _statusTimer;
|
||||||
private readonly RemoteServer _remoteServer;
|
private readonly RemoteServer _remoteServer;
|
||||||
|
private readonly PassiveHub _passiveHub;
|
||||||
|
private RemoteIndicatorWindow _indicator;
|
||||||
|
private readonly Dictionary<string, RemoteViewerWindow> _viewers = new Dictionary<string, RemoteViewerWindow>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
private string _passiveKey = "";
|
||||||
|
private string _passiveListenKey = "";
|
||||||
|
|
||||||
private readonly TabControl _tabs;
|
private readonly TabControl _tabs;
|
||||||
private readonly TabItem _tabAll;
|
private readonly TabItem _tabAll;
|
||||||
|
|
@ -124,6 +129,14 @@ namespace JJServiceManager
|
||||||
|
|
||||||
PreviewKeyDown += OnPreviewKeyDown;
|
PreviewKeyDown += OnPreviewKeyDown;
|
||||||
|
|
||||||
|
// 처음 뜰 때 다른 창 뒤에 가려지지 않도록 맨 앞으로
|
||||||
|
ContentRendered += (s, e) =>
|
||||||
|
{
|
||||||
|
Activate();
|
||||||
|
Topmost = true;
|
||||||
|
Topmost = false;
|
||||||
|
};
|
||||||
|
|
||||||
// 내 PC 상태 자동 갱신 (2초)
|
// 내 PC 상태 자동 갱신 (2초)
|
||||||
_statusTimer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(2) };
|
_statusTimer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(2) };
|
||||||
_statusTimer.Tick += async (s, e) => await RefreshStatuses(false);
|
_statusTimer.Tick += async (s, e) => await RefreshStatuses(false);
|
||||||
|
|
@ -137,7 +150,25 @@ namespace JJServiceManager
|
||||||
_remoteServer = new RemoteServer(
|
_remoteServer = new RemoteServer(
|
||||||
() => _servicesSnapshot,
|
() => _servicesSnapshot,
|
||||||
(m, l) => Log(m, l),
|
(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();
|
CreateTray();
|
||||||
|
|
||||||
|
|
@ -360,6 +391,12 @@ namespace JJServiceManager
|
||||||
|
|
||||||
private async void OnRowButton(object sender, RoutedEventArgs e)
|
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;
|
if (e.OriginalSource is not Button b || b.DataContext is not ServiceRow row) return;
|
||||||
switch (b.Tag as string)
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>좌우 분할 창에서 고를 수 있는 로그 (내 PC + 원격)</summary>
|
/// <summary>좌우 분할 창에서 고를 수 있는 로그 (내 PC + 원격)</summary>
|
||||||
private List<LogTarget> LogChoices()
|
private List<LogTarget> LogChoices()
|
||||||
{
|
{
|
||||||
|
|
@ -674,7 +761,7 @@ namespace JJServiceManager
|
||||||
|
|
||||||
private static string TargetsKey(AppSettings s)
|
private static string TargetsKey(AppSettings s)
|
||||||
=> (s.RemoteManageEnabled ? "on;" : "off;")
|
=> (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}"));
|
||||||
|
|
||||||
/// <summary>관리 대상 PC 목록이 바뀌면 접속을 새로 만듦</summary>
|
/// <summary>관리 대상 PC 목록이 바뀌면 접속을 새로 만듦</summary>
|
||||||
private void ApplyRemoteManage()
|
private void ApplyRemoteManage()
|
||||||
|
|
@ -684,6 +771,8 @@ namespace JJServiceManager
|
||||||
_targetsKey = key;
|
_targetsKey = key;
|
||||||
|
|
||||||
// 기존 원격 PC 정리
|
// 기존 원격 PC 정리
|
||||||
|
_passiveHub.Clear();
|
||||||
|
foreach (var v in _viewers.Values.ToList()) v.Close();
|
||||||
foreach (var g in _groups.Where(g => !g.IsLocal).ToList())
|
foreach (var g in _groups.Where(g => !g.IsLocal).ToList())
|
||||||
{
|
{
|
||||||
g.Client?.Dispose();
|
g.Client?.Dispose();
|
||||||
|
|
@ -708,6 +797,8 @@ namespace JJServiceManager
|
||||||
client.StateChanged += c => Dispatcher.BeginInvoke(new Action(() => OnRemoteState(g, c)));
|
client.StateChanged += c => Dispatcher.BeginInvoke(new Action(() => OnRemoteState(g, c)));
|
||||||
client.ServicesChanged += c => Dispatcher.BeginInvoke(new Action(() => OnRemoteServices(g, c)));
|
client.ServicesChanged += c => Dispatcher.BeginInvoke(new Action(() => OnRemoteServices(g, c)));
|
||||||
client.HostnameDiscovered += (c, host) => Dispatcher.BeginInvoke(new Action(() => OnHostname(g, c, host)));
|
client.HostnameDiscovered += (c, host) => Dispatcher.BeginInvoke(new Action(() => OnHostname(g, c, host)));
|
||||||
|
client.PeerAddressDiscovered += (c, addr) => Dispatcher.BeginInvoke(new Action(() => OnPeerAddress(g, addr)));
|
||||||
|
if (t.Direction == ConnectDirection.Passive) _passiveHub.Register(client);
|
||||||
_groups.Add(g);
|
_groups.Add(g);
|
||||||
client.Start();
|
client.Start();
|
||||||
}
|
}
|
||||||
|
|
@ -723,6 +814,43 @@ namespace JJServiceManager
|
||||||
UpdateCounts();
|
UpdateCounts();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Passive 대상이 있으면 수신 포트를 엶 (방화벽 포함)</summary>
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Passive: 대상 PC가 접속해 온 IP 표시</summary>
|
||||||
|
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)
|
private void OnRemoteState(HostGroup g, RemoteClient c)
|
||||||
{
|
{
|
||||||
if (!_groups.Contains(g)) return;
|
if (!_groups.Contains(g)) return;
|
||||||
|
|
@ -818,48 +946,56 @@ namespace JJServiceManager
|
||||||
private void ApplyRemoteServer(bool startup)
|
private void ApplyRemoteServer(bool startup)
|
||||||
{
|
{
|
||||||
var s = _settings;
|
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)
|
if (key != _serverKey)
|
||||||
{
|
{
|
||||||
bool wasOn = _serverKey != "" && _serverKey != "off";
|
bool wasOn = _serverKey != "" && _serverKey != "off";
|
||||||
_serverKey = key;
|
_serverKey = key;
|
||||||
if (s.RemoteServerEnabled)
|
if (active) _remoteServer.Start(s.RemoteServerMode, s.RemoteServerPort, s.RemotePasswordHash, s.RemotePasswordSalt);
|
||||||
{
|
else _remoteServer.Stop();
|
||||||
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 (s.RemoteServerEnabled || wasOn || !startup)
|
if (active || wasOn || !startup)
|
||||||
{
|
{
|
||||||
bool on = s.RemoteServerEnabled && _remoteServer.IsRunning;
|
bool on = active && _remoteServer.IsRunning;
|
||||||
int port = s.RemoteServerPort;
|
int port = s.RemoteServerPort;
|
||||||
Task.Run(() =>
|
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);
|
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<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;
|
_local.StatusBrush = Brushes2.Running;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -939,6 +1075,7 @@ namespace JJServiceManager
|
||||||
ApplyHotkey(quiet: startup);
|
ApplyHotkey(quiet: startup);
|
||||||
ApplyRemoteServer(startup);
|
ApplyRemoteServer(startup);
|
||||||
ApplyRemoteManage();
|
ApplyRemoteManage();
|
||||||
|
ApplyPassiveListen(startup);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void ApplyHotkey(bool quiet)
|
private void ApplyHotkey(bool quiet)
|
||||||
|
|
@ -1111,8 +1248,11 @@ namespace JJServiceManager
|
||||||
_statusTimer.Stop();
|
_statusTimer.Stop();
|
||||||
SaveSettings();
|
SaveSettings();
|
||||||
_logWindows.CloseAll();
|
_logWindows.CloseAll();
|
||||||
|
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();
|
||||||
|
_passiveHub.Dispose();
|
||||||
_remoteServer.Dispose();
|
_remoteServer.Dispose();
|
||||||
|
_indicator?.Close();
|
||||||
_hotkey?.Dispose();
|
_hotkey?.Dispose();
|
||||||
if (_tray != null)
|
if (_tray != null)
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
{
|
||||||
|
/// <summary>대상 PC: 원격 화면 연결 승인 창 (30초 안에 답하지 않으면 거부)</summary>
|
||||||
|
public sealed class ApprovalWindow : Window
|
||||||
|
{
|
||||||
|
private readonly TaskCompletionSource<bool> _result = new TaskCompletionSource<bool>();
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>UI 스레드에서 호출</summary>
|
||||||
|
public static Task<bool> AskAsync(string who, bool control)
|
||||||
|
{
|
||||||
|
var w = new ApprovalWindow(who, control);
|
||||||
|
w.Show();
|
||||||
|
return w._result.Task;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>대상 PC: 원격 화면 연결 중 화면 위쪽 표시줄 (항상 표시)</summary>
|
||||||
|
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<ScreenViewer> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>관리 PC: 원격 화면 창 (보기 / 제어)</summary>
|
||||||
|
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<int> _pressedKeys = new HashSet<int>();
|
||||||
|
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<JsonObject>().ToList() ?? new List<JsonObject>();
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>창을 벗어나면 눌린 키가 대상 PC에 남지 않도록 모두 뗌</summary>
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -49,6 +49,9 @@ namespace JJServiceManager
|
||||||
/// <summary>마지막으로 기록한 연결 실패 메시지 (같은 메시지 반복 기록 방지)</summary>
|
/// <summary>마지막으로 기록한 연결 실패 메시지 (같은 메시지 반복 기록 방지)</summary>
|
||||||
public string Tag { get; set; }
|
public string Tag { get; set; }
|
||||||
|
|
||||||
|
/// <summary>원격 PC 제목 줄의 [원격관리] 버튼</summary>
|
||||||
|
public Visibility RemoteButtonVisibility => IsLocal ? Visibility.Collapsed : Visibility.Visible;
|
||||||
|
|
||||||
public Brush HeaderBrush => IsLocal ? Brushes2.Make("#E3F2FD") : Brushes2.Make("#F3E5F5");
|
public Brush HeaderBrush => IsLocal ? Brushes2.Make("#E3F2FD") : Brushes2.Make("#F3E5F5");
|
||||||
|
|
||||||
public HostGroup()
|
public HostGroup()
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,7 @@ namespace JJServiceManager
|
||||||
private Key _key;
|
private Key _key;
|
||||||
|
|
||||||
// 원격 접속 허용
|
// 원격 접속 허용
|
||||||
private readonly CheckBox _server;
|
private readonly CheckBox _server, _activeListen, _allowView, _allowControl, _requireApproval;
|
||||||
private readonly ComboBox _serverMode;
|
private readonly ComboBox _serverMode;
|
||||||
private readonly TextBox _serverPort;
|
private readonly TextBox _serverPort;
|
||||||
private readonly TextBlock _serverPortHint, _pwState, _serverInfo;
|
private readonly TextBlock _serverPortHint, _pwState, _serverInfo;
|
||||||
|
|
@ -30,13 +30,25 @@ namespace JJServiceManager
|
||||||
private int _sshPort, _tcpPort;
|
private int _sshPort, _tcpPort;
|
||||||
private int _shownMode = -1;
|
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<PassiveManager> _pms = new ObservableCollection<PassiveManager>();
|
||||||
|
|
||||||
// 관리 대상 PC
|
// 관리 대상 PC
|
||||||
private readonly CheckBox _manage;
|
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 ListBox _targets;
|
||||||
private readonly TextBox _addr, _hostname, _port;
|
private readonly TextBox _addr, _hostname, _port, _code;
|
||||||
private readonly ComboBox _mode;
|
private readonly ComboBox _mode, _direction;
|
||||||
private readonly PasswordBox _pwdTarget;
|
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 Button _btnTest;
|
||||||
private readonly ObservableCollection<TargetItem> _items = new ObservableCollection<TargetItem>();
|
private readonly ObservableCollection<TargetItem> _items = new ObservableCollection<TargetItem>();
|
||||||
|
|
||||||
|
|
@ -46,7 +58,9 @@ namespace JJServiceManager
|
||||||
public RemoteTarget T { get; }
|
public RemoteTarget T { get; }
|
||||||
public TargetItem(RemoteTarget t) => T = t;
|
public TargetItem(RemoteTarget t) => T = t;
|
||||||
public string Label => T.Label;
|
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()
|
public void Changed()
|
||||||
{
|
{
|
||||||
OnPropertyChanged(nameof(Label));
|
OnPropertyChanged(nameof(Label));
|
||||||
|
|
@ -59,8 +73,8 @@ namespace JJServiceManager
|
||||||
_s = current;
|
_s = current;
|
||||||
|
|
||||||
Title = "설정" + AppInstance.TitleTag;
|
Title = "설정" + AppInstance.TitleTag;
|
||||||
Width = 780;
|
Width = 800;
|
||||||
Height = 640;
|
Height = 720;
|
||||||
MinHeight = 560;
|
MinHeight = 560;
|
||||||
ResizeMode = ResizeMode.CanResize;
|
ResizeMode = ResizeMode.CanResize;
|
||||||
FontFamily = new FontFamily("Malgun Gothic");
|
FontFamily = new FontFamily("Malgun Gothic");
|
||||||
|
|
@ -105,6 +119,10 @@ namespace JJServiceManager
|
||||||
|
|
||||||
// ───────── 원격 접속 허용 ─────────
|
// ───────── 원격 접속 허용 ─────────
|
||||||
_server = root.Find<CheckBox>("chkServer");
|
_server = root.Find<CheckBox>("chkServer");
|
||||||
|
_activeListen = root.Find<CheckBox>("chkActiveListen");
|
||||||
|
_allowView = root.Find<CheckBox>("chkAllowView");
|
||||||
|
_allowControl = root.Find<CheckBox>("chkAllowControl");
|
||||||
|
_requireApproval = root.Find<CheckBox>("chkRequireApproval");
|
||||||
_serverMode = root.Find<ComboBox>("cmbServerMode");
|
_serverMode = root.Find<ComboBox>("cmbServerMode");
|
||||||
_serverPort = root.Find<TextBox>("txtServerPort");
|
_serverPort = root.Find<TextBox>("txtServerPort");
|
||||||
_serverPortHint = root.Find<TextBlock>("txtServerPortHint");
|
_serverPortHint = root.Find<TextBlock>("txtServerPortHint");
|
||||||
|
|
@ -116,26 +134,76 @@ namespace JJServiceManager
|
||||||
_sshPort = current.RemoteServerSshPort;
|
_sshPort = current.RemoteServerSshPort;
|
||||||
_tcpPort = current.RemoteServerTcpPort;
|
_tcpPort = current.RemoteServerTcpPort;
|
||||||
_server.IsChecked = current.RemoteServerEnabled;
|
_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.SelectionChanged += (s, e) => OnServerModeChanged();
|
||||||
_serverMode.SelectedIndex = (int)current.RemoteServerMode;
|
_serverMode.SelectedIndex = (int)current.RemoteServerMode;
|
||||||
_pwState.Text = string.IsNullOrEmpty(current.RemotePasswordHash)
|
_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();
|
UpdateServerInfo();
|
||||||
|
|
||||||
|
// Passive 접속 (이 PC → 관리 PC)
|
||||||
|
_pmList = root.Find<ListBox>("lstPassive");
|
||||||
|
_pmAddr = root.Find<TextBox>("txtPmAddr");
|
||||||
|
_pmCode = root.Find<TextBox>("txtPmCode");
|
||||||
|
_pmPort = root.Find<TextBox>("txtPmPort");
|
||||||
|
_pmMode = root.Find<ComboBox>("cmbPmMode");
|
||||||
|
_pmResult = root.Find<TextBlock>("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<Button>("btnPmNew").Click += (s, e) => { _pmList.SelectedItem = null; ClearPmForm(); };
|
||||||
|
root.Find<Button>("btnPmAdd").Click += (s, e) => AddPm();
|
||||||
|
root.Find<Button>("btnPmUpdate").Click += (s, e) => UpdatePm();
|
||||||
|
root.Find<Button>("btnPmDelete").Click += (s, e) => DeletePm();
|
||||||
|
|
||||||
// ───────── 관리 대상 PC ─────────
|
// ───────── 관리 대상 PC ─────────
|
||||||
_manage = root.Find<CheckBox>("chkManage");
|
_manage = root.Find<CheckBox>("chkManage");
|
||||||
|
_passiveMode = root.Find<ComboBox>("cmbPassiveMode");
|
||||||
|
_passivePort = root.Find<TextBox>("txtPassivePort");
|
||||||
_targets = root.Find<ListBox>("lstTargets");
|
_targets = root.Find<ListBox>("lstTargets");
|
||||||
_addr = root.Find<TextBox>("txtAddr");
|
_addr = root.Find<TextBox>("txtAddr");
|
||||||
_hostname = root.Find<TextBox>("txtHostname");
|
_hostname = root.Find<TextBox>("txtHostname");
|
||||||
_port = root.Find<TextBox>("txtPort");
|
_port = root.Find<TextBox>("txtPort");
|
||||||
|
_code = root.Find<TextBox>("txtCode");
|
||||||
_mode = root.Find<ComboBox>("cmbMode");
|
_mode = root.Find<ComboBox>("cmbMode");
|
||||||
|
_direction = root.Find<ComboBox>("cmbDirection");
|
||||||
_pwdTarget = root.Find<PasswordBox>("pwdTarget");
|
_pwdTarget = root.Find<PasswordBox>("pwdTarget");
|
||||||
_pwTargetState = root.Find<TextBlock>("txtPwTarget");
|
_pwTargetState = root.Find<TextBlock>("txtPwTarget");
|
||||||
_test = root.Find<TextBlock>("txtTest");
|
_test = root.Find<TextBlock>("txtTest");
|
||||||
|
_lblAddr = root.Find<TextBlock>("lblAddr");
|
||||||
|
_lblMode = root.Find<FrameworkElement>("lblMode");
|
||||||
|
_lblPort = root.Find<FrameworkElement>("lblPort");
|
||||||
|
_lblCode = root.Find<FrameworkElement>("lblCode");
|
||||||
|
_pnlCode = root.Find<FrameworkElement>("pnlCode");
|
||||||
_btnTest = root.Find<Button>("btnTest");
|
_btnTest = root.Find<Button>("btnTest");
|
||||||
|
|
||||||
_manage.IsChecked = current.RemoteManageEnabled;
|
_manage.IsChecked = current.RemoteManageEnabled;
|
||||||
|
_passiveSshPort = current.PassiveListenSshPort;
|
||||||
|
_passiveTcpPort = current.PassiveListenTcpPort;
|
||||||
|
_passiveMode.SelectionChanged += (s, e) =>
|
||||||
|
{
|
||||||
|
if (_shownPassiveMode == 0 && int.TryParse(_passivePort.Text, out var a)) _passiveSshPort = a;
|
||||||
|
if (_shownPassiveMode == 1 && int.TryParse(_passivePort.Text, out var b)) _passiveTcpPort = b;
|
||||||
|
_shownPassiveMode = _passiveMode.SelectedIndex;
|
||||||
|
_passivePort.Text = (_shownPassiveMode == 0 ? _passiveSshPort : _passiveTcpPort).ToString();
|
||||||
|
};
|
||||||
|
_passiveMode.SelectedIndex = (int)current.PassiveListenMode;
|
||||||
|
|
||||||
foreach (var t in current.Targets) _items.Add(new TargetItem(t.Clone()));
|
foreach (var t in current.Targets) _items.Add(new TargetItem(t.Clone()));
|
||||||
_targets.ItemsSource = _items;
|
_targets.ItemsSource = _items;
|
||||||
_targets.SelectionChanged += (s, e) => ShowTarget(_targets.SelectedItem as TargetItem);
|
_targets.SelectionChanged += (s, e) => ShowTarget(_targets.SelectedItem as TargetItem);
|
||||||
|
|
@ -145,6 +213,8 @@ namespace JJServiceManager
|
||||||
if (_port.Text == "" || _port.Text == "9988" || _port.Text == "2222")
|
if (_port.Text == "" || _port.Text == "9988" || _port.Text == "2222")
|
||||||
_port.Text = _mode.SelectedIndex == 0 ? "2222" : "9988";
|
_port.Text = _mode.SelectedIndex == 0 ? "2222" : "9988";
|
||||||
};
|
};
|
||||||
|
_direction.SelectionChanged += (s, e) => OnDirectionChanged();
|
||||||
|
root.Find<Button>("btnNewCode").Click += (s, e) => _code.Text = RemoteTarget.NewAccessCode();
|
||||||
ClearForm();
|
ClearForm();
|
||||||
|
|
||||||
root.Find<Button>("btnNew").Click += (s, e) => { _targets.SelectedItem = null; ClearForm(); };
|
root.Find<Button>("btnNew").Click += (s, e) => { _targets.SelectedItem = null; ClearForm(); };
|
||||||
|
|
@ -208,7 +278,7 @@ namespace JJServiceManager
|
||||||
if (_shownMode == 1 && int.TryParse(_serverPort.Text, out var b)) _tcpPort = b;
|
if (_shownMode == 1 && int.TryParse(_serverPort.Text, out var b)) _tcpPort = b;
|
||||||
_shownMode = _serverMode.SelectedIndex;
|
_shownMode = _serverMode.SelectedIndex;
|
||||||
_serverPort.Text = (_shownMode == 0 ? _sshPort : _tcpPort).ToString();
|
_serverPort.Text = (_shownMode == 0 ? _sshPort : _tcpPort).ToString();
|
||||||
_serverPortHint.Text = _shownMode == 0 ? "기본 2222 (Windows OpenSSH의 22번과 겹치지 않게)" : "기본 9988";
|
_serverPortHint.Text = _shownMode == 0 ? "기본 2222" : "기본 9988";
|
||||||
UpdateServerInfo();
|
UpdateServerInfo();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -217,35 +287,121 @@ namespace JJServiceManager
|
||||||
var ips = NetInfo.LocalIPv4();
|
var ips = NetInfo.LocalIPv4();
|
||||||
_serverInfo.Text =
|
_serverInfo.Text =
|
||||||
$"이 PC: {Environment.MachineName} IP: {(ips.Count == 0 ? "(확인 안 됨)" : string.Join(", ", ips))}\n" +
|
$"이 PC: {Environment.MachineName} IP: {(ips.Count == 0 ? "(확인 안 됨)" : string.Join(", ", ips))}\n" +
|
||||||
"관리 PC의 [관리 대상 PC]에 위 IP, 같은 통신 방식과 포트, 접속 비밀번호를 입력하면 됩니다.\n" +
|
"Active: 관리 PC의 [관리 대상 PC]에 위 IP, 같은 통신 방식과 포트, 접속 비밀번호를 입력합니다.\n" +
|
||||||
"원격 접속 허용을 켜면 Windows 방화벽에 해당 TCP 포트를 자동으로 열고, 끄면 규칙을 지웁니다.\n" +
|
"Passive: 관리 PC에서 받은 접속 코드를 위 [Passive 접속]에 입력하고, 관리 PC에는 접속 비밀번호를 입력합니다.\n" +
|
||||||
|
"Active 수신을 켜면 Windows 방화벽에 해당 TCP 포트를 자동으로 열고, 끄면 규칙을 지웁니다.\n" +
|
||||||
"원격에서는 이 PC에 등록된 서비스만 보고 Start / Stop 할 수 있습니다.";
|
"원격에서는 이 PC에 등록된 서비스만 보고 Start / Stop 할 수 있습니다.";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void ClearPmForm()
|
||||||
|
{
|
||||||
|
_pmAddr.Text = "";
|
||||||
|
_pmCode.Text = "";
|
||||||
|
_pmMode.SelectedIndex = 1;
|
||||||
|
_pmPort.Text = "9990";
|
||||||
|
_pmResult.Text = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ShowPm(PassiveManager m)
|
||||||
|
{
|
||||||
|
if (m == null) return;
|
||||||
|
_pmAddr.Text = m.Address;
|
||||||
|
_pmCode.Text = m.AccessCode;
|
||||||
|
_pmMode.SelectedIndex = (int)m.Mode;
|
||||||
|
_pmPort.Text = m.Port.ToString();
|
||||||
|
_pmResult.Text = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
private string ReadPm(out PassiveManager m)
|
||||||
|
{
|
||||||
|
m = new PassiveManager
|
||||||
|
{
|
||||||
|
Address = _pmAddr.Text.Trim(),
|
||||||
|
AccessCode = _pmCode.Text.Trim().ToUpperInvariant(),
|
||||||
|
Mode = (RemoteMode)Math.Max(0, _pmMode.SelectedIndex)
|
||||||
|
};
|
||||||
|
if (m.Address.Length == 0) return "관리 PC 주소를 입력하세요.";
|
||||||
|
if (m.AccessCode.Length < 4) return "관리 PC에서 받은 접속 코드를 입력하세요.";
|
||||||
|
if (!int.TryParse(_pmPort.Text.Trim(), out var p) || p < 1 || p > 65535) return "포트는 1~65535 사이 숫자로 입력하세요.";
|
||||||
|
m.Port = p;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void AddPm()
|
||||||
|
{
|
||||||
|
var err = ReadPm(out var m);
|
||||||
|
if (err != null) { ShowResult(_pmResult, err, false); return; }
|
||||||
|
_pms.Add(m);
|
||||||
|
_pmList.SelectedItem = m;
|
||||||
|
ShowResult(_pmResult, "추가했습니다. [확인]을 누르면 저장됩니다.", true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void UpdatePm()
|
||||||
|
{
|
||||||
|
if (_pmList.SelectedItem is not PassiveManager sel) { ShowResult(_pmResult, "수정할 항목을 목록에서 고르세요.", false); return; }
|
||||||
|
var err = ReadPm(out var m);
|
||||||
|
if (err != null) { ShowResult(_pmResult, err, false); return; }
|
||||||
|
int i = _pms.IndexOf(sel);
|
||||||
|
m.Id = sel.Id;
|
||||||
|
_pms[i] = m;
|
||||||
|
_pmList.SelectedItem = m;
|
||||||
|
ShowResult(_pmResult, "수정했습니다. [확인]을 누르면 저장됩니다.", true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void DeletePm()
|
||||||
|
{
|
||||||
|
if (_pmList.SelectedItem is not PassiveManager sel) { ShowResult(_pmResult, "삭제할 항목을 목록에서 고르세요.", false); return; }
|
||||||
|
_pms.Remove(sel);
|
||||||
|
ClearPmForm();
|
||||||
|
ShowResult(_pmResult, "삭제했습니다. [확인]을 누르면 저장됩니다.", true);
|
||||||
|
}
|
||||||
|
|
||||||
// ───────────────────────── 관리 대상 PC ─────────────────────────
|
// ───────────────────────── 관리 대상 PC ─────────────────────────
|
||||||
|
|
||||||
|
private bool FormIsPassive => _direction.SelectedIndex == 1;
|
||||||
|
|
||||||
|
private void OnDirectionChanged()
|
||||||
|
{
|
||||||
|
bool passive = FormIsPassive;
|
||||||
|
// Active: IP·방식·포트 입력 / Passive: 접속 코드
|
||||||
|
_lblAddr.Text = passive ? "대상 IP (자동)" : "대상 IP";
|
||||||
|
_addr.IsEnabled = !passive;
|
||||||
|
_mode.IsEnabled = !passive;
|
||||||
|
_port.IsEnabled = !passive;
|
||||||
|
_lblMode.Opacity = _lblPort.Opacity = passive ? 0.4 : 1;
|
||||||
|
_lblCode.Visibility = _pnlCode.Visibility = passive ? Visibility.Visible : Visibility.Collapsed;
|
||||||
|
_btnTest.IsEnabled = !passive;
|
||||||
|
if (passive && string.IsNullOrEmpty(_code.Text)) _code.Text = RemoteTarget.NewAccessCode();
|
||||||
|
}
|
||||||
|
|
||||||
private void ClearForm()
|
private void ClearForm()
|
||||||
{
|
{
|
||||||
|
_direction.SelectedIndex = 0;
|
||||||
_addr.Text = "";
|
_addr.Text = "";
|
||||||
_hostname.Text = "";
|
_hostname.Text = "";
|
||||||
|
_code.Text = "";
|
||||||
_mode.SelectedIndex = 1;
|
_mode.SelectedIndex = 1;
|
||||||
_port.Text = "9988";
|
_port.Text = "9988";
|
||||||
_pwdTarget.Password = "";
|
_pwdTarget.Password = "";
|
||||||
_pwTargetState.Text = "대상 PC의 접속 비밀번호";
|
_pwTargetState.Text = "대상 PC의 접속 비밀번호";
|
||||||
_test.Text = "";
|
_test.Text = "";
|
||||||
|
OnDirectionChanged();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void ShowTarget(TargetItem item)
|
private void ShowTarget(TargetItem item)
|
||||||
{
|
{
|
||||||
if (item == null) return;
|
if (item == null) return;
|
||||||
var t = item.T;
|
var t = item.T;
|
||||||
_addr.Text = t.Address;
|
_direction.SelectedIndex = (int)t.Direction;
|
||||||
|
_addr.Text = t.Direction == ConnectDirection.Passive ? t.LastAddress : t.Address;
|
||||||
_hostname.Text = t.Hostname;
|
_hostname.Text = t.Hostname;
|
||||||
|
_code.Text = t.AccessCode;
|
||||||
_mode.SelectedIndex = (int)t.Mode;
|
_mode.SelectedIndex = (int)t.Mode;
|
||||||
_port.Text = t.Port.ToString();
|
_port.Text = t.Port.ToString();
|
||||||
_pwdTarget.Password = "";
|
_pwdTarget.Password = "";
|
||||||
_pwTargetState.Text = string.IsNullOrEmpty(t.ProtectedPassword) ? "비밀번호 없음 - 입력하세요" : "저장됨 (바꿀 때만 입력)";
|
_pwTargetState.Text = string.IsNullOrEmpty(t.ProtectedPassword) ? "비밀번호 없음 - 입력하세요" : "저장됨 (바꿀 때만 입력)";
|
||||||
_test.Text = "";
|
_test.Text = "";
|
||||||
|
OnDirectionChanged();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>입력값 검사. 문제가 있으면 메시지</summary>
|
/// <summary>입력값 검사. 문제가 있으면 메시지</summary>
|
||||||
|
|
@ -254,6 +410,12 @@ namespace JJServiceManager
|
||||||
addr = _addr.Text.Trim();
|
addr = _addr.Text.Trim();
|
||||||
mode = (RemoteMode)Math.Max(0, _mode.SelectedIndex);
|
mode = (RemoteMode)Math.Max(0, _mode.SelectedIndex);
|
||||||
port = 0;
|
port = 0;
|
||||||
|
if (FormIsPassive)
|
||||||
|
{
|
||||||
|
if (_code.Text.Trim().Length < 4) return "[새 코드]를 눌러 접속 코드를 만드세요.";
|
||||||
|
int.TryParse(_port.Text.Trim(), out port);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
if (addr.Length == 0) return "대상 IP를 입력하세요.";
|
if (addr.Length == 0) return "대상 IP를 입력하세요.";
|
||||||
if (addr.Contains(' ')) return "대상 IP에 공백이 있습니다.";
|
if (addr.Contains(' ')) return "대상 IP에 공백이 있습니다.";
|
||||||
if (!int.TryParse(_port.Text.Trim(), out port) || port < 1 || port > 65535) return "포트는 1~65535 사이 숫자로 입력하세요.";
|
if (!int.TryParse(_port.Text.Trim(), out port) || port < 1 || port > 65535) return "포트는 1~65535 사이 숫자로 입력하세요.";
|
||||||
|
|
@ -264,22 +426,28 @@ namespace JJServiceManager
|
||||||
{
|
{
|
||||||
var err = ReadForm(out var addr, out var mode, out var port);
|
var err = ReadForm(out var addr, out var mode, out var port);
|
||||||
if (err == null && _pwdTarget.Password.Length == 0) err = "비밀번호를 입력하세요.";
|
if (err == null && _pwdTarget.Password.Length == 0) err = "비밀번호를 입력하세요.";
|
||||||
if (err == null && _items.Any(i => string.Equals(i.T.Address, addr, StringComparison.OrdinalIgnoreCase) && i.T.Port == port))
|
if (err == null && !FormIsPassive && _items.Any(i => i.T.Direction == ConnectDirection.Active && string.Equals(i.T.Address, addr, StringComparison.OrdinalIgnoreCase) && i.T.Port == port))
|
||||||
err = "같은 IP와 포트가 이미 등록되어 있습니다.";
|
err = "같은 IP와 포트가 이미 등록되어 있습니다.";
|
||||||
|
if (err == null && FormIsPassive && _items.Any(i => string.Equals(i.T.AccessCode, _code.Text.Trim(), StringComparison.OrdinalIgnoreCase)))
|
||||||
|
err = "같은 접속 코드가 이미 있습니다. [새 코드]를 누르세요.";
|
||||||
if (err != null) { ShowTest(err, false); return; }
|
if (err != null) { ShowTest(err, false); return; }
|
||||||
|
|
||||||
var t = new RemoteTarget
|
var t = new RemoteTarget
|
||||||
{
|
{
|
||||||
Address = addr,
|
Direction = FormIsPassive ? ConnectDirection.Passive : ConnectDirection.Active,
|
||||||
|
AccessCode = FormIsPassive ? _code.Text.Trim() : "",
|
||||||
|
Address = FormIsPassive ? "" : addr,
|
||||||
Mode = mode,
|
Mode = mode,
|
||||||
Port = port,
|
Port = port > 0 ? port : 9988,
|
||||||
Hostname = _hostname.Text.Trim(),
|
Hostname = _hostname.Text.Trim(),
|
||||||
ProtectedPassword = RemoteSecurity.Protect(_pwdTarget.Password)
|
ProtectedPassword = RemoteSecurity.Protect(_pwdTarget.Password)
|
||||||
};
|
};
|
||||||
var item = new TargetItem(t);
|
var item = new TargetItem(t);
|
||||||
_items.Add(item);
|
_items.Add(item);
|
||||||
_targets.SelectedItem = item;
|
_targets.SelectedItem = item;
|
||||||
ShowTest("추가했습니다. [확인]을 누르면 저장됩니다.", true);
|
ShowTest(FormIsPassive
|
||||||
|
? $"추가했습니다. 대상 PC의 [원격 접속 허용 → Passive 접속]에 이 PC 주소와 코드 {t.AccessCode}를 입력하세요."
|
||||||
|
: "추가했습니다. [확인]을 누르면 저장됩니다.", true);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void UpdateTarget()
|
private void UpdateTarget()
|
||||||
|
|
@ -289,10 +457,20 @@ namespace JJServiceManager
|
||||||
if (err != null) { ShowTest(err, false); return; }
|
if (err != null) { ShowTest(err, false); return; }
|
||||||
|
|
||||||
var t = item.T;
|
var t = item.T;
|
||||||
if (!string.Equals(t.Address, addr, StringComparison.OrdinalIgnoreCase)) t.Hostname = ""; // IP가 바뀌면 hostname 다시 확인
|
var newDir = FormIsPassive ? ConnectDirection.Passive : ConnectDirection.Active;
|
||||||
t.Address = addr;
|
if (newDir != t.Direction || (newDir == ConnectDirection.Active && !string.Equals(t.Address, addr, StringComparison.OrdinalIgnoreCase)))
|
||||||
t.Mode = mode;
|
t.Hostname = ""; // 방향이나 IP가 바뀌면 hostname 다시 확인
|
||||||
t.Port = port;
|
t.Direction = newDir;
|
||||||
|
if (newDir == ConnectDirection.Passive)
|
||||||
|
{
|
||||||
|
t.AccessCode = _code.Text.Trim();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
t.Address = addr;
|
||||||
|
t.Mode = mode;
|
||||||
|
t.Port = port;
|
||||||
|
}
|
||||||
if (_hostname.Text.Trim().Length > 0 && t.Hostname.Length == 0) t.Hostname = _hostname.Text.Trim();
|
if (_hostname.Text.Trim().Length > 0 && t.Hostname.Length == 0) t.Hostname = _hostname.Text.Trim();
|
||||||
if (_pwdTarget.Password.Length > 0) t.ProtectedPassword = RemoteSecurity.Protect(_pwdTarget.Password);
|
if (_pwdTarget.Password.Length > 0) t.ProtectedPassword = RemoteSecurity.Protect(_pwdTarget.Password);
|
||||||
item.Changed();
|
item.Changed();
|
||||||
|
|
@ -339,14 +517,16 @@ namespace JJServiceManager
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
_btnTest.IsEnabled = true;
|
_btnTest.IsEnabled = !FormIsPassive;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void ShowTest(string msg, bool ok)
|
private void ShowTest(string msg, bool ok) => ShowResult(_test, msg, ok);
|
||||||
|
|
||||||
|
private static void ShowResult(TextBlock tb, string msg, bool ok)
|
||||||
{
|
{
|
||||||
_test.Text = msg;
|
tb.Text = msg;
|
||||||
_test.Foreground = ok ? Brushes2.Running : Brushes2.Stopped;
|
tb.Foreground = ok ? Brushes2.Running : Brushes2.Stopped;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ───────────────────────── 저장 ─────────────────────────
|
// ───────────────────────── 저장 ─────────────────────────
|
||||||
|
|
@ -356,11 +536,18 @@ namespace JJServiceManager
|
||||||
// 원격 접속 허용 검사
|
// 원격 접속 허용 검사
|
||||||
if (!int.TryParse(_serverPort.Text.Trim(), out var sp) || sp < 1 || sp > 65535)
|
if (!int.TryParse(_serverPort.Text.Trim(), out var sp) || sp < 1 || sp > 65535)
|
||||||
{
|
{
|
||||||
Fail(1, "원격 접속 허용 포트는 1~65535 사이 숫자로 입력하세요.");
|
Fail(1, "Active 수신 포트는 1~65535 사이 숫자로 입력하세요.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (_serverMode.SelectedIndex == 0) _sshPort = sp; else _tcpPort = sp;
|
if (_serverMode.SelectedIndex == 0) _sshPort = sp; else _tcpPort = sp;
|
||||||
|
|
||||||
|
if (!int.TryParse(_passivePort.Text.Trim(), out var pp) || pp < 1 || pp > 65535)
|
||||||
|
{
|
||||||
|
Fail(2, "Passive 수신 포트는 1~65535 사이 숫자로 입력하세요.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (_passiveMode.SelectedIndex == 0) _passiveSshPort = pp; else _passiveTcpPort = pp;
|
||||||
|
|
||||||
string newHash = null, newSalt = null;
|
string newHash = null, newSalt = null;
|
||||||
if (_pwd1.Password.Length > 0 || _pwd2.Password.Length > 0)
|
if (_pwd1.Password.Length > 0 || _pwd2.Password.Length > 0)
|
||||||
{
|
{
|
||||||
|
|
@ -374,6 +561,11 @@ namespace JJServiceManager
|
||||||
Fail(1, "원격 접속을 허용하려면 접속 비밀번호를 정하세요.");
|
Fail(1, "원격 접속을 허용하려면 접속 비밀번호를 정하세요.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (_server.IsChecked == true && _activeListen.IsChecked != true && _pms.Count == 0)
|
||||||
|
{
|
||||||
|
Fail(1, "원격 접속 허용을 켜려면 Active 수신을 켜거나 Passive 접속을 하나 이상 추가하세요.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// 일반
|
// 일반
|
||||||
_s.RunAtStartup = _startup.IsChecked == true;
|
_s.RunAtStartup = _startup.IsChecked == true;
|
||||||
|
|
@ -389,6 +581,7 @@ namespace JJServiceManager
|
||||||
|
|
||||||
// 원격 접속 허용
|
// 원격 접속 허용
|
||||||
_s.RemoteServerEnabled = _server.IsChecked == true;
|
_s.RemoteServerEnabled = _server.IsChecked == true;
|
||||||
|
_s.RemoteServerActiveListen = _activeListen.IsChecked == true;
|
||||||
_s.RemoteServerMode = (RemoteMode)Math.Max(0, _serverMode.SelectedIndex);
|
_s.RemoteServerMode = (RemoteMode)Math.Max(0, _serverMode.SelectedIndex);
|
||||||
_s.RemoteServerSshPort = _sshPort;
|
_s.RemoteServerSshPort = _sshPort;
|
||||||
_s.RemoteServerTcpPort = _tcpPort;
|
_s.RemoteServerTcpPort = _tcpPort;
|
||||||
|
|
@ -397,9 +590,16 @@ namespace JJServiceManager
|
||||||
_s.RemotePasswordHash = newHash;
|
_s.RemotePasswordHash = newHash;
|
||||||
_s.RemotePasswordSalt = newSalt;
|
_s.RemotePasswordSalt = newSalt;
|
||||||
}
|
}
|
||||||
|
_s.PassiveManagers = _pms.Select(m => m.Clone()).ToList();
|
||||||
|
_s.RemoteAllowView = _allowView.IsChecked == true;
|
||||||
|
_s.RemoteAllowControl = _allowView.IsChecked == true && _allowControl.IsChecked == true;
|
||||||
|
_s.RemoteRequireApproval = _requireApproval.IsChecked == true;
|
||||||
|
|
||||||
// 관리 대상 PC
|
// 관리 대상 PC
|
||||||
_s.RemoteManageEnabled = _manage.IsChecked == true;
|
_s.RemoteManageEnabled = _manage.IsChecked == true;
|
||||||
|
_s.PassiveListenMode = (RemoteMode)Math.Max(0, _passiveMode.SelectedIndex);
|
||||||
|
_s.PassiveListenSshPort = _passiveSshPort;
|
||||||
|
_s.PassiveListenTcpPort = _passiveTcpPort;
|
||||||
_s.Targets = _items.Select(i => i.T.Clone()).ToList();
|
_s.Targets = _items.Select(i => i.T.Clone()).ToList();
|
||||||
|
|
||||||
DialogResult = true;
|
DialogResult = true;
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue