// 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() { // те же переменные, что были в startN.bat: конфиги пользуются // wireguard-outbound'ом, который в sing-box 1.12 объявлен // устаревшим и без этого флага не поднимается // ProcessStartInfo.EnvironmentVariables может упасть, если Windows // передала одновременно Path и PATH. Дочерний процесс наследует // переменные текущего процесса и без обращения к этому словарю. Environment.SetEnvironmentVariable("ENABLE_DEPRECATED_WIREGUARD_OUTBOUND", "true"); Environment.SetEnvironmentVariable("ENABLE_DEPRECATED_TUN_ADDRESS_X", "true"); Environment.SetEnvironmentVariable("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(); 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(); 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; } } } // ═════════════════════════════════════════════════════════════════════ // ИНФРАСТРУКТУРА ПРИВЯЗОК // ═════════════════════════════════════════════════════════════════════ }