diff options
author | crupest <crupest@outlook.com> | 2024-10-03 14:29:09 +0800 |
---|---|---|
committer | crupest <crupest@outlook.com> | 2024-10-03 14:29:09 +0800 |
commit | 39c51129d32659dd93b4d1ab9bd945a0c57df2f9 (patch) | |
tree | 300745f607c2e40aa7525408c9025c6a1e768110 /tools/Crupest.SecretTool | |
parent | 29f91f56829266668f0b65620e8e902218d61c33 (diff) | |
download | crupest-39c51129d32659dd93b4d1ab9bd945a0c57df2f9.tar.gz crupest-39c51129d32659dd93b4d1ab9bd945a0c57df2f9.tar.bz2 crupest-39c51129d32659dd93b4d1ab9bd945a0c57df2f9.zip |
Rename secret tool.
Diffstat (limited to 'tools/Crupest.SecretTool')
28 files changed, 1791 insertions, 0 deletions
diff --git a/tools/Crupest.SecretTool/.gitignore b/tools/Crupest.SecretTool/.gitignore new file mode 100644 index 0000000..ac4d8a4 --- /dev/null +++ b/tools/Crupest.SecretTool/.gitignore @@ -0,0 +1,7 @@ +.vs +bin +obj +*.pubxml.user +*.csproj.user + +publish diff --git a/tools/Crupest.SecretTool/Crupest.SecretTool.sln b/tools/Crupest.SecretTool/Crupest.SecretTool.sln new file mode 100644 index 0000000..fde4347 --- /dev/null +++ b/tools/Crupest.SecretTool/Crupest.SecretTool.sln @@ -0,0 +1,30 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.7.34024.191 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{F4C2CE80-CDF8-4B08-8912-D1F0F14196AD}" + ProjectSection(SolutionItems) = preProject + .gitignore = .gitignore + EndProjectSection +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Crupest.SecretTool", "Crupest.SecretTool\Crupest.SecretTool.csproj", "{D6335AE4-FD22-49CD-9624-37371F3B4F82}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {D6335AE4-FD22-49CD-9624-37371F3B4F82}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {D6335AE4-FD22-49CD-9624-37371F3B4F82}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D6335AE4-FD22-49CD-9624-37371F3B4F82}.Release|Any CPU.ActiveCfg = Release|Any CPU + {D6335AE4-FD22-49CD-9624-37371F3B4F82}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {B1E8FD9C-9157-4F4E-8265-4B37F30EEC5E} + EndGlobalSection +EndGlobal diff --git a/tools/Crupest.SecretTool/Crupest.SecretTool/.gitignore b/tools/Crupest.SecretTool/Crupest.SecretTool/.gitignore new file mode 100644 index 0000000..c936492 --- /dev/null +++ b/tools/Crupest.SecretTool/Crupest.SecretTool/.gitignore @@ -0,0 +1 @@ +vmess.txt diff --git a/tools/Crupest.SecretTool/Crupest.SecretTool/Config.cs b/tools/Crupest.SecretTool/Crupest.SecretTool/Config.cs new file mode 100644 index 0000000..ff58551 --- /dev/null +++ b/tools/Crupest.SecretTool/Crupest.SecretTool/Config.cs @@ -0,0 +1,95 @@ +namespace Crupest.SecretTool; + +public record ConfigItem(string Value, int LineNumber); + +public class DictionaryConfig(string configString, List<string>? requiredKeys = null) +{ + private static Dictionary<string, ConfigItem> Parse(string configString, List<string>? requiredKeys = null) + { + var config = new Dictionary<string, ConfigItem>(); + var lines = configString.Split('\n'); + int lineNumber = 1; + + foreach (var line in lines) + { + var l = line; + var beginOfComment = l.IndexOf('#'); + if (beginOfComment >= 0) + { + l = line[..beginOfComment]; + } + l = l.Trim(); + if (!string.IsNullOrEmpty(l)) + { + var equalIndex = l.IndexOf('='); + if (equalIndex == -1) + { + throw new FormatException($"No '=' found in line {lineNumber}."); + } + + config.Add(l[..equalIndex].Trim(), new ConfigItem(l[(equalIndex + 1)..].Trim(), lineNumber)); + } + + lineNumber++; + } + + if (requiredKeys is not null) + { + foreach (var key in requiredKeys) + { + if (!config.ContainsKey(key)) + { + throw new FormatException($"Required key '{key}' not found in config."); + } + } + } + + return config; + } + + public string ConfigString { get; } = configString; + public List<string>? RequiredKeys { get; } = requiredKeys; + public Dictionary<string, ConfigItem> Config { get; } = Parse(configString); + public ConfigItem GetItemCaseInsensitive(string key) + { + foreach (var (originalKey, value) in Config) + { + if (string.Equals(originalKey, key, StringComparison.OrdinalIgnoreCase)) + { + return value; + } + } + throw new KeyNotFoundException($"Key '{key}' not found in config case-insensitively."); + } +} + +public class ListConfig(string configString) +{ + private static List<ConfigItem> Parse(string configString) + { + var config = new List<ConfigItem>(); + var lines = configString.Split('\n'); + int lineNumber = 1; + + foreach (var line in lines) + { + var l = line; + var beginOfComment = l.IndexOf('#'); + if (beginOfComment >= 0) + { + l = line[..beginOfComment]; + } + l = l.Trim(); + if (!string.IsNullOrEmpty(l)) + { + config.Add(new ConfigItem(l, lineNumber)); + } + lineNumber++; + } + + return config; + } + + public string ConfigString { get; } = configString; + public List<ConfigItem> Config { get; } = Parse(configString); +} diff --git a/tools/Crupest.SecretTool/Crupest.SecretTool/Controller.cs b/tools/Crupest.SecretTool/Crupest.SecretTool/Controller.cs new file mode 100644 index 0000000..0803b01 --- /dev/null +++ b/tools/Crupest.SecretTool/Crupest.SecretTool/Controller.cs @@ -0,0 +1,113 @@ +using System.Diagnostics; + +namespace Crupest.SecretTool; + +public class Controller(string executablePath, string configPath, string? assetPath) +{ + public const string ToolAssetEnvironmentVariableName = "v2ray.location.asset"; + + public static string? FindExecutable(string contentDir, out bool isLocal, string? executableName = null) + { + isLocal = false; + executableName ??= "v2ray"; + + if (OperatingSystem.IsWindows()) + { + executableName += ".exe"; + } + + var localToolPath = Path.Combine(contentDir, executableName); + if (File.Exists(localToolPath)) + { + isLocal = true; + return localToolPath; + } + + var paths = Environment.GetEnvironmentVariable("PATH")?.Split(Path.PathSeparator); + if (paths is not null) + { + foreach (var p in paths) + { + var toolPath = Path.Combine(p, executableName); + if (File.Exists(toolPath)) + { + return toolPath; + } + } + } + + return null; + } + + public string ExecutablePath { get; } = executablePath; + public string ConfigPath { get; } = configPath; + public string? AssetPath { get; } = assetPath; + public Process? CurrentProcess { get; private set; } + + private Process CreateProcess() + { + var process = new Process(); + + var startInfo = new ProcessStartInfo + { + FileName = ExecutablePath, + }; + startInfo.ArgumentList.Add("run"); + startInfo.ArgumentList.Add("-c"); + startInfo.ArgumentList.Add(ConfigPath); + if (AssetPath is not null) + { + startInfo.EnvironmentVariables[ToolAssetEnvironmentVariableName] = AssetPath; + } + + process.StartInfo = startInfo; + process.OutputDataReceived += (_, args) => + { + Console.Out.Write(args.Data); + }; + process.ErrorDataReceived += (_, args) => + { + Console.Error.WriteLine(args.Data); + }; + + return process; + } + + public void Stop() + { + if (CurrentProcess is not null) + { + CurrentProcess.Kill(); + CurrentProcess.Dispose(); + CurrentProcess = null; + Console.WriteLine("V2ray stopped."); + } + } + + public void Start(bool stopOld = false) + { + if (stopOld) Stop(); + + if (CurrentProcess is null) + { + CurrentProcess = CreateProcess(); + CurrentProcess.EnableRaisingEvents = true; + CurrentProcess.Exited += (_, _) => + { + if (CurrentProcess.ExitCode != 0) + { + const string message = "V2ray exited with error."; + Console.Error.WriteLine(message); + throw new Exception(message); + } + }; + CurrentProcess.Start(); + Console.WriteLine("V2ray started."); + } + } + + public void Restart() + { + Start(true); + } +} diff --git a/tools/Crupest.SecretTool/Crupest.SecretTool/Crupest.SecretTool.csproj b/tools/Crupest.SecretTool/Crupest.SecretTool/Crupest.SecretTool.csproj new file mode 100644 index 0000000..0812e4c --- /dev/null +++ b/tools/Crupest.SecretTool/Crupest.SecretTool/Crupest.SecretTool.csproj @@ -0,0 +1,25 @@ +<Project Sdk="Microsoft.NET.Sdk"> + + <PropertyGroup> + <OutputType>Exe</OutputType> + <TargetFramework>net8.0</TargetFramework> + <ImplicitUsings>enable</ImplicitUsings> + <Nullable>enable</Nullable> + </PropertyGroup> + + <ItemGroup> + <None Update="config.json.template"> + <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> + </None> + <None Update="proxy.txt"> + <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> + </None> + <None Update="vmess.txt"> + <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> + </None> + <None Update="hosts.txt"> + <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> + </None> + </ItemGroup> + +</Project> diff --git a/tools/Crupest.SecretTool/Crupest.SecretTool/FileWatcher.cs b/tools/Crupest.SecretTool/Crupest.SecretTool/FileWatcher.cs new file mode 100644 index 0000000..193874f --- /dev/null +++ b/tools/Crupest.SecretTool/Crupest.SecretTool/FileWatcher.cs @@ -0,0 +1,26 @@ +namespace Crupest.SecretTool; + +public class FileWatcher(string directory, List<string> fileNames) +{ + public string Directory { get; set; } = directory; + public List<string> FileNames { get; set; } = fileNames; + + public delegate void OnChangedHandler(); + public event OnChangedHandler? OnChanged; + + public void Run() + { + var sourceWatcher = new FileSystemWatcher(Directory); + foreach (var fileName in FileNames) + { + sourceWatcher.Filters.Add(fileName); + } + sourceWatcher.NotifyFilter = NotifyFilters.LastWrite; + + while (true) + { + var result = sourceWatcher.WaitForChanged(WatcherChangeTypes.Changed); + OnChanged?.Invoke(); + } + } +} diff --git a/tools/Crupest.SecretTool/Crupest.SecretTool/GeoDataManager.cs b/tools/Crupest.SecretTool/Crupest.SecretTool/GeoDataManager.cs new file mode 100644 index 0000000..2d9e2eb --- /dev/null +++ b/tools/Crupest.SecretTool/Crupest.SecretTool/GeoDataManager.cs @@ -0,0 +1,311 @@ +using System.IO.Compression; + +namespace Crupest.SecretTool; + +public interface IGeoSiteEntry +{ + bool IsInclude { get; } + string Value { get; } +} + +public record GeoSiteIncludeEntry(string Value, string ContainingSite) : IGeoSiteEntry +{ + public bool IsInclude => true; +} + +public record GeoSiteRuleEntry(HostMatchKind Kind, string Value, List<string> Attributes, string ContainingSite) : IGeoSiteEntry +{ + public bool IsInclude => false; +} + +public record GeoSite(string Name, List<IGeoSiteEntry> Entries) +{ + public static GeoSite Parse(string name, string str) + { + List<IGeoSiteEntry> entries = []; + var listConfig = new ListConfig(str); + foreach (var item in listConfig.Config) + { + var (value, line) = item; + + if (value.StartsWith("include:")) + { + var include = value["include:".Length..].Trim(); + if (include.Length == 0 || include.Contains(' ')) + { + throw new FormatException($"Invalid geo site rule '{name}' in line {line}. Invalid include value."); + } + entries.Add(new GeoSiteIncludeEntry(include, name)); + continue; + } + + var segments = value.Split(':', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries); + if (segments.Length > 2) + { + throw new FormatException($"Invalid geo site rule '{name}' in line {line}. More than one ':'."); + } + + HostMatchKind kind; + if (segments.Length == 2) + { + kind = segments[0] switch + { + "domain" => kind = HostMatchKind.DomainSuffix, + "full" => kind = HostMatchKind.DomainFull, + "keyword" => kind = HostMatchKind.DomainKeyword, + "regexp" => kind = HostMatchKind.DomainRegex, + _ => throw new FormatException($"Invalid geo site rule '{name}' in line {line}. Unknown matcher.") + }; + } + else + { + kind = HostMatchKind.DomainSuffix; + } + + var domainSegments = segments[^1].Split('@', StringSplitOptions.TrimEntries); + var domain = domainSegments[0]; + if (kind != HostMatchKind.DomainRegex && Uri.CheckHostName(domain) != UriHostNameType.Dns) + { + throw new FormatException($"Invalid geo site rule '{name}' in line {line}. Invalid domain."); + } + + List<string> attributes = []; + foreach (var s in domainSegments[1..]) + { + if (s.Length == 0) + { + throw new FormatException($"Invalid geo site rule '{name}' in line {line}. Empty attribute value."); + } + attributes.Add(s); + } + + entries.Add(new GeoSiteRuleEntry(kind, domain, attributes, name)); + } + return new GeoSite(name, entries); + } +} + +public class GeoSiteData(string directory) +{ + private static List<GeoSite> Parse(string directory) + { + var sites = new List<GeoSite>(); + foreach (var path in Directory.GetFileSystemEntries(directory)) + { + var content = File.ReadAllText(path); + sites.Add(GeoSite.Parse(Path.GetFileName(path), content)); + } + return sites; + } + + public string DataDirectory { get; } = directory; + + public List<GeoSite> Sites { get; } = Parse(directory); + + public GeoSite? GetSite(string name) + { + return Sites.Where(s => s.Name == name).FirstOrDefault(); + } + + public List<GeoSiteRuleEntry> GetEntriesRecursive(List<string> sites, + List<HostMatchKind>? onlyMatcherKinds = null, List<string>? onlyAttributes = null) + { + List<GeoSiteRuleEntry> entries = []; + HashSet<string> visited = []; + HashSet<HostMatchKind>? kinds = onlyMatcherKinds?.ToHashSet(); + + void Visit(string site) + { + if (visited.Contains(site)) + { + return; + } + + visited.Add(site); + var siteData = GetSite(site); + if (siteData == null) + { + return; + } + foreach (var entry in siteData.Entries) + { + if (entry is GeoSiteIncludeEntry includeEntry) + { + Visit(includeEntry.Value); + } + else if (entry is GeoSiteRuleEntry geoSiteRuleEntry) + { + if (kinds != null && !kinds.Contains(geoSiteRuleEntry.Kind)) + { + continue; + } + + if (onlyAttributes != null && !geoSiteRuleEntry.Attributes.Intersect(onlyAttributes).Any()) + { + continue; + } + + entries.Add(geoSiteRuleEntry); + } + } + } + + foreach (var s in sites) + { + Visit(s); + } + + return entries; + } +} + +public class GeoDataManager +{ + public const string GeoSiteFileName = "geosite.dat"; + public const string GeoIpFileName = "geoip.dat"; + public const string GeoIpCnFileName = "geoip-only-cn-private.dat"; + + public static class ToolGithub + { + public const string Organization = "v2fly"; + public const string GeoSiteRepository = "domain-list-community"; + public const string GeoIpRepository = "geoip"; + public const string GeoSiteReleaseFilename = "dlc.dat"; + public const string GeoIpReleaseFilename = "geoip.dat"; + public const string GeoIpCnReleaseFilename = "geoip-only-cn-private.dat"; + } + + public static GeoDataManager Instance { get; } = new GeoDataManager(); + + public record GeoDataAsset(string Name, string FileName, string GithubUser, string GithubRepo, string GithubReleaseFileName); + + public GeoDataManager() + { + Assets = + [ + new("geosite", GeoSiteFileName, ToolGithub.Organization, ToolGithub.GeoSiteRepository, ToolGithub.GeoSiteRepository), + new("geoip", GeoIpFileName, ToolGithub.Organization, ToolGithub.GeoIpRepository, ToolGithub.GeoIpReleaseFilename), + new("geoip-cn", GeoIpCnFileName, ToolGithub.Organization, ToolGithub.GeoIpRepository, ToolGithub.GeoIpCnReleaseFilename), + ]; + } + + public List<GeoDataAsset> Assets { get; set; } + + public GeoSiteData? GeoSiteData { get; set; } + + public GeoSiteData GetOrCreateGeoSiteData(bool clean, bool silent) + { + if (GeoSiteData is not null) { return GeoSiteData; } + GeoSiteData = DownloadAndGenerateGeoSiteData(clean, silent); + return GeoSiteData; + } + + private static string GetReleaseFileUrl(string user, string repo, string fileName) + { + return $"https://github.com/{user}/{repo}/releases/latest/download/{fileName}"; + } + + private static void GithubDownloadRelease(HttpClient httpClient, string user, string repo, string fileName, string outputPath) + { + using var responseStream = httpClient.GetStreamAsync(GetReleaseFileUrl(user, repo, fileName)).Result; + using var outputFileStream = File.OpenWrite(outputPath); + responseStream.CopyTo(outputFileStream); + } + + public bool HasAllAssets(string directory, out List<string> missing) + { + missing = []; + foreach (var asset in Assets) + { + var assetPath = Path.Combine(directory, asset.FileName); + if (!File.Exists(assetPath)) + { + missing.Add(asset.Name); + } + } + return missing.Count == 0; + } + + public void Download(string outputDir, bool silent) + { + using var httpClient = new HttpClient(); + + foreach (var asset in Assets) + { + if (!silent) + { + Console.WriteLine($"Downloading {asset.Name}..."); + } + GithubDownloadRelease(httpClient, asset.GithubUser, asset.GithubRepo, asset.GithubReleaseFileName, Path.Combine(outputDir, asset.FileName)); + if (!silent) + { + Console.WriteLine($"Downloaded {asset.Name}!"); + } + } + } + + private static string GetGithubRepositoryArchiveUrl(string user, string repo) + { + return $"https://github.com/{user}/{repo}/archive/refs/heads/master.zip"; + } + + private static void GithubDownloadRepository(HttpClient httpClient, string user, string repo, string outputPath, bool silent) + { + var url = GetGithubRepositoryArchiveUrl(user, repo); + if (!silent) { Console.WriteLine($"Begin to download data from {url} to {outputPath}."); } + using var responseStream = httpClient.GetStreamAsync(url).Result; + using var outputFileStream = File.OpenWrite(outputPath); + responseStream.CopyTo(outputFileStream); + if (!silent) { Console.WriteLine("Succeeded to download."); } + } + + private static void Unzip(string zipPath, string outputPath) + { + using var zip = ZipFile.OpenRead(zipPath) ?? throw new Exception($"Failed to open zip file {zipPath}"); + zip.ExtractToDirectory(outputPath); + } + + private static string DownloadAndExtractGeoDataRepository(bool cleanTempDirIfFailed, bool silent, out string tempDirectoryPath) + { + tempDirectoryPath = ""; + const string zipFileName = "v2ray-geosite-master.zip"; + using var httpClient = new HttpClient(); + var tempDirectory = Directory.CreateTempSubdirectory(Program.Name); + tempDirectoryPath = tempDirectory.FullName; + try + { + var archivePath = Path.Combine(tempDirectoryPath, zipFileName); + var extractPath = Path.Combine(tempDirectoryPath, "repo"); + GithubDownloadRepository(httpClient, ToolGithub.Organization, ToolGithub.GeoSiteRepository, archivePath, silent); + if (!silent) { Console.WriteLine($"Extract geo data to {extractPath}."); } + Directory.CreateDirectory(extractPath); + Unzip(archivePath, extractPath); + if (!silent) { Console.WriteLine($"Extraction done."); } + return Path.Join(extractPath, "domain-list-community-master"); + } + catch (Exception) + { + if (cleanTempDirIfFailed) + { + Directory.Delete(tempDirectoryPath, true); + } + throw; + } + } + + private static GeoSiteData DownloadAndGenerateGeoSiteData(bool clean, bool silent) + { + var repoDirectory = DownloadAndExtractGeoDataRepository(clean, silent, out var tempDirectoryPath); + try + { + return new GeoSiteData(Path.Join(repoDirectory, "data")); + } + finally + { + if (clean) + { + Directory.Delete(tempDirectoryPath, true); + } + } + } +} diff --git a/tools/Crupest.SecretTool/Crupest.SecretTool/HostMatchConfig.cs b/tools/Crupest.SecretTool/Crupest.SecretTool/HostMatchConfig.cs new file mode 100644 index 0000000..5cc0c3d --- /dev/null +++ b/tools/Crupest.SecretTool/Crupest.SecretTool/HostMatchConfig.cs @@ -0,0 +1,123 @@ +namespace Crupest.SecretTool; + +public enum HostMatchKind +{ + DomainFull, + DomainSuffix, + DomainKeyword, + DomainRegex, + Ip, + GeoSite, + GeoIp, +} + +public static class HostMatchKindExtensions +{ + public static bool IsDomain(this HostMatchKind kind) + { + return kind.IsNonRegexDomain() || kind == HostMatchKind.DomainRegex; + } + + public static bool IsNonRegexDomain(this HostMatchKind kind) + { + return kind is HostMatchKind.DomainFull or HostMatchKind.DomainSuffix or HostMatchKind.DomainKeyword; + } + + + public static List<HostMatchKind> DomainMatchKinds { get; } = [HostMatchKind.DomainFull, HostMatchKind.DomainSuffix, HostMatchKind.DomainKeyword, HostMatchKind.DomainRegex]; + + public static List<HostMatchKind> NonRegexDomainMatchKinds { get; } = [HostMatchKind.DomainFull, HostMatchKind.DomainSuffix, HostMatchKind.DomainKeyword]; +} + +public record HostMatchConfigItem(HostMatchKind Kind, string MatchString, List<string> Values); + +public class HostMatchConfig(string configString, List<HostMatchKind> allowedMatchKinds, int minComponentCount = -1, int maxComponentCount = -1) +{ + private static List<HostMatchConfigItem> Parse(string configString, List<HostMatchKind> allowedMatchKinds, int minComponentCount = -1, int maxComponentCount = -1) + { + var items = new ListConfig(configString).Config; + var result = new List<HostMatchConfigItem>(); + + foreach (var item in items) + { + var lineNumber = item.LineNumber; + var line = item.Value; + var hasExplicitMatchKind = false; + var segments = line.Split(' ', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries).ToList(); + + foreach (var matchKind in Enum.GetValues<HostMatchKind>()) + { + var matchKindName = Enum.GetName(matchKind) ?? throw new Exception("No such match kind."); + hasExplicitMatchKind = true; + if (segments[0] == matchKindName) + { + if (segments.Count < 2) + { + throw new FormatException($"Explicit match item needs a value in line {lineNumber}."); + } + if (allowedMatchKinds.Contains(matchKind)) + { + if (matchKind.IsNonRegexDomain() && Uri.CheckHostName(matchKindName) != UriHostNameType.Dns) + { + throw new FormatException($"Invalid domain format in line {lineNumber}."); + } + + var components = segments[2..].ToList(); + if (minComponentCount > 0 && components.Count < minComponentCount) + { + throw new FormatException($"Too few components in line {lineNumber}, at least {minComponentCount} required."); + } + if (maxComponentCount >= 0 && components.Count > maxComponentCount) + { + throw new FormatException($"Too many components in line {lineNumber}, only {maxComponentCount} allowed."); + } + result.Add(new HostMatchConfigItem(matchKind, segments[1], components)); + } + else + { + throw new FormatException($"Match kind {matchKindName} is not allowed at line {lineNumber}."); + } + } + } + + if (!hasExplicitMatchKind) + { + if (minComponentCount > 0 && segments.Count - 1 < minComponentCount) + { + throw new FormatException($"Too few components in line {lineNumber}, at least {minComponentCount} required."); + } + if (maxComponentCount >= 0 && segments.Count - 1 > maxComponentCount) + { + throw new FormatException($"Too many components in line {lineNumber}, only {maxComponentCount} allowed."); + } + result.Add(new HostMatchConfigItem(HostMatchKind.DomainSuffix, segments[0], segments.Count == 1 ? [] : segments[1..])); + } + } + return result; + } + + public string ConfigString { get; } = configString; + public List<HostMatchKind> AllowedMatchKinds { get; } = allowedMatchKinds; + public int MinComponentCount { get; } = minComponentCount; + public int MaxComponentCount { get; } = maxComponentCount; + public List<HostMatchConfigItem> Items { get; } = Parse(configString, allowedMatchKinds, minComponentCount, maxComponentCount); +} + +public class HostMatchConfigFile +{ + public HostMatchConfigFile(string path, List<HostMatchKind> allowedMatchKinds, int minComponentCount = -1, int maxComponentCount = -1) + { + Path = path; + FileContent = File.ReadAllText(path); + Config = new HostMatchConfig(FileContent, allowedMatchKinds, minComponentCount, maxComponentCount); ; + } + + public string Path { get; } + public string FileContent { get; } + public HostMatchConfig Config { get; } +} + +public class ProxyFile(string path) : + HostMatchConfigFile(path, [.. Enum.GetValues<HostMatchKind>()], maxComponentCount: 0) +{ +} diff --git a/tools/Crupest.SecretTool/Crupest.SecretTool/Program.cs b/tools/Crupest.SecretTool/Crupest.SecretTool/Program.cs new file mode 100644 index 0000000..afbcde9 --- /dev/null +++ b/tools/Crupest.SecretTool/Crupest.SecretTool/Program.cs @@ -0,0 +1,94 @@ +using System.Reflection; + +namespace Crupest.SecretTool; + +public static class Program +{ + public static string Name { get; } = typeof(Program).Namespace ?? throw new Exception("Can't get the name of Crupest.SecretTool."); + + public static string CrupestSecretToolDirectory { get; } = + Environment.GetEnvironmentVariable("CRUPEST_V2RAY_DIR") ?? + Path.GetFullPath(Path.GetDirectoryName( + Assembly.GetExecutingAssembly().Location) ?? throw new Exception("Can't get the path of Crupest.SecretTool.")); + + private const string ConfigOutputFileName = "config.json"; + private const string SurgeRuleSetChinaOutputFileName = "ChinaRuleSet.txt"; + private const string SurgeRuleSetGlobalOutputFileName = "GlobalRuleSet.txt"; + + public static void RunToolAndWatchConfigChange() + { + var executablePath = Controller.FindExecutable(CrupestSecretToolDirectory, out var isLocal) ?? + throw new Exception("Can't find v2ray executable either in Crupest.SecretTool directory or in PATH."); + + string? assetsPath; + if (isLocal) + { + assetsPath = CrupestSecretToolDirectory; + var assetsComplete = GeoDataManager.Instance.HasAllAssets(CrupestSecretToolDirectory, out var missing); + if (!assetsComplete) + { + throw new Exception($"Missing assets: {string.Join(", ", missing)} in {CrupestSecretToolDirectory}. This v2ray is local. So only use assets in Crupest.SecretTool directory."); + } + } + else + { + assetsPath = CrupestSecretToolDirectory; + var assetsComplete = GeoDataManager.Instance.HasAllAssets(CrupestSecretToolDirectory, out var missing); + if (!assetsComplete) + { + Console.WriteLine($"Missing assets: {string.Join(", ", missing)} in {CrupestSecretToolDirectory}. This v2ray is global. So fallback to its own assets."); + assetsPath = null; + } + } + + var controller = new Controller(executablePath, Path.Combine(CrupestSecretToolDirectory, ConfigOutputFileName), assetsPath); + var configFileWatcher = new FileWatcher(CrupestSecretToolDirectory, ToolConfig.ConfigFileNames); + + ToolConfig.FromDirectoryAndWriteToFile(CrupestSecretToolDirectory, Path.Join(CrupestSecretToolDirectory, ConfigOutputFileName)); + controller.Start(); + + configFileWatcher.OnChanged += () => + { + ToolConfig.FromDirectoryAndWriteToFile(CrupestSecretToolDirectory, Path.Join(CrupestSecretToolDirectory, ConfigOutputFileName)); + controller.Restart(); + }; + + configFileWatcher.Run(); + } + + public static void Main(string[] args) + { + if (args.Length != 0) + { + if (args.Length != 1) + { + throw new Exception("Invalid command line arguments."); + } + var verb = args[0].ToLower(); + if (verb == "download-geodata" || verb == "dg") + { + GeoDataManager.Instance.Download(CrupestSecretToolDirectory, false); + return; + } + else if (verb == "generate-surge-rule-set" || verb == "gs") + { + SurgeConfigGenerator.GenerateTo( + Path.Join(CrupestSecretToolDirectory, "proxy.txt"), + Path.Join(CrupestSecretToolDirectory, SurgeRuleSetChinaOutputFileName), + Path.Join(CrupestSecretToolDirectory, SurgeRuleSetGlobalOutputFileName), + true, false + ); + return; + } + else if (verb == "generate" || verb == "g") + { + var config = ToolConfig.FromDirectory(CrupestSecretToolDirectory); + Console.Out.WriteLine(config.ToJsonStringV4()); + return; + } + throw new Exception("Invalid command line arguments."); + } + + RunToolAndWatchConfigChange(); + } +} diff --git a/tools/Crupest.SecretTool/Crupest.SecretTool/Properties/PublishProfiles/FolderProfile.pubxml b/tools/Crupest.SecretTool/Crupest.SecretTool/Properties/PublishProfiles/FolderProfile.pubxml new file mode 100644 index 0000000..5fca454 --- /dev/null +++ b/tools/Crupest.SecretTool/Crupest.SecretTool/Properties/PublishProfiles/FolderProfile.pubxml @@ -0,0 +1,13 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- +https://go.microsoft.com/fwlink/?LinkID=208121. +--> +<Project> + <PropertyGroup> + <Configuration>Release</Configuration> + <Platform>Any CPU</Platform> + <PublishDir>bin\Release\net8.0\publish\</PublishDir> + <PublishProtocol>FileSystem</PublishProtocol> + <_TargetId>Folder</_TargetId> + </PropertyGroup> +</Project>
\ No newline at end of file diff --git a/tools/Crupest.SecretTool/Crupest.SecretTool/Proxy.cs b/tools/Crupest.SecretTool/Crupest.SecretTool/Proxy.cs new file mode 100644 index 0000000..638edb6 --- /dev/null +++ b/tools/Crupest.SecretTool/Crupest.SecretTool/Proxy.cs @@ -0,0 +1,58 @@ +namespace Crupest.SecretTool; + +public abstract class Proxy(string tag) : IV4ConfigObject +{ + public string Tag { get; set; } = tag; + + public abstract V4ConfigJsonObjects.Outbound ToJsonObjectV4(); + + object IV4ConfigObject.ToJsonObjectV4() + { + return ToJsonObjectV4(); + } +} + +public class HttpProxy(string host, int port, string tag) : Proxy(tag) +{ + public string Host { get; set; } = host; + public int Port { get; set; } = port; + + public override V4ConfigJsonObjects.Outbound ToJsonObjectV4() + { + return new V4ConfigJsonObjects.Outbound(Tag, "http", + new V4ConfigJsonObjects.HttpOutboundSettings([new V4ConfigJsonObjects.HttpOutboundServer(Host, Port, [])]), + null + ); + } +} + + +public class VmessProxy(string host, int port, string userId, string path, string tag) : Proxy(tag) +{ + public string Host { get; set; } = host; + public int Port { get; set; } = port; + public string Path { get; set; } = path; + public string UserId { get; set; } = userId; + + public override V4ConfigJsonObjects.Outbound ToJsonObjectV4() + { + return new V4ConfigJsonObjects.Outbound(Tag, "vmess", + new V4ConfigJsonObjects.VmessOutboundSettings( + [new V4ConfigJsonObjects.VnextServer(Host, Port, [new V4ConfigJsonObjects.VnextServerUser(UserId, 0, "auto", 0)])]), + new V4ConfigJsonObjects.WsStreamSettings("ws", "tls", new V4ConfigJsonObjects.WsSettings(Path, new() { ["Host"] = Host })) + ); + } + + public static VmessProxy CreateFromConfigString(string configString, string tag) + { + var config = new DictionaryConfig(configString, ["host", "port", "userid", "path"]); + var portString = config.GetItemCaseInsensitive("port").Value; + if (!int.TryParse(portString, out var port) || port <= 0) + { + throw new FormatException($"Invalid port number: {portString}: not an integer or is a invalid number."); + } + return new VmessProxy(config.GetItemCaseInsensitive("host").Value, port, + config.GetItemCaseInsensitive("userid").Value, config.GetItemCaseInsensitive("path").Value, tag + ); + } +} diff --git a/tools/Crupest.SecretTool/Crupest.SecretTool/Routing.cs b/tools/Crupest.SecretTool/Crupest.SecretTool/Routing.cs new file mode 100644 index 0000000..dbced0e --- /dev/null +++ b/tools/Crupest.SecretTool/Crupest.SecretTool/Routing.cs @@ -0,0 +1,97 @@ +namespace Crupest.SecretTool; + +public record RoutingRule(HostMatchKind MatchKind, string MatchString, string OutboundTag) : IV4ConfigObject +{ + public string ToolConfigString => MatchKind switch + { + HostMatchKind.DomainFull => $"full:{MatchString}", + HostMatchKind.DomainSuffix => $"domain:{MatchString}", + HostMatchKind.DomainKeyword => MatchString, + HostMatchKind.DomainRegex => $"regexp:{MatchString}", + HostMatchKind.Ip => MatchString, + HostMatchKind.GeoSite => $"geosite:{MatchString}", + HostMatchKind.GeoIp => $"geoip:{MatchString}", + _ => throw new ArgumentException("Invalid matcher kind.") + }; + + public static Dictionary<string, List<RoutingRule>> GroupByOutboundTag(List<RoutingRule> rules) + => rules.GroupBy(r => r.OutboundTag).Select(g => (g.Key, g.ToList())).ToDictionary(); + + public static Dictionary<HostMatchKind, List<RoutingRule>> GroupByMatchKind(List<RoutingRule> rules) + => rules.GroupBy(r => r.MatchKind).Select(g => (g.Key, g.ToList())).ToDictionary(); + + public static List<List<RoutingRule>> GroupByOutboundTagAndMatcherKind(List<RoutingRule> rules) + => GroupByOutboundTag(rules).Values.SelectMany((groupByTag) => GroupByMatchKind(groupByTag).Values).ToList(); + + public static V4ConfigJsonObjects.RoutingRule ListToJsonObject(List<RoutingRule> rules) + { + if (rules.Count == 0) + { + throw new ArgumentException("Rule list is empty."); + } + + var matchKind = rules[0].MatchKind; + var outboundTag = rules[0].OutboundTag; + + if (rules.Any(r => r.OutboundTag != outboundTag) || rules.Any(r => r.MatchKind != matchKind)) + { + throw new ArgumentException("Rules must have the same matcher kind and outbound tag."); + } + + List<string> toolConfigList = rules.Select(r => r.ToolConfigString).ToList(); + + return new V4ConfigJsonObjects.RoutingRule(OutboundTag: outboundTag, + Ip: (matchKind is HostMatchKind.Ip or HostMatchKind.GeoIp) ? toolConfigList : null, + Domains: (matchKind.IsDomain() || matchKind == HostMatchKind.GeoSite) ? toolConfigList : null + ); + } + + public RoutingRule CloneGeositeWithCnAttribute(string outboundTag) + { + if (MatchKind is not HostMatchKind.GeoSite) + { + throw new ArgumentException("Matcher kind must be GeoSite."); + } + + return new RoutingRule(HostMatchKind.GeoSite, $"{MatchString}@cn", outboundTag); + } + + public V4ConfigJsonObjects.RoutingRule ToJsonObjectV4() => ListToJsonObject([this]); + + object IV4ConfigObject.ToJsonObjectV4() => ToJsonObjectV4(); +} + +public record Routing(List<RoutingRule> Rules, bool DirectGeositeCn = true, string DomainStrategy = "IpOnDemand") : IV4ConfigObject +{ + public List<RoutingRule> CreateGeositeCnDirectRules() + { + return Rules.Where(r => r.MatchKind is HostMatchKind.GeoSite) + .Select(r => r.CloneGeositeWithCnAttribute("direct")).ToList(); + } + + public V4ConfigJsonObjects.Routing ToJsonObjectV4(bool directGeositeCn = true) + { + List<V4ConfigJsonObjects.RoutingRule> ruleJsonObjects = []; + + if (directGeositeCn) + { + ruleJsonObjects.Add(RoutingRule.ListToJsonObject(CreateGeositeCnDirectRules())); + } + + ruleJsonObjects.AddRange(RoutingRule.GroupByOutboundTagAndMatcherKind(Rules).Select(RoutingRule.ListToJsonObject)); + + return new V4ConfigJsonObjects.Routing(ruleJsonObjects); + } + + object IV4ConfigObject.ToJsonObjectV4() => ToJsonObjectV4(); + + public static Routing FromProxyFile(ProxyFile proxyFile, string outboundTag, bool directGeositeCn) + { + + return new Routing( + proxyFile.Config.Items.Select( + i => new RoutingRule(i.Kind, i.MatchString, outboundTag)).ToList(), + directGeositeCn + ); + } +} diff --git a/tools/Crupest.SecretTool/Crupest.SecretTool/StaticHosts.cs b/tools/Crupest.SecretTool/Crupest.SecretTool/StaticHosts.cs new file mode 100644 index 0000000..b112e1c --- /dev/null +++ b/tools/Crupest.SecretTool/Crupest.SecretTool/StaticHosts.cs @@ -0,0 +1,40 @@ +namespace Crupest.SecretTool; + +public record StaticHostRule(HostMatchKind MatchKind, string MatchString, List<string> ResolveResult) +{ + public string AddressString() + { + return MatchKind switch + { + HostMatchKind.DomainFull => MatchString, + HostMatchKind.DomainSuffix => $"domain:{MatchString}", + HostMatchKind.DomainKeyword => $"keyword:{MatchString}", + HostMatchKind.DomainRegex => $"regexp:{MatchString}", + _ => throw new ArgumentOutOfRangeException($"Match kind {MatchKind} is not allowed in static host rule."), + }; + } + + public object ResolveResultToJsonObject() + { + return ResolveResult.Count == 1 ? ResolveResult[0] : ResolveResult; + } +} + +public class StaticHosts(List<StaticHostRule> rules) : IV4ConfigObject +{ + public List<StaticHostRule> Rules { get; } = rules; + + public Dictionary<string, object> ToJsonObjectV4() => + Rules.ToDictionary(rule => rule.AddressString(), rule => rule.ResolveResultToJsonObject()); + + object IV4ConfigObject.ToJsonObjectV4() + { + return ToJsonObjectV4(); + } + + public static StaticHosts CreateFromHostMatchConfigString(string configString) + { + var config = new HostMatchConfig(configString, HostMatchKindExtensions.DomainMatchKinds, minComponentCount: 1); + return new StaticHosts(config.Items.Select(i => new StaticHostRule(i.Kind, i.MatchString, [.. i.Values])).ToList()); + } +} diff --git a/tools/Crupest.SecretTool/Crupest.SecretTool/SurgeConfigGenerator.cs b/tools/Crupest.SecretTool/Crupest.SecretTool/SurgeConfigGenerator.cs new file mode 100644 index 0000000..451db3e --- /dev/null +++ b/tools/Crupest.SecretTool/Crupest.SecretTool/SurgeConfigGenerator.cs @@ -0,0 +1,60 @@ +namespace Crupest.SecretTool; + +public class SurgeConfigGenerator(ProxyFile proxyFile, GeoSiteData geoSiteData) +{ + public ProxyFile ProxyFile { get; } = proxyFile; + public GeoSiteData GeoSiteData { get; } = geoSiteData; + + private static string ToSurgeRuleString(HostMatchKind kind, string value) + { + var ruleType = kind switch + { + HostMatchKind.DomainFull => "DOMAIN", + HostMatchKind.DomainSuffix => "DOMAIN-SUFFIX", + HostMatchKind.DomainKeyword => "DOMAIN-KEYWORD", + HostMatchKind.DomainRegex => "URL-REGEX", + _ => throw new Exception("Unacceptable matcher kind for Surge rule.") + }; + + return $"{ruleType},{value}"; + } + + private static List<HostMatchKind> DomainMatcherKinds { get; } = [ + HostMatchKind.DomainFull, HostMatchKind.DomainKeyword, + HostMatchKind.DomainRegex, HostMatchKind.DomainSuffix, + ]; + + public string GenerateChinaRuleSet() + { + var geoSites = ProxyFile.Config.Items.Where(i => i.Kind == HostMatchKind.GeoSite).Select(i => i.MatchString).ToList(); + var cnRules = GeoSiteData.GetEntriesRecursive(geoSites, DomainMatcherKinds, ["cn"]).ToList(); + return string.Join('\n', cnRules.Select(r => ToSurgeRuleString(r.Kind, r.Value))); + } + + public string GenerateGlobalRuleSet() + { + var geoSites = ProxyFile.Config.Items.Where(i => i.Kind == HostMatchKind.GeoSite).Select(i => i.MatchString).ToList(); + var nonCnRules = GeoSiteData.GetEntriesRecursive(geoSites, DomainMatcherKinds).Where(e => !e.Attributes.Contains("cn")).ToList(); + var domainRules = ProxyFile.Config.Items.Where(i => DomainMatcherKinds.Contains(i.Kind)).ToList(); + return string.Join('\n', [ + ..nonCnRules.Select(r => ToSurgeRuleString(r.Kind, r.Value)), + ..domainRules.Select(r => ToSurgeRuleString(r.Kind, r.MatchString)) + ]); + } + + public static SurgeConfigGenerator Create(string proxyFilePath, bool clean, bool silent) + { + var proxyFile = new ProxyFile(proxyFilePath); + var geoSiteData = GeoDataManager.Instance.GetOrCreateGeoSiteData(clean, silent); + return new SurgeConfigGenerator(proxyFile, geoSiteData); + } + + public static void GenerateTo(string proxyFilePath, string cnPath, string globalPath, bool clean, bool silent) + { + var generator = Create(proxyFilePath, clean, silent); + File.WriteAllText(cnPath, generator.GenerateChinaRuleSet()); + if (!silent) Console.WriteLine($"China rule set written to {cnPath}."); + File.WriteAllText(globalPath, generator.GenerateGlobalRuleSet()); + if (!silent) Console.WriteLine($"Global rule set written to {globalPath}."); + } +} diff --git a/tools/Crupest.SecretTool/Crupest.SecretTool/Template.cs b/tools/Crupest.SecretTool/Crupest.SecretTool/Template.cs new file mode 100644 index 0000000..1fe91b1 --- /dev/null +++ b/tools/Crupest.SecretTool/Crupest.SecretTool/Template.cs @@ -0,0 +1,231 @@ +using System.Diagnostics.CodeAnalysis; +using System.Text; + +namespace Crupest.SecretTool; + +public class Template +{ + private enum ParseState + { + Text, + Dollar, + LeftBracket, + VariableName, + VariableNameFinish, + } + + private interface ITemplateNode + { + string Render(Dictionary<string, string> values); + } + + private class TextNode(string text) : ITemplateNode + { + + public string Text { get; } = text; + + public string Render(Dictionary<string, string> values) + { + return Text; + } + } + + private class VariableNode(string variableName) : ITemplateNode + { + public string VariableName { get; } = variableName; + + public string Render(Dictionary<string, string> values) + { + return values.GetValueOrDefault(VariableName) ?? ""; + } + } + + public Template(string templateString) + { + TemplateString = templateString; + Nodes = Parse(templateString); + VariableNames = Nodes.OfType<VariableNode>().Select(node => node.VariableName).ToList(); + } + + private static List<ITemplateNode> Parse(string templateString) + { + int lineNumber = 1; + int columnNumber = 0; + List<ITemplateNode> nodes = []; + ParseState state = ParseState.Text; + StringBuilder stringBuilder = new(); + + string GetPosition() => $"line {lineNumber} column{columnNumber}"; + + [DoesNotReturn] + void ReportInvalidState(string message) + { + throw new Exception($"Invalid state at {GetPosition()}: {message}"); + } + + [DoesNotReturn] + void ReportInvalidCharacter(char c) + { + throw new FormatException($"Unexpected '{c}' at {GetPosition()}."); + } + + void FinishText() + { + if (state != ParseState.Text) + { + ReportInvalidState($"Can't call FinishText here."); + } + + if (stringBuilder.Length > 0) + { + nodes.Add(new TextNode(stringBuilder.ToString())); + stringBuilder.Clear(); + } + } + + foreach (var c in templateString) + { + if (c == '\n') + { + lineNumber++; + columnNumber = 0; + } + + columnNumber++; + + switch (c) + { + case '$': + if (state == ParseState.Text) + { + FinishText(); + state = ParseState.Dollar; + } + else if (state == ParseState.Dollar) + { + if (stringBuilder.Length > 0) + { + throw new Exception($"Invalid state at {GetPosition()}: when we meet the second '$', text builder should be empty."); + } + stringBuilder.Append(c); + state = ParseState.Text; + } + else + { + throw new FormatException($"Unexpected '$' at {GetPosition()}."); + } + break; + case '{': + if (state == ParseState.Text) + { + stringBuilder.Append(c); + } + else if (state == ParseState.Dollar) + { + state = ParseState.LeftBracket; + } + else + { + throw new Exception($"Unexpected '{{' at {GetPosition()}."); + } + break; + case '}': + if (state == ParseState.Text) + { + stringBuilder.Append(c); + state = ParseState.Text; + } + else if (state == ParseState.VariableName || state == ParseState.VariableNameFinish) + { + nodes.Add(new VariableNode(stringBuilder.ToString())); + stringBuilder.Clear(); + state = ParseState.Text; + } + else + { + ReportInvalidCharacter(c); + } + break; + default: + if (state == ParseState.Dollar) + { + ReportInvalidCharacter(c); + } + + if (char.IsWhiteSpace(c)) + { + if (state == ParseState.LeftBracket || state == ParseState.VariableNameFinish) + { + continue; + } + else if (state == ParseState.Text) + { + stringBuilder.Append(c); + } + else if (state == ParseState.VariableName) + { + state = ParseState.VariableNameFinish; + } + else + { + ReportInvalidCharacter(c); + } + } + else + { + if (state == ParseState.Text) + { + stringBuilder.Append(c); + } + else if (state == ParseState.LeftBracket || state == ParseState.VariableName) + { + stringBuilder.Append(c); + state = ParseState.VariableName; + } + else + { + ReportInvalidCharacter(c); + } + } + break; + } + } + + if (state == ParseState.Text) + { + FinishText(); + } + else + { + throw new FormatException("Unexpected end of template string."); + } + + return nodes; + } + + public string TemplateString { get; } + private List<ITemplateNode> Nodes { get; set; } + public List<string> VariableNames { get; } + + public string Generate(Dictionary<string, string> values, bool allowMissingVariable = false) + { + StringBuilder stringBuilder = new(); + foreach (var node in Nodes) + { + if (node is TextNode textNode) + { + stringBuilder.Append(textNode.Text); + } + else if (node is VariableNode variableNode) + { + var hasValue = values.TryGetValue(variableNode.VariableName, out var value); + if (!hasValue && !allowMissingVariable) + { + throw new Exception($"Variable '{variableNode.VariableName}' is not set."); + } + stringBuilder.Append(hasValue ? value : string.Empty); + } + } + return stringBuilder.ToString(); + } +} diff --git a/tools/Crupest.SecretTool/Crupest.SecretTool/ToolConfig.cs b/tools/Crupest.SecretTool/Crupest.SecretTool/ToolConfig.cs new file mode 100644 index 0000000..4fe9a40 --- /dev/null +++ b/tools/Crupest.SecretTool/Crupest.SecretTool/ToolConfig.cs @@ -0,0 +1,154 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Crupest.SecretTool; + +public interface IV4ConfigObject +{ + object ToJsonObjectV4(); +} + +public class ToolConfig(Template template, List<Proxy> proxies, Routing router, StaticHosts? hosts) +{ + private class JsonInterfaceConverter<Interface> : JsonConverter<Interface> + { + public override Interface Read( + ref Utf8JsonReader reader, + Type typeToConvert, + JsonSerializerOptions options) + { + throw new NotImplementedException(); + } + + public override void Write( + Utf8JsonWriter writer, + Interface value, + JsonSerializerOptions options) + { + JsonSerializer.Serialize(writer, value, typeof(object), options); + } + } + + + public const string ConfigTemplateFileName = "config.json.template"; + public const string VmessConfigFileName = "vmess.txt"; + public const string ProxyConfigFileName = "proxy.txt"; + public const string HostsConfigFileName = "hosts.txt"; + + public static List<string> RequiredConfigFileNames { get; } = [ConfigTemplateFileName, VmessConfigFileName, ProxyConfigFileName]; + public static List<string> ConfigFileNames { get; } = [ConfigTemplateFileName, VmessConfigFileName, ProxyConfigFileName, HostsConfigFileName]; + + private const string ProxyAnchor = "PROXY_ANCHOR"; + private const string RoutingAnchor = "ROUTING_ANCHOR"; + private const string HostsAnchor = "HOSTS_ANCHOR"; + + public const string AddCnAttributeToGeositeEnvironmentVariable = "CRUPEST_V2RAY_GEOSITE_USE_CN"; + + private static bool UseCnGeoSite => Environment.GetEnvironmentVariable(AddCnAttributeToGeositeEnvironmentVariable) switch + { + "0" or "false" or "off" or "disable" => false, + _ => true + }; + + public Template Template { get; set; } = template; + public List<Proxy> Proxies { get; set; } = proxies; + public Routing Routing { get; set; } = router; + public StaticHosts Hosts { get; set; } = hosts is null ? new StaticHosts([]) : hosts; + + public string ToJsonStringV4(bool pretty = true) + { + var jsonOptions = new JsonSerializerOptions(new JsonSerializerOptions + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + DictionaryKeyPolicy = JsonNamingPolicy.CamelCase, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + }); + // TODO: Make interface converter generic. + jsonOptions.Converters.Add(new JsonInterfaceConverter<V4ConfigJsonObjects.IOutboundSettings>()); + jsonOptions.Converters.Add(new JsonInterfaceConverter<V4ConfigJsonObjects.IOutboundStreamSettings>()); + + var templateValues = new Dictionary<string, string> + { + [ProxyAnchor] = string.Join(',', Proxies.Select(p => JsonSerializer.Serialize(p.ToJsonObjectV4(), jsonOptions))), + [RoutingAnchor] = JsonSerializer.Serialize(Routing.ToJsonObjectV4(), jsonOptions), + [HostsAnchor] = JsonSerializer.Serialize(Hosts.ToJsonObjectV4(), jsonOptions), + }; + + var configString = Template.Generate(templateValues); + + if (pretty) + { + var jsonOptionsPretty = new JsonSerializerOptions(jsonOptions) + { + WriteIndented = true, + }; + return JsonSerializer.Serialize(JsonSerializer.Deserialize<object>(configString, jsonOptionsPretty), jsonOptionsPretty); + } + else + { + return configString; + } + } + + public static ToolConfig FromFiles(string templatePath, string vmessPath, string proxyPath, string? hostsPath) + { + foreach (var path in new List<string>([templatePath, vmessPath, proxyPath])) + { + if (!File.Exists(path)) + { + throw new FileNotFoundException($"Required config file not found: {path}."); + } + } + + ProxyFile proxyFile = new(proxyPath); + string templateString, vmessString; + string? hostsString; + + string file = ""; + try + { + file = templatePath; + templateString = File.ReadAllText(templatePath); + file = vmessPath; + vmessString = File.ReadAllText(vmessPath); + hostsString = hostsPath is not null ? File.ReadAllText(hostsPath) : null; + } + catch (Exception e) + { + throw new Exception($"Error reading config file {file}.", e); + } + + try + { + file = templatePath; + var template = new Template(templateString); + file = vmessPath; + var vmess = VmessProxy.CreateFromConfigString(vmessString, "proxy"); + file = proxyPath; + var routing = Routing.FromProxyFile(proxyFile, "proxy", UseCnGeoSite); + file = hostsPath ?? ""; + var hosts = hostsString is not null ? StaticHosts.CreateFromHostMatchConfigString(hostsString) : null; + return new ToolConfig(template, [vmess], routing, hosts); + } + catch (Exception e) + { + throw new Exception($"Error parsing config file {file}.", e); + } + } + + public static ToolConfig FromDirectory(string directory) + { + return FromFiles( + Path.Join(directory, ConfigTemplateFileName), + Path.Join(directory, VmessConfigFileName), + Path.Join(directory, ProxyConfigFileName), + Path.Join(directory, HostsConfigFileName) + ); + } + + public static void FromDirectoryAndWriteToFile(string directory, string outputPath) + { + var config = FromDirectory(directory); + File.WriteAllText(outputPath, config.ToJsonStringV4()); + } +} diff --git a/tools/Crupest.SecretTool/Crupest.SecretTool/V4ConfigJsonObjects.cs b/tools/Crupest.SecretTool/Crupest.SecretTool/V4ConfigJsonObjects.cs new file mode 100644 index 0000000..3e81dbb --- /dev/null +++ b/tools/Crupest.SecretTool/Crupest.SecretTool/V4ConfigJsonObjects.cs @@ -0,0 +1,25 @@ +namespace Crupest.SecretTool; + +public static class V4ConfigJsonObjects +{ + public interface IObject; + public interface IOutboundSettings : IObject; + public interface IOutboundStreamSettings : IObject; + + public record WsSettings(string Path, Dictionary<string, string> Headers) : IObject; + public record WsStreamSettings(string Network, string Security, WsSettings WsSettings) : IOutboundStreamSettings; + public record VnextServerUser(string Id, int AlterId, string Security, int Level) : IObject; + public record VnextServer(string Address, int Port, List<VnextServerUser> Users) : IObject; + public record VmessOutboundSettings(List<VnextServer> Vnext) : IOutboundSettings; + public record HttpOutboundUser(string User, string Pass) : IObject; + public record HttpOutboundServer(string Address, int Port, List<HttpOutboundUser> Users) : IObject; + public record HttpOutboundSettings(List<HttpOutboundServer> Servers) : IOutboundSettings; + public record Outbound(string Tag, string Protocol, IOutboundSettings Settings, + IOutboundStreamSettings? StreamSettings) : IObject; + + public record RoutingRule(string DomainMatcher = "mph", string Type = "field", List<string>? Domains = null, List<string>? Ip = null, + string? Port = null, string? SourcePort = null, string? Network = null, List<string>? Source = null, + List<string>? User = null, List<string>? InboundTag = null, List<string>? Protocol = null, string? Attrs = null, + string? OutboundTag = null, string? BalancerTag = null) : IObject; + public record Routing(List<RoutingRule> Rules, string DomainStrategy = "IpOnDemand", string DomainMatcher = "mph") : IObject; +} diff --git a/tools/Crupest.SecretTool/Crupest.SecretTool/V5ConfigJsonObjects.cs b/tools/Crupest.SecretTool/Crupest.SecretTool/V5ConfigJsonObjects.cs new file mode 100644 index 0000000..a50e9be --- /dev/null +++ b/tools/Crupest.SecretTool/Crupest.SecretTool/V5ConfigJsonObjects.cs @@ -0,0 +1,31 @@ +namespace Crupest.SecretTool; + +public static class V5ConfigJsonObjects +{ + public record OutboundObject(string Protocol, object Settings, string Tag, object? StreamSettings) + { + public static OutboundObject VmessViaWs(string tag, string address, int port, string uuid, string path) + { + return new OutboundObject("vmess", new VmessSettings(address, port, uuid), tag, StreamSettingsObject.Ws(path)); + } + + public static OutboundObject Http(string tag, string address, int port) + { + return new OutboundObject("http", new HttpSettingsObject(address, port), tag, null); + } + } + + public record WsSettingsObject(string Path, Dictionary<string, string> Headers); + + public record StreamSettingsObject(string Transport, object TransportSettings, string Security, object SecuritySettings) + { + public static StreamSettingsObject Ws(string path) + { + return new StreamSettingsObject("ws", new WsSettingsObject(path, new()), "tls", new()); + } + } + + public record VmessSettings(string Address, int Port, string Uuid); + + public record HttpSettingsObject(string Address, int Port); +} diff --git a/tools/Crupest.SecretTool/Crupest.SecretTool/config.json.template b/tools/Crupest.SecretTool/Crupest.SecretTool/config.json.template new file mode 100644 index 0000000..424e996 --- /dev/null +++ b/tools/Crupest.SecretTool/Crupest.SecretTool/config.json.template @@ -0,0 +1,63 @@ +{ + "log": { + "loglevel": "warning" + }, + "inbounds": [ + { + "port": 2081, + "listen": "127.0.0.1", + "tag": "socks-inbound", + "protocol": "socks", + "settings": { + "auth": "noauth" + } + }, + { + "port": 2080, + "listen": "127.0.0.1", + "tag": "http-inbound", + "protocol": "http", + "settings": { + "auth": "noauth" + } + } + ], + "outbounds": [ + { + "protocol": "freedom", + "settings": {}, + "tag": "direct" + }, + { + "protocol": "blackhole", + "settings": {}, + "tag": "blocked" + }, + ${PROXY_ANCHOR} + ], + "routing": ${ROUTING_ANCHOR}, + "dns": { + "hosts": ${HOSTS_ANCHOR}, + "servers": [ + "https://doh.pub/dns-query", + "1.1.1.1", + "8.8.8.8", + "localhost" + ] + }, + "policy": { + "levels": { + "0": { + "uplinkOnly": 0, + "downlinkOnly": 0 + } + }, + "system": { + "statsInboundUplink": false, + "statsInboundDownlink": false, + "statsOutboundUplink": false, + "statsOutboundDownlink": false + } + }, + "other": {} +} diff --git a/tools/Crupest.SecretTool/Crupest.SecretTool/config.v5.json.template b/tools/Crupest.SecretTool/Crupest.SecretTool/config.v5.json.template new file mode 100644 index 0000000..01ccf7a --- /dev/null +++ b/tools/Crupest.SecretTool/Crupest.SecretTool/config.v5.json.template @@ -0,0 +1,55 @@ +{ + "log": { + "access": { + "type": "Console", + "level": "Info" + } + }, + "dns": { + "nameServer": [{ + "address": "https://doh.pub/dns-query" + }, { + "address": "1.1.1.1" + }, { + "address": "8.8.8.8" + }, { + "address": "localhost" + }], + "staticHosts": ${HOSTS_ANCHOR} + }, + "inbounds": [{ + { + "protocol": "socks", + "port": 2081, + "listen": "127.0.0.1", + "tag": "socks-inbound", + "settings": { + "auth": "noauth" + } + }, + { + "protocol": "http", + "port": 2080, + "listen": "127.0.0.1", + "tag": "http-inbound", + "settings": { + "auth": "noauth" + } + } + }], + "outbounds": [ + { + "protocol": "freedom", + "settings": {}, + "tag": "direct" + }, + { + "protocol": "blackhole", + "settings": {}, + "tag": "blocked" + }, + ${PROXY_ANCHOR} + ], + "router": ${ROUTER_ANCHOR} +} + diff --git a/tools/Crupest.SecretTool/Crupest.SecretTool/hosts.txt b/tools/Crupest.SecretTool/Crupest.SecretTool/hosts.txt new file mode 100644 index 0000000..88d5015 --- /dev/null +++ b/tools/Crupest.SecretTool/Crupest.SecretTool/hosts.txt @@ -0,0 +1,2 @@ +cdn.jsdelivr.net cdn.jsdelivr.net.cdn.cloudflare.net + diff --git a/tools/Crupest.SecretTool/Crupest.SecretTool/proxy.txt b/tools/Crupest.SecretTool/Crupest.SecretTool/proxy.txt new file mode 100644 index 0000000..6273e35 --- /dev/null +++ b/tools/Crupest.SecretTool/Crupest.SecretTool/proxy.txt @@ -0,0 +1,45 @@ +GeoSite github +GeoSite google +GeoSite youtube +GeoSite twitter +GeoSite facebook +GeoSite discord +GeoSite reddit +GeoSite twitch +GeoSite onedrive +GeoSite quora +GeoSite telegram +GeoSite imgur +GeoSite stackexchange + +GeoSite duckduckgo +GeoSite wikimedia +GeoSite gitbook +GeoSite gitlab +GeoSite sourceforge +GeoSite creativecommons +GeoSite archive +GeoSite matrix +GeoSite tor + +GeoSite python +GeoSite ruby +GeoSite rust +GeoSite nodejs +GeoSite npmjs +GeoSite qt +GeoSite docker +GeoSite v2ray +GeoSite homebrew + +GeoSite azure +GeoSite jsdelivr +GeoSite fastly +GeoSite heroku +GeoSite bootstrap +GeoSite vercel + +GeoSite ieee +GeoSite sci-hub +GeoSite libgen +GeoSite z-library diff --git a/tools/Crupest.SecretTool/build-secret.bash b/tools/Crupest.SecretTool/build-secret.bash new file mode 100755 index 0000000..8878049 --- /dev/null +++ b/tools/Crupest.SecretTool/build-secret.bash @@ -0,0 +1,41 @@ +#! /usr/bin/env bash + +set -e + +function print_argument_error_message_and_exit() { + argument_error_message="You must specify exactly one argument, the build target (win-x64 | linux-x64 | osx-x64)." + echo "$argument_error_message" + exit 1 +} + + + +if [[ $# != 1 ]]; then + print_argument_error_message_and_exit +fi + +case "$1" in + win-x64 | linux-x64 | osx-x64) + echo "Build target: $1" + ;; + *) + print_argument_error_message_and_exit + ;; +esac + +secret_dir=$(realpath "$(dirname "$0")") + +echo "Secret dir: ${secret_dir}" + +echo "Check dotnet..." +dotnet --version + +echo "Enter \"secret\" dir..." +pushd "$secret_dir" + +echo "Begin to build..." +dotnet publish Crupest.SecretTool -c Release -o "$secret_dir/publish" --sc -r "$1" + +popd + +echo "Finish!" diff --git a/tools/Crupest.SecretTool/tools/cru-proxy-edit b/tools/Crupest.SecretTool/tools/cru-proxy-edit new file mode 100755 index 0000000..51a33e1 --- /dev/null +++ b/tools/Crupest.SecretTool/tools/cru-proxy-edit @@ -0,0 +1,12 @@ +#! /usr/bin/env bash + +set -e + +p="$HOME/codes/crupest/tools/Crupest.SecretTool/publish/proxy.txt" + +if [[ ! -f "$p" ]]; then + echo "File $p does not exist!" >&2 + exit 1 +fi + +exec vim "$p" diff --git a/tools/Crupest.SecretTool/tools/cru-proxy-log b/tools/Crupest.SecretTool/tools/cru-proxy-log new file mode 100755 index 0000000..6ec6ee1 --- /dev/null +++ b/tools/Crupest.SecretTool/tools/cru-proxy-log @@ -0,0 +1,13 @@ +#! /usr/bin/env bash + +set -e + +if [[ -e /proc ]]; then + # I don't believe your system is Linux but there is no /proc. + exec journalctl --user -u crupest-secret-tool "$@" +elif [[ "$(uname)" == "Darwin" ]]; then + exec less "$HOME/.local/state/Crupest.SecretTool/log" +else + echo "Not supported on systems other than macOS and Linux now." >&2 + exit 1 +fi diff --git a/tools/Crupest.SecretTool/tools/crupest-secret-tool.service b/tools/Crupest.SecretTool/tools/crupest-secret-tool.service new file mode 100644 index 0000000..df6d172 --- /dev/null +++ b/tools/Crupest.SecretTool/tools/crupest-secret-tool.service @@ -0,0 +1,8 @@ +[Unit] +Description=crupest v2ray service + +[Service] +ExecStart=%h/.local/bin/Crupest.SecretTool + +[Install] +WantedBy=default.target diff --git a/tools/Crupest.SecretTool/tools/life.crupest.secret-tool.plist b/tools/Crupest.SecretTool/tools/life.crupest.secret-tool.plist new file mode 100644 index 0000000..bdfe490 --- /dev/null +++ b/tools/Crupest.SecretTool/tools/life.crupest.secret-tool.plist @@ -0,0 +1,18 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> +<plist version="1.0"> +<dict> + <key>Label</key> + <string>life.crupest.secret-tool</string> + <key>ProgramArguments</key> + <array> + <string>/Users/crupest/.local/bin/Crupest.SecretTool</string> + </array> + <key>KeepAlive</key> + <true/> + <key>StandardOutPath</key> + <string>/Users/crupest/.local/state/Crupest.SecretTool/log</string> + <key>StandardErrorPath</key> + <string>/Users/crupest/.local/state/Crupest.SecretTool/error</string> +</dict> +</plist> |