From c8fa82b74348b5c9d7d1aded37194d1a6ae8b8ee Mon Sep 17 00:00:00 2001 From: diadmin Date: Tue, 27 Oct 2026 03:44:31 +0000 Subject: [PATCH] =?UTF-8?q?=EC=9B=90=EA=B2=A9=20=EB=AA=A8=EB=8B=88?= =?UTF-8?q?=ED=84=B0=EB=A7=81=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80=20?= =?UTF-8?q?(=EC=9E=91=EC=97=85=20=EC=A4=91,=20=EA=B2=80=ED=86=A0=20?= =?UTF-8?q?=EB=B0=98=EC=98=81=20=EC=A0=84)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 원격 접속 허용(대상): 내장 SSH(2222) 또는 지정 포트 TLS(9988), 접속 비밀번호(PBKDF2) - 다른 PC 관리(관리): 대상 PC 등록 서비스 목록/상태/Start/Stop/Status/로그 실시간 - 초기화면: 전체 탭(PC별 묶음) + 콤보박스로 PC 탭 추가/닫기, 원격 서비스 앞 IP/hostname - Start/Stop 숫자 6자리 확인 (설정에서 내 PC/원격 각각 선택) - 설정 3탭(일반/원격 접속 허용/관리 대상 PC), hostname 자동 등록, 방화벽 자동 - 테스트용 --instance N 중복 실행 (정식 릴리즈 시 삭제) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013U497kUNL8ArhV7jkzCpKa --- Core/LogTailer.cs | 21 +- Core/LogWindows.cs | 299 ++++++++++------- Core/Models.cs | 90 ++++- Core/Remote/RemoteClient.cs | 486 +++++++++++++++++++++++++++ Core/Remote/RemoteCommon.cs | 261 +++++++++++++++ Core/Remote/RemoteServer.cs | 499 ++++++++++++++++++++++++++++ Core/SystemIntegration.cs | 6 +- JJServiceManager.csproj | 7 +- Program.cs | 8 +- README.md | 86 +++-- Views/MainWindow.xaml | 122 ++++--- Views/Settings.xaml | 267 +++++++++++---- Windows/MainWindow.cs | 635 +++++++++++++++++++++++++++++------- Windows/ServiceRow.cs | 139 +++++++- Windows/SettingsWindow.cs | 314 +++++++++++++++++- 15 files changed, 2859 insertions(+), 381 deletions(-) create mode 100644 Core/Remote/RemoteClient.cs create mode 100644 Core/Remote/RemoteCommon.cs create mode 100644 Core/Remote/RemoteServer.cs diff --git a/Core/LogTailer.cs b/Core/LogTailer.cs index f1b91a0..47af906 100644 --- a/Core/LogTailer.cs +++ b/Core/LogTailer.cs @@ -8,13 +8,22 @@ using System.Threading; namespace JJServiceManager { + /// 로그 보기 창이 사용하는 로그 공급원 (내 PC 파일 / 원격) + public interface ILogSource : IDisposable + { + event Action FileChanged; + string CurrentFile { get; } + void Start(); + void Reload(); + } + /// /// 서비스 로그 파일을 실시간으로 읽음 (tail). /// - 파일 지정: 그 파일을 계속 추적 /// - 폴더 지정: 폴더 안에서 가장 최근에 수정된 파일을 자동으로 추적 (날짜별 로그에 유리) /// - 읽기 전용으로만 열며, 파일을 수정·삭제하지 않음 /// - public sealed class LogTailer : IDisposable + public sealed class LogTailer : ILogSource { private const int PollMs = 500; private const int FolderScanEvery = 4; // 폴더 모드: 2초마다 최신 파일 확인 @@ -51,6 +60,9 @@ namespace JJServiceManager public string CurrentFile => _current?.Path; + /// true면 처음 열 때 기존 내용은 건너뛰고 새로 추가되는 줄만 표시 (원격 재연결용) + public bool SkipInitial { get; set; } + public LogTailer(ServiceEntry entry, Action, LogLevel> output) { _entry = entry; @@ -190,6 +202,13 @@ namespace JJServiceManager PrepareEncoding(st, fs); long len = fs.Length; + if (SkipInitial) + { + SkipInitial = false; + st.Position = len; + st.Partial = ""; + return; + } long start = Math.Max(st.BomLength, len - InitialBytes); if (IsUtf16(st.Encoding) && ((start - st.BomLength) % 2 == 1)) start++; bool fromMiddle = start > st.BomLength; diff --git a/Core/LogWindows.cs b/Core/LogWindows.cs index 744b38d..502ee6c 100644 --- a/Core/LogWindows.cs +++ b/Core/LogWindows.cs @@ -9,27 +9,70 @@ using System.Windows.Media; namespace JJServiceManager { - /// 서비스 한 개의 로그 파일 보기 (상단 정보줄 + 로그 목록) + /// + /// 로그창에 띄울 대상 하나 (내 PC 서비스 로그 파일 또는 원격 PC 서비스 로그) + /// + public sealed class LogTarget + { + /// 창/탭을 구분하는 키 ("local|서비스명" / "remote|대상Id|서비스명") + public string Key { get; init; } = ""; + + /// 제목 (원격이면 "IP / hostname - 서비스명") + public string Title { get; init; } = ""; + + /// 경로 표시 + public Func PathText { get; init; } = () => ""; + + public bool IsLocal { get; init; } + + /// 내 PC 서비스일 때 (인코딩 저장, 탐색기 열기용) + public ServiceEntry LocalEntry { get; init; } + + /// 인코딩 (내 PC는 설정에 저장, 원격은 창을 연 동안만) + public string Encoding + { + get => LocalEntry?.LogEncoding ?? _encoding; + set { if (LocalEntry != null) LocalEntry.LogEncoding = value; else _encoding = value; } + } + private string _encoding = "auto"; + + /// 로그 공급원 만들기 (출력 콜백을 받아서) + public Func, LogLevel>, ILogSource> CreateSource { get; init; } + + public override string ToString() => Title; + + public static LogTarget ForLocal(ServiceEntry e) => new LogTarget + { + Key = "local|" + e.ServiceName, + Title = string.IsNullOrEmpty(e.DisplayName) ? e.ServiceName : e.DisplayName, + PathText = () => (e.LogIsFolder ? "폴더: " : "파일: ") + e.LogPath, + IsLocal = true, + LocalEntry = e, + CreateSource = (t, output) => new LogTailer(e, output) + }; + } + + /// 로그 보기 하나 (상단 정보줄 + 로그 목록) public sealed class LogTailView : DockPanel, IDisposable { - private readonly LogTailer _tailer; + private ILogSource _source; private readonly TextBlock _path; private readonly LogPanel _panel; - public ServiceEntry Entry { get; } + public LogTarget Target { get; } /// 인코딩을 바꾸면 설정 저장용으로 호출됨 - public event Action EntryChanged; + public event Action TargetChanged; - public LogTailView(ServiceEntry entry) + public LogTailView(LogTarget target) { - Entry = entry; + Target = target; LastChildFill = true; // ── 상단 정보줄 var bar = new Border { - Background = Brushes2.Make("#F1F8FB"), + Background = Brushes2.Make(target.IsLocal ? "#F1F8FB" : "#F3E5F5"), BorderBrush = Brushes2.Make("#B0BEC5"), BorderThickness = new Thickness(0, 0, 0, 1), Padding = new Thickness(6, 4, 6, 4), @@ -46,18 +89,18 @@ namespace JJServiceManager var title = new TextBlock { - Text = entry.DisplayName, + Text = target.Title, FontWeight = FontWeights.Bold, FontSize = 14, VerticalAlignment = VerticalAlignment.Center, Margin = new Thickness(0, 0, 12, 0), - Foreground = Brushes2.Make("#155E75") + Foreground = Brushes2.Make(target.IsLocal ? "#155E75" : "#6A1B9A") }; Grid.SetColumn(title, 0); _path = new TextBlock { - Text = (entry.LogIsFolder ? "폴더: " : "파일: ") + entry.LogPath, + Text = target.PathText(), Foreground = Brushes.Gray, VerticalAlignment = VerticalAlignment.Center, TextTrimming = TextTrimming.CharacterEllipsis @@ -71,18 +114,21 @@ namespace JJServiceManager enc.Items.Add(new ComboBoxItem { Content = "자동", Tag = "auto" }); enc.Items.Add(new ComboBoxItem { Content = "UTF-8", Tag = "utf-8" }); enc.Items.Add(new ComboBoxItem { Content = "CP949", Tag = "cp949" }); - enc.SelectedIndex = (entry.LogEncoding ?? "auto") switch { "utf-8" => 1, "cp949" => 2, _ => 0 }; + enc.SelectedIndex = (target.Encoding ?? "auto") switch { "utf-8" => 1, "cp949" => 2, _ => 0 }; Grid.SetColumn(enc, 3); - var open = new Button { Content = "탐색기에서 열기", Margin = new Thickness(6, 0, 0, 0) }; - Grid.SetColumn(open, 4); - open.Click += (s, e) => OpenInExplorer(); - grid.Children.Add(title); grid.Children.Add(_path); grid.Children.Add(encLabel); grid.Children.Add(enc); - grid.Children.Add(open); + + if (target.IsLocal) + { + var open = new Button { Content = "탐색기에서 열기", Margin = new Thickness(6, 0, 0, 0) }; + Grid.SetColumn(open, 4); + open.Click += (s, e) => OpenInExplorer(); + grid.Children.Add(open); + } bar.Child = grid; Children.Add(bar); @@ -90,48 +136,57 @@ namespace JJServiceManager _panel = new LogPanel { ShowTimestamp = false, MaxLines = 50000 }; Children.Add(_panel); - _tailer = new LogTailer(entry, (lines, level) => _panel.AddMany(lines, level)); - _tailer.FileChanged += f => Dispatcher.BeginInvoke(new Action(() => - { - _path.Text = entry.LogIsFolder ? $"폴더: {entry.LogPath} → {Path.GetFileName(f)}" : "파일: " + f; - _path.ToolTip = f; - })); + CreateSource(); enc.SelectionChanged += (s, e) => { if (enc.SelectedItem is ComboBoxItem item) { - entry.LogEncoding = (string)item.Tag; - EntryChanged?.Invoke(); + target.Encoding = (string)item.Tag; + TargetChanged?.Invoke(); _panel.Add("──────── 인코딩 변경: " + item.Content + " ────────", LogLevel.Notice); - _tailer.Reload(); + _source.Reload(); } }; + } - _tailer.Start(); + private void CreateSource() + { + _source = Target.CreateSource(Target, (lines, level) => _panel.AddMany(lines, level)); + _source.FileChanged += f => Dispatcher.BeginInvoke(new Action(() => + { + var e = Target.LocalEntry; + _path.Text = e != null && e.LogIsFolder + ? $"폴더: {e.LogPath} → {Path.GetFileName(f)}" + : (Target.IsLocal ? "파일: " : "대상 PC 파일: ") + f; + _path.ToolTip = f; + })); + _source.Start(); } /// 로그 경로가 바뀌면 표시를 갱신하고 새 경로로 다시 읽음 public void PathChanged() { - _path.Text = (Entry.LogIsFolder ? "폴더: " : "파일: ") + Entry.LogPath; + _path.Text = Target.PathText(); _path.ToolTip = null; - _panel.Add("──────── 로그 경로 변경: " + Entry.LogPath + " ────────", LogLevel.Notice); - _tailer.Reload(); + _panel.Add("──────── 로그 경로 변경: " + Target.PathText() + " ────────", LogLevel.Notice); + _source.Reload(); } private void OpenInExplorer() { + var entry = Target.LocalEntry; + if (entry == null) return; try { - var f = _tailer.CurrentFile; + var f = _source.CurrentFile; if (!string.IsNullOrEmpty(f) && File.Exists(f)) Process.Start(new ProcessStartInfo("explorer.exe", $"/select,\"{f}\"") { UseShellExecute = true }); - else if (Directory.Exists(Entry.LogPath)) - Process.Start(new ProcessStartInfo("explorer.exe", $"\"{Entry.LogPath}\"") { UseShellExecute = true }); + else if (Directory.Exists(entry.LogPath)) + Process.Start(new ProcessStartInfo("explorer.exe", $"\"{entry.LogPath}\"") { UseShellExecute = true }); else { - var dir = Path.GetDirectoryName(Entry.LogPath); + var dir = Path.GetDirectoryName(entry.LogPath); if (!string.IsNullOrEmpty(dir) && Directory.Exists(dir)) Process.Start(new ProcessStartInfo("explorer.exe", $"\"{dir}\"") { UseShellExecute = true }); } @@ -139,7 +194,7 @@ namespace JJServiceManager catch { } } - public void Dispose() => _tailer.Dispose(); + public void Dispose() => _source?.Dispose(); } /// @@ -150,6 +205,7 @@ namespace JJServiceManager { private readonly Func _settings; private readonly Action _save; + private readonly Func> _choices; // 개별 창 private readonly Dictionary _individual = new Dictionary(StringComparer.OrdinalIgnoreCase); @@ -162,52 +218,54 @@ namespace JJServiceManager private Window _splitWindow; private SplitPane _left, _right; - public LogWindowManager(Func settings, Action save) + /// 좌우 분할 창에서 고를 수 있는 로그 목록 + public LogWindowManager(Func settings, Action save, Func> choices) { _settings = settings; _save = save; + _choices = choices; } - public void Open(ServiceEntry entry) + public void Open(LogTarget target) { switch (_settings().LogWindowMode) { - case LogWindowMode.Tabs: OpenTab(entry); break; - case LogWindowMode.Split: OpenSplit(entry); break; - default: OpenIndividual(entry); break; + case LogWindowMode.Tabs: OpenTab(target); break; + case LogWindowMode.Split: OpenSplit(target); break; + default: OpenIndividual(target); break; } } - /// 서비스 목록이 바뀌었을 때 (분할 창의 선택 목록 갱신) - public void ServicesChanged() + /// 목록이 바뀌었을 때 (분할 창의 선택 목록 갱신) + public void ChoicesChanged() { - _left?.RefreshChoices(LogServices()); - _right?.RefreshChoices(LogServices()); + if (_left == null) return; + var list = _choices(); + _left.RefreshChoices(list); + _right.RefreshChoices(list); } - /// 서비스의 로그 경로가 바뀌었을 때. 경로가 비었으면 그 서비스의 로그창을 닫음 - public void EntryPathChanged(ServiceEntry entry) + /// 내 PC 서비스의 로그 경로가 바뀌었을 때. 경로가 비었으면 그 로그창을 닫음 + public void LocalPathChanged(ServiceEntry entry) { - if (string.IsNullOrWhiteSpace(entry.LogPath)) - { - Close(entry.ServiceName); - ServicesChanged(); - return; - } - foreach (var v in OpenViews(entry.ServiceName)) v.PathChanged(); - ServicesChanged(); + var key = "local|" + entry.ServiceName; + if (string.IsNullOrWhiteSpace(entry.LogPath)) Close(key); + else foreach (var v in OpenViews(key)) v.PathChanged(); + ChoicesChanged(); } - /// 서비스가 목록에서 빠졌을 때 그 서비스의 로그창을 닫음 - public void Close(string serviceName) + /// 키가 같은 로그창을 닫음. prefix=true면 키가 그 문자열로 시작하는 것 모두 + public void Close(string key, bool prefix = false) { - if (_individual.TryGetValue(serviceName, out var w)) w.Close(); + bool Match(string k) => prefix ? k.StartsWith(key, StringComparison.OrdinalIgnoreCase) : Same(k, key); + + foreach (var k in _individual.Keys.Where(Match).ToList()) _individual[k].Close(); if (_tabs != null) { foreach (var t in _tabs.Items.Cast().ToList()) { - if (t.Content is LogTailView v && Same(v.Entry.ServiceName, serviceName)) + if (t.Content is LogTailView v && Match(v.Target.Key)) { v.Dispose(); _tabs.Items.Remove(t); @@ -216,19 +274,19 @@ namespace JJServiceManager if (_tabs.Items.Count == 0) _tabWindow?.Close(); } - if (_left != null && _left.ShowsName(serviceName)) _left.Clear(); - if (_right != null && _right.ShowsName(serviceName)) _right.Clear(); + if (_left?.View != null && Match(_left.View.Target.Key)) _left.Clear(); + if (_right?.View != null && Match(_right.View.Target.Key)) _right.Clear(); } - private IEnumerable OpenViews(string serviceName) + private IEnumerable OpenViews(string key) { - if (_individual.TryGetValue(serviceName, out var w) && w.Content is Border b && b.Child is LogTailView iv) + if (_individual.TryGetValue(key, out var w) && w.Content is Border b && b.Child is LogTailView iv) yield return iv; if (_tabs != null) foreach (TabItem t in _tabs.Items) - if (t.Content is LogTailView tv && Same(tv.Entry.ServiceName, serviceName)) yield return tv; - if (_left?.View != null && _left.ShowsName(serviceName)) yield return _left.View; - if (_right?.View != null && _right.ShowsName(serviceName)) yield return _right.View; + if (t.Content is LogTailView tv && Same(tv.Target.Key, key)) yield return tv; + if (_left?.View != null && Same(_left.View.Target.Key, key)) yield return _left.View; + if (_right?.View != null && Same(_right.View.Target.Key, key)) yield return _right.View; } private static bool Same(string a, string b) => string.Equals(a, b, StringComparison.OrdinalIgnoreCase); @@ -240,13 +298,10 @@ namespace JJServiceManager _splitWindow?.Close(); } - private List LogServices() - => _settings().Services.Where(s => !string.IsNullOrWhiteSpace(s.LogPath)).ToList(); - - private LogTailView NewView(ServiceEntry entry) + private LogTailView NewView(LogTarget target) { - var v = new LogTailView(entry); - v.EntryChanged += _save; + var v = new LogTailView(target); + if (target.IsLocal) v.TargetChanged += _save; return v; } @@ -254,7 +309,7 @@ namespace JJServiceManager { var win = new Window { - Title = title, + Title = title + AppInstance.TitleTag, Content = content, Width = w, Height = h, @@ -276,32 +331,32 @@ namespace JJServiceManager // ───────────────────────── 1. 개별 창 ───────────────────────── - private void OpenIndividual(ServiceEntry entry) + private void OpenIndividual(LogTarget target) { - if (_individual.TryGetValue(entry.ServiceName, out var existing)) + if (_individual.TryGetValue(target.Key, out var existing)) { Activate(existing); return; } - var view = NewView(entry); - var win = NewWindow($"로그 - {entry.DisplayName}", new Border { Padding = new Thickness(6), Child = view }); + var view = NewView(target); + var win = NewWindow($"로그 - {target.Title}", new Border { Padding = new Thickness(6), Child = view }); win.Closed += (s, e) => { view.Dispose(); - _individual.Remove(entry.ServiceName); + _individual.Remove(target.Key); }; - _individual[entry.ServiceName] = win; + _individual[target.Key] = win; // 여러 창이 겹치지 않도록 조금씩 비켜서 띄움 int n = _individual.Count - 1; win.WindowStartupLocation = WindowStartupLocation.Manual; - win.Left = Math.Max(0, (SystemParameters.WorkArea.Width - win.Width) / 2 + n * 28 % 200); - win.Top = Math.Max(0, (SystemParameters.WorkArea.Height - win.Height) / 2 + n * 28 % 200); + win.Left = Math.Max(0, (SystemParameters.WorkArea.Width - win.Width) / 2 + (n * 28) % 200); + win.Top = Math.Max(0, (SystemParameters.WorkArea.Height - win.Height) / 2 + (n * 28) % 200); Activate(win); } // ───────────────────────── 2. 탭 ───────────────────────── - private void OpenTab(ServiceEntry entry) + private void OpenTab(LogTarget target) { if (_tabWindow == null) { @@ -318,7 +373,7 @@ namespace JJServiceManager foreach (TabItem t in _tabs.Items) { - if (t.Content is LogTailView v && string.Equals(v.Entry.ServiceName, entry.ServiceName, StringComparison.OrdinalIgnoreCase)) + if (t.Content is LogTailView v && Same(v.Target.Key, target.Key)) { _tabs.SelectedItem = t; Activate(_tabWindow); @@ -326,11 +381,26 @@ namespace JJServiceManager } } - var view = NewView(entry); + var view = NewView(target); var tab = new TabItem { Content = view }; + tab.Header = TabHeader(target.Title, () => + { + view.Dispose(); + _tabs.Items.Remove(tab); + if (_tabs.Items.Count == 0) _tabWindow.Close(); + }); + _tabs.Items.Add(tab); + _tabs.SelectedItem = tab; + Activate(_tabWindow); + } + + /// 제목 + ✕ 닫기 버튼이 있는 탭 머리글 + public static StackPanel TabHeader(object title, Action onClose) + { var header = new StackPanel { Orientation = Orientation.Horizontal }; - header.Children.Add(new TextBlock { Text = entry.DisplayName, VerticalAlignment = VerticalAlignment.Center }); + if (title is UIElement el) header.Children.Add(el); + else header.Children.Add(new TextBlock { Text = title?.ToString() ?? "", VerticalAlignment = VerticalAlignment.Center }); var close = new Button { Content = "✕", @@ -343,23 +413,14 @@ namespace JJServiceManager Background = Brushes.Transparent, ToolTip = "탭 닫기" }; - close.Click += (s, e) => - { - view.Dispose(); - _tabs.Items.Remove(tab); - if (_tabs.Items.Count == 0) _tabWindow.Close(); - }; + close.Click += (s, e) => onClose(); header.Children.Add(close); - tab.Header = header; - - _tabs.Items.Add(tab); - _tabs.SelectedItem = tab; - Activate(_tabWindow); + return header; } // ───────────────────────── 3. 좌우 분할 ───────────────────────── - private void OpenSplit(ServiceEntry entry) + private void OpenSplit(LogTarget target) { if (_splitWindow == null) { @@ -370,8 +431,9 @@ namespace JJServiceManager _left = new SplitPane(NewView, "왼쪽"); _right = new SplitPane(NewView, "오른쪽"); - _left.RefreshChoices(LogServices()); - _right.RefreshChoices(LogServices()); + var list = _choices(); + _left.RefreshChoices(list); + _right.RefreshChoices(list); var splitter = new GridSplitter { @@ -398,11 +460,11 @@ namespace JJServiceManager }; } - if (!_left.Shows(entry) && !_right.Shows(entry)) + if (!_left.Shows(target.Key) && !_right.Shows(target.Key)) { - if (_left.IsEmpty) _left.Show(entry); - else if (_right.IsEmpty) _right.Show(entry); - else _right.Show(entry); + if (_left.IsEmpty) _left.Show(target); + else if (_right.IsEmpty) _right.Show(target); + else _right.Show(target); } Activate(_splitWindow); } @@ -410,18 +472,17 @@ namespace JJServiceManager /// 좌우 분할 창의 한쪽 private sealed class SplitPane : DockPanel, IDisposable { - private readonly Func _factory; + private readonly Func _factory; private readonly ComboBox _combo; private readonly ContentControl _host; + private readonly UIElement _placeholder; private LogTailView _view; private bool _updating; public bool IsEmpty => _view == null; public LogTailView View => _view; - private readonly UIElement _placeholder; - - public SplitPane(Func factory, string side) + public SplitPane(Func factory, string side) { _factory = factory; @@ -429,7 +490,7 @@ namespace JJServiceManager SetDock(top, Dock.Top); var lbl = new TextBlock { Text = side + " 서비스:", VerticalAlignment = VerticalAlignment.Center, Margin = new Thickness(0, 0, 6, 0) }; SetDock(lbl, Dock.Left); - _combo = new ComboBox { DisplayMemberPath = nameof(ServiceEntry.DisplayName), MinWidth = 200 }; + _combo = new ComboBox { DisplayMemberPath = nameof(LogTarget.Title), MinWidth = 200 }; top.Children.Add(lbl); top.Children.Add(_combo); Children.Add(top); @@ -447,14 +508,12 @@ namespace JJServiceManager _combo.SelectionChanged += (s, e) => { if (_updating) return; - if (_combo.SelectedItem is ServiceEntry se) Show(se); + if (_combo.SelectedItem is LogTarget t) Show(t); }; } - public bool Shows(ServiceEntry e) => ShowsName(e.ServiceName); - - public bool ShowsName(string serviceName) - => _view != null && string.Equals(_view.Entry.ServiceName, serviceName, StringComparison.OrdinalIgnoreCase); + public bool Shows(string key) + => _view != null && string.Equals(_view.Target.Key, key, StringComparison.OrdinalIgnoreCase); public void Clear() { @@ -466,27 +525,27 @@ namespace JJServiceManager _updating = false; } - public void Show(ServiceEntry entry) + public void Show(LogTarget target) { _view?.Dispose(); - _view = _factory(entry); + _view = _factory(target); _host.Content = _view; - SelectInCombo(entry); + SelectInCombo(target.Key); } - public void RefreshChoices(List services) + public void RefreshChoices(List list) { _updating = true; - _combo.ItemsSource = services; + _combo.ItemsSource = list; _updating = false; - if (_view != null) SelectInCombo(_view.Entry); + if (_view != null) SelectInCombo(_view.Target.Key); } - private void SelectInCombo(ServiceEntry entry) + private void SelectInCombo(string key) { _updating = true; - _combo.SelectedItem = (_combo.ItemsSource as IEnumerable)? - .FirstOrDefault(x => string.Equals(x.ServiceName, entry.ServiceName, StringComparison.OrdinalIgnoreCase)); + _combo.SelectedItem = (_combo.ItemsSource as IEnumerable)? + .FirstOrDefault(x => string.Equals(x.Key, key, StringComparison.OrdinalIgnoreCase)); _updating = false; } diff --git a/Core/Models.cs b/Core/Models.cs index 3d39222..bd9abb6 100644 --- a/Core/Models.cs +++ b/Core/Models.cs @@ -25,6 +25,60 @@ namespace JJServiceManager => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name)); } + // ───────────────────────────────────────────────────────────── + // [테스트용 중복 실행] --instance N 옵션 — 정식 릴리즈 시 이 클래스와 사용하는 곳을 삭제 + // ───────────────────────────────────────────────────────────── + public static class AppInstance + { + /// 1 = 기본 실행, 2 이상 = 테스트용 추가 실행 + public static int Number { get; private set; } = 1; + + public static bool IsTest => Number > 1; + + /// 이름 뒤에 붙는 꼬리표 ("" 또는 "_instance2") + public static string Suffix => IsTest ? "_instance" + Number : ""; + + /// 창 제목에 붙는 표시 + 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; + } + } + } + } + + /// 원격 통신 방식 + public enum RemoteMode + { + Ssh = 0, // 내장 SSH (기본 2222) + Port = 1 // 지정 포트 + TLS (기본 9988) + } + + /// 관리 대상 PC + public class RemoteTarget + { + public string Id { get; set; } = Guid.NewGuid().ToString("N"); + public string Address { get; set; } = ""; + /// 접속할 때 자동으로 채워짐 + public string Hostname { get; set; } = ""; + public RemoteMode Mode { get; set; } = RemoteMode.Port; + public int Port { get; set; } = 9988; + /// 접속 비밀번호 (Windows 사용자 계정으로 암호화, Base64) + public string ProtectedPassword { get; set; } = ""; + + [JsonIgnore] + public string Label => string.IsNullOrWhiteSpace(Hostname) ? Address : $"{Address} / {Hostname}"; + + public RemoteTarget Clone() => (RemoteTarget)MemberwiseClone(); + } + /// 로그창 띄우기 방식 public enum LogWindowMode { @@ -85,6 +139,35 @@ namespace JJServiceManager /// Windows 강제종료 / 재시작 버튼 표시 public bool ShowShutdownButtons { get; set; } = true; + // ───────── 숫자 6자리 확인 ───────── + + /// 내 PC 서비스 Start/Stop 시 숫자 확인 + public bool ConfirmLocalStartStop { get; set; } = false; + + /// 원격 서비스 Start/Stop 시 숫자 확인 + 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; + /// 접속 비밀번호 해시 (PBKDF2) + public string RemotePasswordHash { get; set; } = ""; + public string RemotePasswordSalt { get; set; } = ""; + + [JsonIgnore] + public int RemoteServerPort => RemoteServerMode == RemoteMode.Ssh ? RemoteServerSshPort : RemoteServerTcpPort; + + // ───────── 다른 PC 관리 (관리 역할) ───────── + + public bool RemoteManageEnabled { get; set; } + public List Targets { get; set; } = new List(); + + /// 초기화면에 열어둔 PC 탭 ("local" 또는 대상 Id) + public List OpenHostTabs { get; set; } = new List(); + /// 창 크기·위치 public double WindowLeft { get; set; } = double.NaN; public double WindowTop { get; set; } = double.NaN; @@ -95,8 +178,9 @@ namespace JJServiceManager /// 설정 파일 저장/불러오기 public static class SettingsStore { - public static string DataFolder { get; } = - Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "JJServiceManager"); + // [테스트용 중복 실행] 인스턴스마다 설정 폴더를 따로 씀 (정식 릴리즈 시 Suffix 제거) + public static string DataFolder => + Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "JJServiceManager" + AppInstance.Suffix); public static string SettingsPath => Path.Combine(DataFolder, "settings.json"); @@ -118,6 +202,8 @@ namespace JJServiceManager if (s != null) { s.Services ??= new List(); + s.Targets ??= new List(); + s.OpenHostTabs ??= new List(); return s; } } diff --git a/Core/Remote/RemoteClient.cs b/Core/Remote/RemoteClient.cs new file mode 100644 index 0000000..e82d893 --- /dev/null +++ b/Core/Remote/RemoteClient.cs @@ -0,0 +1,486 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Net.Security; +using System.Net.Sockets; +using System.Security.Claims; +using System.Text.Json.Nodes; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.DevTunnels.Ssh; +using SshClientTcp = Microsoft.DevTunnels.Ssh.Tcp.SshClient; + +namespace JJServiceManager.Remote +{ + public enum RemoteState { Connecting, Connected, Disconnected, AuthFailed } + + /// + /// 관리 역할: 대상 PC 하나에 접속을 유지하며 서비스 목록을 주기적으로 받아옴. + /// 끊기면 자동으로 다시 접속함. + /// + public sealed class RemoteClient : IDisposable + { + private static readonly TimeSpan ConnectTimeout = TimeSpan.FromSeconds(10); + private static readonly TimeSpan PollInterval = TimeSpan.FromSeconds(2); + + private readonly RemoteTarget _target; + private readonly CancellationTokenSource _cts = new CancellationTokenSource(); + private readonly ConcurrentDictionary> _pending = new ConcurrentDictionary>(); + private readonly ConcurrentDictionary _logs = new ConcurrentDictionary(); + private JsonLineConnection _conn; + private int _nextId; + + private sealed class LogSubscriber + { + public Action, LogLevel> OnLines; + public Action OnFile; + } + + public RemoteTarget Target => _target; + public RemoteState State { get; private set; } = RemoteState.Connecting; + public string StateMessage { get; private set; } = "연결 중…"; + public string Hostname { get; private set; } = ""; + public List Services { get; private set; } = new List(); + + /// 연결 상태가 바뀜 (백그라운드 스레드에서 발생) + public event Action StateChanged; + /// 서비스 목록을 새로 받음 (백그라운드 스레드에서 발생) + public event Action ServicesChanged; + /// 대상 PC의 hostname을 처음 알게 되었거나 바뀜 + public event Action HostnameDiscovered; + + private readonly Func _password; + + public RemoteClient(RemoteTarget target, Func passwordProvider = null) + { + _target = target.Clone(); + Hostname = target.Hostname ?? ""; + var prot = _target.ProtectedPassword; + _password = passwordProvider ?? (() => RemoteSecurity.Unprotect(prot)); + } + + public void Start() => _ = Task.Run(RunAsync); + + public void Dispose() + { + try { _cts.Cancel(); } catch { } + _conn?.Dispose(); + } + + // ───────────────────────── 접속 유지 ───────────────────────── + + private async Task RunAsync() + { + var ct = _cts.Token; + while (!ct.IsCancellationRequested) + { + SetState(RemoteState.Connecting, "연결 중…"); + TimeSpan retry = TimeSpan.FromSeconds(5); + try + { + var password = _password(); + var (conn, hello) = await ConnectAsync(_target, password, ct).ConfigureAwait(false); + _conn = conn; + + var host = Json.Str(hello, "hostname"); + if (!string.IsNullOrEmpty(host) && host != Hostname) + { + Hostname = host; + HostnameDiscovered?.Invoke(this, host); + } + SetState(RemoteState.Connected, "연결됨"); + + var reader = Task.Run(() => ReadLoopAsync(conn, ct)); + // 목록 주기적으로 받기 + while (!ct.IsCancellationRequested && !reader.IsCompleted) + { + var res = await RequestAsync("list", null, TimeSpan.FromSeconds(15)).ConfigureAwait(false); + Services = ParseServices(res); + ServicesChanged?.Invoke(this); + await Task.WhenAny(reader, Task.Delay(PollInterval, ct)).ConfigureAwait(false); + } + if (reader.IsFaulted) throw reader.Exception.GetBaseException(); + throw new RemoteException("연결이 끊겼습니다."); + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) + { + break; + } + catch (RemoteAuthException ex) + { + SetState(RemoteState.AuthFailed, ex.Message); + retry = TimeSpan.FromSeconds(30); + } + catch (Exception ex) + { + SetState(RemoteState.Disconnected, Friendly(ex)); + } + finally + { + var c = _conn; + _conn = null; + c?.Dispose(); + FailPending("연결이 끊겼습니다."); + _logs.Clear(); + } + + try { await Task.Delay(retry, ct).ConfigureAwait(false); } + catch { break; } + } + } + + private async Task ReadLoopAsync(JsonLineConnection conn, CancellationToken ct) + { + while (!ct.IsCancellationRequested) + { + var msg = await conn.ReadAsync(ct).ConfigureAwait(false); + if (msg == null) return; + + if (msg.ContainsKey("event")) + { + var ev = Json.Str(msg, "event"); + int sid = Json.Int(msg, "stream"); + if (!_logs.TryGetValue(sid, out var sub)) continue; + if (ev == "log") + { + var lines = (msg["lines"] as JsonArray)?.Select(n => n?.ToString() ?? "").ToList() ?? new List(); + Enum.TryParse(Json.Str(msg, "level"), out var level); + sub.OnLines?.Invoke(lines, level); + } + else if (ev == "logfile") + { + sub.OnFile?.Invoke(Json.Str(msg, "path")); + } + continue; + } + + int id = Json.Int(msg, "id"); + if (_pending.TryRemove(id, out var tcs)) tcs.TrySetResult(msg); + } + } + + private void SetState(RemoteState state, string message) + { + bool changed = State != state || StateMessage != message; + State = state; + StateMessage = message; + if (changed) StateChanged?.Invoke(this); + } + + private void FailPending(string message) + { + foreach (var kv in _pending.ToArray()) + if (_pending.TryRemove(kv.Key, out var tcs)) + tcs.TrySetException(new RemoteException(message)); + } + + // ───────────────────────── 요청 ───────────────────────── + + /// 요청을 보내고 결과(result)를 받음. 실패하면 RemoteException + public async Task RequestAsync(string op, JsonObject args, TimeSpan timeout) + { + var conn = _conn; + if (conn == null || State != RemoteState.Connected && op != "list") + throw new RemoteException("대상 PC에 연결되어 있지 않습니다."); + + int id = Interlocked.Increment(ref _nextId); + var msg = args != null ? (JsonObject)args.DeepClone() : new JsonObject(); + msg["id"] = id; + msg["op"] = op; + + var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + _pending[id] = tcs; + try + { + await conn.SendAsync(msg).ConfigureAwait(false); + } + catch (Exception ex) + { + _pending.TryRemove(id, out _); + throw new RemoteException("보내기 실패: " + ex.Message); + } + + var done = await Task.WhenAny(tcs.Task, Task.Delay(timeout)).ConfigureAwait(false); + if (done != tcs.Task) + { + _pending.TryRemove(id, out _); + throw new RemoteException("대상 PC의 응답이 없습니다."); + } + + var reply = await tcs.Task.ConfigureAwait(false); + if (!Json.Bool(reply, "ok")) + throw new RemoteException(Json.Str(reply, "error"), Json.Bool(reply, "info")); + return reply["result"] as JsonObject ?? new JsonObject(); + } + + public Task StartServiceAsync(string service) + => RequestAsync("start", new JsonObject { ["service"] = service }, TimeSpan.FromSeconds(90)); + + public Task StopServiceAsync(string service) + => RequestAsync("stop", new JsonObject { ["service"] = service }, TimeSpan.FromSeconds(90)); + + public async Task> GetDetailsAsync(string service) + { + var r = await RequestAsync("details", new JsonObject { ["service"] = service }, TimeSpan.FromSeconds(20)).ConfigureAwait(false); + return (r["lines"] as JsonArray)?.Select(n => n?.ToString() ?? "").ToList() ?? new List(); + } + + /// 대상 PC의 서비스 로그를 실시간으로 받기 시작. 돌려받은 번호로 CloseLog + public async Task OpenLogAsync(string service, string encoding, Action, LogLevel> onLines, Action onFile, bool newOnly = false) + { + var r = await RequestAsync("logOpen", new JsonObject { ["service"] = service, ["encoding"] = encoding ?? "auto", ["newOnly"] = newOnly }, TimeSpan.FromSeconds(20)).ConfigureAwait(false); + int sid = Json.Int(r, "stream"); + _logs[sid] = new LogSubscriber { OnLines = onLines, OnFile = onFile }; + return sid; + } + + public void CloseLog(int stream) + { + if (stream <= 0) return; + _logs.TryRemove(stream, out _); + if (State == RemoteState.Connected) + _ = RequestAsync("logClose", new JsonObject { ["stream"] = stream }, TimeSpan.FromSeconds(10)).ContinueWith(t => { _ = t.Exception; }); + } + + // ───────────────────────── 연결 ───────────────────────── + + /// 접속 + 인증 + hello. 성공하면 연결과 hello 결과 + public static async Task<(JsonLineConnection conn, JsonObject hello)> ConnectAsync(RemoteTarget t, string password, CancellationToken ct) + { + if (string.IsNullOrWhiteSpace(t.Address)) throw new RemoteException("대상 IP가 비어 있습니다."); + if (string.IsNullOrEmpty(password)) throw new RemoteAuthException("접속 비밀번호가 저장되어 있지 않습니다."); + + using var timeout = CancellationTokenSource.CreateLinkedTokenSource(ct); + timeout.CancelAfter(ConnectTimeout); + var tok = timeout.Token; + + JsonLineConnection conn; + bool sendPassword; + + try + { + if (t.Mode == RemoteMode.Port) + { + var tcp = new TcpClient { NoDelay = true }; + try + { + await tcp.ConnectAsync(t.Address.Trim(), t.Port, tok).ConfigureAwait(false); + var ssl = new SslStream(tcp.GetStream(), false); // 암호화만, 서버 지문 확인 안 함 (아래 옵션) + await ssl.AuthenticateAsClientAsync(new SslClientAuthenticationOptions + { + TargetHost = "JJServiceManager", + RemoteCertificateValidationCallback = (a, b, c, d) => true + }, tok).ConfigureAwait(false); + conn = new JsonLineConnection(ssl, tcp); + } + catch + { + tcp.Dispose(); + throw; + } + sendPassword = true; + } + else + { + var client = new SshClientTcp(SshSessionConfiguration.Default, new TraceSource("JJServiceManager.SshClient")); + try + { + var session = await client.OpenSessionAsync(t.Address.Trim(), t.Port, tok).ConfigureAwait(false); + // 서버 지문 확인 안 함 (설정: 확인 없이 암호화만) + session.Authenticating += (s, e) => e.AuthenticationTask = Task.FromResult(new ClaimsPrincipal()); + bool ok = await session.AuthenticateAsync(new SshClientCredentials(RemoteServer.SshUser, password), tok).ConfigureAwait(false); + if (!ok) + { + session.Dispose(); + throw new RemoteAuthException("접속 비밀번호가 맞지 않습니다."); + } + var channel = await session.OpenChannelAsync(tok).ConfigureAwait(false); + conn = new JsonLineConnection(new SshStream(channel), session, client); + } + catch + { + client.Dispose(); + throw; + } + sendPassword = false; + } + } + catch (OperationCanceledException) when (!ct.IsCancellationRequested) + { + throw new RemoteException("연결 시간이 초과되었습니다. (IP, 포트, 방화벽, 원격 접속 허용 여부 확인)"); + } + + try + { + var hello = new JsonObject + { + ["id"] = 0, + ["op"] = "hello", + ["client"] = Environment.MachineName, + ["version"] = RemoteServer.Version + }; + if (sendPassword) hello["password"] = password; + await conn.SendAsync(hello, tok).ConfigureAwait(false); + + var reply = await conn.ReadAsync(tok).ConfigureAwait(false); + if (reply == null) throw new RemoteException("대상 PC가 연결을 끊었습니다."); + if (!Json.Bool(reply, "ok")) + { + var err = Json.Str(reply, "error"); + if (Json.Bool(reply, "authFailed")) throw new RemoteAuthException(err); + throw new RemoteException(err); + } + return (conn, reply["result"] as JsonObject ?? new JsonObject()); + } + catch (OperationCanceledException) when (!ct.IsCancellationRequested) + { + conn.Dispose(); + throw new RemoteException("대상 PC의 응답이 없습니다. (방식(SSH/지정 포트)이 맞는지 확인)"); + } + catch + { + conn.Dispose(); + throw; + } + } + + /// 설정 화면의 [연결 테스트]: 성공하면 hostname + public static async Task TestAsync(RemoteTarget t, string password) + { + var (conn, hello) = await ConnectAsync(t, password, CancellationToken.None).ConfigureAwait(false); + conn.Dispose(); + return Json.Str(hello, "hostname"); + } + + private static List ParseServices(JsonObject res) + { + var list = new List(); + if (res?["services"] is not JsonArray arr) return list; + foreach (var n in arr.OfType()) + { + int? st = null; + try { if (n["status"] != null) st = n["status"].GetValue(); } catch { } + list.Add(new RemoteServiceInfo + { + ServiceName = Json.Str(n, "name"), + DisplayName = Json.Str(n, "display"), + Status = st, + LogPath = Json.Str(n, "logPath"), + LogIsFolder = Json.Bool(n, "logIsFolder") + }); + } + return list; + } + + public static string Friendly(Exception ex) + { + ex = ex is AggregateException ae ? ae.GetBaseException() : ex; + return ex switch + { + RemoteException re => re.Message, + SocketException se when se.SocketErrorCode == SocketError.ConnectionRefused + => "연결이 거부되었습니다. (대상 PC에서 원격 접속 허용이 꺼져 있거나 포트가 다름)", + SocketException se when se.SocketErrorCode == SocketError.HostNotFound + => "대상 PC를 찾을 수 없습니다. (IP 확인)", + SocketException se when se.SocketErrorCode == SocketError.TimedOut || se.SocketErrorCode == SocketError.HostUnreachable || se.SocketErrorCode == SocketError.NetworkUnreachable + => "대상 PC에 닿지 않습니다. (네트워크, 방화벽 확인)", + System.Security.Authentication.AuthenticationException + => "암호화 연결에 실패했습니다. (방식(SSH/지정 포트)이 맞는지 확인)", + SshConnectionException + => "SSH 연결에 실패했습니다. (방식(SSH/지정 포트)이 맞는지 확인)", + System.IO.IOException io => "연결이 끊겼습니다. " + io.Message, + _ => ex.Message + }; + } + } + + /// 인증 실패 (비밀번호 틀림 등) + public sealed class RemoteAuthException : Exception + { + public RemoteAuthException(string message) : base(message) { } + } + + /// 원격 서비스 로그 (LogTailView에서 로컬 파일과 같은 방식으로 사용) + public sealed class RemoteLogSource : ILogSource + { + private readonly RemoteClient _client; + private readonly string _service; + private readonly Func _encoding; + private readonly Action, LogLevel> _output; + private int _stream; + private bool _disposed; + private bool _wasConnected; + + public event Action FileChanged; + public string CurrentFile { get; private set; } + + public RemoteLogSource(RemoteClient client, string service, Func encoding, Action, LogLevel> output) + { + _client = client; + _service = service; + _encoding = encoding; + _output = output; + } + + public void Start() + { + _client.StateChanged += OnState; + _wasConnected = _client.State == RemoteState.Connected; + if (_wasConnected) _ = OpenAsync(); + else _output(new[] { "대상 PC에 연결되어 있지 않습니다. 연결되면 자동으로 표시합니다." }, LogLevel.Warning); + } + + public void Reload() + { + var s = Interlocked.Exchange(ref _stream, 0); + _client.CloseLog(s); + _ = OpenAsync(); + } + + private async Task OpenAsync(bool newOnly = false) + { + if (_disposed) return; + try + { + int sid = await _client.OpenLogAsync(_service, _encoding(), (l, lv) => { if (!_disposed) _output(l, lv); }, f => + { + CurrentFile = f; + FileChanged?.Invoke(f); + }, newOnly).ConfigureAwait(false); + if (_disposed) { _client.CloseLog(sid); return; } + _stream = sid; + } + catch (Exception ex) + { + _output(new[] { "원격 로그를 열 수 없습니다: " + RemoteClient.Friendly(ex) }, LogLevel.Warning); + } + } + + private void OnState(RemoteClient c) + { + if (_disposed) return; + bool now = c.State == RemoteState.Connected; + if (now && !_wasConnected) + { + _output(new[] { "──────── 대상 PC에 다시 연결됨 (이후 새 로그만 표시) ────────" }, LogLevel.Notice); + _ = OpenAsync(newOnly: true); + } + else if (!now && _wasConnected) + { + _stream = 0; + _output(new[] { "──────── 대상 PC 연결 끊김: " + c.StateMessage + " ────────" }, LogLevel.Warning); + } + _wasConnected = now; + } + + public void Dispose() + { + _disposed = true; + _client.StateChanged -= OnState; + var s = Interlocked.Exchange(ref _stream, 0); + _client.CloseLog(s); + } + } +} diff --git a/Core/Remote/RemoteCommon.cs b/Core/Remote/RemoteCommon.cs new file mode 100644 index 0000000..997ac22 --- /dev/null +++ b/Core/Remote/RemoteCommon.cs @@ -0,0 +1,261 @@ +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 +{ + /// 비밀번호 처리 + public static class RemoteSecurity + { + private const int Iterations = 120_000; + + /// 대상 PC: 접속 비밀번호를 해시로 저장 + 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; + } + } + + /// 관리 PC: 대상 PC 비밀번호를 현재 Windows 사용자로 암호화해 저장 + 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 ""; + } + } + } + + /// + /// 한 줄에 JSON 하나씩 주고받는 연결 (SSH 채널 / TLS 스트림 공통) + /// + 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(); + } + + public bool IsDisposed => Volatile.Read(ref _disposed) == 1; + + /// 다음 메시지. 연결이 끊기면 null + public async Task 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 { } + } + } + } + + /// 원격으로 받은 서비스 정보 + public sealed class RemoteServiceInfo + { + public string ServiceName { get; set; } = ""; + public string DisplayName { get; set; } = ""; + /// System.ServiceProcess.ServiceControllerStatus 값, 서비스가 없으면 null + public int? Status { get; set; } + public string LogPath { get; set; } = ""; + public bool LogIsFolder { get; set; } + } + + /// 원격 명령 실패 + public sealed class RemoteException : Exception + { + /// 오류가 아닌 안내 (이미 실행 중 등) + public bool IsInfo { get; } + public RemoteException(string message, bool isInfo = false) : base(message) { IsInfo = isInfo; } + } + + public static class NetInfo + { + /// 이 PC의 IPv4 주소 목록 (설정 화면 안내용) + public static List LocalIPv4() + { + var list = new List(); + 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(); + } + } + + /// + /// Windows 방화벽 규칙 (원격 접속 허용 포트) + /// 규칙 이름: "JJServiceManager Remote" (+ 테스트 인스턴스 꼬리표) + /// + public static class FirewallManager + { + private static string RuleName => "JJServiceManager Remote" + AppInstance.Suffix; + + /// enabled면 해당 포트를 열고, 아니면 규칙을 지움 + 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() ?? ""; } + catch { return o?[key]?.ToString() ?? ""; } + } + + public static int Int(JsonObject o, string key, int def = 0) + { + try { return o?[key]?.GetValue() ?? def; } + catch { return def; } + } + + public static bool Bool(JsonObject o, string key) + { + try { return o?[key]?.GetValue() ?? false; } + catch { return false; } + } + } +} diff --git a/Core/Remote/RemoteServer.cs b/Core/Remote/RemoteServer.cs new file mode 100644 index 0000000..376c6ed --- /dev/null +++ b/Core/Remote/RemoteServer.cs @@ -0,0 +1,499 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Net; +using System.Net.Security; +using System.Net.Sockets; +using System.Security.Claims; +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; +using System.Text.Json.Nodes; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.DevTunnels.Ssh; +using Microsoft.DevTunnels.Ssh.Algorithms; +using Microsoft.DevTunnels.Ssh.Events; +using Microsoft.DevTunnels.Ssh.Messages; +using SshServerTcp = Microsoft.DevTunnels.Ssh.Tcp.SshServer; + +namespace JJServiceManager.Remote +{ + /// + /// 원격 접속 허용 (대상 역할). + /// 관리 PC의 접속을 받아, 이 PC에 "등록된 서비스"에 한해서만 + /// 목록 / 상태 / Start / Stop / 상세 / 로그 보기를 처리함. + /// + public sealed class RemoteServer : IDisposable + { + public const string Version = "1.1"; + public const string SshUser = "jjsm"; + + private static readonly TimeSpan OpTimeout = TimeSpan.FromSeconds(60); + + private readonly Func> _services; + private readonly Action _log; + private readonly Action _changed; + private readonly object _sync = new object(); + + private CancellationTokenSource _cts; + private TcpListener _listener; + private X509Certificate2 _cert; + private SshServerTcp _ssh; + private string _hash = "", _salt = ""; + private readonly Queue _failures = new Queue(); + + public bool IsRunning { get; private set; } + public RemoteMode Mode { get; private set; } + public int Port { get; private set; } + + /// 등록된 서비스 목록 (스레드 안전한 복사본을 돌려줘야 함) + /// 작업 로그 기록 + /// 원격에서 Start/Stop 했을 때 (상태 갱신용) + public RemoteServer(Func> services, Action log, Action changed) + { + _services = services; + _log = log; + _changed = changed; + } + + /// 비밀번호만 바뀐 경우 (재시작 없이) + public void UpdatePassword(string hash, string salt) + { + lock (_sync) + { + _hash = hash ?? ""; + _salt = salt ?? ""; + } + } + + public void Start(RemoteMode mode, int port, string hash, string salt) + { + Stop(); + UpdatePassword(hash, salt); + Mode = mode; + Port = port; + _cts = new CancellationTokenSource(); + + try + { + if (mode == RemoteMode.Port) StartTls(port, _cts.Token); + else StartSsh(port); + IsRunning = true; + _log($"원격 접속 허용 시작: {(mode == RemoteMode.Ssh ? "SSH" : "지정 포트(TLS)")} {port}", LogLevel.Success); + } + catch (Exception ex) + { + IsRunning = false; + _log($"원격 접속 허용 시작 실패 (포트 {port}): {ex.Message}", LogLevel.Error); + Stop(); + } + } + + public void Stop() + { + bool wasRunning = IsRunning; + IsRunning = false; + try { _cts?.Cancel(); } catch { } + try { _listener?.Stop(); } catch { } + try { _ssh?.Dispose(); } catch { } + _listener = null; + _ssh = null; + _cts = null; + if (wasRunning) _log("원격 접속 허용 중지", LogLevel.Info); + } + + public void Dispose() => Stop(); + + // ───────────────────────── 지정 포트 + TLS ───────────────────────── + + private void StartTls(int port, CancellationToken ct) + { + _cert ??= CreateCertificate(); + _listener = new TcpListener(IPAddress.Any, port); + _listener.Start(); + var listener = _listener; + _ = Task.Run(async () => + { + while (!ct.IsCancellationRequested) + { + TcpClient tcp; + try { tcp = await listener.AcceptTcpClientAsync(ct).ConfigureAwait(false); } + catch { break; } + _ = Task.Run(() => HandleTcpAsync(tcp, ct)); + } + }); + } + + private async Task HandleTcpAsync(TcpClient tcp, CancellationToken ct) + { + string peer = (tcp.Client.RemoteEndPoint as IPEndPoint)?.Address.ToString() ?? "?"; + 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); + } + var conn = new JsonLineConnection(ssl, tcp); + await ServeAsync(conn, peer, requireHelloPassword: true, ct).ConfigureAwait(false); + } + catch + { + try { tcp.Dispose(); } catch { } + } + } + + /// TLS용 자체 서명 인증서 (실행할 때마다 새로 만듦, 지문 확인은 하지 않는 설정) + private static X509Certificate2 CreateCertificate() + { + using var rsa = RSA.Create(2048); + var req = new CertificateRequest("CN=JJServiceManager", rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); + req.CertificateExtensions.Add(new X509KeyUsageExtension(X509KeyUsageFlags.DigitalSignature | X509KeyUsageFlags.KeyEncipherment, false)); + req.CertificateExtensions.Add(new X509EnhancedKeyUsageExtension(new OidCollection { new Oid("1.3.6.1.5.5.7.3.1") }, false)); + using var cert = req.CreateSelfSigned(DateTimeOffset.UtcNow.AddDays(-1), DateTimeOffset.UtcNow.AddYears(10)); + // Windows(SChannel)에서 개인 키를 쓰려면 PFX로 한 번 내보냈다가 다시 읽어야 함 + return new X509Certificate2(cert.Export(X509ContentType.Pfx), (string)null, X509KeyStorageFlags.Exportable); + } + + // ───────────────────────── 내장 SSH ───────────────────────── + + private void StartSsh(int port) + { + var hostKey = SshAlgorithms.PublicKey.ECDsaSha2Nistp384.GenerateKeyPair(); + var ssh = new SshServerTcp(SshSessionConfiguration.Default, new TraceSource("JJServiceManager.SshServer")); + ssh.Credentials = new SshServerCredentials(new[] { hostKey }); + + ssh.SessionAuthenticating += (s, e) => + { + if (e.AuthenticationType == SshAuthenticationType.ClientPassword) + e.AuthenticationTask = CheckSshPasswordAsync(e.Username, e.Password); + // 그 밖의 인증 방식(공개 키 등)은 허용하지 않음 + }; + + ssh.SessionOpened += (s, session) => + { + session.ChannelOpening += (s2, e) => + { + if (!e.IsRemoteRequest) return; + if (session.Principal == null) + { + e.FailureReason = SshChannelOpenFailureReason.AdministrativelyProhibited; + e.FailureDescription = "인증되지 않았습니다."; + return; + } + var channel = e.Channel; + var token = _cts?.Token ?? CancellationToken.None; + _ = Task.Run(() => ServeAsync(new JsonLineConnection(new SshStream(channel)), "SSH", requireHelloPassword: false, token)); + }; + }; + + ssh.ExceptionRaised += (s, ex) => + { + if (ex is SocketException se && se.SocketErrorCode == SocketError.AddressAlreadyInUse) + _log($"원격 접속 허용 실패: 포트 {port}를 다른 프로그램이 사용 중입니다.", LogLevel.Error); + }; + + _ssh = ssh; + _ = ssh.AcceptSessionsAsync(port, IPAddress.Any).ContinueWith(t => + { + if (t.IsFaulted && IsRunning) + { + IsRunning = false; + _log($"원격 접속 허용(SSH) 중단: {t.Exception?.GetBaseException().Message}", LogLevel.Error); + } + }, TaskScheduler.Default); + } + + private async Task CheckSshPasswordAsync(string user, string password) + { + if (IsThrottled()) return null; + if (CheckPassword(password)) return new ClaimsPrincipal(new ClaimsIdentity("password")); + RecordFailure(); + await Task.Delay(1000).ConfigureAwait(false); + _log("원격 접속 거부 (SSH): 비밀번호가 맞지 않습니다.", LogLevel.Warning); + return null; + } + + // ───────────────────────── 인증 ───────────────────────── + + private bool CheckPassword(string password) + { + string h, s; + lock (_sync) { h = _hash; s = _salt; } + return RemoteSecurity.Verify(password, h, s); + } + + /// 5분 동안 10번 넘게 틀리면 잠시 거부 + private bool IsThrottled() + { + lock (_failures) + { + while (_failures.Count > 0 && DateTime.UtcNow - _failures.Peek() > TimeSpan.FromMinutes(5)) _failures.Dequeue(); + return _failures.Count >= 10; + } + } + + private void RecordFailure() + { + lock (_failures) _failures.Enqueue(DateTime.UtcNow); + } + + // ───────────────────────── 요청 처리 ───────────────────────── + + private async Task ServeAsync(JsonLineConnection conn, string peer, bool requireHelloPassword, CancellationToken ct) + { + bool authed = !requireHelloPassword; + string who = peer; + var streams = new Dictionary(); + int nextStream = 1; + + try + { + while (!ct.IsCancellationRequested) + { + var msg = await conn.ReadAsync(ct).ConfigureAwait(false); + if (msg == null) break; + + var op = Json.Str(msg, "op"); + int id = Json.Int(msg, "id"); + + if (op == "hello") + { + var client = Json.Str(msg, "client"); + who = string.IsNullOrEmpty(client) ? peer : $"{client}({peer})"; + if (!authed) + { + if (IsThrottled()) + { + await Reply(conn, id, error: "비밀번호를 여러 번 틀려 잠시 접속이 막혔습니다. 몇 분 뒤 다시 시도하세요.", extra: new JsonObject { ["authFailed"] = true }); + break; + } + if (!CheckPassword(Json.Str(msg, "password"))) + { + RecordFailure(); + await Task.Delay(1000, ct).ConfigureAwait(false); + _log($"원격 접속 거부: {who} 비밀번호가 맞지 않습니다.", LogLevel.Warning); + await Reply(conn, id, error: "접속 비밀번호가 맞지 않습니다.", extra: new JsonObject { ["authFailed"] = true }); + break; + } + authed = true; + } + _log($"원격 접속: {who}", LogLevel.Notice); + await Reply(conn, id, result: new JsonObject + { + ["hostname"] = Environment.MachineName, + ["version"] = Version, + ["instance"] = AppInstance.Number + }); + continue; + } + + if (!authed) + { + await Reply(conn, id, error: "먼저 인증해야 합니다."); + break; + } + + switch (op) + { + case "ping": + await Reply(conn, id, result: new JsonObject()); + break; + + case "list": + _ = Task.Run(() => SafeReply(conn, id, () => ListServices())); + break; + + case "start": + case "stop": + _ = Task.Run(() => SafeReply(conn, id, () => StartStop(op == "start", Json.Str(msg, "service"), who))); + break; + + case "details": + _ = Task.Run(() => SafeReply(conn, id, () => + { + var e = Registered(Json.Str(msg, "service")); + var arr = new JsonArray(); + foreach (var l in ServiceOps.GetDetails(e.ServiceName)) arr.Add(l); + return new JsonObject { ["lines"] = arr }; + })); + break; + + case "logOpen": + { + try + { + var e = Registered(Json.Str(msg, "service")); + if (string.IsNullOrWhiteSpace(e.LogPath)) + throw new RemoteException("대상 PC에서 이 서비스의 로그 경로가 지정되지 않았습니다."); + var enc = Json.Str(msg, "encoding"); + var copy = new ServiceEntry + { + ServiceName = e.ServiceName, + DisplayName = e.DisplayName, + LogPath = e.LogPath, + LogIsFolder = e.LogIsFolder, + LogEncoding = string.IsNullOrEmpty(enc) ? e.LogEncoding : enc + }; + int sid = nextStream++; + var tailer = new LogTailer(copy, (lines, level) => SendLog(conn, sid, lines, level)) + { + SkipInitial = Json.Bool(msg, "newOnly") + }; + tailer.FileChanged += f => Send(conn, new JsonObject { ["event"] = "logfile", ["stream"] = sid, ["path"] = f }); + lock (streams) streams[sid] = tailer; + await Reply(conn, id, result: new JsonObject + { + ["stream"] = sid, + ["path"] = e.LogPath, + ["isFolder"] = e.LogIsFolder + }); + tailer.Start(); + } + catch (Exception ex) + { + await Reply(conn, id, error: ex.Message); + } + break; + } + + case "logClose": + { + int sid = Json.Int(msg, "stream"); + lock (streams) + { + if (streams.Remove(sid, out var t)) t.Dispose(); + } + await Reply(conn, id, result: new JsonObject()); + break; + } + + default: + await Reply(conn, id, error: "알 수 없는 요청: " + op); + break; + } + } + } + catch + { + // 연결 끊김 + } + finally + { + lock (streams) + { + foreach (var t in streams.Values) t.Dispose(); + streams.Clear(); + } + conn.Dispose(); + if (authed && who != peer) _log($"원격 연결 종료: {who}", LogLevel.Info); + } + } + + private ServiceEntry Registered(string serviceName) + { + var e = _services().FirstOrDefault(x => string.Equals(x.ServiceName, serviceName, StringComparison.OrdinalIgnoreCase)); + if (e == null) throw new RemoteException("대상 PC에 등록되지 않은 서비스입니다: " + serviceName); + return e; + } + + private JsonObject ListServices() + { + var arr = new JsonArray(); + foreach (var e in _services()) + { + var st = ServiceOps.GetStatus(e.ServiceName); + arr.Add(new JsonObject + { + ["name"] = e.ServiceName, + ["display"] = e.DisplayName, + ["status"] = st.HasValue ? (int)st.Value : null, + ["logPath"] = e.LogPath ?? "", + ["logIsFolder"] = e.LogIsFolder + }); + } + return new JsonObject { ["services"] = arr }; + } + + private JsonObject StartStop(bool start, string serviceName, string who) + { + var e = Registered(serviceName); + var name = string.IsNullOrEmpty(e.DisplayName) ? e.ServiceName : e.DisplayName; + var what = start ? "시작" : "중지"; + _log($"[원격 {who}] [{name}] {what} 요청", LogLevel.Notice); + var sw = Stopwatch.StartNew(); + try + { + if (start) ServiceOps.Start(e.ServiceName, OpTimeout); + else ServiceOps.Stop(e.ServiceName, OpTimeout); + _log($"[원격 {who}] [{name}] {what} 완료 ({sw.Elapsed.TotalSeconds:0.0}초)", LogLevel.Success); + return new JsonObject { ["seconds"] = Math.Round(sw.Elapsed.TotalSeconds, 1) }; + } + catch (InfoException ex) + { + throw new RemoteException(ex.Message, isInfo: true); + } + catch (Exception ex) + { + var msg = ServiceOps.Describe(ex); + _log($"[원격 {who}] [{name}] {what} 실패: {msg}", LogLevel.Error); + throw new RemoteException(msg); + } + finally + { + _changed?.Invoke(); + } + } + + private static async Task SafeReply(JsonLineConnection conn, int id, Func work) + { + try + { + await Reply(conn, id, result: work()); + } + catch (RemoteException ex) + { + await Reply(conn, id, error: ex.Message, extra: ex.IsInfo ? new JsonObject { ["info"] = true } : null); + } + catch (Exception ex) + { + await Reply(conn, id, error: ex.Message); + } + } + + private static async Task Reply(JsonLineConnection conn, int id, JsonObject result = null, string error = null, JsonObject extra = null) + { + var msg = new JsonObject { ["id"] = id, ["ok"] = error == null }; + if (error == null) msg["result"] = result ?? new JsonObject(); + else msg["error"] = error; + if (extra != null) + foreach (var kv in extra.ToList()) + { + extra.Remove(kv.Key); + msg[kv.Key] = kv.Value; + } + try { await conn.SendAsync(msg).ConfigureAwait(false); } catch { } + } + + private static void SendLog(JsonLineConnection conn, int sid, IList lines, LogLevel level) + { + var arr = new JsonArray(); + foreach (var l in lines) arr.Add(l); + Send(conn, new JsonObject { ["event"] = "log", ["stream"] = sid, ["level"] = level.ToString(), ["lines"] = arr }); + } + + /// 로그 이벤트는 순서가 중요하므로 보낼 때까지 기다림 (로그 타이머 스레드에서 호출) + private static void Send(JsonLineConnection conn, JsonObject msg) + { + if (conn.IsDisposed) return; + try { conn.SendAsync(msg).GetAwaiter().GetResult(); } catch { } + } + } +} diff --git a/Core/SystemIntegration.cs b/Core/SystemIntegration.cs index 75ef869..b09d263 100644 --- a/Core/SystemIntegration.cs +++ b/Core/SystemIntegration.cs @@ -17,7 +17,8 @@ namespace JJServiceManager /// public static class StartupManager { - private const string TaskName = "JJServiceManager"; + // [테스트용 중복 실행] 인스턴스마다 작업 이름을 따로 (정식 릴리즈 시 Suffix 제거) + private static string TaskName => "JJServiceManager" + AppInstance.Suffix; public static bool IsEnabled() { @@ -29,6 +30,7 @@ namespace JJServiceManager { var exe = Environment.ProcessPath ?? ""; var user = WindowsIdentity.GetCurrent().Name; + var taskArgs = AppInstance.IsTest ? "--tray --instance " + AppInstance.Number : "--tray"; var xml = $@" @@ -64,7 +66,7 @@ $@" {SecurityElement.Escape(exe)} - --tray + {taskArgs} {SecurityElement.Escape(Path.GetDirectoryName(exe) ?? "")} diff --git a/JJServiceManager.csproj b/JJServiceManager.csproj index c371ab1..8be902e 100644 --- a/JJServiceManager.csproj +++ b/JJServiceManager.csproj @@ -12,7 +12,7 @@ disable disable latest - 1.0.0 + 1.1.0 JJServiceManager Windows 서비스 기동/종료 관리 ko @@ -22,6 +22,11 @@ + + + + + diff --git a/Program.cs b/Program.cs index 13e9b89..8031796 100644 --- a/Program.cs +++ b/Program.cs @@ -9,8 +9,9 @@ namespace JJServiceManager { public static class Program { - private const string MutexName = "JJServiceManager_SingleInstance_Mutex"; - private const string ShowEventName = "JJServiceManager_SingleInstance_Show"; + // [테스트용 중복 실행] 인스턴스 번호를 이름에 붙여 같은 PC에서 따로 실행 (정식 릴리즈 시 Suffix 제거) + private static string MutexName => "JJServiceManager_SingleInstance_Mutex" + AppInstance.Suffix; + private static string ShowEventName => "JJServiceManager_SingleInstance_Show" + AppInstance.Suffix; [STAThread] public static int Main(string[] args) @@ -18,6 +19,9 @@ namespace JJServiceManager // CP949(한국어 ANSI) 로그 파일 읽기용 Encoding.RegisterProvider(CodePagesEncodingProvider.Instance); + // [테스트용 중복 실행] --instance N (정식 릴리즈 시 삭제) + AppInstance.Parse(args); + // 한 번만 실행: 이미 실행 중이면 기존 창을 띄우고 종료 using var mutex = new Mutex(true, MutexName, out bool createdNew); if (!createdNew) diff --git a/README.md b/README.md index 53721c8..e9f821a 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,13 @@ -# JJServiceManager +# JJServiceManager (Ent) Windows 서비스 기동/종료 관리 프로그램 (C# WPF, .NET 8) +v1.1부터 **다른 PC의 등록된 서비스를 원격으로 모니터링·기동·종료**할 수 있습니다. ## 실행 파일 | 파일 | 크기 | 필요 조건 | |---|---|---| -| `JJServiceManager.exe` (런타임 필요판) | 약 1.4MB | [.NET 8 Desktop Runtime (x64)](https://dotnet.microsoft.com/download/dotnet/8.0) 설치 | +| `JJServiceManager.exe` (런타임 필요판) | 약 2MB | [.NET 8 Desktop Runtime (x64)](https://dotnet.microsoft.com/download/dotnet/8.0) 설치 | | `JJServiceManager.exe` (단독 실행판) | 약 65MB | 없음 | 항상 **관리자 권한**으로 실행됩니다 (실행할 때 UAC 창이 한 번 뜹니다). @@ -14,14 +15,17 @@ Windows 서비스 기동/종료 관리 프로그램 (C# WPF, .NET 8) ## 화면 ### 초기화면 -- **추가**: Windows 서비스 전체 목록에서 관리할 서비스 선택 -- **삭제**: 체크한 서비스를 목록에서 제외 (Windows 서비스 자체는 건드리지 않음) -- **Start / Stop**: 체크한 서비스를 차례로 시작/중지 +- **추가**: 내 PC의 Windows 서비스 전체 목록에서 관리할 서비스 선택 +- **삭제**: 체크한 내 PC 서비스를 목록에서 제외 (Windows 서비스 자체는 건드리지 않음, 원격 서비스는 삭제 불가) +- **Start / Stop**: 체크한 서비스(원격 포함)를 차례로 시작/중지 - **Windows 강제종료 / Windows 재시작**: 확인창에 표시된 무작위 숫자 6자리를 그대로 입력해야만 실행 (붙여넣기 불가, 설정에서 버튼 숨김 가능) -- **설정** -- **종료**: JJServiceManager 완전 종료 (트레이에도 남지 않음, 실행 중인 서비스는 그대로 유지) -- 서비스마다 **Start / Stop / Status / Log** 버튼, 상태는 2초마다 자동 갱신 (초록=실행 중, 빨강=중지됨) -- 서비스 줄 **우클릭**: 로그 파일 지정 / 로그 폴더 지정 / 경로 해제 +- **설정**, **종료**(JJServiceManager 완전 종료, 서비스는 그대로 유지) +- 서비스 목록 + - **전체** 탭: PC별 묶음 ("내 PC", "192.168.0.10 / SERVER01" …), PC별 접기/펼치기 + - **PC 탭 추가** 콤보박스: 고른 PC만 보여주는 탭을 옆에 추가, ✕로 닫기 (다음 실행 때도 유지) + - 원격 서비스 앞에는 `IP/hostname` 표시 + - 서비스마다 **Start / Stop / Status / Log**, 상태 자동 갱신 (초록=실행 중, 빨강=중지됨) +- 서비스 줄 **우클릭**(내 PC): 로그 파일 지정 / 로그 폴더 지정 / 경로 해제 - 하단 **로그**: 작업 기록 (번호 + 체크박스) - 전체선택 / 전체취소 / 복사(체크한 줄) - **화면클리어**: 새 로그를 화면 맨 위부터 출력, 위로 스크롤하면 이전 로그가 보임, 번호는 이어짐 @@ -33,22 +37,57 @@ Windows 서비스 기동/종료 관리 프로그램 (C# WPF, .NET 8) - 설명 / 상태 / 이름 / 시작유형 열, 열 제목을 누르면 정렬(▲▼) - 전체선택 / 전체취소 / 선택완료, 검색 - 이미 등록된 서비스는 노란색 + 체크된 상태로 표시 -- 체크를 해제하고 선택완료하면 초기화면에서 빠짐 -### 로그창 (서비스 로그 파일) -- 로그 **파일** 지정 또는 로그 **폴더** 지정(폴더 안의 가장 최근 수정 파일을 자동 추적) -- 실시간 표시 (0.5초 간격), 인코딩 자동/UTF-8/CP949 -- 띄우기 방식: 1. 개별 창 / 2. 하나의 창에 탭 / 3. 좌우 분할 비교 +### 로그창 +- 내 PC: 로그 **파일** 또는 **폴더**(가장 최근 수정 파일 자동 추적) 지정 +- 원격: 대상 PC에서 지정한 로그를 실시간으로 받아서 표시 (연결이 끊겼다 다시 붙으면 새 로그부터 이어서) +- 인코딩 자동/UTF-8/CP949, 띄우기 방식: 개별 창 / 탭 / 좌우 분할 비교 -### 설정 -| 항목 | 내용 | +## 원격 모니터링 + +모든 PC에 같은 프로그램을 설치하고, 설정에서 역할을 각각 켜고 끕니다. 한 PC가 두 역할을 동시에 할 수 있습니다. + +| 역할 | 설정 탭 | 하는 일 | +|---|---|---| +| 대상 | **원격 접속 허용** | 다른 PC의 접속을 받아 **이 PC에 등록된 서비스만** 보여주고 Start/Stop 실행 | +| 관리 | **관리 대상 PC** | 등록한 PC에 접속해 그 PC의 서비스를 초기화면에 표시 | + +### 통신 방식 +| 방식 | 기본 포트 | 설명 | +|---|---|---| +| SSH (프로그램 내장) | 2222 | OpenSSH 설치 없이 프로그램 안의 SSH 서버 사용 | +| 지정 포트 (TLS) | 9988 | 프로그램끼리 TLS로 암호화해 통신 | + +- 인증: 대상 PC에서 정한 **접속 비밀번호** (대상 PC에는 해시로만 저장, 관리 PC에는 Windows 계정으로 암호화해 저장) +- 서버 지문(인증서) 확인은 하지 않음 (암호화만) +- 비밀번호를 5분 동안 10번 넘게 틀리면 잠시 접속 거부 +- 원격 접속 허용을 켜면 Windows 방화벽에 TCP 포트를 자동으로 열고, 끄면 규칙(`JJServiceManager Remote`)을 지움 + +### 사용 순서 +1. **대상 PC**: 설정 → 원격 접속 허용 → 켜기, 방식·포트·접속 비밀번호 입력 → 확인 +2. **관리 PC**: 설정 → 관리 대상 PC → 다른 PC 관리 켜기 → 대상 IP, 같은 방식·포트, 비밀번호 입력 → [연결 테스트] → [추가] → 확인 +3. 초기화면에 대상 PC 묶음이 나타나고, hostname은 자동으로 채워짐 + +### 숫자 6자리 확인 +설정 → 일반에서 **내 PC** / **원격** Start/Stop 각각 켜고 끔 (기본: 내 PC 끔, 원격 켬). +여러 서비스를 한꺼번에 Start/Stop할 때는 한 번만 입력합니다. + +## 테스트용 중복 실행 (정식 릴리즈 시 삭제 예정) +같은 PC에서 두 개를 띄워 원격 기능을 시험할 때: +``` +JJServiceManager.exe --instance 2 +``` +- 창 제목에 `[테스트 인스턴스 2]` 표시 +- 설정 폴더를 따로 씀: `%AppData%\JJServiceManager_instance2` +- 기본 포트: 지정 포트 9989, SSH 2223 +- 코드에서 `[테스트용 중복 실행]` 주석이 붙은 부분을 지우면 기능이 사라집니다. + +## 설정 +| 탭 | 항목 | |---|---| -| 컴퓨터 시작시 기동 | 로그인 시 트레이로 자동 실행 (작업 스케줄러 `JJServiceManager` 등록) | -| 단축키 | 직접 눌러서 지정 (기본 Ctrl + Shift + A), Enable 토글. 누르면 창 보이기/숨기기 | -| 서비스 시작시 로그보기 | 작업 로그를 초기화면 하단에 표시할지 여부 | -| 로그창 띄우기 | 개별 창 / 탭 / 좌우 분할 | -| Esc | 트레이로 (백그라운드) / 완전 종료 | -| Windows 종료키 | Windows 강제종료 / 재시작 버튼 표시 여부 | +| 일반 | 컴퓨터 시작시 기동, 단축키(기본 Ctrl+Shift+A), 서비스 시작시 로그보기, 로그창 띄우기, Esc, Windows 종료키, 숫자 확인(내 PC/원격) | +| 원격 접속 허용 | 켜기/끄기, 통신 방식, 포트, 접속 비밀번호, 이 PC의 IP 안내 | +| 관리 대상 PC | 다른 PC 관리 켜기/끄기, 대상 IP·방식·포트·비밀번호, hostname 자동, 연결 테스트 | ## 저장 위치 - 설정: `%AppData%\JJServiceManager\settings.json` @@ -57,4 +96,5 @@ Windows 서비스 기동/종료 관리 프로그램 (C# WPF, .NET 8) ## 빌드 [.NET 8 SDK](https://dotnet.microsoft.com/download/dotnet/8.0) 설치 후 `build.bat` 실행 → `publish` 폴더에 두 가지 exe 생성 -화면 정의는 `Views/*.xaml`이며 실행 시 읽어들입니다 (XAML 컴파일 없음). +- 화면 정의는 `Views/*.xaml`이며 실행 시 읽어들입니다 (XAML 컴파일 없음). +- 원격 통신: `Core/Remote/` (SSH 라이브러리: Microsoft.DevTunnels.Ssh, MIT) diff --git a/Views/MainWindow.xaml b/Views/MainWindow.xaml index 27a1664..9e27be9 100644 --- a/Views/MainWindow.xaml +++ b/Views/MainWindow.xaml @@ -1,9 +1,62 @@ + + + + + + + + + + + + + + + + + + + + + + + +