Files
SbLauncher/SbLauncher.cs
T
2026-10-10 14:58:04 +03:00

1063 lines
50 KiB
C#
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// SbLauncher — GUI для sing-box.
//
// ПРИНЦИП (изменён по сравнению с прошлой версией):
// Источник правды — profiles.base.json (базовые серверы), 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 BaseProfiles { get { return Path.Combine(Root, "profiles.base.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 BaseId = ""; // стабильный id только у серверов из релиза
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.base.json. profiles.json хранит только личные
// серверы и скрытые базовые id; local.json независимо хранит ключ и адрес.
public class Profiles
{
public WgCfg Wg = new WgCfg();
public List<ServerCfg> Servers = new List<ServerCfg>();
public HashSet<string> HiddenBaseServers = new HashSet<string>(StringComparer.Ordinal);
public bool Layered;
public bool NeedsMigration;
bool _wgOverride;
public static Profiles Load(string path)
{
Profiles personal = ReadFile(path, false);
string basePath = Path.Combine(Path.GetDirectoryName(Path.GetFullPath(path)), "profiles.base.json");
if (!string.Equals(Path.GetFileName(path), "profiles.json", StringComparison.OrdinalIgnoreCase) ||
!File.Exists(basePath)) return personal;
Profiles baseline = ReadFile(basePath, true);
Profiles merged = new Profiles();
merged.Layered = true;
merged.NeedsMigration = File.Exists(path) && !personal.Layered;
merged.HiddenBaseServers.UnionWith(personal.HiddenBaseServers);
merged._wgOverride = personal._wgOverride && (personal.Layered || !SameWg(personal.Wg, baseline.Wg));
merged.Wg = merged._wgOverride ? personal.Wg : baseline.Wg;
// Старый общий список превращается в личный слой без дубликатов.
// Непохожие на базовые записи сохраняем, включая старые настройки.
List<ServerCfg> own = new List<ServerCfg>();
foreach (ServerCfg server in personal.Servers)
{
bool sharedLegacy = false;
if (!personal.Layered)
foreach (ServerCfg shared in baseline.Servers)
if (SameProfile(shared, server)) { sharedLegacy = true; break; }
if (!sharedLegacy) own.Add(server);
}
// Личные коды не меняем при появлении базового сервера с тем же кодом.
List<ServerCfg> reserved = new List<ServerCfg>(own);
foreach (ServerCfg shared in baseline.Servers)
{
if (merged.HiddenBaseServers.Contains(shared.BaseId)) continue;
GiveUniqueCode(shared, reserved);
reserved.Add(shared);
merged.Servers.Add(shared);
}
merged.Servers.AddRange(own);
return merged;
}
static bool SameWg(WgCfg a, WgCfg b)
{
return a.Server == b.Server && a.Port == b.Port &&
a.PeerPublicKey == b.PeerPublicKey && a.Mtu == b.Mtu;
}
static bool SameProfile(ServerCfg a, ServerCfg b)
{
// Compare complete connection settings: a customized SNI/transport
// with the same endpoint and UUID must remain a personal profile.
return KeyImporter.Same(
new ServerCfg { Protocol = a.Protocol, Server = a.Server, Port = a.Port,
Outbound = ProxyOutbound.Build(a, ConfigBuilder.ModeDirect) },
new ServerCfg { Protocol = b.Protocol, Server = b.Server, Port = b.Port,
Outbound = ProxyOutbound.Build(b, ConfigBuilder.ModeDirect) });
}
public static void GiveUniqueCode(ServerCfg server, List<ServerCfg> all)
{
string original = server.Code;
int number = 2;
while (all.Exists(delegate(ServerCfg existing) {
return string.Equals(existing.Code, server.Code, StringComparison.OrdinalIgnoreCase);
})) server.Code = original + (number++).ToString(CultureInfo.InvariantCulture);
}
public Profiles Copy()
{
Profiles next = new Profiles();
next.Wg = Wg;
next.Servers.AddRange(Servers);
next.HiddenBaseServers.UnionWith(HiddenBaseServers);
next.Layered = Layered;
next.NeedsMigration = NeedsMigration;
next._wgOverride = _wgOverride;
return next;
}
public void Remove(ServerCfg server)
{
Servers.Remove(server);
if (server.BaseId.Length > 0) HiddenBaseServers.Add(server.BaseId);
}
static Profiles ReadFile(string path, bool isBase)
{
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;
p.Layered = J.Int(root, "schema_version", 0) >= 2;
object hidden;
if (root.TryGetValue("hidden_base_servers", out hidden))
{
object[] ids = hidden as object[];
if (ids != null) foreach (object id in ids)
if (id is string) p.HiddenBaseServers.Add((string)id);
}
object wo;
if (root.TryGetValue("wireguard", out wo))
{
Dictionary<string, object> w = J.Obj(wo);
if (w != null)
{
p._wgOverride = true;
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");
if (isBase)
{
s.BaseId = J.Str(d, "id", s.Code);
if (s.BaseId.Length == 0 || p.Servers.Exists(delegate(ServerCfg existing) { return existing.BaseId == s.BaseId; }))
throw new FormatException("У базовых серверов должны быть уникальные непустые id.");
}
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)
{
if (NeedsMigration && File.Exists(path))
{
string backup = Path.Combine(Path.GetDirectoryName(Path.GetFullPath(path)), "profiles.legacy-backup.json");
if (!File.Exists(backup)) File.Copy(path, backup);
}
List<ServerCfg> stored = Layered
? Servers.FindAll(delegate(ServerCfg server) { return server.BaseId.Length == 0; })
: Servers;
StringBuilder sb = new StringBuilder();
sb.AppendLine("// Личные серверы и настройки списка. Не включайте этот файл в релиз.");
sb.AppendLine("// Базовые серверы — profiles.base.json; ключ и адрес — local.json.");
sb.AppendLine("{");
if (Layered)
{
sb.AppendLine(" \"schema_version\": 2,");
List<string> hidden = new List<string>(HiddenBaseServers);
hidden.Sort(StringComparer.Ordinal);
sb.AppendLine(" \"hidden_base_servers\": " + J.ToJson(hidden.ToArray()) + ",");
}
if (!Layered || _wgOverride)
{
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 < stored.Count; i++)
{
ServerCfg s = stored[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 == stored.Count - 1 ? " }" : " },");
}
sb.AppendLine(" ]");
sb.AppendLine("}");
J.Write(path, sb.ToString());
NeedsMigration = false;
}
}
// 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()
{
// те же переменные, что были в 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<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; }
}
}
// ═════════════════════════════════════════════════════════════════════
// ИНФРАСТРУКТУРА ПРИВЯЗОК
// ═════════════════════════════════════════════════════════════════════
}