commit 1975c1ceff60fd43cf66033a20be79b2bdc19978 Author: nikit3s Date: Sat Sep 26 14:41:04 2026 +0300 Create glass launcher prototype diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ed6d54a --- /dev/null +++ b/.gitignore @@ -0,0 +1,15 @@ +# ── секреты и параметры конкретной машины ──────────────────────────── +# private_key, local_address, имя сетевого адаптера. Никогда не коммитим. +local.json +profiles.json + +# ── рантайм ────────────────────────────────────────────────────────── +# сюда лаунчер генерирует config.json перед запуском +run/ +# sb.exe (36 МБ) и doc.srs — бинарники, лежат рядом на боевой машине +bin/ +doc/ + +# ── результат сборки ───────────────────────────────────────────────── +*.exe +*.pdb diff --git a/App.cs b/App.cs new file mode 100644 index 0000000..14a7404 --- /dev/null +++ b/App.cs @@ -0,0 +1,672 @@ +// SbLauncher — GUI для sing-box. +// +// ПРИНЦИП (изменён по сравнению с прошлой версией): +// Источник правды — profiles.json (серверы, общее) и local.json (ключ, +// адрес, адаптер — своё на каждой машине). Конфиг sing-box НЕ хранится: +// он собирается из шаблона templates\*.json при каждом запуске и кладётся +// в run\config.json. Поэтому папок conf_* больше нет, и править один и +// тот же private_key в пяти файлах не требуется. +// +// ПОРТАТИВНОСТЬ: всё ищется рядом с exe. Скопировали папку — работает. +// +// Компилятор — встроенный в Windows csc.exe (C# 5). Поэтому здесь нет +// интерполяции строк ($""), ?. и expression-bodied членов. См. build.cmd. + +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Collections.Concurrent; +using System.ComponentModel; +using System.Diagnostics; +using System.Globalization; +using System.IO; +using System.Net; +using System.Net.NetworkInformation; +using System.Net.Sockets; +using System.Reflection; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; +using System.Text; +using System.Text.RegularExpressions; +using System.Threading; +using System.Threading.Tasks; +using System.Web.Script.Serialization; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Input; +using System.Windows.Interop; +using System.Windows.Markup; +using System.Windows.Media; +using System.Windows.Media.Imaging; +using System.Windows.Threading; + +namespace SbLauncher +{ + // Ask the Windows compositor to round the opaque, borderless window. + // Unlike per-pixel transparency, this keeps the fast normal WPF render path. + public static class WindowCorners + { + [DllImport("dwmapi.dll", PreserveSig = true)] + static extern int DwmSetWindowAttribute(IntPtr hwnd, int attribute, ref int value, int size); + + public static void Enable(Window window) + { + window.SourceInitialized += delegate + { + try + { + int preference = 2; // DWMWCP_ROUND + DwmSetWindowAttribute(new WindowInteropHelper(window).Handle, + 33, ref preference, sizeof(int)); // DWMWA_WINDOW_CORNER_PREFERENCE + } + catch (DllNotFoundException) { } // Windows before DWM corner support + catch (EntryPointNotFoundException) { } + }; + } + } + + public static class Theme + { + public const string Dark = "dark"; + public const string Light = "light"; + + public static string DetectSystem() + { + try + { + object v = Microsoft.Win32.Registry.GetValue( + "HKEY_CURRENT_USER\\Software\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize", + "AppsUseLightTheme", null); + if (v is int) return ((int)v) == 0 ? Dark : Light; + } + catch { } + return Dark; + } + + // Палитра кладётся СРАЗУ В ДВА места, и это не избыточность: + // + // * w.Resources — чтобы {DynamicResource} в разметке гарантированно + // получил уведомление об изменении. Окно всегда слушает свой + // собственный словарь. Уведомления от Application.Resources доходят + // только до окон, зарегистрированных в Application.Current, а окно + // попадает туда лишь если Application существовал в момент его + // создания. Промах по этому условию даёт фон null и полностью + // прозрачное окно (при AllowsTransparency="True"). + // + // * Application.Resources — оттуда Theme.Res() берёт смысловые цвета + // для статуса, пингов и подсказок; у ViewModel ссылки на окно нет. + public static void Apply(Window w, string theme) + { + string key = (theme == Light) ? "Theme.Light" : "Theme.Dark"; + ResourceDictionary src = w.Resources[key] as ResourceDictionary; + if (src == null) return; + + foreach (object k in src.Keys) + { + object v = src[k]; + w.Resources[k] = v; + if (Application.Current != null) Application.Current.Resources[k] = v; + } + } + + // Смысловые цвета (Ok/Warn/Bad/Idle/Na/Muted/Accent) тоже живут в + // палитре, поэтому берём их из ресурсов, а не из зашитых констант. + public static Brush Res(string key) + { + try + { + if (Application.Current != null) + { + object o = Application.Current.TryFindResource(key); + if (o is Brush) return (Brush)o; + } + } + catch { } + return Brushes.Gray; + } + } + + // ═════════════════════════════════════════════════════════════════════ + // ТРЕЙ + // + // Крестик не закрывает приложение, а прячет окно: sing-box продолжает + // работать. Реальный выход — только через меню трея, и он останавливает + // туннель. Имена типов WinForms пишем полностью: NotifyIcon соседствует + // с WPF, где есть свои Application/MessageBox. + // ═════════════════════════════════════════════════════════════════════ + public class Tray : IDisposable + { + readonly System.Windows.Forms.NotifyIcon _ni; + readonly System.Windows.Forms.ToolStripMenuItem _miToggle; + readonly Window _w; + readonly MainVm _vm; + bool _balloonShown; + + public Tray(Window w, MainVm vm) + { + _w = w; _vm = vm; + + _ni = new System.Windows.Forms.NotifyIcon(); + _ni.Icon = LoadIcon(); + _ni.Text = "sb launcher"; + _ni.Visible = true; + _ni.DoubleClick += delegate { Restore(); }; + + System.Windows.Forms.ContextMenuStrip m = new System.Windows.Forms.ContextMenuStrip(); + m.Items.Add("Показать", null, delegate { Restore(); }); + _miToggle = new System.Windows.Forms.ToolStripMenuItem("Подключить", null, + delegate { if (_vm.ConnectCommand.CanExecute(null)) _vm.ConnectCommand.Execute(null); }); + m.Items.Add(_miToggle); + m.Items.Add(new System.Windows.Forms.ToolStripSeparator()); + m.Items.Add("Выход", null, delegate { ExitApp(); }); + m.Opening += delegate { _miToggle.Text = _vm.ConnectText; }; + _ni.ContextMenuStrip = m; + } + + static System.Drawing.Icon LoadIcon() + { + try + { + Stream s = Assembly.GetExecutingAssembly().GetManifestResourceStream("app.ico"); + if (s != null) + using (s) + return new System.Drawing.Icon(s, System.Windows.Forms.SystemInformation.SmallIconSize); + } + catch { } + try { return System.Drawing.Icon.ExtractAssociatedIcon(Assembly.GetExecutingAssembly().Location); } + catch { return System.Drawing.SystemIcons.Application; } + } + + public void HideToTray() + { + _w.Hide(); + if (!_balloonShown) + { + _balloonShown = true; + _ni.ShowBalloonTip(3000, "sb launcher", + _vm.IsConnected + ? "Свернулся в трей — туннель продолжает работать." + : "Свернулся в трей.", + System.Windows.Forms.ToolTipIcon.Info); + } + } + + public void Restore() + { + Action a = delegate + { + _w.Show(); + if (_w.WindowState == WindowState.Minimized) _w.WindowState = WindowState.Normal; + _w.Activate(); + _w.Topmost = true; _w.Topmost = false; + _w.Focus(); + }; + if (_w.Dispatcher.CheckAccess()) a(); else _w.Dispatcher.BeginInvoke(a); + } + + public void ExitApp() + { + Action a = delegate + { + _vm.DisposeTimers(); + Dispose(); + Application.Current.Shutdown(); + }; + if (_w.Dispatcher.CheckAccess()) a(); else _w.Dispatcher.BeginInvoke(a); + } + + public void Dispose() + { + try { _ni.Visible = false; _ni.Dispose(); } catch { } + } + } + + // ═════════════════════════════════════════════════════════════════════ + // ТОЧКА ВХОДА + // ═════════════════════════════════════════════════════════════════════ + public class App + { + [STAThread] + public static int Main(string[] argv) + { + Paths paths = new Paths(ResolveRoot(argv)); + + if (Array.IndexOf(argv, "--selftest") >= 0) return SelfTest(paths); + + int thIdx = Array.IndexOf(argv, "--theme"); + string themeArg = (thIdx >= 0 && thIdx + 1 < argv.Length) ? argv[thIdx + 1] : null; + + int shotIdx = Array.IndexOf(argv, "--screenshot"); + if (shotIdx >= 0 && shotIdx + 1 < argv.Length) + return Screenshot(argv[shotIdx + 1], paths, themeArg, + Array.IndexOf(argv, "--settings") >= 0); + + // Одна копия за раз: два sing-box одновременно сломают маршруты. + // Вторая копия не ругается, а поднимает окно уже запущенной — + // иначе после сворачивания в трей ярлык выглядел бы сломанным. + // Namespace именно Local\: Global\ требует SeCreateGlobalPrivilege, + // которого у неэлевированного процесса нет. + // Проверку делаем ПОСЛЕ служебных режимов: --selftest и + // --screenshot не должны занимать блокировку. + bool isNew; + Mutex mtx = new Mutex(true, "Local\\SbLauncherGlass_SingleInstance", out isNew); + EventWaitHandle showSignal = new EventWaitHandle( + false, EventResetMode.AutoReset, "Local\\SbLauncherGlass_Show"); + if (!isNew) + { + showSignal.Set(); + return 0; + } + + // ВНИМАНИЕ: Application создаётся ПЕРВЫМ, до разбора окна. + // Window регистрируется в Application.Current в своём конструкторе. + // Если Application ещё нет, окно в этот список не попадает, и + // уведомление об изменении Application.Resources до него не доходит: + // палитра применяется, TryFindResource её находит, а вот + // {DynamicResource} остаётся неразрешённым — фон становится null, + // и при AllowsTransparency="True" окно выглядит полностью прозрачным. + Application app = new Application(); + app.ShutdownMode = ShutdownMode.OnExplicitShutdown; + + Window w; + try { w = (Window)XamlReader.Parse(LoadXaml()); } + catch (Exception ex) + { + MessageBox.Show("Не разобран Ui.xaml:\n\n" + ex.Message, "SbLauncher", + MessageBoxButton.OK, MessageBoxImage.Error); + return 1; + } + + MainVm vm = new MainVm(); + w.DataContext = vm; + vm.Load(paths); + WindowCorners.Enable(w); + + vm.ThemeApplier = delegate(string t) { Theme.Apply(w, t); }; + string th = themeArg != null ? themeArg : vm.Local.Theme; + if (string.IsNullOrEmpty(th)) th = Theme.DetectSystem(); + vm.SetTheme(th); + + Tray tray = new Tray(w, vm); + Wire(w, vm, tray); + + w.Closing += delegate(object s, CancelEventArgs e) + { + e.Cancel = true; // крестик = свернуть в трей + vm.FlushLocal(); + tray.HideToTray(); + }; + app.Exit += delegate { tray.Dispose(); vm.DisposeTimers(); }; + + // фоновый слушатель: повторный запуск ярлыка поднимает это окно + Thread listener = new Thread(delegate() + { + while (true) { showSignal.WaitOne(); tray.Restore(); } + }); + listener.IsBackground = true; + listener.Start(); + + app.MainWindow = w; + w.Show(); + int rc = app.Run(); + GC.KeepAlive(mtx); + return rc; + } + + // ── --selftest ─────────────────────────────────────────────────── + // Разбирает XAML, проверяет темы и флаги, читает profiles/local и + // СОБИРАЕТ все три конфига, прогоняя каждый через sb.exe check. + // Ничего не запускает и не требует администратора. + static int SelfTest(Paths paths) + { + int problems = 0; + Window tw; + try + { + // Порядок ТОЧНО как в боевом Main: сначала Application, + // потом окно. Иначе тест не поймает регрессию с прозрачным + // окном — она проявляется только при обратном порядке. + new Application(); + tw = (Window)XamlReader.Parse(LoadXaml()); + Console.WriteLine("XAML .......... ok (" + tw.Width + "x" + tw.Height + ")"); + string[] names = { "TitleBar", "BtnTheme", "BtnMin", "BtnClose", "LogBox", + "BtnTabLog", "BtnTabSettings", "LinkBox", "BtnAddLink", + "AdapterBox" }; + foreach (string n in names) + { + bool ok = tw.FindName(n) != null; + if (!ok) problems++; + Console.WriteLine(" x:Name " + n.PadRight(16) + (ok ? "found" : "MISSING")); + } + } + catch (Exception ex) + { + Console.WriteLine("XAML .......... FAIL: " + ex.Message); + return 1; + } + + // Регрессия «прозрачное окно»: палитра применяется, но + // {DynamicResource} не разрешается, и фон остаётся null. + foreach (string th in new string[] { Theme.Dark, Theme.Light }) + { + Theme.Apply(tw, th); + Border rootBorder = tw.Content as Border; + object bg = (rootBorder != null) ? rootBorder.Background : null; + bool ok = bg != null; + if (!ok) problems++; + Console.WriteLine(string.Format(CultureInfo.InvariantCulture, + "theme {0,-6} ... {1}", th, ok ? "фон " + bg : "ФОН NULL — окно будет прозрачным!")); + } + + // Флаги строятся кодом. Если Build бросит исключение, WPF + // проглотит его при вычислении биндинга и молча покажет + // текстовый бейдж — внешне похоже на «флаг просто не добавлен». + foreach (string fc in new string[] { "RU", "BU", "DE", "EE", "FI", "NL", "SE" }) + { + string res; + try { res = (Flags.Get(fc) != null) ? "ok" : "нет ветки в Build"; } + catch (Exception ex) { res = ex.GetType().Name + ": " + ex.Message; } + if (res != "ok") problems++; + Console.WriteLine(("flag " + fc).PadRight(14) + "... " + res); + } + + try + { + using (Stream ic = Assembly.GetExecutingAssembly().GetManifestResourceStream("app.ico")) + { + if (ic == null) { Console.WriteLine("app.ico ....... MISSING resource"); problems++; } + else + { + IconBitmapDecoder d = new IconBitmapDecoder( + ic, BitmapCreateOptions.None, BitmapCacheOption.OnLoad); + Console.WriteLine("app.ico ....... ok, " + d.Frames.Count + " sizes"); + } + } + } + catch (Exception ex) { Console.WriteLine("app.ico ....... FAIL: " + ex.Message); problems++; } + + Console.WriteLine(); + Console.WriteLine("root .......... " + paths.Root); + Console.WriteLine("sb.exe ........ " + (File.Exists(paths.SbExe) ? "ok" : "MISSING") + " " + paths.SbExe); + Console.WriteLine("doc.srs ....... " + (File.Exists(paths.RuleSet) ? "ok" : "MISSING")); + + Profiles pr; + LocalCfg lc; + try + { + pr = Profiles.Load(paths.Profiles); + lc = LocalCfg.Load(paths.Local); + } + catch (Exception ex) + { + Console.WriteLine("profiles/local FAIL: " + ex.Message); + return 1; + } + + Console.WriteLine("profiles.json . " + pr.Servers.Count + " серверов, WG " + + pr.Wg.Server + ":" + pr.Wg.Port.ToString(CultureInfo.InvariantCulture)); + foreach (ServerCfg s in pr.Servers) + Console.WriteLine(string.Format(CultureInfo.InvariantCulture, + " {0,-4} {1,-24} {2,-22} sni={3}", s.Code, s.Name, s.Endpoint, s.Sni)); + + Console.WriteLine("local.json .... ключ " + + (ConfigBuilder.IsWgKey(lc.PrivateKey) ? "ok" : "НЕ КОРРЕКТЕН") + + ", адрес " + (ConfigBuilder.IsCidr(lc.LocalAddress) ? "ok" : "НЕ КОРРЕКТЕН") + + ", адаптер \"" + lc.Adapter + "\" " + + (Adapters.Find(lc.Adapter) != null ? "найден" : "НЕ НАЙДЕН")); + + Console.WriteLine("адаптеры ...... "); + foreach (AdapterInfo a in Adapters.List()) + Console.WriteLine(" " + (a.IsUp ? "[up] " : "[down] ") + a.Name.PadRight(30) + a.Description); + + // Разбор ссылки — чистая функция, проверяем на синтетической. + // Порядок параметров в vless:// у разных панелей разный, поэтому + // важно, что читаем мы их по имени, а не по позиции. + string link = "vless://58863334-3b8a-4c81-9145-a18311621a7f@rus.krasavchik.org:443" + + "?type=tcp&security=reality&fp=random&sni=exomates.ru" + + "&pbk=Ot1KGILofMNTQa5Ewu5j3EoO1Ap0LEHOhYzzfANiB14&sid=6d25" + + "&flow=xtls-rprx-vision#%D0%91%D1%83%D1%80%D1%8F%D1%82%D0%B8%D1%8F"; + ServerCfg parsed = VlessLink.Parse(link); + bool linkOk = parsed != null + && parsed.Server == "rus.krasavchik.org" + && parsed.Port == 443 + && parsed.Uuid == "58863334-3b8a-4c81-9145-a18311621a7f" + && parsed.Sni == "exomates.ru" + && parsed.PublicKey == "Ot1KGILofMNTQa5Ewu5j3EoO1Ap0LEHOhYzzfANiB14" + && parsed.ShortId == "6d25" + && parsed.Fingerprint == "random" + && parsed.Flow == "xtls-rprx-vision" + && parsed.Name == "Бурятия"; + if (!linkOk) problems++; + Console.WriteLine("vless:// ...... " + (linkOk + ? "разобрана верно (имя из #-фрагмента: " + parsed.Name + ", код " + parsed.Code + ")" + : "РАЗБОР СЛОМАН")); + + // Второй тест: фрагмент с ?serverDescription=, без flow, с spx + string link2 = "vless://dbbb755e-cf8d-4701-9f97-37bf90cf530f@95.81.76.106:443" + + "?sid=68b1b1&type=tcp&spx=%2Fzx6XGVuN0oHb3Mm&headerType=none" + + "&sni=server.yamal-house.ru&security=reality&fp=chrome" + + "&pbk=G-IE8mHDdRX9600xdDuRm75upOoCSE3FHZN2Dj1hdWk&encryption=none" + + "#VPN_56RUS-Rostik?serverDescription=TVMxLeKEljE="; + ServerCfg p2 = VlessLink.Parse(link2); + bool link2Ok = p2 != null + && p2.Server == "95.81.76.106" + && p2.Uuid == "dbbb755e-cf8d-4701-9f97-37bf90cf530f" + && p2.Sni == "server.yamal-house.ru" + && p2.ShortId == "68b1b1" + && p2.Flow == "" + && p2.SpiderX == "/zx6XGVuN0oHb3Mm" + && p2.Name == "VPN_56RUS-Rostik"; // без ?serverDescription=... + if (!link2Ok) problems++; + Console.WriteLine("vless:// #2 ... " + (link2Ok + ? "разобрана верно (имя: " + p2.Name + ", flow пуст, spx=" + p2.SpiderX + ")" + : "РАЗБОР СЛОМАН")); + + Console.WriteLine(); + ServerCfg first = pr.Servers.Count > 0 ? pr.Servers[0] : null; + string[] modes = { ConfigBuilder.ModeWg, ConfigBuilder.ModeDirect, ConfigBuilder.ModeWgOnly }; + foreach (string mode in modes) + { + BuildResult br = ConfigBuilder.Build(paths, pr, lc, first, mode, + Path.Combine(paths.RunDir, "selftest-" + mode + ".json")); + if (!br.Ok) + { + problems++; + Console.WriteLine("build " + mode.PadRight(8) + "... FAIL: " + br.Error); + continue; + } + string verdict = "собран"; + if (File.Exists(paths.SbExe)) + { + string outp; + bool ok = SingBoxRunner.Check(paths.SbExe, br.ConfigPath, paths.Root, out outp); + if (!ok) { problems++; verdict = "sb check ОШИБКА: " + outp; } + else verdict = "собран, sb check ok"; + } + else verdict = "собран (sb.exe нет, check пропущен)"; + Console.WriteLine("build " + mode.PadRight(8) + "... " + verdict); + } + + Console.WriteLine(); + Console.WriteLine(problems == 0 + ? "ИТОГ .......... всё в порядке" + : "ИТОГ .......... проблем: " + problems); + return problems == 0 ? 0 : 1; + } + + // ── --screenshot ─────────────────────────────────────────── + static int Screenshot(string outPath, Paths paths, string themeArg, bool settingsTab) + { + try + { + new Application(); // нужен для ресурсов/шрифтов + Window w = (Window)XamlReader.Parse(LoadXaml()); + MainVm vm = new MainVm(); + w.DataContext = vm; + vm.ThemeApplier = delegate(string t) { Theme.Apply(w, t); }; + vm.SetTheme(themeArg != null ? themeArg : Theme.Dark); + vm.Load(paths); + vm.ShowSettings = settingsTab; + vm.AppendLog("демо-рендер интерфейса — sing-box не запускался"); + + ImageSource logo = LoadLogo(); + Image li = w.FindName("LogoImg") as Image; + if (li != null && logo != null) li.Source = logo; + Image statusLogo = w.FindName("StatusLogoImg") as Image; + if (statusLogo != null && logo != null) statusLogo.Source = logo; + + // немного «живых» данных, чтобы картинка была честной + int i = 0; + int[] demo = { 28, 41, 96, 214 }; + foreach (ServerVm s in vm.Servers) + { + if (s.Pingable) + { + int ms = demo[i++ % demo.Length]; + s.PingText = ms.ToString(CultureInfo.InvariantCulture) + " ms"; + s.PingKey = ms < 60 ? "Ok" : ms < 150 ? "Warn" : "Bad"; + } + else { s.PingText = "н/д"; s.PingKey = "Na"; } + } + + FrameworkElement root2 = (FrameworkElement)w.Content; + double ww = w.Width, hh = w.Height; + root2.Measure(new Size(ww, hh)); + root2.Arrange(new Rect(0, 0, ww, hh)); + root2.UpdateLayout(); + Pump(); // дать ItemsControl создать карточки + root2.UpdateLayout(); + + RenderTargetBitmap rtb = new RenderTargetBitmap( + (int)(ww * 1.5), (int)(hh * 1.5), 144, 144, PixelFormats.Pbgra32); + rtb.Render(root2); + PngBitmapEncoder enc = new PngBitmapEncoder(); + enc.Frames.Add(BitmapFrame.Create(rtb)); + using (FileStream fs = new FileStream(outPath, FileMode.Create)) + enc.Save(fs); + Console.WriteLine("saved: " + outPath); + return 0; + } + catch (Exception ex) + { + Console.WriteLine("screenshot FAIL: " + ex.GetType().Name + ": " + ex.Message); + return 1; + } + } + + static void Pump() + { + DispatcherFrame f = new DispatcherFrame(); + Application.Current.Dispatcher.BeginInvoke( + System.Windows.Threading.DispatcherPriority.ContextIdle, + new Action(delegate { f.Continue = false; })); + Dispatcher.PushFrame(f); + } + + // --root, иначе папка с exe, иначе соседняя (на случай exe в подпапке) + static string ResolveRoot(string[] argv) + { + for (int i = 0; i < argv.Length - 1; i++) + if (argv[i] == "--root") return argv[i + 1]; + + string exeDir = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location); + if (File.Exists(Path.Combine(exeDir, "profiles.json"))) return exeDir; + + string parent = Path.GetDirectoryName(exeDir); + if (parent != null && File.Exists(Path.Combine(parent, "profiles.json"))) return parent; + + return exeDir; + } + + static ImageSource LoadLogo() + { + try + { + string exeDir = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location); + string ext = Path.Combine(exeDir, "logo.png"); + BitmapImage b = new BitmapImage(); + b.BeginInit(); + if (File.Exists(ext)) b.UriSource = new Uri(ext); + else b.StreamSource = Assembly.GetExecutingAssembly().GetManifestResourceStream("logo.png"); + b.CacheOption = BitmapCacheOption.OnLoad; + b.EndInit(); + b.Freeze(); + return b; + } + catch { return null; } + } + + // Ui.xaml рядом с exe перекрывает встроенный — можно править дизайн + // без пересборки: сохранил файл, перезапустил. + static string LoadXaml() + { + string exeDir = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location); + string ext = Path.Combine(exeDir, "Ui.xaml"); + if (File.Exists(ext)) return File.ReadAllText(ext, Encoding.UTF8); + + using (Stream s = Assembly.GetExecutingAssembly().GetManifestResourceStream("Ui.xaml")) + using (StreamReader r = new StreamReader(s, Encoding.UTF8)) + return r.ReadToEnd(); + } + + // В runtime-XAML нельзя писать Click="..." — обработчики цепляем здесь. + static void Wire(Window w, MainVm vm, Tray tray) + { + // логотип в шапке и в Alt+Tab + ImageSource logo = LoadLogo(); + if (logo != null) + { + Image li = w.FindName("LogoImg") as Image; + if (li != null) li.Source = logo; + Image statusLogo = w.FindName("StatusLogoImg") as Image; + if (statusLogo != null) statusLogo.Source = logo; + try { w.Icon = logo; } catch { } + } + + UIElement bar = w.FindName("TitleBar") as UIElement; + if (bar != null) + bar.MouseLeftButtonDown += delegate(object s, MouseButtonEventArgs e) + { + if (e.ClickCount == 1) w.DragMove(); + }; + + Button theme = w.FindName("BtnTheme") as Button; + if (theme != null) theme.Click += delegate { vm.ToggleTheme(); }; + + Button min = w.FindName("BtnMin") as Button; + if (min != null) min.Click += delegate { w.WindowState = WindowState.Minimized; }; + + Button close = w.FindName("BtnClose") as Button; + if (close != null) close.Click += delegate { tray.HideToTray(); }; + + Button tabLog = w.FindName("BtnTabLog") as Button; + if (tabLog != null) tabLog.Click += delegate { vm.ShowSettings = false; }; + + Button tabSet = w.FindName("BtnTabSettings") as Button; + if (tabSet != null) tabSet.Click += delegate { vm.ShowSettings = true; }; + + // «добавить сервер из ссылки»: берём текст из поля и чистим его + TextBox link = w.FindName("LinkBox") as TextBox; + Button addLink = w.FindName("BtnAddLink") as Button; + if (addLink != null) + addLink.Click += async delegate + { + string text = link != null ? link.Text : null; + if (await vm.AddFromLinkAsync(text) && link != null) link.Text = ""; + }; + + // список адаптеров пересобираем при раскрытии: кабель могли + // воткнуть уже после запуска лаунчера + ComboBox ad = w.FindName("AdapterBox") as ComboBox; + if (ad != null) + ad.DropDownOpened += delegate { vm.RefreshAdapters(); }; + + // автопрокрутка лога + TextBox log = w.FindName("LogBox") as TextBox; + if (log != null) + log.TextChanged += delegate { log.ScrollToEnd(); }; + + w.Loaded += delegate { vm.PingAll(); }; + } + } +} diff --git a/Import.cs b/Import.cs new file mode 100644 index 0000000..ff95c46 --- /dev/null +++ b/Import.cs @@ -0,0 +1,734 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Net; +using System.Text; +using System.Text.RegularExpressions; +using System.Web.Script.Serialization; + +namespace SbLauncher +{ + public class ImportResult + { + public readonly List Servers = new List(); + public readonly List Errors = new List(); + } + + // Turns share links and pasted subscriptions into sing-box outbounds. + // Unknown transports are rejected rather than silently changing the protocol. + public static class KeyImporter + { + public static bool Same(ServerCfg a, ServerCfg b) + { + if (!string.Equals(a.Protocol, b.Protocol, StringComparison.OrdinalIgnoreCase) || + !string.Equals(a.Server, b.Server, StringComparison.OrdinalIgnoreCase) || a.Port != b.Port) + return false; + if (a.Outbound != null && b.Outbound != null) + return Canonical(a.Outbound) == Canonical(b.Outbound); + if (a.Protocol == "vless") return string.Equals(a.Uuid, b.Uuid, StringComparison.OrdinalIgnoreCase); + return false; + } + + static string Canonical(object value) + { + Dictionary map = value as Dictionary; + if (map != null) + { + List keys = new List(map.Keys); + keys.Sort(StringComparer.Ordinal); + StringBuilder output = new StringBuilder("{"); + foreach (string key in keys) + output.Append(J.ToJson(key)).Append(':').Append(Canonical(map[key])).Append(','); + return output.Append('}').ToString(); + } + object[] items = value as object[]; + if (items != null) + { + StringBuilder output = new StringBuilder("["); + foreach (object item in items) output.Append(Canonical(item)).Append(','); + return output.Append(']').ToString(); + } + return J.ToJson(value); + } + + public static bool CheckWithSingBox(Paths paths, ServerCfg server, out string error) + { + error = ""; + if (!File.Exists(paths.SbExe)) return true; + string file = Path.Combine(paths.RunDir, "import-check-" + Guid.NewGuid().ToString("N") + ".json"); + try + { + Dictionary outbound = J.Parse(J.ToJson(server.Outbound)); + outbound["tag"] = "import-test"; + J.Write(file, "{\"outbounds\":[" + J.ToJson(outbound) + "]}"); + string output; + if (SingBoxRunner.Check(paths.SbExe, file, paths.Root, out output)) return true; + error = string.IsNullOrEmpty(output) ? "sing-box отклонил конфигурацию" : output; + Dictionary tls = J.Obj(outbound.ContainsKey("tls") ? outbound["tls"] : null); + Dictionary reality = tls == null ? null : J.Obj(tls.ContainsKey("reality") ? tls["reality"] : null); + Dictionary obfs = J.Obj(outbound.ContainsKey("obfs") ? outbound["obfs"] : null); + foreach (string secret in new string[] { J.Str(outbound, "uuid", ""), J.Str(outbound, "password", ""), + J.Str(reality, "public_key", ""), J.Str(obfs, "password", "") }) + if (!string.IsNullOrEmpty(secret)) error = error.Replace(secret, "[скрыто]"); + if (error.Length > 300) error = error.Substring(0, 300) + "…"; + return false; + } + catch (Exception ex) { error = ex.Message; return false; } + finally { try { if (File.Exists(file)) File.Delete(file); } catch { } } + } + + const int MaxInput = 2000000; + static readonly Regex LinkPattern = new Regex( + @"(?i)(?:vless|vmess|trojan|ss|hysteria2|hy2|tuic|anytls)://\S+", + RegexOptions.Compiled); + + public static ImportResult Parse(string input) + { + ImportResult result = new ImportResult(); + if (string.IsNullOrWhiteSpace(input)) + { + result.Errors.Add("поле ввода пусто"); + return result; + } + if (input.Length > MaxInput) + { + result.Errors.Add("слишком большой текст подписки (максимум 2 МБ)"); + return result; + } + ParseText(input.Trim().Trim('\uFEFF'), result, 0); + if (result.Servers.Count == 0 && result.Errors.Count == 0) + result.Errors.Add("поддерживаемых ключей не найдено"); + return result; + } + + static void ParseText(string text, ImportResult result, int depth) + { + if (depth > 2) { result.Errors.Add("слишком много слоёв base64"); return; } + string trimmed = text.Trim(); + if (trimmed.StartsWith("{") || trimmed.StartsWith("[")) + { + ParseJson(trimmed, result); + return; + } + if (!trimmed.Contains("://")) + { + string decoded; + if (TryDecodeBase64(trimmed, out decoded) && decoded != trimmed && + (decoded.Contains("://") || decoded.TrimStart().StartsWith("{") || decoded.TrimStart().StartsWith("["))) + { + ParseText(decoded, result, depth + 1); + return; + } + } + + string[] lines = text.Replace("\r", "").Split('\n'); + for (int i = 0; i < lines.Length; i++) + { + string line = lines[i].Trim(); + if (line.Length == 0 || line.StartsWith("#") || line.StartsWith("//")) continue; + MatchCollection links = LinkPattern.Matches(line); + if (links.Count == 0) + { + if (Regex.IsMatch(line, @"(?i)^https?://\S+$")) + { + try { ParseText(FetchSubscription(line), result, depth + 1); } + catch (Exception ex) { result.Errors.Add("строка " + (i + 1) + ": " + ex.Message); } + } + else result.Errors.Add("строка " + (i + 1) + ": неизвестный формат ключа"); + continue; + } + foreach (Match match in links) + { + if (result.Servers.Count >= 1000) + { + result.Errors.Add("лимит импорта — 1000 серверов"); + return; + } + ServerCfg server; + string error; + if (TryParseOne(match.Value, out server, out error)) result.Servers.Add(server); + else result.Errors.Add("строка " + (i + 1) + ": " + error); + } + } + } + + static string FetchSubscription(string url) + { + try + { + HttpWebRequest request = (HttpWebRequest)WebRequest.Create(url); + if (new Uri(url).IsLoopback) request.Proxy = null; + request.Timeout = 10000; + request.ReadWriteTimeout = 10000; + request.AllowAutoRedirect = true; + request.MaximumAutomaticRedirections = 3; + request.AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate; + request.UserAgent = "SbLauncher/2"; + using (HttpWebResponse response = (HttpWebResponse)request.GetResponse()) + using (Stream stream = response.GetResponseStream()) + using (StreamReader reader = new StreamReader(stream, new UTF8Encoding(false, true), true)) + { + if (response.ContentLength > MaxInput) + throw new FormatException("подписка больше 2 МБ"); + StringBuilder body = new StringBuilder(); + char[] buffer = new char[4096]; + int count; + while ((count = reader.Read(buffer, 0, buffer.Length)) > 0) + { + body.Append(buffer, 0, count); + if (body.Length > MaxInput) throw new FormatException("подписка больше 2 МБ"); + } + return body.ToString(); + } + } + catch (FormatException) { throw; } + catch (WebException ex) { throw new FormatException("не удалось загрузить подписку (" + ex.Status + ")"); } + catch (Exception) { throw new FormatException("не удалось прочитать подписку"); } + } + + static void ParseJson(string text, ImportResult result) + { + try + { + JavaScriptSerializer ser = new JavaScriptSerializer(); + ser.MaxJsonLength = MaxInput; + object root = ser.DeserializeObject(J.StripComments(text)); + Dictionary document = root as Dictionary; + object[] outbounds = root as object[]; + if (document != null) + { + object listed; + if (document.TryGetValue("outbounds", out listed)) outbounds = listed as object[]; + else if (document.ContainsKey("type")) outbounds = new object[] { document }; + } + if (outbounds == null) { result.Errors.Add("JSON не содержит outbounds"); return; } + foreach (object item in outbounds) + { + Dictionary outbound = J.Obj(item); + if (outbound == null) continue; + string type = J.Str(outbound, "type", "").ToLowerInvariant(); + if (!Supported(type)) continue; // direct, selector, block etc. are not keys + try + { + if (result.Servers.Count >= 1000) throw new FormatException("лимит импорта — 1000 серверов"); + result.Servers.Add(FromOutbound(outbound)); + } + catch (Exception ex) { result.Errors.Add("JSON: " + ex.Message); } + } + if (result.Servers.Count == 0 && result.Errors.Count == 0) + result.Errors.Add("в JSON нет поддерживаемых прокси-серверов"); + } + catch (Exception ex) { result.Errors.Add("не удалось прочитать JSON: " + ex.Message); } + } + + public static bool TryParseOne(string text, out ServerCfg server, out string error) + { + server = null; + error = ""; + try + { + if (string.IsNullOrWhiteSpace(text)) throw new FormatException("пустой ключ"); + string raw = text.Trim(); + int schemeEnd = raw.IndexOf("://", StringComparison.Ordinal); + if (schemeEnd < 0) throw new FormatException("нет схемы протокола"); + string scheme = raw.Substring(0, schemeEnd).ToLowerInvariant(); + string body = raw.Substring(schemeEnd + 3); + if (!Supported(scheme)) throw new FormatException("протокол " + scheme + " пока не поддерживается"); + if (scheme == "vmess" && body.IndexOf('@') < 0) + server = ParseVmessJson(body); + else if (scheme == "ss") server = ParseShadowsocks(body); + else + { + Link link = ParseLink(body); + if (scheme == "vless") server = ParseVless(link); + else if (scheme == "vmess") server = ParseVmessUri(link); + else if (scheme == "trojan") server = ParseTrojan(link); + else if (scheme == "hysteria2" || scheme == "hy2") server = ParseHysteria2(link); + else if (scheme == "tuic") server = ParseTuic(link); + else if (scheme == "anytls") server = ParseAnyTls(link); + } + if (server == null) throw new FormatException("ключ не распознан"); + return true; + } + catch (Exception ex) + { + error = ex.Message; + return false; + } + } + + static bool Supported(string type) + { + return type == "vless" || type == "vmess" || type == "trojan" || + type == "ss" || type == "shadowsocks" || type == "hysteria2" || + type == "hy2" || type == "tuic" || type == "anytls"; + } + + sealed class Link + { + public string User, Host, Name; + public int Port; + public Dictionary Query; + } + + static Link ParseLink(string body) + { + string name = ""; + int hash = body.IndexOf('#'); + if (hash >= 0) + { + name = Decode(body.Substring(hash + 1)); + int metadata = name.IndexOf("?serverDescription=", StringComparison.OrdinalIgnoreCase); + if (metadata >= 0) name = name.Substring(0, metadata); + body = body.Substring(0, hash); + } + string query = ""; + int question = body.IndexOf('?'); + if (question >= 0) { query = body.Substring(question + 1); body = body.Substring(0, question); } + body = body.TrimEnd('/'); + int at = body.LastIndexOf('@'); + if (at < 1) throw new FormatException("в ключе нет учётных данных или адреса сервера"); + string user = Decode(body.Substring(0, at)); + string address = body.Substring(at + 1); + string host; + int port; + ParseAddress(address, out host, out port); + return new Link { User = user, Host = host, Port = port, + Name = name.Length > 0 ? name : host, Query = Query(query) }; + } + + static void ParseAddress(string address, out string host, out int port) + { + string digits; + if (address.StartsWith("[")) + { + int close = address.IndexOf(']'); + if (close < 0 || close + 1 >= address.Length || address[close + 1] != ':') + throw new FormatException("неверный IPv6-адрес или порт"); + host = address.Substring(1, close - 1); + digits = address.Substring(close + 2); + } + else + { + int colon = address.LastIndexOf(':'); + if (colon < 1 || address.IndexOf(':') != colon) + throw new FormatException("адрес должен иметь вид host:port (IPv6 — в квадратных скобках)"); + host = address.Substring(0, colon); + digits = address.Substring(colon + 1); + } + host = Decode(host).Trim(); + if (host.Length == 0 || host.Contains(" ")) throw new FormatException("пустой или неверный адрес сервера"); + if (!int.TryParse(digits, NumberStyles.None, CultureInfo.InvariantCulture, out port) || port < 1 || port > 65535) + throw new FormatException("порт должен быть числом от 1 до 65535"); + } + + static Dictionary Query(string text) + { + Dictionary values = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (string pair in text.Split('&')) + { + if (pair.Length == 0) continue; + int eq = pair.IndexOf('='); + string key = Decode((eq < 0 ? pair : pair.Substring(0, eq)).Replace('+', ' ')); + string value = eq < 0 ? "" : Decode(pair.Substring(eq + 1).Replace('+', ' ')); + values[key] = value; + } + return values; + } + + static string Get(Dictionary q, string key, string fallback) + { + string value; + return q.TryGetValue(key, out value) ? value : fallback; + } + + static string First(Dictionary q, string a, string b, string fallback) + { + string value = Get(q, a, ""); + return value.Length > 0 ? value : Get(q, b, fallback); + } + + static string Decode(string value) { return Uri.UnescapeDataString(value); } + + static bool TryDecodeBase64(string text, out string decoded) + { + decoded = null; + try + { + string clean = Regex.Replace(text, @"\s", "").Replace('-', '+').Replace('_', '/'); + if (clean.Length == 0 || clean.Length > MaxInput * 2) return false; + clean = clean.PadRight((clean.Length + 3) / 4 * 4, '='); + decoded = new UTF8Encoding(false, true).GetString(Convert.FromBase64String(clean)); + return true; + } + catch { return false; } + } + + static string Base64(string value) + { + string decoded; + if (!TryDecodeBase64(value, out decoded)) throw new FormatException("неверная base64-строка"); + return decoded; + } + + static Dictionary Base(string protocol, Link link) + { + Dictionary outbound = new Dictionary(); + outbound["type"] = protocol; + outbound["server"] = link.Host; + outbound["server_port"] = link.Port; + return outbound; + } + + static ServerCfg Finish(string protocol, Link link, Dictionary outbound) + { + ServerCfg server = new ServerCfg(); + server.Protocol = protocol; + server.Server = link.Host; + server.Port = link.Port; + server.Name = link.Name; + server.Code = MakeCode(server.Name); + server.Uuid = J.Str(outbound, "uuid", ""); + server.Sni = J.Str(J.Obj(outbound.ContainsKey("tls") ? outbound["tls"] : null), "server_name", ""); + server.Outbound = outbound; + return server; + } + + static string MakeCode(string name) + { + StringBuilder code = new StringBuilder(); + foreach (char c in name) + { + if (char.IsLetterOrDigit(c)) code.Append(char.ToUpperInvariant(c)); + if (code.Length == 2) break; + } + return code.Length > 0 ? code.ToString() : "??"; + } + + static void AddTls(Dictionary outbound, Link link, string mode, bool defaultEnabled) + { + string security = Get(link.Query, "security", mode).ToLowerInvariant(); + if (security.Length == 0) security = defaultEnabled ? "tls" : "none"; + if (security == "none" || security == "false") return; + if (security != "tls" && security != "reality") + throw new FormatException("режим TLS " + security + " не поддерживается"); + Dictionary tls = new Dictionary(); + tls["enabled"] = true; + string sni = First(link.Query, "sni", "peer", link.Host); + if (sni.Length > 0) tls["server_name"] = sni; + string alpn = Get(link.Query, "alpn", ""); + if (alpn.Length > 0) tls["alpn"] = alpn.Split(','); + string insecure = First(link.Query, "allowInsecure", "insecure", ""); + if (insecure == "1" || insecure.Equals("true", StringComparison.OrdinalIgnoreCase)) tls["insecure"] = true; + string fp = Get(link.Query, "fp", security == "reality" ? "chrome" : ""); + if (fp.Length > 0 && fp != "none") + tls["utls"] = new Dictionary { { "enabled", true }, { "fingerprint", fp } }; + if (security == "reality") + { + string publicKey = First(link.Query, "pbk", "publicKey", ""); + if (publicKey.Length == 0) throw new FormatException("в Reality отсутствует public key (pbk)"); + tls["reality"] = new Dictionary { + { "enabled", true }, { "public_key", publicKey }, + { "short_id", First(link.Query, "sid", "shortId", "") } + }; + } + outbound["tls"] = tls; + } + + static void AddTransport(Dictionary outbound, Dictionary q) + { + string type = First(q, "type", "net", "tcp").ToLowerInvariant(); + if (type == "" || type == "tcp" || type == "raw") + { + string header = Get(q, "headerType", "none"); + if (header.Length > 0 && header != "none") + throw new FormatException("TCP headerType=" + header + " пока не поддерживается"); + return; + } + Dictionary transport = new Dictionary(); + if (type == "ws" || type == "websocket") + { + transport["type"] = "ws"; + string path = Get(q, "path", "/"); + Match early = Regex.Match(path, @"(?:\?|&)ed=(\d+)(?:&|$)"); + if (early.Success) + { + int ed; + if (int.TryParse(early.Groups[1].Value, out ed) && ed > 0) + { + transport["max_early_data"] = ed; + transport["early_data_header_name"] = "Sec-WebSocket-Protocol"; + } + path = path.Substring(0, early.Index).TrimEnd('?','&'); + } + transport["path"] = path.Length > 0 ? path : "/"; + string host = Get(q, "host", ""); + if (host.Length > 0) + transport["headers"] = new Dictionary { { "Host", host } }; + } + else if (type == "grpc") + { + transport["type"] = "grpc"; + transport["service_name"] = First(q, "serviceName", "path", "").TrimStart('/'); + } + else if (type == "httpupgrade") + { + transport["type"] = "httpupgrade"; + transport["path"] = Get(q, "path", "/"); + string host = Get(q, "host", ""); + if (host.Length > 0) transport["host"] = host; + } + else if (type == "http" || type == "h2") + { + transport["type"] = "http"; + transport["path"] = Get(q, "path", "/"); + string host = Get(q, "host", ""); + if (host.Length > 0) transport["host"] = host.Split(','); + } + else if (type == "quic") transport["type"] = "quic"; + else throw new FormatException("транспорт " + type + " пока не поддерживается"); + outbound["transport"] = transport; + } + + static ServerCfg ParseVless(Link link) + { + Guid id; + if (!Guid.TryParse(link.User, out id)) throw new FormatException("неверный UUID VLESS"); + string encryption = Get(link.Query, "encryption", "none"); + if (encryption.Length > 0 && encryption != "none") + throw new FormatException("шифрование VLESS " + encryption + " не поддерживается"); + Dictionary outbound = Base("vless", link); + outbound["uuid"] = link.User; + string flow = Get(link.Query, "flow", ""); + if (flow.Length > 0) + { + if (flow != "xtls-rprx-vision") throw new FormatException("flow " + flow + " не поддерживается"); + outbound["flow"] = flow; + } + string security = Get(link.Query, "security", Get(link.Query, "pbk", "").Length > 0 ? "reality" : "none"); + AddTls(outbound, link, security, false); + AddTransport(outbound, link.Query); + outbound["packet_encoding"] = First(link.Query, "packetEncoding", "packet_encoding", "xudp"); + ServerCfg result = Finish("vless", link, outbound); + result.Flow = flow; + result.PublicKey = Get(link.Query, "pbk", ""); + result.ShortId = Get(link.Query, "sid", ""); + result.Fingerprint = Get(link.Query, "fp", "chrome"); + result.SpiderX = Get(link.Query, "spx", ""); + return result; + } + + static ServerCfg ParseVmessJson(string body) + { + string decoded = Base64(body.Split('#')[0]); + Dictionary data = J.Parse(decoded); + if (data == null) throw new FormatException("VMess base64 не содержит JSON-объект"); + string address = J.Str(data, "add", ""); + int port = J.Int(data, "port", 0); + if (address.Length == 0 || port < 1 || port > 65535) throw new FormatException("VMess: неверный адрес или порт"); + Link link = new Link(); + link.User = J.Str(data, "id", ""); + link.Host = address; + link.Port = port; + link.Name = J.Str(data, "ps", address); + link.Query = new Dictionary(StringComparer.OrdinalIgnoreCase); + link.Query["type"] = J.Str(data, "net", "tcp"); + link.Query["path"] = J.Str(data, "path", ""); + link.Query["host"] = J.Str(data, "host", ""); + link.Query["sni"] = J.Str(data, "sni", ""); + link.Query["fp"] = J.Str(data, "fp", ""); + link.Query["alpn"] = J.Str(data, "alpn", ""); + string vmessTls = J.Str(data, "tls", "").ToLowerInvariant(); + if (vmessTls != "" && vmessTls != "tls" && vmessTls != "none") + throw new FormatException("VMess: режим TLS " + vmessTls + " не поддерживается"); + link.Query["security"] = vmessTls == "tls" ? "tls" : "none"; + link.Query["headerType"] = J.Str(data, "type", "none"); + link.Query["vmess_security"] = J.Str(data, "scy", "auto"); + link.Query["alter_id"] = J.Str(data, "aid", "0"); + return ParseVmessUri(link); + } + + static ServerCfg ParseVmessUri(Link link) + { + Guid id; + if (!Guid.TryParse(link.User, out id)) throw new FormatException("неверный UUID VMess"); + Dictionary outbound = Base("vmess", link); + outbound["uuid"] = link.User; + outbound["security"] = First(link.Query, "vmess_security", "encryption", "auto"); + int aid; + if (!int.TryParse(First(link.Query, "alter_id", "aid", "0"), out aid) || aid < 0) + throw new FormatException("неверный alter_id VMess"); + outbound["alter_id"] = aid; + AddTls(outbound, link, Get(link.Query, "security", "none"), false); + AddTransport(outbound, link.Query); + return Finish("vmess", link, outbound); + } + + static ServerCfg ParseTrojan(Link link) + { + if (link.User.Length == 0) throw new FormatException("пустой пароль Trojan"); + Dictionary outbound = Base("trojan", link); + outbound["password"] = link.User; + AddTls(outbound, link, Get(link.Query, "security", "tls"), true); + AddTransport(outbound, link.Query); + return Finish("trojan", link, outbound); + } + + static ServerCfg ParseShadowsocks(string body) + { + string original = body; + int hash = body.IndexOf('#'); + string name = ""; + if (hash >= 0) { name = Decode(body.Substring(hash + 1)); body = body.Substring(0, hash); } + int queryAt = body.IndexOf('?'); + string query = ""; + if (queryAt >= 0) { query = body.Substring(queryAt + 1); body = body.Substring(0, queryAt); } + body = body.TrimEnd('/'); + if (body.IndexOf('@') < 0) body = Base64(body); + int at = body.LastIndexOf('@'); + if (at < 1) throw new FormatException("Shadowsocks: нет адреса или пароля"); + string user = body.Substring(0, at); + if (user.IndexOf(':') < 0) user = Base64(user); + else user = Decode(user); + int colon = user.IndexOf(':'); + if (colon < 1 || colon == user.Length - 1) throw new FormatException("Shadowsocks: ожидается method:password"); + string method = user.Substring(0, colon); + string password = user.Substring(colon + 1); + string host; int port; + ParseAddress(body.Substring(at + 1), out host, out port); + Link link = new Link { User = password, Host = host, Port = port, + Name = name.Length > 0 ? name : host, Query = Query(query) }; + Dictionary outbound = Base("shadowsocks", link); + outbound["method"] = method; + outbound["password"] = password; + string plugin = Get(link.Query, "plugin", ""); + if (plugin.Length > 0) + { + string[] parts = plugin.Split(new char[] {';'}, 2); + if (parts[0] != "obfs-local" && parts[0] != "v2ray-plugin") + throw new FormatException("плагин Shadowsocks " + parts[0] + " не поддерживается sing-box"); + outbound["plugin"] = parts[0]; + if (parts.Length > 1) outbound["plugin_opts"] = parts[1]; + } + return Finish("shadowsocks", link, outbound); + } + + static ServerCfg ParseHysteria2(Link link) + { + if (link.User.Length == 0) throw new FormatException("пустой пароль Hysteria2"); + Dictionary outbound = Base("hysteria2", link); + outbound["password"] = link.User; + Dictionary tls = new Dictionary { { "enabled", true } }; + tls["server_name"] = Get(link.Query, "sni", link.Host); + string alpn = Get(link.Query, "alpn", ""); + if (alpn.Length > 0) tls["alpn"] = alpn.Split(','); + string insecure = Get(link.Query, "insecure", ""); + if (insecure == "1" || insecure.Equals("true", StringComparison.OrdinalIgnoreCase)) tls["insecure"] = true; + outbound["tls"] = tls; + string obfs = Get(link.Query, "obfs", ""); + if (obfs.Length > 0) + { + if (obfs != "salamander") throw new FormatException("обфускация Hysteria2 " + obfs + " не поддерживается этой версией sing-box"); + string secret = First(link.Query, "obfs-password", "obfsPassword", ""); + if (secret.Length == 0) throw new FormatException("нет obfs-password Hysteria2"); + outbound["obfs"] = new Dictionary { { "type", "salamander" }, { "password", secret } }; + } + string ports = Get(link.Query, "mport", ""); + if (ports.Length > 0) + { + outbound.Remove("server_port"); + outbound["server_ports"] = ports.Replace('-', ':').Split(','); + } + string up = Get(link.Query, "upmbps", ""), down = Get(link.Query, "downmbps", ""); + int value; + if (int.TryParse(up, out value) && value > 0) outbound["up_mbps"] = value; + if (int.TryParse(down, out value) && value > 0) outbound["down_mbps"] = value; + return Finish("hysteria2", link, outbound); + } + + static ServerCfg ParseTuic(Link link) + { + int colon = link.User.IndexOf(':'); + if (colon < 1 || colon == link.User.Length - 1) throw new FormatException("TUIC: ожидается UUID:пароль"); + string uuid = link.User.Substring(0, colon), password = link.User.Substring(colon + 1); + Guid id; + if (!Guid.TryParse(uuid, out id)) throw new FormatException("неверный UUID TUIC"); + Dictionary outbound = Base("tuic", link); + outbound["uuid"] = uuid; + outbound["password"] = password; + string cc = Get(link.Query, "congestion_control", ""); + if (cc.Length > 0) outbound["congestion_control"] = cc; + string relay = Get(link.Query, "udp_relay_mode", ""); + if (relay.Length > 0) outbound["udp_relay_mode"] = relay; + Dictionary tls = new Dictionary { { "enabled", true } }; + tls["server_name"] = Get(link.Query, "sni", link.Host); + string alpn = Get(link.Query, "alpn", ""); + if (alpn.Length > 0) tls["alpn"] = alpn.Split(','); + string insecure = Get(link.Query, "insecure", ""); + if (insecure == "1" || insecure.Equals("true", StringComparison.OrdinalIgnoreCase)) tls["insecure"] = true; + outbound["tls"] = tls; + return Finish("tuic", link, outbound); + } + + static ServerCfg ParseAnyTls(Link link) + { + if (link.User.Length == 0) throw new FormatException("пустой пароль AnyTLS"); + Dictionary outbound = Base("anytls", link); + outbound["password"] = link.User; + AddTls(outbound, link, "tls", true); + if (!outbound.ContainsKey("tls")) throw new FormatException("AnyTLS требует TLS"); + return Finish("anytls", link, outbound); + } + + static ServerCfg FromOutbound(Dictionary input) + { + Dictionary outbound = J.Parse(J.ToJson(input)); + string type = J.Str(outbound, "type", "").ToLowerInvariant(); + if (type == "hy2") type = "hysteria2"; + if (type == "ss") type = "shadowsocks"; + string host = J.Str(outbound, "server", ""); + int port = J.Int(outbound, "server_port", 0); + if (host.Length == 0 || port < 1 || port > 65535) + throw new FormatException("outbound " + type + ": неверный server/server_port"); + string auth = type == "vless" || type == "vmess" || type == "tuic" ? + J.Str(outbound, "uuid", "") : J.Str(outbound, "password", ""); + if (auth.Length == 0) throw new FormatException("outbound " + type + ": нет UUID или пароля"); + string name = J.Str(outbound, "tag", host); + outbound["type"] = type; + outbound.Remove("tag"); + outbound.Remove("detour"); + Link link = new Link { Host = host, Port = port, Name = name }; + return Finish(type, link, outbound); + } + } + + public static class ProxyOutbound + { + public static Dictionary Build(ServerCfg server, string mode) + { + Dictionary outbound; + if (server.Outbound != null) outbound = J.Parse(J.ToJson(server.Outbound)); + else + { + outbound = new Dictionary(); + outbound["type"] = "vless"; + outbound["server"] = server.Server; + outbound["server_port"] = server.Port; + outbound["uuid"] = server.Uuid; + if (!string.IsNullOrEmpty(server.Flow)) outbound["flow"] = server.Flow; + outbound["tls"] = new Dictionary { + { "enabled", true }, { "server_name", server.Sni }, + { "utls", new Dictionary { { "enabled", true }, { "fingerprint", server.Fingerprint } } }, + { "reality", new Dictionary { { "enabled", true }, + { "public_key", server.PublicKey }, { "short_id", server.ShortId } } } + }; + outbound["packet_encoding"] = "xudp"; + outbound["domain_strategy"] = "ipv4_only"; + } + outbound["tag"] = "proxy-vless"; // shared route tag, independent of protocol + outbound["detour"] = mode == ConfigBuilder.ModeWg ? "proxy-wireguard" : "bound"; + return outbound; + } + } +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..9a8b5d9 --- /dev/null +++ b/README.md @@ -0,0 +1,23 @@ +# sb launcher · glass preview + +Отдельный тестовый репозиторий интерфейса для Windows 11. Рабочий `sb_launcher_rev2` не меняется. Это функционирующий лаунчер на WPF/.NET Framework 4.x: выбор и импорт серверов, проверка конфигурации, запуск и остановка sing-box, логи и настройки остались на месте. + +Дизайн использует полупрозрачные панели, мягкие градиенты и логотип «щит с арбузом». При подключении кольцо вокруг щита вращается по часовой стрелке; при отключении — обратно. Успешное соединение отмечает короткий световой импульс. Анимации меняют только поворот и прозрачность, без непрерывного размытия фона. + +## Запуск + +В папке уже лежат локальные `profiles.json`, `bin/sb.exe` и `doc/doc.srs` для теста; они не входят в Git. `local.json` создан с пустым приватным ключом. Заполните ключ и локальный адрес на вкладке «Настройки», затем в PowerShell: + +```powershell +cd F:\gitea\sb_launcher_glass +cmd /c build.cmd +.\SbLauncher.exe +``` + +Сборка использует `csc.exe` из установленного .NET Framework 4.x. Запуск основного exe запросит права администратора: они нужны sing-box для сетевых маршрутов. Для проверки интерфейса без запуска сети: `cmd /c build.cmd stress`. + +Для чистого клона скопируйте `profiles.example.json` в `profiles.json` и `local.example.json` в `local.json`, затем добавьте реальные серверы и ключ. Примерные адреса из `profiles.example.json` служат только для демонстрации. Профили и локальные настройки исключены из Git, поскольку могут содержать учётные данные. + +`build.cmd deploy` принимает явный путь назначения; папка рабочего лаунчера никогда не выбирается по умолчанию. + +Офлайн-превью с демонстрационными адресами: [светлая тема](preview/glass-light.png) и [тёмная тема](preview/glass-dark.png). diff --git a/SbLauncher.cs b/SbLauncher.cs new file mode 100644 index 0000000..3353056 --- /dev/null +++ b/SbLauncher.cs @@ -0,0 +1,934 @@ +// SbLauncher — GUI для sing-box. +// +// ПРИНЦИП (изменён по сравнению с прошлой версией): +// Источник правды — profiles.json (серверы, общее) и local.json (ключ, +// адрес, адаптер — своё на каждой машине). Конфиг sing-box НЕ хранится: +// он собирается из шаблона templates\*.json при каждом запуске и кладётся +// в run\config.json. Поэтому папок conf_* больше нет, и править один и +// тот же private_key в пяти файлах не требуется. +// +// ПОРТАТИВНОСТЬ: всё ищется рядом с exe. Скопировали папку — работает. +// +// Компилятор — встроенный в Windows csc.exe (C# 5). Поэтому здесь нет +// интерполяции строк ($""), ?. и expression-bodied членов. См. build.cmd. + +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Collections.Concurrent; +using System.ComponentModel; +using System.Diagnostics; +using System.Globalization; +using System.IO; +using System.Net; +using System.Net.NetworkInformation; +using System.Net.Sockets; +using System.Reflection; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; +using System.Text; +using System.Text.RegularExpressions; +using System.Threading; +using System.Threading.Tasks; +using System.Web.Script.Serialization; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Input; +using System.Windows.Markup; +using System.Windows.Media; +using System.Windows.Media.Imaging; +using System.Windows.Threading; + +namespace SbLauncher +{ + // ═════════════════════════════════════════════════════════════════════ + // JSON + // + // Свои файлы (profiles.json / local.json) содержат комментарии //, + // поэтому перед разбором их снимаем. JavaScriptSerializer входит в + // .NET Framework (System.Web.Extensions.dll) — внешних библиотек нет. + // ═════════════════════════════════════════════════════════════════════ + public static class J + { + public static Dictionary Parse(string text) + { + JavaScriptSerializer ser = new JavaScriptSerializer(); + ser.MaxJsonLength = int.MaxValue; + return ser.DeserializeObject(StripComments(text)) as Dictionary; + } + + public static Dictionary Obj(object o) + { + return o as Dictionary; + } + + public static List> Arr(object o) + { + List> list = new List>(); + object[] a = o as object[]; + if (a == null) return list; + foreach (object it in a) + { + Dictionary d = it as Dictionary; + if (d != null) list.Add(d); + } + return list; + } + + public static string Str(Dictionary d, string key, string def) + { + object v; + if (d == null || !d.TryGetValue(key, out v) || v == null) return def; + return Convert.ToString(v, CultureInfo.InvariantCulture); + } + + public static int Int(Dictionary d, string key, int def) + { + object v; + if (d == null || !d.TryGetValue(key, out v) || v == null) return def; + try { return Convert.ToInt32(v, CultureInfo.InvariantCulture); } + catch { return def; } + } + + // Экранирование значения, которое подставляется ВНУТРЬ строки JSON. + // Обязательно: ключ или SNI с кавычкой иначе сломают весь файл. + public static string Esc(string s) + { + if (s == null) return ""; + StringBuilder sb = new StringBuilder(s.Length + 8); + foreach (char c in s) + { + switch (c) + { + case '"': sb.Append("\\\""); break; + case '\\': sb.Append("\\\\"); break; + case '\b': sb.Append("\\b"); break; + case '\f': sb.Append("\\f"); break; + case '\n': sb.Append("\\n"); break; + case '\r': sb.Append("\\r"); break; + case '\t': sb.Append("\\t"); break; + default: + if (c < ' ') sb.Append("\\u").Append(((int)c).ToString("x4", CultureInfo.InvariantCulture)); + else sb.Append(c); + break; + } + } + return sb.ToString(); + } + + // Снимает // и /* */ с учётом строковых литералов: внутри кавычек + // слэши не трогаем, иначе разъедется "https://..." и base64-ключ. + public static string StripComments(string s) + { + StringBuilder sb = new StringBuilder(s.Length); + bool inStr = false, esc = false; + for (int i = 0; i < s.Length; i++) + { + char c = s[i]; + if (inStr) + { + sb.Append(c); + if (esc) esc = false; + else if (c == '\\') esc = true; + else if (c == '"') inStr = false; + continue; + } + if (c == '"') { inStr = true; sb.Append(c); continue; } + if (c == '/' && i + 1 < s.Length && s[i + 1] == '/') + { + while (i < s.Length && s[i] != '\n') i++; + if (i < s.Length) sb.Append('\n'); + continue; + } + if (c == '/' && i + 1 < s.Length && s[i + 1] == '*') + { + i += 2; + while (i + 1 < s.Length && !(s[i] == '*' && s[i + 1] == '/')) i++; + i++; + continue; + } + sb.Append(c); + } + return sb.ToString(); + } + + public static string ToJson(object value) + { + JavaScriptSerializer ser = new JavaScriptSerializer(); + ser.MaxJsonLength = int.MaxValue; + return ser.Serialize(value); + } + + public static readonly Encoding Utf8NoBom = new UTF8Encoding(false); + + public static void Write(string path, string text) + { + string dir = Path.GetDirectoryName(path); + if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir); + // A failed write must never leave half of a settings/config file. + string temp = path + ".tmp"; + File.WriteAllText(temp, text, Utf8NoBom); + try + { + if (File.Exists(path)) File.Replace(temp, path, null); + else File.Move(temp, path); + } + finally { if (File.Exists(temp)) File.Delete(temp); } + } + } + + // ═════════════════════════════════════════════════════════════════════ + // ПУТИ. Всё относительно папки с exe — это и есть портативность. + // ═════════════════════════════════════════════════════════════════════ + public class Paths + { + public string Root; + + public Paths(string root) { Root = root; } + + public string Profiles { get { return Path.Combine(Root, "profiles.json"); } } + public string Local { get { return Path.Combine(Root, "local.json"); } } + public string TemplatesDir { get { return Path.Combine(Root, "templates"); } } + public string SbExe { get { return Path.Combine(Root, Path.Combine("bin", "sb.exe")); } } + public string RuleSet { get { return Path.Combine(Root, Path.Combine("doc", "doc.srs")); } } + public string RunDir { get { return Path.Combine(Root, "run"); } } + public string RunConfig { get { return Path.Combine(RunDir, "config.json"); } } + + // sing-box на Windows понимает прямые слэши, а обратные пришлось бы + // экранировать в JSON. Поэтому путь к rule-set отдаём через "/". + public string RuleSetJson { get { return RuleSet.Replace('\\', '/'); } } + } + + // ═════════════════════════════════════════════════════════════════════ + // МОДЕЛЬ + // ═════════════════════════════════════════════════════════════════════ + public class ServerCfg + { + public string Code = ""; + public string Name = ""; + public string Server = ""; + public int Port = 443; + public string Uuid = ""; + public string Sni = ""; + public string Fingerprint = "chrome"; + public string PublicKey = ""; + public string ShortId = ""; + public string Flow = ""; // xtls-rprx-vision или пусто (без flow) + public string SpiderX = ""; // старые профили сохраняют это поле + public string Protocol = "vless"; + public Dictionary Outbound; // точная конфигурация импортированного ключа + + public string Endpoint + { + get { return Server + ":" + Port.ToString(CultureInfo.InvariantCulture); } + } + } + + public class WgCfg + { + public string Server = ""; + public int Port = 51820; + public string PeerPublicKey = ""; + public int Mtu = 1280; + } + + // profiles.json — локальная копия серверов; в тестовом репозитории не коммитится + public class Profiles + { + public WgCfg Wg = new WgCfg(); + public List Servers = new List(); + + public static Profiles Load(string path) + { + Profiles p = new Profiles(); + if (!File.Exists(path)) return p; + + Dictionary root = J.Parse(File.ReadAllText(path, Encoding.UTF8)); + if (root == null) return p; + + object wo; + if (root.TryGetValue("wireguard", out wo)) + { + Dictionary w = J.Obj(wo); + if (w != null) + { + p.Wg.Server = J.Str(w, "server", ""); + p.Wg.Port = J.Int(w, "port", 51820); + p.Wg.PeerPublicKey = J.Str(w, "peer_public_key", ""); + p.Wg.Mtu = J.Int(w, "mtu", 1280); + } + } + + object so; + if (root.TryGetValue("servers", out so)) + foreach (Dictionary d in J.Arr(so)) + { + ServerCfg s = new ServerCfg(); + s.Code = J.Str(d, "code", ""); + s.Name = J.Str(d, "name", s.Code); + s.Server = J.Str(d, "server", ""); + s.Port = J.Int(d, "port", 443); + s.Uuid = J.Str(d, "uuid", ""); + s.Sni = J.Str(d, "sni", ""); + s.Fingerprint = J.Str(d, "fingerprint", "chrome"); + s.PublicKey = J.Str(d, "public_key", ""); + s.ShortId = J.Str(d, "short_id", ""); + s.Flow = J.Str(d, "flow", "xtls-rprx-vision"); + s.SpiderX = J.Str(d, "spider_x", ""); + s.Protocol = J.Str(d, "protocol", "vless"); + object outbound; + if (d.TryGetValue("outbound", out outbound)) s.Outbound = J.Obj(outbound); + if (s.Outbound != null) s.Protocol = J.Str(s.Outbound, "type", s.Protocol); + if (s.Server.Length > 0) p.Servers.Add(s); + } + return p; + } + + // Пишется только когда сервер добавили/удалили из GUI. Файл + // пересобирается целиком, поэтому свои комментарии ВНУТРИ списка + // серверов при этом теряются — шапка восстанавливается. + public void Save(string path) + { + StringBuilder sb = new StringBuilder(); + sb.AppendLine("// Серверы и общие параметры этого тестового экземпляра."); + sb.AppendLine("// Храните данные серверов локально, а ключ WireGuard — в local.json."); + sb.AppendLine("{"); + sb.AppendLine(" \"wireguard\": {"); + sb.AppendLine(" \"server\": \"" + J.Esc(Wg.Server) + "\","); + sb.AppendLine(" \"port\": " + Wg.Port.ToString(CultureInfo.InvariantCulture) + ","); + sb.AppendLine(" \"peer_public_key\": \"" + J.Esc(Wg.PeerPublicKey) + "\","); + sb.AppendLine(" \"mtu\": " + Wg.Mtu.ToString(CultureInfo.InvariantCulture)); + sb.AppendLine(" },"); + sb.AppendLine(" \"servers\": ["); + for (int i = 0; i < Servers.Count; i++) + { + ServerCfg s = Servers[i]; + sb.AppendLine(" {"); + sb.AppendLine(" \"code\": \"" + J.Esc(s.Code) + "\","); + sb.AppendLine(" \"name\": \"" + J.Esc(s.Name) + "\","); + sb.AppendLine(" \"server\": \"" + J.Esc(s.Server) + "\","); + sb.AppendLine(" \"port\": " + s.Port.ToString(CultureInfo.InvariantCulture) + ","); + sb.AppendLine(" \"uuid\": \"" + J.Esc(s.Uuid) + "\","); + sb.AppendLine(" \"sni\": \"" + J.Esc(s.Sni) + "\","); + sb.AppendLine(" \"fingerprint\": \"" + J.Esc(s.Fingerprint) + "\","); + sb.AppendLine(" \"public_key\": \"" + J.Esc(s.PublicKey) + "\","); + // flow пишем ВСЕГДА (даже пустой), чтобы отличать «flow не задан» + // от «flow по умолчанию» при загрузке (обратная совместимость) + sb.AppendLine(" \"flow\": \"" + J.Esc(s.Flow) + "\","); + // spider_x пишем только когда задан + if (!string.IsNullOrEmpty(s.SpiderX)) + sb.AppendLine(" \"spider_x\": \"" + J.Esc(s.SpiderX) + "\","); + sb.AppendLine(" \"short_id\": \"" + J.Esc(s.ShortId) + "\","); + sb.AppendLine(" \"protocol\": \"" + J.Esc(s.Protocol) + "\"" + (s.Outbound != null ? "," : "")); + if (s.Outbound != null) + sb.AppendLine(" \"outbound\": " + J.ToJson(s.Outbound)); + sb.AppendLine(i == Servers.Count - 1 ? " }" : " },"); + } + sb.AppendLine(" ]"); + sb.AppendLine("}"); + J.Write(path, sb.ToString()); + } + } + + // local.json — параметры ЭТОЙ машины, в .gitignore + public class LocalCfg + { + public string PrivateKey = ""; + public string LocalAddress = ""; + public string Adapter = ""; + public string Theme = ""; // пусто -> берём тему Windows + public string Mode = "wg"; // wg | direct | wgonly + public string LastServer = ""; + + public static LocalCfg Load(string path) + { + LocalCfg c = new LocalCfg(); + if (!File.Exists(path)) return c; + Dictionary d = J.Parse(File.ReadAllText(path, Encoding.UTF8)); + if (d == null) return c; + c.PrivateKey = J.Str(d, "private_key", ""); + c.LocalAddress = J.Str(d, "local_address", ""); + c.Adapter = J.Str(d, "adapter", ""); + c.Theme = J.Str(d, "theme", ""); + c.Mode = J.Str(d, "mode", "wg"); + c.LastServer = J.Str(d, "last_server", ""); + return c; + } + + public void Save(string path) + { + StringBuilder sb = new StringBuilder(); + sb.AppendLine("// Параметры ЭТОЙ машины. В .gitignore — здесь приватный ключ."); + sb.AppendLine("// Правится в GUI, руками лезть сюда не нужно."); + sb.AppendLine("{"); + sb.AppendLine(" \"private_key\": \"" + J.Esc(PrivateKey) + "\","); + sb.AppendLine(" \"local_address\": \"" + J.Esc(LocalAddress) + "\","); + sb.AppendLine(" \"adapter\": \"" + J.Esc(Adapter) + "\","); + sb.AppendLine(" \"theme\": \"" + J.Esc(Theme) + "\","); + sb.AppendLine(" \"mode\": \"" + J.Esc(Mode) + "\","); + sb.AppendLine(" \"last_server\": \"" + J.Esc(LastServer) + "\""); + sb.AppendLine("}"); + J.Write(path, sb.ToString()); + } + } + + // ═════════════════════════════════════════════════════════════════════ + // СЕТЕВЫЕ АДАПТЕРЫ + // + // bind_interface в sing-box хочет ИМЯ подключения из «Сетевых + // подключений», а не описание устройства. У Windows по умолчанию это + // «Wi-Fi» с дефисом, но адаптер часто переименован — из-за этого + // конфиг с зашитым "WiFi" на другом ПК молча не работал. + // ═════════════════════════════════════════════════════════════════════ + public class AdapterInfo + { + public string Name = ""; + public string Description = ""; + public bool IsUp; + } + + public static class Adapters + { + public static List List() + { + List list = new List(); + try + { + foreach (NetworkInterface ni in NetworkInterface.GetAllNetworkInterfaces()) + { + if (ni.NetworkInterfaceType == NetworkInterfaceType.Loopback) continue; + if (ni.NetworkInterfaceType == NetworkInterfaceType.Tunnel) continue; + // свой же TUN: привязываться к нему бессмысленно + if (ni.Name == "sing-box-tun") continue; + + AdapterInfo a = new AdapterInfo(); + a.Name = ni.Name; + a.Description = ni.Description; + a.IsUp = ni.OperationalStatus == OperationalStatus.Up; + list.Add(a); + } + } + catch { } + // сначала живые: обычно нужен именно тот, через который есть сеть + list.Sort(delegate(AdapterInfo x, AdapterInfo y) + { + if (x.IsUp != y.IsUp) return x.IsUp ? -1 : 1; + return string.Compare(x.Name, y.Name, StringComparison.OrdinalIgnoreCase); + }); + return list; + } + + public static AdapterInfo Find(string name) + { + if (string.IsNullOrEmpty(name)) return null; + foreach (AdapterInfo a in List()) + if (string.Equals(a.Name, name, StringComparison.OrdinalIgnoreCase)) return a; + return null; + } + } + + // ═════════════════════════════════════════════════════════════════════ + // СБОРКА КОНФИГА ИЗ ШАБЛОНА + // + // Числовые токены записаны в шаблоне в кавычках ("@@WG_PORT@@"), чтобы + // шаблон оставался валидным JSON и его можно было открыть в редакторе. + // При подстановке кавычки снимаются вместе с токеном. + // ═════════════════════════════════════════════════════════════════════ + public class BuildResult + { + public bool Ok; + public string Error; + public string ConfigPath; + } + + public static class ConfigBuilder + { + public const string ModeWg = "wg"; + public const string ModeDirect = "direct"; + public const string ModeWgOnly = "wgonly"; + + public static bool UsesWireguard(string mode) + { + return mode == ModeWg || mode == ModeWgOnly; + } + public static bool UsesProxy(string mode) + { + return mode == ModeWg || mode == ModeDirect; + } + + public static string ModeTitle(string mode) + { + if (mode == ModeDirect) return "напрямую через адаптер"; + if (mode == ModeWgOnly) return "только WireGuard"; + return "через WireGuard"; + } + + // Файл templates\.json рядом с exe перекрывает встроенный — + // тот же приём, что с Ui.xaml: правка шаблона без пересборки. + public static string LoadTemplate(Paths paths, string mode) + { + string ext = Path.Combine(paths.TemplatesDir, mode + ".json"); + if (File.Exists(ext)) return File.ReadAllText(ext, Encoding.UTF8); + + using (Stream s = Assembly.GetExecutingAssembly().GetManifestResourceStream(mode + ".json")) + { + if (s == null) throw new FileNotFoundException("нет шаблона " + mode + ".json (ни рядом с exe, ни внутри)"); + using (StreamReader r = new StreamReader(s, Encoding.UTF8)) + return r.ReadToEnd(); + } + } + + // WireGuard-ключ — 32 байта в base64. Проверка ловит обрезанную + // вставку из буфера, которую иначе увидел бы только sing-box. + public static bool IsWgKey(string s) + { + if (string.IsNullOrEmpty(s)) return false; + try { return Convert.FromBase64String(s.Trim()).Length == 32; } + catch { return false; } + } + + public static bool IsCidr(string s) + { + if (string.IsNullOrEmpty(s)) return false; + string[] parts = s.Trim().Split('/'); + if (parts.Length != 2) return false; + IPAddress ip; + if (!IPAddress.TryParse(parts[0], out ip)) return false; + int bits; + if (!int.TryParse(parts[1], NumberStyles.Integer, CultureInfo.InvariantCulture, out bits)) return false; + int max = ip.AddressFamily == AddressFamily.InterNetworkV6 ? 128 : 32; + return bits >= 0 && bits <= max; + } + + public static List Validate(Paths paths, Profiles pr, LocalCfg lc, ServerCfg srv, string mode) + { + List bad = new List(); + + if (UsesWireguard(mode)) + { + if (!IsWgKey(lc.PrivateKey)) + bad.Add("приватный ключ WireGuard пуст или не 32 байта base64"); + if (!IsCidr(lc.LocalAddress)) + bad.Add("локальный IP должен быть вида 10.10.0.19/24"); + if (string.IsNullOrEmpty(pr.Wg.Server)) + bad.Add("не задан адрес WireGuard-сервера в profiles.json"); + if (!IsWgKey(pr.Wg.PeerPublicKey)) + bad.Add("peer_public_key в profiles.json пуст или не 32 байта base64"); + } + + if (mode == ModeDirect) + { + if (string.IsNullOrEmpty(lc.Adapter)) + bad.Add("не выбран сетевой адаптер"); + else if (Adapters.Find(lc.Adapter) == null) + bad.Add("адаптера \"" + lc.Adapter + "\" нет в системе"); + } + + if (UsesProxy(mode)) + { + if (srv == null) bad.Add("не выбран сервер"); + else + { + if (string.IsNullOrEmpty(srv.Server)) bad.Add("у сервера не задан адрес"); + if (srv.Port < 1 || srv.Port > 65535) bad.Add("у сервера некорректный порт"); + if (srv.Outbound == null && string.IsNullOrEmpty(srv.Uuid)) + bad.Add("у сервера не задан uuid"); + } + } + + if (!File.Exists(paths.RuleSet)) + bad.Add("не найден " + paths.RuleSet + " (geoip-ru)"); + if (!File.Exists(paths.SbExe)) + bad.Add("не найден " + paths.SbExe); + + return bad; + } + + public static string Render(string tpl, + Dictionary strings, + Dictionary numbers) + { + // числовые — первыми: они забирают кавычки вокруг токена + foreach (KeyValuePair kv in numbers) + tpl = tpl.Replace("\"@@" + kv.Key + "@@\"", + kv.Value.ToString(CultureInfo.InvariantCulture)); + + foreach (KeyValuePair kv in strings) + tpl = tpl.Replace("@@" + kv.Key + "@@", J.Esc(kv.Value)); + + // Незакрытый токен — это опечатка в шаблоне. Молча отдать такой + // конфиг sing-box'у нельзя: ошибка будет невнятной. + Match m = Regex.Match(tpl, "@@[A-Za-z0-9_]+@@"); + if (m.Success) + throw new InvalidOperationException("в шаблоне остался неподставленный токен " + m.Value); + + return tpl; + } + + public static BuildResult Build(Paths paths, Profiles pr, LocalCfg lc, ServerCfg srv, string mode) + { + return Build(paths, pr, lc, srv, mode, null); + } + + // outPath задаётся только в --selftest: там нужны все три режима + // рядом, а не один перезаписанный run\config.json. + public static BuildResult Build(Paths paths, Profiles pr, LocalCfg lc, ServerCfg srv, + string mode, string outPath) + { + BuildResult r = new BuildResult(); + try + { + List bad = Validate(paths, pr, lc, srv, mode); + if (bad.Count > 0) + { + r.Error = string.Join("; ", bad.ToArray()); + return r; + } + + Dictionary s = new Dictionary(); + Dictionary n = new Dictionary(); + + s["RULESET_PATH"] = paths.RuleSetJson; + + if (UsesWireguard(mode)) + { + s["WG_SERVER"] = pr.Wg.Server; + s["WG_LOCAL_ADDRESS"] = lc.LocalAddress.Trim(); + s["WG_PRIVATE_KEY"] = lc.PrivateKey.Trim(); + s["WG_PEER_PUBLIC_KEY"] = pr.Wg.PeerPublicKey.Trim(); + n["WG_PORT"] = pr.Wg.Port; + n["WG_MTU"] = pr.Wg.Mtu; + } + if (mode == ModeDirect) + s["ADAPTER"] = lc.Adapter; + + string tpl = LoadTemplate(paths, mode); + if (UsesProxy(mode)) + { + const string marker = "\"@@PROXY_OUTBOUND@@\""; + if (!tpl.Contains(marker)) + throw new InvalidOperationException("шаблон " + mode + ".json устарел: нет @@PROXY_OUTBOUND@@"); + tpl = tpl.Replace(marker, J.ToJson(ProxyOutbound.Build(srv, mode))); + } + + string body = Render(tpl, s, n); + + string head = + "// СГЕНЕРИРОВАНО SbLauncher — правки здесь затрутся." + Environment.NewLine + + "// Режим: " + mode + " (" + ModeTitle(mode) + ")" + + (UsesProxy(mode) ? ", сервер: " + srv.Code + " " + srv.Endpoint : "") + + Environment.NewLine + + "// Правьте templates\\" + mode + ".json, profiles.json или local.json." + + Environment.NewLine; + + string dest = string.IsNullOrEmpty(outPath) ? paths.RunConfig : outPath; + J.Write(dest, head + body); + r.Ok = true; + r.ConfigPath = dest; + return r; + } + catch (Exception ex) + { + r.Error = ex.Message; + return r; + } + } + } + + // Старое имя остаётся для внешних сценариев и selftest. + public static class VlessLink + { + public static ServerCfg Parse(string link) + { + ServerCfg server; + string error; + return KeyImporter.TryParseOne(link, out server, out error) && + server.Protocol == "vless" ? server : null; + } + } + + // ═════════════════════════════════════════════════════════════════════ + // ЗАПУСК sing-box + Job Object + // + // ВАЖНО: при auto_route/strict_route осиротевший sing-box оставляет + // систему со сломанным маршрутом по умолчанию. Job Object гарантирует, + // что дочерний процесс умрёт вместе с лаунчером — даже если тот убит + // через Диспетчер задач. + // ═════════════════════════════════════════════════════════════════════ + public class SingBoxRunner + { + public event Action Log; + public event Action StateChanged; + + Process _proc; + IntPtr _job = IntPtr.Zero; + + public bool IsRunning + { + get + { + Process p = _proc; + if (p == null) return false; + try { return !p.HasExited; } + catch (InvalidOperationException) { return false; } + } + } + + static void ApplyEnv(ProcessStartInfo psi) + { + // те же переменные, что были в startN.bat: конфиги пользуются + // wireguard-outbound'ом, который в sing-box 1.12 объявлен + // устаревшим и без этого флага не поднимается + psi.EnvironmentVariables["ENABLE_DEPRECATED_WIREGUARD_OUTBOUND"] = "true"; + psi.EnvironmentVariables["ENABLE_DEPRECATED_TUN_ADDRESS_X"] = "true"; + psi.EnvironmentVariables["ENABLE_DEPRECATED_SPECIAL_OUTBOUNDS"] = "true"; + } + + // sb.exe check — синхронно, до запуска. Ошибку конфига лучше увидеть + // в логе лаунчера, чем ловить её сломанными маршрутами. + public static bool Check(string sbExe, string cfgPath, string workDir, out string output) + { + output = ""; + StringBuilder sb = new StringBuilder(); + try + { + ProcessStartInfo psi = new ProcessStartInfo(); + psi.FileName = sbExe; + psi.Arguments = "check -c \"" + cfgPath + "\""; + psi.WorkingDirectory = workDir; + psi.UseShellExecute = false; + psi.CreateNoWindow = true; + psi.RedirectStandardOutput = true; + psi.RedirectStandardError = true; + psi.StandardOutputEncoding = Encoding.UTF8; + psi.StandardErrorEncoding = Encoding.UTF8; + ApplyEnv(psi); + + using (Process p = new Process()) + { + p.StartInfo = psi; + DataReceivedEventHandler grab = delegate(object s, DataReceivedEventArgs e) + { + if (e.Data != null) lock (sb) sb.AppendLine(e.Data); + }; + p.OutputDataReceived += grab; + p.ErrorDataReceived += grab; + p.Start(); + p.BeginOutputReadLine(); + p.BeginErrorReadLine(); + if (!p.WaitForExit(10000)) + { + try { p.Kill(); } catch { } + output = "sb.exe check не ответил за 10 секунд"; + return false; + } + p.WaitForExit(); // finish asynchronous stdout/stderr callbacks + lock (sb) output = sb.ToString().Trim(); + return p.ExitCode == 0; + } + } + catch (Exception ex) + { + output = ex.Message; + return false; + } + } + + public void Start(string sbExe, string cfgPath, string workDir) + { + if (IsRunning) return; + EnsureJob(); + + ProcessStartInfo psi = new ProcessStartInfo(); + psi.FileName = sbExe; + psi.Arguments = "run -c \"" + cfgPath + "\""; + psi.WorkingDirectory = workDir; + psi.UseShellExecute = false; + psi.CreateNoWindow = true; + psi.RedirectStandardOutput = true; + psi.RedirectStandardError = true; + psi.StandardOutputEncoding = Encoding.UTF8; + psi.StandardErrorEncoding = Encoding.UTF8; + ApplyEnv(psi); + + Process p = new Process(); + p.StartInfo = psi; + p.EnableRaisingEvents = true; + p.OutputDataReceived += OnOut; + p.ErrorDataReceived += OnOut; + p.Exited += delegate + { + int code; + string result = "?"; + try { code = p.ExitCode; result = code.ToString(CultureInfo.InvariantCulture); } + catch { } + Emit("— sing-box завершился (код " + result + ")"); + if (object.ReferenceEquals(_proc, p) && StateChanged != null) StateChanged(false); + }; + + Emit("> " + psi.FileName + " " + psi.Arguments); + try + { + p.Start(); + if (!AssignProcessToJobObject(_job, p.Handle)) + { + int error = Marshal.GetLastWin32Error(); + try { p.Kill(); } catch { } + throw new System.ComponentModel.Win32Exception(error, "Не удалось привязать sing-box к Job Object"); + } + _proc = p; + p.BeginOutputReadLine(); + p.BeginErrorReadLine(); + if (StateChanged != null) StateChanged(true); + } + catch + { + if (object.ReferenceEquals(_proc, p)) _proc = null; + try { if (!p.HasExited) p.Kill(); } catch { } + p.Dispose(); + throw; + } + } + + public void Stop() + { + Process p = _proc; + if (p == null) return; + bool running; + try { running = !p.HasExited; } + catch { return; } + if (!running) return; + Emit("— останавливаю sing-box…"); + try + { + p.CloseMainWindow(); + if (!p.WaitForExit(2500)) p.Kill(); + p.WaitForExit(3000); + } + catch (Exception ex) { Emit("! " + ex.Message); } + if (object.ReferenceEquals(_proc, p) && StateChanged != null) StateChanged(false); + } + + void OnOut(object s, DataReceivedEventArgs e) + { + if (e.Data != null) Emit(e.Data); + } + + void Emit(string s) + { + if (Log != null) Log(s); + } + + // ── Job Object ─────────────────────────────────────────────────── + void EnsureJob() + { + if (_job != IntPtr.Zero) return; + _job = CreateJobObject(IntPtr.Zero, null); + if (_job == IntPtr.Zero) + throw new System.ComponentModel.Win32Exception(Marshal.GetLastWin32Error(), "Не удалось создать Job Object"); + JOBOBJECT_EXTENDED_LIMIT_INFORMATION info = new JOBOBJECT_EXTENDED_LIMIT_INFORMATION(); + info.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE; + int len = Marshal.SizeOf(typeof(JOBOBJECT_EXTENDED_LIMIT_INFORMATION)); + IntPtr ptr = Marshal.AllocHGlobal(len); + Marshal.StructureToPtr(info, ptr, false); + try + { + if (!SetInformationJobObject(_job, JobObjectExtendedLimitInformation, ptr, (uint)len)) + { + int error = Marshal.GetLastWin32Error(); + CloseHandle(_job); + _job = IntPtr.Zero; + throw new System.ComponentModel.Win32Exception(error, "Не удалось настроить Job Object"); + } + } + finally { Marshal.FreeHGlobal(ptr); } + } + + const int JobObjectExtendedLimitInformation = 9; + const uint JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE = 0x2000; + + [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + static extern IntPtr CreateJobObject(IntPtr a, string lpName); + [DllImport("kernel32.dll", SetLastError = true)] + static extern bool SetInformationJobObject(IntPtr hJob, int infoType, IntPtr info, uint cb); + [DllImport("kernel32.dll", SetLastError = true)] + static extern bool AssignProcessToJobObject(IntPtr job, IntPtr process); + [DllImport("kernel32.dll")] + static extern bool CloseHandle(IntPtr handle); + + [StructLayout(LayoutKind.Sequential)] + struct IO_COUNTERS + { + public ulong ReadOperationCount, WriteOperationCount, OtherOperationCount; + public ulong ReadTransferCount, WriteTransferCount, OtherTransferCount; + } + [StructLayout(LayoutKind.Sequential)] + struct JOBOBJECT_BASIC_LIMIT_INFORMATION + { + public long PerProcessUserTimeLimit, PerJobUserTimeLimit; + public uint LimitFlags; + public UIntPtr MinimumWorkingSetSize, MaximumWorkingSetSize; + public uint ActiveProcessLimit; + public UIntPtr Affinity; + public uint PriorityClass, SchedulingClass; + } + [StructLayout(LayoutKind.Sequential)] + struct JOBOBJECT_EXTENDED_LIMIT_INFORMATION + { + public JOBOBJECT_BASIC_LIMIT_INFORMATION BasicLimitInformation; + public IO_COUNTERS IoInfo; + public UIntPtr ProcessMemoryLimit, JobMemoryLimit, PeakProcessMemoryUsed, PeakJobMemoryUsed; + } + } + + // ═════════════════════════════════════════════════════════════════════ + // ПИНГ + // + // ICMP не годится: сервер 132.243.202.111 на echo-request не отвечает + // (проверено). Меряем время TCP-коннекта до server:port — это и ближе + // к реальности, и работает всегда, пока порт открыт. + // ═════════════════════════════════════════════════════════════════════ + public static class Pinger + { + public static async Task TcpLatencyAsync(string host, int port, int timeoutMs) + { + if (string.IsNullOrEmpty(host)) return -1; + Stopwatch sw = Stopwatch.StartNew(); + TcpClient c = new TcpClient(); + try + { + Task connect = c.ConnectAsync(host, port); + Task done = await Task.WhenAny(connect, Task.Delay(timeoutMs)); + if (done != connect || !c.Connected) return -1; + sw.Stop(); + return (int)sw.ElapsedMilliseconds; + } + catch { return -1; } + finally { try { c.Close(); } catch { } } + } + + // Внешний IP — уходит через TUN, то есть показывает реальную точку выхода + public static async Task ExitIpAsync() + { + try + { + using (WebClient w = new WebClient()) + { + w.Headers.Add("User-Agent", "sb-launcher"); + Task fetch = w.DownloadStringTaskAsync("https://api4.ipify.org"); + if (await Task.WhenAny(fetch, Task.Delay(5000)) != fetch) + { + w.CancelAsync(); + return null; + } + return (await fetch).Trim(); + } + } + catch { return null; } + } + } + + // ═════════════════════════════════════════════════════════════════════ + // ИНФРАСТРУКТУРА ПРИВЯЗОК + // ═════════════════════════════════════════════════════════════════════ +} diff --git a/Ui.xaml b/Ui.xaml new file mode 100644 index 0000000..addf02f --- /dev/null +++ b/Ui.xaml @@ -0,0 +1,937 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 0.55 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 0.16 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +