Files
SbLauncher/SbLauncher.cs
T

935 lines
43 KiB
C#
Raw Normal View History

2026-09-26 14:41:04 +03:00
// 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<string, object> Parse(string text)
{
JavaScriptSerializer ser = new JavaScriptSerializer();
ser.MaxJsonLength = int.MaxValue;
return ser.DeserializeObject(StripComments(text)) as Dictionary<string, object>;
}
public static Dictionary<string, object> Obj(object o)
{
return o as Dictionary<string, object>;
}
public static List<Dictionary<string, object>> Arr(object o)
{
List<Dictionary<string, object>> list = new List<Dictionary<string, object>>();
object[] a = o as object[];
if (a == null) return list;
foreach (object it in a)
{
Dictionary<string, object> d = it as Dictionary<string, object>;
if (d != null) list.Add(d);
}
return list;
}
public static string Str(Dictionary<string, object> 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<string, object> 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<string, object> 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<ServerCfg> Servers = new List<ServerCfg>();
public static Profiles Load(string path)
{
Profiles p = new Profiles();
if (!File.Exists(path)) return p;
Dictionary<string, object> root = J.Parse(File.ReadAllText(path, Encoding.UTF8));
if (root == null) return p;
object wo;
if (root.TryGetValue("wireguard", out wo))
{
Dictionary<string, object> 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<string, object> 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<string, object> 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<AdapterInfo> List()
{
List<AdapterInfo> list = new List<AdapterInfo>();
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\<mode>.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<string> Validate(Paths paths, Profiles pr, LocalCfg lc, ServerCfg srv, string mode)
{
List<string> bad = new List<string>();
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<string, string> strings,
Dictionary<string, int> numbers)
{
// числовые — первыми: они забирают кавычки вокруг токена
foreach (KeyValuePair<string, int> kv in numbers)
tpl = tpl.Replace("\"@@" + kv.Key + "@@\"",
kv.Value.ToString(CultureInfo.InvariantCulture));
foreach (KeyValuePair<string, string> 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<string> bad = Validate(paths, pr, lc, srv, mode);
if (bad.Count > 0)
{
r.Error = string.Join("; ", bad.ToArray());
return r;
}
Dictionary<string, string> s = new Dictionary<string, string>();
Dictionary<string, int> n = new Dictionary<string, int>();
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<string> Log;
public event Action<bool> 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<int> 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<string> ExitIpAsync()
{
try
{
using (WebClient w = new WebClient())
{
w.Headers.Add("User-Agent", "sb-launcher");
Task<string> 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; }
}
}
// ═════════════════════════════════════════════════════════════════════
// ИНФРАСТРУКТУРА ПРИВЯЗОК
// ═════════════════════════════════════════════════════════════════════
}