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 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
+
@@ -18,67 +71,46 @@
-
-
+
+
-
-
+
+
-
+
+
-
+
-
+
-
-
+
+
+
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
diff --git a/Views/Settings.xaml b/Views/Settings.xaml
index 833ea14..df1514b 100644
--- a/Views/Settings.xaml
+++ b/Views/Settings.xaml
@@ -7,71 +7,218 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
+
+
+
+
+
+
-
-
-
-
-
-
+
+
+
+
+
+
+
-
-
-
-
-
-
-
+
+
+
+
+
+
-
-
-
-
-
-
+
+
+
+
+
+
+
-
-
-
-
-
-
+
+
+
+
+
+
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Windows/MainWindow.cs b/Windows/MainWindow.cs
index b7cd257..e4893d6 100644
--- a/Windows/MainWindow.cs
+++ b/Windows/MainWindow.cs
@@ -10,10 +10,12 @@ using System.Threading.Tasks;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Controls.Primitives;
+using System.Windows.Data;
using System.Windows.Input;
using System.Windows.Interop;
using System.Windows.Media;
using System.Windows.Threading;
+using JJServiceManager.Remote;
using Forms = System.Windows.Forms;
namespace JJServiceManager
@@ -24,16 +26,27 @@ namespace JJServiceManager
private static readonly TimeSpan OpTimeout = TimeSpan.FromSeconds(60);
private readonly AppSettings _settings;
- private readonly ObservableCollection _rows = new ObservableCollection();
+ private readonly FrameworkElement _root;
+ private readonly ObservableCollection _groups = new ObservableCollection();
+ private readonly HostGroup _local;
private readonly LogPanel _log;
private readonly LogWindowManager _logWindows;
private readonly DispatcherTimer _statusTimer;
+ private readonly RemoteServer _remoteServer;
- private readonly ItemsControl _list;
+ private readonly TabControl _tabs;
+ private readonly TabItem _tabAll;
+ private readonly ComboBox _cmbAddTab;
private readonly CheckBox _chkAll;
- private readonly TextBlock _txtCount, _txtEmpty, _txtLogHidden;
+ private readonly TextBlock _txtCount, _txtLogHidden;
private readonly ContentControl _ccLog;
private readonly FrameworkElement _pnlShutdown;
+ private readonly Dictionary _hostTabs = new Dictionary(StringComparer.OrdinalIgnoreCase);
+
+ /// 원격 접속 허용 쪽에 넘겨줄 등록 서비스 목록 (다른 스레드에서 읽음)
+ private volatile IReadOnlyList _servicesSnapshot = Array.Empty();
+ private string _serverKey = "";
+ private string _targetsKey = "";
private Forms.NotifyIcon _tray;
private HotkeyManager _hotkey;
@@ -46,58 +59,72 @@ namespace JJServiceManager
{
_settings = settings;
- Title = "JJServiceManager - 서비스 관리";
+ Title = "JJServiceManager - 서비스 관리" + AppInstance.TitleTag;
FontFamily = new FontFamily("Malgun Gothic");
FontSize = 13;
Background = Brushes2.Make("#F5F7F8");
- MinWidth = 760;
- MinHeight = 520;
+ MinWidth = 860;
+ MinHeight = 560;
AppIcon.Apply(this);
RestoreWindowBounds(settings);
- var root = XamlLoader.LoadElement("MainWindow.xaml");
- Content = root;
+ _root = XamlLoader.LoadElement("MainWindow.xaml");
+ Content = _root;
- _list = root.Find("icServices");
- _chkAll = root.Find("chkAll");
- _txtCount = root.Find("txtCount");
- _txtEmpty = root.Find("txtEmpty");
- _txtLogHidden = root.Find("txtLogHidden");
- _ccLog = root.Find("ccLog");
- _pnlShutdown = root.Find("pnlShutdown");
+ _tabs = _root.Find("tabHosts");
+ _tabAll = _root.Find("tabAll");
+ _cmbAddTab = _root.Find("cmbAddTab");
+ _chkAll = _root.Find("chkAll");
+ _txtCount = _root.Find("txtCount");
+ _txtLogHidden = _root.Find("txtLogHidden");
+ _ccLog = _root.Find("ccLog");
+ _pnlShutdown = _root.Find("pnlShutdown");
_log = new LogPanel { ShowTimestamp = true };
_ccLog.Content = _log;
- _logWindows = new LogWindowManager(() => _settings, SaveSettings);
+ _logWindows = new LogWindowManager(() => _settings, SaveSettings, LogChoices);
- foreach (var e in settings.Services) _rows.Add(new ServiceRow(e));
- _list.ItemsSource = _rows;
- _rows.CollectionChanged += (s, e) => UpdateCounts();
+ // 내 PC
+ _local = new HostGroup
+ {
+ Id = HostGroup.LocalId,
+ Title = $"내 PC ({Environment.MachineName})",
+ EmptyText = "[추가] 버튼을 눌러 관리할 서비스를 등록하세요."
+ };
+ foreach (var e in settings.Services) _local.Rows.Add(new ServiceRow(e, _local));
+ _local.Rows.CollectionChanged += (s, e) => UpdateCounts();
+ _groups.Add(_local);
+ UpdateSnapshot();
+
+ _root.Find("icGroups").ItemsSource = _groups;
+ _cmbAddTab.ItemsSource = _groups;
+ _cmbAddTab.SelectionChanged += OnAddTabSelected;
+ _tabs.SelectionChanged += (s, e) => { if (e.Source == _tabs) UpdateCounts(); };
// 상단 버튼
- root.Find