938 lines
44 KiB
C#
938 lines
44 KiB
C#
// 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()
|
||
{
|
||
// те же переменные, что были в 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; }
|
||
}
|
||
}
|
||
|
||
// ═════════════════════════════════════════════════════════════════════
|
||
// ИНФРАСТРУКТУРА ПРИВЯЗОК
|
||
// ═════════════════════════════════════════════════════════════════════
|
||
}
|