249 lines
9.3 KiB
C#
249 lines
9.3 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.Diagnostics;
|
|
using System.IO;
|
|
using System.Linq;
|
|
using System.Net;
|
|
using System.Net.NetworkInformation;
|
|
using System.Net.Sockets;
|
|
using System.Security.Cryptography;
|
|
using System.Text;
|
|
using System.Text.Json;
|
|
using System.Text.Json.Nodes;
|
|
using System.Threading;
|
|
using System.Threading.Tasks;
|
|
|
|
namespace JJServiceManager.Remote
|
|
{
|
|
/// <summary>비밀번호 처리</summary>
|
|
public static class RemoteSecurity
|
|
{
|
|
private const int Iterations = 120_000;
|
|
|
|
/// <summary>대상 PC: 접속 비밀번호를 해시로 저장</summary>
|
|
public static (string hash, string salt) HashPassword(string password)
|
|
{
|
|
var salt = RandomNumberGenerator.GetBytes(16);
|
|
var hash = Rfc2898DeriveBytes.Pbkdf2(Encoding.UTF8.GetBytes(password), salt, Iterations, HashAlgorithmName.SHA256, 32);
|
|
return (Convert.ToBase64String(hash), Convert.ToBase64String(salt));
|
|
}
|
|
|
|
public static bool Verify(string password, string hashB64, string saltB64)
|
|
{
|
|
if (string.IsNullOrEmpty(password) || string.IsNullOrEmpty(hashB64) || string.IsNullOrEmpty(saltB64)) return false;
|
|
try
|
|
{
|
|
var salt = Convert.FromBase64String(saltB64);
|
|
var expected = Convert.FromBase64String(hashB64);
|
|
var actual = Rfc2898DeriveBytes.Pbkdf2(Encoding.UTF8.GetBytes(password), salt, Iterations, HashAlgorithmName.SHA256, expected.Length);
|
|
return CryptographicOperations.FixedTimeEquals(actual, expected);
|
|
}
|
|
catch
|
|
{
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/// <summary>관리 PC: 대상 PC 비밀번호를 현재 Windows 사용자로 암호화해 저장</summary>
|
|
public static string Protect(string password)
|
|
{
|
|
if (string.IsNullOrEmpty(password)) return "";
|
|
var data = ProtectedData.Protect(Encoding.UTF8.GetBytes(password), null, DataProtectionScope.CurrentUser);
|
|
return Convert.ToBase64String(data);
|
|
}
|
|
|
|
public static string Unprotect(string protectedB64)
|
|
{
|
|
if (string.IsNullOrEmpty(protectedB64)) return "";
|
|
try
|
|
{
|
|
var data = ProtectedData.Unprotect(Convert.FromBase64String(protectedB64), null, DataProtectionScope.CurrentUser);
|
|
return Encoding.UTF8.GetString(data);
|
|
}
|
|
catch
|
|
{
|
|
return "";
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// 한 줄에 JSON 하나씩 주고받는 연결 (SSH 채널 / TLS 스트림 공통)
|
|
/// </summary>
|
|
public sealed class JsonLineConnection : IDisposable
|
|
{
|
|
private const int MaxLineChars = 16 * 1024 * 1024;
|
|
|
|
private readonly Stream _stream;
|
|
private readonly StreamReader _reader;
|
|
private readonly SemaphoreSlim _writeLock = new SemaphoreSlim(1, 1);
|
|
private readonly IDisposable[] _owned;
|
|
private int _disposed;
|
|
|
|
public JsonLineConnection(Stream stream, params IDisposable[] owned)
|
|
{
|
|
_stream = stream;
|
|
_reader = new StreamReader(stream, new UTF8Encoding(false), false, 16 * 1024, leaveOpen: true);
|
|
_owned = owned ?? Array.Empty<IDisposable>();
|
|
}
|
|
|
|
public bool IsDisposed => Volatile.Read(ref _disposed) == 1;
|
|
|
|
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)
|
|
{
|
|
_line.Clear();
|
|
while (true)
|
|
{
|
|
if (_bufPos >= _bufLen)
|
|
{
|
|
_bufLen = await _reader.ReadAsync(_buf.AsMemory(), ct).ConfigureAwait(false);
|
|
_bufPos = 0;
|
|
if (_bufLen <= 0) return null;
|
|
}
|
|
|
|
int idx = Array.IndexOf(_buf, '\n', _bufPos, _bufLen - _bufPos);
|
|
if (idx < 0)
|
|
{
|
|
_line.Append(_buf, _bufPos, _bufLen - _bufPos);
|
|
_bufPos = _bufLen;
|
|
if (_line.Length > MaxLineChars) throw new IOException("메시지가 너무 큽니다.");
|
|
continue;
|
|
}
|
|
|
|
_line.Append(_buf, _bufPos, idx - _bufPos);
|
|
_bufPos = idx + 1;
|
|
var text = _line.ToString().Trim();
|
|
_line.Clear();
|
|
if (text.Length == 0) continue;
|
|
try
|
|
{
|
|
return JsonNode.Parse(text) as JsonObject ?? new JsonObject();
|
|
}
|
|
catch (JsonException)
|
|
{
|
|
throw new IOException("잘못된 메시지를 받았습니다.");
|
|
}
|
|
}
|
|
}
|
|
|
|
public async Task SendAsync(JsonObject msg, CancellationToken ct = default)
|
|
{
|
|
var bytes = Encoding.UTF8.GetBytes(msg.ToJsonString() + "\n");
|
|
await _writeLock.WaitAsync(ct).ConfigureAwait(false);
|
|
try
|
|
{
|
|
await _stream.WriteAsync(bytes, ct).ConfigureAwait(false);
|
|
await _stream.FlushAsync(ct).ConfigureAwait(false);
|
|
}
|
|
finally
|
|
{
|
|
_writeLock.Release();
|
|
}
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
if (Interlocked.Exchange(ref _disposed, 1) == 1) return;
|
|
try { _reader.Dispose(); } catch { }
|
|
try { _stream.Dispose(); } catch { }
|
|
foreach (var d in _owned)
|
|
{
|
|
try { d?.Dispose(); } catch { }
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>원격으로 받은 서비스 정보</summary>
|
|
public sealed class RemoteServiceInfo
|
|
{
|
|
public string ServiceName { get; set; } = "";
|
|
public string DisplayName { get; set; } = "";
|
|
/// <summary>System.ServiceProcess.ServiceControllerStatus 값, 서비스가 없으면 null</summary>
|
|
public int? Status { get; set; }
|
|
public string LogPath { get; set; } = "";
|
|
public bool LogIsFolder { get; set; }
|
|
}
|
|
|
|
/// <summary>원격 명령 실패</summary>
|
|
public sealed class RemoteException : Exception
|
|
{
|
|
/// <summary>오류가 아닌 안내 (이미 실행 중 등)</summary>
|
|
public bool IsInfo { get; }
|
|
public RemoteException(string message, bool isInfo = false) : base(message) { IsInfo = isInfo; }
|
|
}
|
|
|
|
public static class NetInfo
|
|
{
|
|
/// <summary>이 PC의 IPv4 주소 목록 (설정 화면 안내용)</summary>
|
|
public static List<string> LocalIPv4()
|
|
{
|
|
var list = new List<string>();
|
|
try
|
|
{
|
|
foreach (var ni in NetworkInterface.GetAllNetworkInterfaces())
|
|
{
|
|
if (ni.OperationalStatus != OperationalStatus.Up) continue;
|
|
if (ni.NetworkInterfaceType == NetworkInterfaceType.Loopback) continue;
|
|
foreach (var ua in ni.GetIPProperties().UnicastAddresses)
|
|
if (ua.Address.AddressFamily == AddressFamily.InterNetwork)
|
|
list.Add(ua.Address.ToString());
|
|
}
|
|
}
|
|
catch { }
|
|
return list.Distinct().ToList();
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Windows 방화벽 규칙
|
|
/// - "JJServiceManager Remote" : 대상 PC의 Active 수신 포트
|
|
/// - "JJServiceManager Passive" : 관리 PC의 Passive 수신 포트
|
|
/// (+ 테스트 인스턴스 꼬리표)
|
|
/// </summary>
|
|
public static class FirewallManager
|
|
{
|
|
public const string ActiveRule = "JJServiceManager Remote";
|
|
public const string PassiveRule = "JJServiceManager Passive";
|
|
|
|
/// <summary>enabled면 해당 포트를 열고, 아니면 규칙을 지움</summary>
|
|
public static (bool ok, string message) Apply(string rule, bool enabled, int port)
|
|
{
|
|
var name = rule + AppInstance.Suffix;
|
|
// 기존 규칙은 지우고 다시 만듦 (포트가 바뀌었을 수 있음)
|
|
ConsoleRunner.Run("netsh.exe", $"advfirewall firewall delete rule name=\"{name}\"");
|
|
if (!enabled) return (true, $"방화벽 규칙 삭제 ({name})");
|
|
|
|
var r = ConsoleRunner.Run("netsh.exe", $"advfirewall firewall add rule name=\"{name}\" dir=in action=allow protocol=TCP localport={port} profile=any");
|
|
return r.exitCode == 0
|
|
? (true, $"방화벽 TCP {port} 허용 ({name})")
|
|
: (false, $"방화벽 규칙 추가 실패 ({name}): {r.output}");
|
|
}
|
|
}
|
|
|
|
public static class Json
|
|
{
|
|
public static string Str(JsonObject o, string key)
|
|
{
|
|
try { return o?[key]?.GetValue<string>() ?? ""; }
|
|
catch { return o?[key]?.ToString() ?? ""; }
|
|
}
|
|
|
|
public static int Int(JsonObject o, string key, int def = 0)
|
|
{
|
|
try { return o?[key]?.GetValue<int>() ?? def; }
|
|
catch { return def; }
|
|
}
|
|
|
|
public static bool Bool(JsonObject o, string key)
|
|
{
|
|
try { return o?[key]?.GetValue<bool>() ?? false; }
|
|
catch { return false; }
|
|
}
|
|
}
|
|
}
|