344 lines
14 KiB
C#
344 lines
14 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.ComponentModel;
|
|
using System.IO;
|
|
using System.Runtime.CompilerServices;
|
|
using System.Text.Json;
|
|
using System.Text.Json.Serialization;
|
|
|
|
namespace JJServiceManager
|
|
{
|
|
/// <summary>바인딩용 기본 클래스</summary>
|
|
public abstract class ObservableObject : INotifyPropertyChanged
|
|
{
|
|
public event PropertyChangedEventHandler PropertyChanged;
|
|
|
|
protected bool Set<T>(ref T field, T value, [CallerMemberName] string name = null)
|
|
{
|
|
if (EqualityComparer<T>.Default.Equals(field, value)) return false;
|
|
field = value;
|
|
OnPropertyChanged(name);
|
|
return true;
|
|
}
|
|
|
|
protected void OnPropertyChanged([CallerMemberName] string name = null)
|
|
=> PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────
|
|
// [테스트용 중복 실행] --instance N 옵션 — 정식 릴리즈 시 이 클래스와 사용하는 곳을 삭제
|
|
// ─────────────────────────────────────────────────────────────
|
|
public static class AppInstance
|
|
{
|
|
/// <summary>1 = 기본 실행, 2 이상 = 테스트용 추가 실행</summary>
|
|
public static int Number { get; private set; } = 1;
|
|
|
|
public static bool IsTest => Number > 1;
|
|
|
|
/// <summary>이름 뒤에 붙는 꼬리표 ("" 또는 "_instance2")</summary>
|
|
public static string Suffix => IsTest ? "_instance" + Number : "";
|
|
|
|
/// <summary>창 제목에 붙는 표시</summary>
|
|
public static string TitleTag => IsTest ? $" [테스트 인스턴스 {Number}]" : "";
|
|
|
|
public static void Parse(string[] args)
|
|
{
|
|
for (int i = 0; i < args.Length - 1; i++)
|
|
{
|
|
if (string.Equals(args[i], "--instance", StringComparison.OrdinalIgnoreCase)
|
|
&& int.TryParse(args[i + 1], out int n) && n >= 1 && n <= 9)
|
|
{
|
|
Number = n;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>원격 통신 방식</summary>
|
|
public enum RemoteMode
|
|
{
|
|
Ssh = 0, // 내장 SSH (기본 2222)
|
|
Port = 1 // 지정 포트 + TLS (기본 9988)
|
|
}
|
|
|
|
/// <summary>접속 방향</summary>
|
|
public enum ConnectDirection
|
|
{
|
|
Active = 0, // 관리 PC → 대상 PC 로 접속 (대상 PC가 포트를 엶)
|
|
Passive = 1 // 대상 PC → 관리 PC 로 접속 (관리 PC가 포트를 엶)
|
|
}
|
|
|
|
/// <summary>관리 대상 PC</summary>
|
|
public class RemoteTarget
|
|
{
|
|
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; } = "";
|
|
/// <summary>접속할 때 자동으로 채워짐</summary>
|
|
public string Hostname { get; set; } = "";
|
|
public RemoteMode Mode { get; set; } = RemoteMode.Port;
|
|
public int Port { get; set; } = 9988;
|
|
/// <summary>접속 비밀번호 (Windows 사용자 계정으로 암호화, Base64)</summary>
|
|
public string ProtectedPassword { get; set; } = "";
|
|
|
|
/// <summary>Passive로 접속해 온 대상 PC의 IP (표시용, 자동)</summary>
|
|
public string LastAddress { get; set; } = "";
|
|
|
|
[JsonIgnore]
|
|
public string Label
|
|
{
|
|
get
|
|
{
|
|
if (Direction == ConnectDirection.Passive)
|
|
{
|
|
// 아직 한 번도 접속하지 않았으면 코드로 표시
|
|
if (string.IsNullOrWhiteSpace(LastAddress)) return "Passive " + AccessCode;
|
|
var p = string.IsNullOrWhiteSpace(Hostname) ? LastAddress : $"{LastAddress} / {Hostname}";
|
|
return p + " (Passive)";
|
|
}
|
|
return string.IsNullOrWhiteSpace(Hostname) ? Address : $"{Address} / {Hostname}";
|
|
}
|
|
}
|
|
|
|
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>
|
|
public enum LogWindowMode
|
|
{
|
|
Individual = 0, // 개별 창
|
|
Tabs = 1, // 하나의 창에 탭
|
|
Split = 2 // 창을 좌우로 나누어 비교
|
|
}
|
|
|
|
/// <summary>Esc 키 동작</summary>
|
|
public enum EscAction
|
|
{
|
|
Tray = 0, // 트레이로 (백그라운드)
|
|
Exit = 1 // 완전 종료
|
|
}
|
|
|
|
/// <summary>등록된 서비스 한 건</summary>
|
|
public class ServiceEntry
|
|
{
|
|
public string ServiceName { get; set; } = "";
|
|
public string DisplayName { get; set; } = "";
|
|
|
|
/// <summary>로그 파일 경로 또는 로그 폴더 경로 (없으면 빈 값)</summary>
|
|
public string LogPath { get; set; } = "";
|
|
|
|
/// <summary>true면 LogPath는 폴더이며, 그 안의 가장 최근 수정 파일을 표시</summary>
|
|
public bool LogIsFolder { get; set; }
|
|
|
|
/// <summary>로그 인코딩: auto / utf-8 / cp949</summary>
|
|
public string LogEncoding { get; set; } = "auto";
|
|
}
|
|
|
|
/// <summary>프로그램 설정 (settings.json)</summary>
|
|
public class AppSettings
|
|
{
|
|
public List<ServiceEntry> Services { get; set; } = new List<ServiceEntry>();
|
|
|
|
/// <summary>컴퓨터 시작 시 기동</summary>
|
|
public bool RunAtStartup { get; set; }
|
|
|
|
/// <summary>단축키 사용 여부</summary>
|
|
public bool HotkeyEnabled { get; set; } = true;
|
|
|
|
/// <summary>단축키 보조키 (System.Windows.Input.ModifierKeys 값)</summary>
|
|
public int HotkeyModifiers { get; set; } = 2 | 4; // Ctrl + Shift
|
|
|
|
/// <summary>단축키 키 (System.Windows.Input.Key 값)</summary>
|
|
public int HotkeyKey { get; set; } = 44; // Key.A
|
|
|
|
/// <summary>서비스 시작 시 로그보기 = 작업 로그를 화면 하단에 표시할지 여부</summary>
|
|
public bool ShowOperationLog { get; set; } = true;
|
|
|
|
/// <summary>로그창 띄우기 방식</summary>
|
|
public LogWindowMode LogWindowMode { get; set; } = LogWindowMode.Individual;
|
|
|
|
/// <summary>Esc 키 동작</summary>
|
|
public EscAction EscAction { get; set; } = EscAction.Tray;
|
|
|
|
/// <summary>Windows 강제종료 / 재시작 버튼 표시</summary>
|
|
public bool ShowShutdownButtons { get; set; } = true;
|
|
|
|
// ───────── 숫자 6자리 확인 ─────────
|
|
|
|
/// <summary>내 PC 서비스 Start/Stop 시 숫자 확인</summary>
|
|
public bool ConfirmLocalStartStop { get; set; } = false;
|
|
|
|
/// <summary>원격 서비스 Start/Stop 시 숫자 확인</summary>
|
|
public bool ConfirmRemoteStartStop { get; set; } = true;
|
|
|
|
// ───────── 원격 접속 허용 (대상 역할) ─────────
|
|
|
|
public bool RemoteServerEnabled { get; set; }
|
|
public RemoteMode RemoteServerMode { get; set; } = RemoteMode.Port;
|
|
public int RemoteServerTcpPort { get; set; } = 9988 + AppInstance.Number - 1;
|
|
public int RemoteServerSshPort { get; set; } = 2222 + AppInstance.Number - 1;
|
|
/// <summary>접속 비밀번호 해시 (PBKDF2)</summary>
|
|
public string RemotePasswordHash { get; set; } = "";
|
|
public string RemotePasswordSalt { get; set; } = "";
|
|
|
|
[JsonIgnore]
|
|
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 관리 (관리 역할) ─────────
|
|
|
|
public bool RemoteManageEnabled { get; set; }
|
|
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>
|
|
public List<string> OpenHostTabs { get; set; } = new List<string>();
|
|
|
|
/// <summary>창 크기·위치</summary>
|
|
public double WindowLeft { get; set; } = double.NaN;
|
|
public double WindowTop { get; set; } = double.NaN;
|
|
public double WindowWidth { get; set; } = 1000;
|
|
public double WindowHeight { get; set; } = 720;
|
|
}
|
|
|
|
/// <summary>설정 파일 저장/불러오기</summary>
|
|
public static class SettingsStore
|
|
{
|
|
// [테스트용 중복 실행] 인스턴스마다 설정 폴더를 따로 씀 (정식 릴리즈 시 Suffix 제거)
|
|
public static string DataFolder =>
|
|
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "JJServiceManager" + AppInstance.Suffix);
|
|
|
|
public static string SettingsPath => Path.Combine(DataFolder, "settings.json");
|
|
|
|
private static readonly JsonSerializerOptions Options = new JsonSerializerOptions
|
|
{
|
|
WriteIndented = true,
|
|
NumberHandling = JsonNumberHandling.AllowNamedFloatingPointLiterals,
|
|
Converters = { new JsonStringEnumConverter() }
|
|
};
|
|
|
|
public static AppSettings Load()
|
|
{
|
|
try
|
|
{
|
|
if (File.Exists(SettingsPath))
|
|
{
|
|
var json = File.ReadAllText(SettingsPath);
|
|
var s = JsonSerializer.Deserialize<AppSettings>(json, Options);
|
|
if (s != null)
|
|
{
|
|
s.Services ??= new List<ServiceEntry>();
|
|
s.Targets ??= new List<RemoteTarget>();
|
|
s.PassiveManagers ??= new List<PassiveManager>();
|
|
s.OpenHostTabs ??= new List<string>();
|
|
return s;
|
|
}
|
|
}
|
|
}
|
|
catch
|
|
{
|
|
// 설정 파일이 손상된 경우 기본값 사용 (원본 파일은 지우지 않음)
|
|
}
|
|
return new AppSettings();
|
|
}
|
|
|
|
public static void Save(AppSettings settings)
|
|
{
|
|
try
|
|
{
|
|
Directory.CreateDirectory(DataFolder);
|
|
var tmp = SettingsPath + ".tmp";
|
|
File.WriteAllText(tmp, JsonSerializer.Serialize(settings, Options));
|
|
File.Copy(tmp, SettingsPath, true);
|
|
File.Delete(tmp);
|
|
}
|
|
catch
|
|
{
|
|
// 저장 실패는 무시 (다음 저장 때 다시 시도)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>작업 로그를 날짜별 파일에 추가 기록 (삭제 기능 없음)</summary>
|
|
public static class OperationLogFile
|
|
{
|
|
private static readonly object Sync = new object();
|
|
|
|
public static string Folder => Path.Combine(SettingsStore.DataFolder, "logs");
|
|
|
|
public static void Append(DateTime time, string text)
|
|
{
|
|
try
|
|
{
|
|
lock (Sync)
|
|
{
|
|
Directory.CreateDirectory(Folder);
|
|
var path = Path.Combine(Folder, time.ToString("yyyy-MM-dd") + ".log");
|
|
File.AppendAllText(path, $"[{time:yyyy-MM-dd HH:mm:ss}] {text}{Environment.NewLine}");
|
|
}
|
|
}
|
|
catch
|
|
{
|
|
// 기록 실패는 무시
|
|
}
|
|
}
|
|
}
|
|
}
|