262 lines
9.6 KiB
C#
262 lines
9.6 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 = 4 * 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;
|
|
|
|
/// <summary>다음 메시지. 연결이 끊기면 null</summary>
|
|
public async Task<JsonObject> ReadAsync(CancellationToken ct)
|
|
{
|
|
var sb = new StringBuilder();
|
|
var buf = new char[1];
|
|
while (true)
|
|
{
|
|
int n = await _reader.ReadAsync(buf.AsMemory(0, 1), ct).ConfigureAwait(false);
|
|
if (n == 0) return null;
|
|
char c = buf[0];
|
|
if (c == '\n')
|
|
{
|
|
var line = sb.ToString().Trim();
|
|
sb.Clear();
|
|
if (line.Length == 0) continue;
|
|
try
|
|
{
|
|
return JsonNode.Parse(line) as JsonObject ?? new JsonObject();
|
|
}
|
|
catch (JsonException)
|
|
{
|
|
throw new IOException("잘못된 메시지를 받았습니다.");
|
|
}
|
|
}
|
|
sb.Append(c);
|
|
if (sb.Length > MaxLineChars) 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" (+ 테스트 인스턴스 꼬리표)
|
|
/// </summary>
|
|
public static class FirewallManager
|
|
{
|
|
private static string RuleName => "JJServiceManager Remote" + AppInstance.Suffix;
|
|
|
|
/// <summary>enabled면 해당 포트를 열고, 아니면 규칙을 지움</summary>
|
|
public static (bool ok, string message) Apply(bool enabled, int port)
|
|
{
|
|
// 기존 규칙은 지우고 다시 만듦 (포트가 바뀌었을 수 있음)
|
|
Run($"advfirewall firewall delete rule name=\"{RuleName}\"");
|
|
if (!enabled) return (true, "방화벽 규칙 삭제");
|
|
|
|
var r = Run($"advfirewall firewall add rule name=\"{RuleName}\" dir=in action=allow protocol=TCP localport={port} profile=any");
|
|
return r.exit == 0
|
|
? (true, $"방화벽 TCP {port} 허용")
|
|
: (false, "방화벽 규칙 추가 실패: " + r.output);
|
|
}
|
|
|
|
private static (int exit, string output) Run(string args)
|
|
{
|
|
try
|
|
{
|
|
var psi = new ProcessStartInfo("netsh.exe", args)
|
|
{
|
|
UseShellExecute = false,
|
|
CreateNoWindow = true,
|
|
RedirectStandardOutput = true,
|
|
RedirectStandardError = true,
|
|
StandardOutputEncoding = OemEncoding(),
|
|
StandardErrorEncoding = OemEncoding()
|
|
};
|
|
using var p = Process.Start(psi);
|
|
var o = p.StandardOutput.ReadToEnd() + p.StandardError.ReadToEnd();
|
|
p.WaitForExit(15000);
|
|
return (p.ExitCode, o.Trim());
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
return (-1, ex.Message);
|
|
}
|
|
}
|
|
|
|
private static Encoding OemEncoding()
|
|
{
|
|
try { return Encoding.GetEncoding(System.Globalization.CultureInfo.CurrentCulture.TextInfo.OEMCodePage); }
|
|
catch { return Encoding.UTF8; }
|
|
}
|
|
}
|
|
|
|
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; }
|
|
}
|
|
}
|
|
}
|