diff --git a/docs/designs/network-accelerator-ui-v1.md b/docs/designs/network-accelerator-ui-v1.md index eb44840..ce07909 100644 --- a/docs/designs/network-accelerator-ui-v1.md +++ b/docs/designs/network-accelerator-ui-v1.md @@ -47,7 +47,7 @@ ## 核心安装 - 首次使用可直接在“订阅信息”卡片点击“安装核心”。 -- 当前固定使用稳定版 `1.13.12`,按 `windows-amd64`、`darwin-amd64` 或 `darwin-arm64` 自动选择官方包。 +- 当前固定使用稳定版 `1.13.12`,按 `windows-amd64`、`darwin-amd64`、`darwin-arm64`、`linux-amd64` 或 `linux-arm64` 自动选择官方包。Linux 动态包在本机没有其动态链接器时(常见于未启用 nix-ld 的 NixOS)改用同版本官方 musl 静态包。 - 下载地址只允许 SagerNet 官方 GitHub Release,并校验 Release API 对应资产提供的 SHA-256 digest。 - 核心安装到应用数据目录的 `core` 子目录;也兼容应用包内 `core` 目录或系统 `PATH` 中已有的 sing-box。 - Windows/macOS 开启 TUN 仍可能需要系统管理员权限;权限失败时页面保持“未连接”并显示核心错误。 diff --git a/src/NetworkAccelerator.Core/Services/LinuxExecutableFormat.cs b/src/NetworkAccelerator.Core/Services/LinuxExecutableFormat.cs new file mode 100644 index 0000000..7c8d666 --- /dev/null +++ b/src/NetworkAccelerator.Core/Services/LinuxExecutableFormat.cs @@ -0,0 +1,168 @@ +using System.Buffers.Binary; +using System.Text; + +namespace NetworkAccelerator.Core.Services; + +/// +/// File capabilities are applied to the ELF the kernel executes. A shebang script, including the +/// NixOS pattern that execs ld-linux against a sibling sing-box.bin, never receives them. +/// +public static class LinuxExecutableFormat +{ + public static bool CanHoldFileCapabilities(string path) + { + try + { + using var stream = File.OpenRead(path); + var header = new byte[64]; + var read = stream.Read(header, 0, header.Length); + return CanHoldFileCapabilities(header.AsSpan(0, read)); + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException) + { + return false; + } + } + + public static bool CanHoldFileCapabilities(ReadOnlySpan header) + { + if (IsShebang(header)) return false; + return IsElfExecutable(header); + } + + public static bool IsShebang(ReadOnlySpan header) => + header.Length >= 2 && header[0] == (byte)'#' && header[1] == (byte)'!'; + + public static bool IsElfExecutable(ReadOnlySpan header) + { + if (header.Length < 20 || !HasElfMagic(header)) return false; + var klass = header[4]; + var data = header[5]; + if (klass is not (1 or 2) || data is not (1 or 2) || header[6] != 1) return false; + var type = ReadU16(header, 16, data); + return type is 2 or 3; + } + + /// + /// Returns the PT_INTERP path when it is recorded and not present on this machine. + /// Static binaries and scripts return null. + /// + public static string? MissingInterpreter(string path) + { + try + { + var interpreter = ReadInterpreter(path); + if (string.IsNullOrWhiteSpace(interpreter)) return null; + return File.Exists(interpreter) ? null : interpreter; + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException) + { + return null; + } + } + + public static string? ReadInterpreter(string path) + { + using var stream = File.OpenRead(path); + var header = new byte[64]; + var read = stream.Read(header, 0, header.Length); + if (!IsElfExecutable(header.AsSpan(0, read))) return null; + var klass = header[4]; + var data = header[5]; + long phoff; + int phentsize; + int phnum; + if (klass == 2) + { + if (read < 64) return null; + phoff = (long)ReadU64(header, 32, data); + phentsize = ReadU16(header, 54, data); + phnum = ReadU16(header, 56, data); + } + else + { + if (read < 52) return null; + phoff = ReadU32(header, 28, data); + phentsize = ReadU16(header, 42, data); + phnum = ReadU16(header, 44, data); + } + + if (phoff <= 0 || phnum <= 0 || phentsize is < 16 or > 512 || phnum > 128) return null; + if (phoff > 16L * 1024 * 1024) return null; + var tableLength = phentsize * phnum; + var table = new byte[tableLength]; + stream.Position = phoff; + if (stream.Read(table, 0, table.Length) != table.Length) return null; + + for (var index = 0; index < phnum; index++) + { + var entry = table.AsSpan(index * phentsize, phentsize); + if (ReadU32(entry, 0, data) != 3) continue; + long offset; + long size; + if (klass == 2) + { + if (entry.Length < 40) return null; + offset = (long)ReadU64(entry, 8, data); + size = (long)ReadU64(entry, 32, data); + } + else + { + if (entry.Length < 20) return null; + offset = ReadU32(entry, 4, data); + size = ReadU32(entry, 16, data); + } + + if (offset < 0 || size is <= 1 or > 4096) return null; + var text = new byte[size]; + stream.Position = offset; + if (stream.Read(text, 0, text.Length) != text.Length) return null; + var end = Array.IndexOf(text, (byte)0); + if (end < 0) end = text.Length; + var interpreter = Encoding.UTF8.GetString(text, 0, end); + return interpreter.Length > 0 && interpreter[0] == '/' ? interpreter : null; + } + + return null; + } + + public static bool LooksLikeMissingLoader(string? text) + { + if (string.IsNullOrWhiteSpace(text)) return false; + return text.Contains("ld-linux", StringComparison.Ordinal) + || text.Contains("ld.so", StringComparison.Ordinal) + || text.Contains("error while loading shared libraries", StringComparison.OrdinalIgnoreCase) + || text.Contains("cannot open shared object", StringComparison.OrdinalIgnoreCase); + } + + private static bool HasElfMagic(ReadOnlySpan header) => + header.Length >= 4 + && header[0] == 0x7F + && header[1] == (byte)'E' + && header[2] == (byte)'L' + && header[3] == (byte)'F'; + + private static ushort ReadU16(ReadOnlySpan buffer, int offset, byte data) + { + var slice = buffer.Slice(offset, 2); + return data == 2 + ? BinaryPrimitives.ReadUInt16BigEndian(slice) + : BinaryPrimitives.ReadUInt16LittleEndian(slice); + } + + private static uint ReadU32(ReadOnlySpan buffer, int offset, byte data) + { + var slice = buffer.Slice(offset, 4); + return data == 2 + ? BinaryPrimitives.ReadUInt32BigEndian(slice) + : BinaryPrimitives.ReadUInt32LittleEndian(slice); + } + + private static ulong ReadU64(ReadOnlySpan buffer, int offset, byte data) + { + var slice = buffer.Slice(offset, 8); + return data == 2 + ? BinaryPrimitives.ReadUInt64BigEndian(slice) + : BinaryPrimitives.ReadUInt64LittleEndian(slice); + } +} diff --git a/src/NetworkAccelerator.Core/Services/LinuxTunAuthorization.cs b/src/NetworkAccelerator.Core/Services/LinuxTunAuthorization.cs index eacf241..6583937 100644 --- a/src/NetworkAccelerator.Core/Services/LinuxTunAuthorization.cs +++ b/src/NetworkAccelerator.Core/Services/LinuxTunAuthorization.cs @@ -29,7 +29,8 @@ public readonly record struct LinuxTunFacts( bool SetcapAvailable, bool PkexecAvailable, bool FilesystemHonorsFileCapabilities, - bool PlainPkexecExists = false); + bool PlainPkexecExists = false, + bool SourceCanHoldFileCapabilities = true); public static class LinuxTunAuthorization { @@ -38,13 +39,16 @@ public static class LinuxTunAuthorization public static LinuxTunDecision Decide(LinuxTunFacts facts) { - if (facts.CopyExists && facts.CopyHasNetAdmin && facts.HashMatches) + // getcap/xattr on a shell wrapper still report cap_net_admin after setcap, but the kernel + // never grants those capabilities to a shebang or ld-linux script. Ignore them. + var capsUsable = facts.SourceCanHoldFileCapabilities; + if (capsUsable && facts.CopyExists && facts.CopyHasNetAdmin && facts.HashMatches) return LinuxTunDecision.UseCapableCopy; - if (facts.SourceHasNetAdmin) + if (capsUsable && facts.SourceHasNetAdmin) return LinuxTunDecision.UseSource; if (!facts.PkexecAvailable) return facts.PlainPkexecExists ? LinuxTunDecision.SetuidWrapperMissing : LinuxTunDecision.PkexecMissing; - if (facts.SetcapAvailable && facts.FilesystemHonorsFileCapabilities) + if (capsUsable && facts.SetcapAvailable && facts.FilesystemHonorsFileCapabilities) return LinuxTunDecision.CopyAndSetcap; return LinuxTunDecision.LaunchWithPkexec; } @@ -71,6 +75,9 @@ public static LinuxPkexecOutcome ClassifyPkexec(int exitCode, string? standardEr public static bool OutputHasNetAdmin(string? text) => text?.Contains("cap_net_admin", StringComparison.Ordinal) == true; + public static bool PkexecHandedOff(string? command) => + !string.IsNullOrEmpty(command) && !command.Equals("pkexec", StringComparison.Ordinal); + public static bool RequiresSetuidWrapper(string? text) => Contains(text ?? string.Empty, "setuid") || Contains(text ?? string.Empty, "enablePkexecWrapper"); diff --git a/src/NetworkAccelerator.Core/Services/LinuxTunPrivilege.cs b/src/NetworkAccelerator.Core/Services/LinuxTunPrivilege.cs index a2158ec..c71db79 100644 --- a/src/NetworkAccelerator.Core/Services/LinuxTunPrivilege.cs +++ b/src/NetworkAccelerator.Core/Services/LinuxTunPrivilege.cs @@ -1,3 +1,4 @@ +using System.Diagnostics; using System.Security.Cryptography; namespace NetworkAccelerator.Core.Services; @@ -16,19 +17,22 @@ public sealed class LinuxTunPrivilege private readonly ILinuxCommandResolver _commands; private readonly ICapabilityXattrReader _xattr; private readonly IFileCapabilityPolicy _filesystem; + private readonly Func>? _canRun; public LinuxTunPrivilege( string dataDirectory, IProcessRunner processRunner, ILinuxCommandResolver commands, ICapabilityXattrReader xattr, - IFileCapabilityPolicy? filesystem = null) + IFileCapabilityPolicy? filesystem = null, + Func>? canRun = null) { _dataDirectory = dataDirectory; _runner = processRunner; _commands = commands; _xattr = xattr; _filesystem = filesystem ?? new LinuxMountCapabilityPolicy(); + _canRun = canRun; } public Func? BeginAuthorization { get; set; } @@ -39,6 +43,10 @@ public LinuxTunPrivilege( public async Task PrepareAsync(string sourceExecutable, CancellationToken cancellationToken = default) { + var canHold = LinuxExecutableFormat.CanHoldFileCapabilities(sourceExecutable); + if (!canHold) + RemoveCapableCopy(); + var sourceHash = await HashFileAsync(sourceExecutable, cancellationToken).ConfigureAwait(false); var copyPath = CapableCopyPath; var copyExists = File.Exists(copyPath); @@ -56,11 +64,19 @@ public async Task PrepareAsync(string sourceExecutable, Cancella setcap is not null, pkexec is not null, _filesystem.HonorsFileCapabilities(copyPath), - pkexec is null && _commands.HasPlainPkexec())); + pkexec is null && _commands.HasPlainPkexec(), + canHold)); switch (decision) { case LinuxTunDecision.UseCapableCopy: + if (!await CanRunAsync(copyPath, cancellationToken).ConfigureAwait(false)) + { + RemoveCapableCopy(); + return PkexecOrThrow(sourceExecutable, pkexec); + } + + SyncCompanionLibraries(sourceExecutable, copyPath); return LinuxTunLaunch.AsUser(copyPath); case LinuxTunDecision.UseSource: return LinuxTunLaunch.AsUser(sourceExecutable); @@ -83,6 +99,7 @@ public async Task PrepareAsync(string sourceExecutable, Cancella if (!await HasNetAdminAsync(copyPath, cancellationToken).ConfigureAwait(false)) throw new TunAuthorizationException(TunAuthorizationException.Failed("setcap 未生效"), cancelled: false); await File.WriteAllTextAsync(HashSidecarPath, sourceHash, cancellationToken).ConfigureAwait(false); + SyncCompanionLibraries(sourceExecutable, copyPath); return LinuxTunLaunch.AsUser(copyPath); default: throw new InvalidOperationException("未知的 TUN 授权决定。"); @@ -91,6 +108,9 @@ public async Task PrepareAsync(string sourceExecutable, Cancella public async Task HasNetAdminAsync(string path, CancellationToken cancellationToken = default) { + if (!LinuxExecutableFormat.CanHoldFileCapabilities(path)) + return false; + var getcap = _commands.FindGetcap(); if (getcap is not null) { @@ -118,6 +138,67 @@ private void EnsureSetcapSucceeded(ProcessResult result) } } + private LinuxTunLaunch PkexecOrThrow(string sourceExecutable, string? pkexec) + { + if (pkexec is not null) + return LinuxTunLaunch.WithPkexec(sourceExecutable, pkexec); + if (_commands.HasPlainPkexec()) + throw new TunAuthorizationException(TunAuthorizationException.SetuidWrapperMessage, cancelled: false, setuidWrapperMissing: true); + throw new TunAuthorizationException(TunAuthorizationException.PkexecMissingMessage, cancelled: false); + } + + private Task CanRunAsync(string path, CancellationToken cancellationToken) => + _canRun is not null + ? _canRun(path, cancellationToken) + : SingBoxLaunchProbe.CanRunAsync(path, cancellationToken); + + private void RemoveCapableCopy() + { + TryDelete(CapableCopyPath); + TryDelete(HashSidecarPath); + TryDelete(CapableCopyPath + ".tmp"); + var directory = Path.GetDirectoryName(CapableCopyPath); + if (directory is null) return; + foreach (var name in SingBoxCoreLayout.CompanionFileNames) + TryDelete(Path.Combine(directory, name)); + } + + private static void SyncCompanionLibraries(string sourceExecutable, string destinationExecutable) + { + var sourceDirectory = Path.GetDirectoryName(sourceExecutable); + var destinationDirectory = Path.GetDirectoryName(destinationExecutable); + if (sourceDirectory is null || destinationDirectory is null) return; + foreach (var name in SingBoxCoreLayout.CompanionFileNames) + { + var from = Path.Combine(sourceDirectory, name); + var to = Path.Combine(destinationDirectory, name); + if (!File.Exists(from)) + { + TryDelete(to); + continue; + } + + var sourceInfo = new FileInfo(from); + var destinationInfo = new FileInfo(to); + if (destinationInfo.Exists && destinationInfo.Length == sourceInfo.Length) continue; + Directory.CreateDirectory(destinationDirectory); + var temporary = to + ".tmp"; + File.Copy(from, temporary, overwrite: true); + File.Move(temporary, to, overwrite: true); + } + } + + private static void TryDelete(string path) + { + try + { + if (File.Exists(path)) File.Delete(path); + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException) + { + } + } + private IDisposable BeginScope() => BeginAuthorization?.Invoke() ?? EmptyDisposable.Instance; private void CopyExecutable(string source, string destination) @@ -165,3 +246,56 @@ public void Dispose() } } } + +public static class SingBoxLaunchProbe +{ + public static async Task CanRunAsync(string executable, CancellationToken cancellationToken) + { + Process? process = null; + try + { + using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + timeout.CancelAfter(TimeSpan.FromSeconds(5)); + var info = new ProcessStartInfo(executable) + { + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true + }; + info.ArgumentList.Add("version"); + process = new Process { StartInfo = info }; + if (!process.Start()) return false; + var stdout = process.StandardOutput.ReadToEndAsync(timeout.Token); + var stderr = process.StandardError.ReadToEndAsync(timeout.Token); + await process.WaitForExitAsync(timeout.Token).ConfigureAwait(false); + await Task.WhenAll(stdout, stderr).ConfigureAwait(false); + return process.ExitCode == 0; + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + TryKill(process); + throw; + } + catch + { + TryKill(process); + return false; + } + finally + { + process?.Dispose(); + } + } + + private static void TryKill(Process? process) + { + try + { + if (process is { HasExited: false }) process.Kill(entireProcessTree: true); + } + catch + { + } + } +} diff --git a/src/NetworkAccelerator.Core/Services/SingBoxCoreLayout.cs b/src/NetworkAccelerator.Core/Services/SingBoxCoreLayout.cs new file mode 100644 index 0000000..409edf3 --- /dev/null +++ b/src/NetworkAccelerator.Core/Services/SingBoxCoreLayout.cs @@ -0,0 +1,100 @@ +using System.Formats.Tar; +using System.IO.Compression; + +namespace NetworkAccelerator.Core.Services; + +public static class SingBoxCoreLayout +{ + public static readonly string[] CompanionFileNames = ["libcronet.so", "libcronet.dylib", "libcronet.dll"]; + + public static async Task ExtractAsync( + string archivePath, + string destinationDirectory, + string executableName, + CancellationToken cancellationToken) + { + Directory.CreateDirectory(destinationDirectory); + if (archivePath.EndsWith(".zip", StringComparison.OrdinalIgnoreCase)) + await ExtractZipAsync(archivePath, destinationDirectory, executableName, cancellationToken).ConfigureAwait(false); + else + await ExtractTarGzipAsync(archivePath, destinationDirectory, executableName, cancellationToken).ConfigureAwait(false); + + if (!File.Exists(Path.Combine(destinationDirectory, executableName))) + throw new InvalidDataException("sing-box 下载包中未找到核心文件"); + } + + private static async Task ExtractZipAsync( + string archivePath, + string destinationDirectory, + string executableName, + CancellationToken cancellationToken) + { + using var archive = ZipFile.OpenRead(archivePath); + var wroteExecutable = false; + var wroteCompanions = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var entry in archive.Entries) + { + var name = Path.GetFileName(entry.FullName); + if (!ShouldExtract(name, executableName, wroteExecutable, wroteCompanions)) continue; + var destinationName = CanonicalName(name, executableName); + await using var input = entry.Open(); + await CopyEntryAsync(input, Path.Combine(destinationDirectory, destinationName), cancellationToken).ConfigureAwait(false); + if (destinationName.Equals(executableName, StringComparison.OrdinalIgnoreCase)) wroteExecutable = true; + else wroteCompanions.Add(destinationName); + } + } + + private static async Task ExtractTarGzipAsync( + string archivePath, + string destinationDirectory, + string executableName, + CancellationToken cancellationToken) + { + await using var archiveStream = File.OpenRead(archivePath); + await using var gzip = new GZipStream(archiveStream, CompressionMode.Decompress); + using var reader = new TarReader(gzip, leaveOpen: false); + var wroteExecutable = false; + var wroteCompanions = new HashSet(StringComparer.OrdinalIgnoreCase); + while (reader.GetNextEntry(copyData: false) is { } entry) + { + if (entry.EntryType is not TarEntryType.RegularFile || entry.DataStream is null) continue; + var name = Path.GetFileName(entry.Name.Replace('\\', '/')); + if (!ShouldExtract(name, executableName, wroteExecutable, wroteCompanions)) + { + await entry.DataStream.CopyToAsync(Stream.Null, cancellationToken).ConfigureAwait(false); + continue; + } + + var destinationName = CanonicalName(name, executableName); + await CopyEntryAsync(entry.DataStream, Path.Combine(destinationDirectory, destinationName), cancellationToken).ConfigureAwait(false); + if (destinationName.Equals(executableName, StringComparison.OrdinalIgnoreCase)) wroteExecutable = true; + else wroteCompanions.Add(destinationName); + } + } + + private static string CanonicalName(string name, string executableName) + { + if (name.Equals(executableName, StringComparison.OrdinalIgnoreCase)) return executableName; + foreach (var companion in CompanionFileNames) + { + if (companion.Equals(name, StringComparison.OrdinalIgnoreCase)) return companion; + } + + return name; + } + + private static bool ShouldExtract(string name, string executableName, bool wroteExecutable, HashSet wroteCompanions) + { + if (name.Length == 0) return false; + if (name.Equals(executableName, StringComparison.OrdinalIgnoreCase)) return !wroteExecutable; + return CompanionFileNames.Contains(name, StringComparer.OrdinalIgnoreCase) && !wroteCompanions.Contains(name); + } + + private static async Task CopyEntryAsync(Stream input, string destination, CancellationToken cancellationToken) + { + var temporary = destination + ".partial"; + await using (var output = File.Create(temporary)) + await input.CopyToAsync(output, cancellationToken).ConfigureAwait(false); + File.Move(temporary, destination, overwrite: true); + } +} diff --git a/src/NetworkAccelerator.Core/Services/SingBoxEngineService.cs b/src/NetworkAccelerator.Core/Services/SingBoxEngineService.cs index cd96ee4..b41e589 100644 --- a/src/NetworkAccelerator.Core/Services/SingBoxEngineService.cs +++ b/src/NetworkAccelerator.Core/Services/SingBoxEngineService.cs @@ -194,7 +194,8 @@ private async Task WaitForPkexecHandoffAsync(Process process, StringBuilder erro if (TryReadComm(process.Id, out var command)) { - if (command.Equals("sing-box", StringComparison.Ordinal)) return; + // A NixOS wrapper is exec'd as sh and then ld-linux, so comm is not "sing-box". + if (LinuxTunAuthorization.PkexecHandedOff(command)) return; } else if (Interlocked.CompareExchange(ref _pkexecOutputSeen, 0, 0) == 1 && !LooksLikePkexec(error)) { diff --git a/src/NetworkAccelerator.Core/Services/SingBoxInstallerService.cs b/src/NetworkAccelerator.Core/Services/SingBoxInstallerService.cs index d9ada57..a52ce00 100644 --- a/src/NetworkAccelerator.Core/Services/SingBoxInstallerService.cs +++ b/src/NetworkAccelerator.Core/Services/SingBoxInstallerService.cs @@ -1,6 +1,5 @@ +using System.ComponentModel; using System.Diagnostics; -using System.Formats.Tar; -using System.IO.Compression; using System.Runtime.InteropServices; using System.Security.Cryptography; using System.Text.Json.Nodes; @@ -26,13 +25,27 @@ public SingBoxInstallerService(string dataDirectory) public async Task InstallAsync(CancellationToken cancellationToken = default) { var package = GetPackageName(); + try + { + return await InstallPackageAsync(package, cancellationToken).ConfigureAwait(false); + } + catch (LinuxDynamicLoaderException) when (MuslFallbackPackage(package) is { } musl) + { + // The plain linux asset is dynamically linked. NixOS without nix-ld cannot start it. + // The matching official musl asset is a static ELF and does not need a system package. + return await InstallPackageAsync(musl, cancellationToken).ConfigureAwait(false); + } + } + + private async Task InstallPackageAsync(string package, CancellationToken cancellationToken) + { var (archiveUrl, expectedHash) = await GetReleaseAssetAsync(package, cancellationToken).ConfigureAwait(false); Directory.CreateDirectory(_coreDirectory); var archivePath = Path.Combine(_coreDirectory, package + ".download"); var executableName = OperatingSystem.IsWindows() ? "sing-box.exe" : "sing-box"; var executablePath = Path.Combine(_coreDirectory, executableName); - var temporaryExecutable = executablePath + ".new"; + var staging = Path.Combine(_coreDirectory, ".staging-" + Guid.NewGuid().ToString("N")); try { @@ -41,26 +54,41 @@ public async Task InstallAsync(CancellationToken cancellationToken = def if (!actualHash.Equals(expectedHash, StringComparison.OrdinalIgnoreCase)) throw new InvalidDataException("sing-box 下载包校验失败,文件可能不完整"); - if (File.Exists(temporaryExecutable)) File.Delete(temporaryExecutable); - if (package.EndsWith(".zip", StringComparison.OrdinalIgnoreCase)) - await ExtractFromZipAsync(archivePath, temporaryExecutable, executableName, cancellationToken).ConfigureAwait(false); - else - await ExtractFromTarGzipAsync(archivePath, temporaryExecutable, executableName, cancellationToken).ConfigureAwait(false); - + Directory.CreateDirectory(staging); + await SingBoxCoreLayout.ExtractAsync(archivePath, staging, executableName, cancellationToken).ConfigureAwait(false); + var stagedExecutable = Path.Combine(staging, executableName); if (!OperatingSystem.IsWindows()) - File.SetUnixFileMode(temporaryExecutable, - UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute | - UnixFileMode.GroupRead | UnixFileMode.GroupExecute | - UnixFileMode.OtherRead | UnixFileMode.OtherExecute); + File.SetUnixFileMode(stagedExecutable, ExecutableMode); - await VerifyAsync(temporaryExecutable, cancellationToken).ConfigureAwait(false); - File.Move(temporaryExecutable, executablePath, overwrite: true); + await VerifyAsync(stagedExecutable, cancellationToken).ConfigureAwait(false); + File.Move(stagedExecutable, executablePath, overwrite: true); + InstallCompanions(staging); return executablePath; } finally { TryDelete(archivePath); - TryDelete(temporaryExecutable); + TryDeleteDirectory(staging); + } + } + + private void InstallCompanions(string staging) + { + foreach (var name in SingBoxCoreLayout.CompanionFileNames) + { + var from = Path.Combine(staging, name); + var to = Path.Combine(_coreDirectory, name); + if (!File.Exists(from)) + { + TryDelete(to); + continue; + } + + File.Move(from, to, overwrite: true); + if (!OperatingSystem.IsWindows()) + File.SetUnixFileMode(to, + UnixFileMode.UserRead | UnixFileMode.UserWrite | + UnixFileMode.GroupRead | UnixFileMode.OtherRead); } } @@ -114,34 +142,11 @@ private static async Task ComputeSha256Async(string path, CancellationTo return Convert.ToHexString(hash); } - private static async Task ExtractFromZipAsync(string archivePath, string destination, string executableName, CancellationToken cancellationToken) - { - using var archive = ZipFile.OpenRead(archivePath); - var entry = archive.Entries.FirstOrDefault(candidate => - Path.GetFileName(candidate.FullName).Equals(executableName, StringComparison.OrdinalIgnoreCase)) - ?? throw new InvalidDataException("sing-box 下载包中未找到核心文件"); - await using var input = entry.Open(); - await using var output = File.Create(destination); - await input.CopyToAsync(output, cancellationToken).ConfigureAwait(false); - } - - private static async Task ExtractFromTarGzipAsync(string archivePath, string destination, string executableName, CancellationToken cancellationToken) - { - await using var archiveStream = File.OpenRead(archivePath); - await using var gzip = new GZipStream(archiveStream, CompressionMode.Decompress); - using var reader = new TarReader(gzip, leaveOpen: false); - while (reader.GetNextEntry(copyData: false) is { } entry) - { - if (!Path.GetFileName(entry.Name).Equals(executableName, StringComparison.Ordinal) || entry.DataStream is null) continue; - await using var output = File.Create(destination); - await entry.DataStream.CopyToAsync(output, cancellationToken).ConfigureAwait(false); - return; - } - throw new InvalidDataException("sing-box 下载包中未找到核心文件"); - } - private static async Task VerifyAsync(string executable, CancellationToken cancellationToken) { + if (LinuxExecutableFormat.MissingInterpreter(executable) is { } missing) + throw new LinuxDynamicLoaderException(missing); + var info = new ProcessStartInfo(executable) { UseShellExecute = false, @@ -150,28 +155,125 @@ private static async Task VerifyAsync(string executable, CancellationToken cance CreateNoWindow = true }; info.ArgumentList.Add("version"); - using var process = Process.Start(info) ?? throw new InvalidOperationException("无法验证 sing-box 核心"); + using var process = new Process { StartInfo = info }; + try + { + if (!process.Start()) throw new InvalidOperationException("无法验证 sing-box 核心"); + } + catch (Exception exception) when (exception is Win32Exception or IOException) + { + var interpreter = LinuxExecutableFormat.MissingInterpreter(executable) ?? exception.GetBaseException().Message; + throw new LinuxDynamicLoaderException(interpreter, exception); + } + + var stdoutTask = process.StandardOutput.ReadToEndAsync(cancellationToken); + var stderrTask = process.StandardError.ReadToEndAsync(cancellationToken); await process.WaitForExitAsync(cancellationToken).ConfigureAwait(false); - if (process.ExitCode != 0) throw new InvalidDataException("下载的 sing-box 核心无法运行"); + var stdout = await stdoutTask.ConfigureAwait(false); + var stderr = await stderrTask.ConfigureAwait(false); + if (process.ExitCode == 0 + && (stdout.Contains("sing-box", StringComparison.OrdinalIgnoreCase) + || stderr.Contains("sing-box", StringComparison.OrdinalIgnoreCase))) + return; + + var detail = FirstLine(stderr) ?? FirstLine(stdout); + if (LinuxExecutableFormat.LooksLikeMissingLoader(detail) + || LinuxExecutableFormat.LooksLikeMissingLoader(stdout) + || LinuxExecutableFormat.LooksLikeMissingLoader(stderr)) + throw new LinuxDynamicLoaderException(string.IsNullOrWhiteSpace(detail) ? "动态链接器" : detail); + throw new InvalidDataException("下载的 sing-box 核心无法运行"); } - private static string GetPackageName() + public static string GetPackageName() => + GetPackageName(CurrentOperatingSystem(), RuntimeInformation.ProcessArchitecture); + + public static string GetPackageName(string operatingSystem, Architecture architecture) { - var os = OperatingSystem.IsWindows() ? "windows" : OperatingSystem.IsMacOS() ? "darwin" : - throw new PlatformNotSupportedException("当前只支持在 Windows 或 macOS 安装 sing-box 核心"); - var architecture = RuntimeInformation.ProcessArchitecture switch + var os = operatingSystem switch + { + "windows" or "darwin" or "linux" => operatingSystem, + _ => throw new PlatformNotSupportedException("当前只支持在 Windows、macOS 或 Linux 安装 sing-box 核心") + }; + var arch = architecture switch { Architecture.X64 => "amd64", Architecture.Arm64 => "arm64", - _ => throw new PlatformNotSupportedException($"不支持的处理器架构:{RuntimeInformation.ProcessArchitecture}") + _ => throw new PlatformNotSupportedException($"不支持的处理器架构:{architecture}") }; - var extension = OperatingSystem.IsWindows() ? "zip" : "tar.gz"; - return $"sing-box-{StableVersion}-{os}-{architecture}.{extension}"; + var extension = os == "windows" ? "zip" : "tar.gz"; + return $"sing-box-{StableVersion}-{os}-{arch}.{extension}"; + } + + public static string? MuslFallbackPackage(string package) + { + const string suffix = ".tar.gz"; + if (!package.EndsWith(suffix, StringComparison.Ordinal) + || !package.Contains("-linux-", StringComparison.Ordinal) + || package.Contains("-musl", StringComparison.Ordinal)) + return null; + return package[..^suffix.Length] + "-musl.tar.gz"; + } + + public static string MissingDynamicLoaderMessage(string interpreter) + { + var loader = string.IsNullOrWhiteSpace(interpreter) ? "动态链接器" : interpreter.Trim(); + var line = loader.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries).FirstOrDefault()?.Trim() ?? loader; + if (line.Length > 180) line = line[..180]; + return $"下载的 sing-box 核心无法在当前系统直接运行:缺少动态链接器 {line}。NixOS 请设置 programs.nix-ld.enable = true; 后重新构建系统,或将已能运行的 sing-box 放进应用数据目录的 core 文件夹。"; } + public static bool IsLinuxLoaderFailure(Exception exception) + { + for (var current = exception; current is not null; current = current.InnerException) + { + if (current is LinuxDynamicLoaderException) return true; + } + + return false; + } + + private static string CurrentOperatingSystem() => + OperatingSystem.IsWindows() ? "windows" + : OperatingSystem.IsMacOS() ? "darwin" + : OperatingSystem.IsLinux() ? "linux" + : throw new PlatformNotSupportedException("当前只支持在 Windows、macOS 或 Linux 安装 sing-box 核心"); + + private static string? FirstLine(string? text) + { + if (string.IsNullOrWhiteSpace(text)) return null; + return text.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries).FirstOrDefault()?.Trim(); + } + + private const UnixFileMode ExecutableMode = + UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute | + UnixFileMode.GroupRead | UnixFileMode.GroupExecute | + UnixFileMode.OtherRead | UnixFileMode.OtherExecute; + private static void TryDelete(string path) { try { if (File.Exists(path)) File.Delete(path); } catch { } } + + private static void TryDeleteDirectory(string path) + { + try { if (Directory.Exists(path)) Directory.Delete(path, recursive: true); } + catch { } + } +} + +public sealed class LinuxDynamicLoaderException : Exception +{ + public LinuxDynamicLoaderException(string interpreter) + : this(interpreter, null) + { + } + + public LinuxDynamicLoaderException(string interpreter, Exception? inner) + : base(SingBoxInstallerService.MissingDynamicLoaderMessage(interpreter), inner) + { + Interpreter = interpreter; + } + + public string Interpreter { get; } } diff --git a/tests/NetworkAccelerator.Checks/Program.cs b/tests/NetworkAccelerator.Checks/Program.cs index 34959ff..eb2359d 100644 --- a/tests/NetworkAccelerator.Checks/Program.cs +++ b/tests/NetworkAccelerator.Checks/Program.cs @@ -1,5 +1,10 @@ using System.Buffers.Binary; +using System.Formats.Tar; +using System.IO.Compression; using System.Reflection; +using System.Runtime.InteropServices; +using System.Security.Cryptography; +using System.Text; using System.Text.Json; using System.Text.Json.Nodes; using AsterDock.Contracts; @@ -278,6 +283,11 @@ [new ProxyNode("node-a", "socks", "127.0.0.1", 1, outbound)], } Check(LinuxTunAuthorization.OutputHasNetAdmin("/opt/sing-box cap_net_admin,cap_net_bind_service=ep"), "getcap output reports net admin"); +Check(LinuxTunAuthorization.PkexecHandedOff("sing-box"), "direct sing-box is a pkexec handoff"); +Check(LinuxTunAuthorization.PkexecHandedOff("sh"), "shell wrapper is a pkexec handoff"); +Check(LinuxTunAuthorization.PkexecHandedOff("ld-linux-x86-64"), "ld-linux comm is a pkexec handoff"); +Check(!LinuxTunAuthorization.PkexecHandedOff("pkexec"), "pkexec dialog is still waiting"); +Check(!LinuxTunAuthorization.PkexecHandedOff(null) && !LinuxTunAuthorization.PkexecHandedOff(""), "missing comm is not a handoff"); Check(!LinuxTunAuthorization.OutputHasNetAdmin("/opt/sing-box"), "getcap output without caps"); Check(!LinuxTunAuthorization.OutputHasNetAdmin(null), "missing getcap output"); var capableXattr = new byte[20]; @@ -307,8 +317,8 @@ [new ProxyNode("node-a", "socks", "127.0.0.1", 1, outbound)], Check(!LinuxMountOptions.IsNoSuid("/home/user/.local/share/app", ["overlay / overlay rw 0 0", "/dev/nvme0n1p2 /home ext4 rw,nodev 0 0"]), "home honors file capabilities"); -static LinuxTunFacts Facts(bool copy, bool copyCap, bool hash, bool sourceCap, bool setcap, bool pkexec, bool honors = true, bool plainPkexec = false) => - new(copy, copyCap, hash, sourceCap, setcap, pkexec, honors, plainPkexec); +static LinuxTunFacts Facts(bool copy, bool copyCap, bool hash, bool sourceCap, bool setcap, bool pkexec, bool honors = true, bool plainPkexec = false, bool canHold = true) => + new(copy, copyCap, hash, sourceCap, setcap, pkexec, honors, plainPkexec, canHold); Check(LinuxTunAuthorization.Decide(Facts(true, true, true, false, true, true)) == LinuxTunDecision.UseCapableCopy, "reuse capable copy"); Check(LinuxTunAuthorization.Decide(Facts(false, false, false, true, true, true)) == LinuxTunDecision.UseSource, "source already capable"); Check(LinuxTunAuthorization.Decide(Facts(true, true, false, false, true, true)) == LinuxTunDecision.CopyAndSetcap, "hash change reauthorizes"); @@ -317,13 +327,73 @@ static LinuxTunFacts Facts(bool copy, bool copyCap, bool hash, bool sourceCap, b Check(LinuxTunAuthorization.Decide(Facts(false, false, false, false, true, true, honors: false)) == LinuxTunDecision.LaunchWithPkexec, "nosuid filesystem uses pkexec"); Check(LinuxTunAuthorization.Decide(Facts(false, false, false, false, false, false)) == LinuxTunDecision.PkexecMissing, "no pkexec cannot authorize"); Check(LinuxTunAuthorization.Decide(Facts(false, false, false, false, true, false, plainPkexec: true)) == LinuxTunDecision.SetuidWrapperMissing, "pkexec without setuid asks for the NixOS wrapper"); +Check(LinuxTunAuthorization.Decide(Facts(true, true, true, true, true, true, canHold: false)) == LinuxTunDecision.LaunchWithPkexec, "wrapper ignores a capable copy and source caps"); +Check(LinuxTunAuthorization.Decide(Facts(false, false, false, false, true, true, canHold: false)) == LinuxTunDecision.LaunchWithPkexec, "wrapper is not copied for setcap"); +Check(LinuxTunAuthorization.Decide(Facts(true, true, true, true, true, false, canHold: false)) == LinuxTunDecision.PkexecMissing, "wrapper without pkexec keeps the missing pkexec error"); +Check(LinuxTunAuthorization.Decide(Facts(true, true, true, true, true, false, plainPkexec: true, canHold: false)) == LinuxTunDecision.SetuidWrapperMissing, "wrapper without setuid pkexec keeps the NixOS wrapper error"); + +var nixWrapper = Encoding.ASCII.GetBytes("#!/bin/sh\nexec /nix/store/deadbeef-glibc/lib/ld-linux-x86-64.so.2 --library-path /nix/store/deadbeef-glibc/lib \"$DIR/sing-box.bin\" \"$@\"\n"); +Check(!LinuxExecutableFormat.CanHoldFileCapabilities(nixWrapper), "nix ld-linux wrapper cannot hold file caps"); +Check(LinuxTunAuthorization.OutputHasNetAdmin("bin/sing-box cap_net_admin,cap_net_bind_service=ep") + && !LinuxExecutableFormat.CanHoldFileCapabilities(nixWrapper), "getcap on a wrapper is not usable"); +Check(!LinuxExecutableFormat.CanHoldFileCapabilities("sing-box-v1"u8), "plain text cannot hold file caps"); +Check(!LinuxExecutableFormat.CanHoldFileCapabilities([(byte)0x7F, (byte)'E', (byte)'L', (byte)'F']), "short ELF header cannot hold file caps"); +var elfExec = new byte[64]; +elfExec[0] = 0x7F; elfExec[1] = (byte)'E'; elfExec[2] = (byte)'L'; elfExec[3] = (byte)'F'; +elfExec[4] = 2; elfExec[5] = 1; elfExec[6] = 1; +BinaryPrimitives.WriteUInt16LittleEndian(elfExec.AsSpan(16), 2); +Check(LinuxExecutableFormat.CanHoldFileCapabilities(elfExec), "ELF executable can hold file caps"); +BinaryPrimitives.WriteUInt16LittleEndian(elfExec.AsSpan(16), 3); +Check(LinuxExecutableFormat.CanHoldFileCapabilities(elfExec), "PIE ELF can hold file caps"); +BinaryPrimitives.WriteUInt16LittleEndian(elfExec.AsSpan(16), 1); +Check(!LinuxExecutableFormat.CanHoldFileCapabilities(elfExec), "relocatable ELF cannot hold file caps"); +var bigEndianElf = new byte[20]; +bigEndianElf[0] = 0x7F; bigEndianElf[1] = (byte)'E'; bigEndianElf[2] = (byte)'L'; bigEndianElf[3] = (byte)'F'; +bigEndianElf[4] = 2; bigEndianElf[5] = 2; bigEndianElf[6] = 1; +bigEndianElf[16] = 0; bigEndianElf[17] = 2; +Check(LinuxExecutableFormat.CanHoldFileCapabilities(bigEndianElf), "big-endian ELF executable can hold file caps"); + +var packageVersion = SingBoxInstallerService.StableVersion; +Check(SingBoxInstallerService.GetPackageName("linux", Architecture.X64) == $"sing-box-{packageVersion}-linux-amd64.tar.gz", "linux amd64 package"); +Check(SingBoxInstallerService.GetPackageName("linux", Architecture.Arm64) == $"sing-box-{packageVersion}-linux-arm64.tar.gz", "linux arm64 package"); +Check(SingBoxInstallerService.GetPackageName("windows", Architecture.X64) == $"sing-box-{packageVersion}-windows-amd64.zip", "windows package unchanged"); +Check(SingBoxInstallerService.GetPackageName("windows", Architecture.Arm64) == $"sing-box-{packageVersion}-windows-arm64.zip", "windows arm64 package unchanged"); +Check(SingBoxInstallerService.GetPackageName("darwin", Architecture.X64) == $"sing-box-{packageVersion}-darwin-amd64.tar.gz", "macos amd64 package unchanged"); +Check(SingBoxInstallerService.GetPackageName("darwin", Architecture.Arm64) == $"sing-box-{packageVersion}-darwin-arm64.tar.gz", "macos arm64 package unchanged"); +if (OperatingSystem.IsLinux() && RuntimeInformation.ProcessArchitecture == Architecture.X64) + Check(SingBoxInstallerService.GetPackageName() == $"sing-box-{packageVersion}-linux-amd64.tar.gz", "this linux host selects the official amd64 archive"); +var rejectedArch = false; +try { SingBoxInstallerService.GetPackageName("linux", Architecture.Arm); } +catch (PlatformNotSupportedException) { rejectedArch = true; } +Check(rejectedArch, "linux rejects unsupported architectures"); +var rejectedOs = false; +try { SingBoxInstallerService.GetPackageName("freebsd", Architecture.X64); } +catch (PlatformNotSupportedException) { rejectedOs = true; } +Check(rejectedOs, "unknown operating system is rejected"); +var linuxPackage = SingBoxInstallerService.GetPackageName("linux", Architecture.X64); +var muslPackage = SingBoxInstallerService.MuslFallbackPackage(linuxPackage); +Check(muslPackage == $"sing-box-{packageVersion}-linux-amd64-musl.tar.gz", "missing loader falls back to the official musl archive"); +Check(SingBoxInstallerService.MuslFallbackPackage(muslPackage!) is null, "musl archive is not wrapped again"); +Check(SingBoxInstallerService.MuslFallbackPackage(SingBoxInstallerService.GetPackageName("darwin", Architecture.Arm64)) is null, "macos archive has no musl fallback"); +Check(SingBoxInstallerService.MuslFallbackPackage(SingBoxInstallerService.GetPackageName("windows", Architecture.X64)) is null, "windows archive has no musl fallback"); +var loaderMessage = SingBoxInstallerService.MissingDynamicLoaderMessage("/lib64/ld-linux-x86-64.so.2"); +Check(loaderMessage.Contains("/lib64/ld-linux-x86-64.so.2", StringComparison.Ordinal) + && loaderMessage.Contains("programs.nix-ld.enable = true;", StringComparison.Ordinal) + && loaderMessage.Contains("core", StringComparison.Ordinal), "missing loader message names nix-ld and the core folder"); +var loaderFailure = new LinuxDynamicLoaderException("/lib64/ld-linux-x86-64.so.2"); +Check(SingBoxInstallerService.IsLinuxLoaderFailure(loaderFailure), "loader exception is recognized"); +Check(!SingBoxInstallerService.IsLinuxLoaderFailure(new InvalidDataException("sing-box 下载包校验失败,文件可能不完整")), "hash failures do not switch to musl"); +Check(!SingBoxInstallerService.IsLinuxLoaderFailure(new InvalidDataException("下载的 sing-box 核心无法运行")), "generic version failure does not switch to musl"); +Check(LinuxExecutableFormat.LooksLikeMissingLoader("error while loading shared libraries: libc.so.6: cannot open shared object file"), "shared library failure looks like a missing loader"); +Check(!LinuxExecutableFormat.LooksLikeMissingLoader("sing-box 配置检查失败"), "config errors are not loader failures"); var authRoot = Path.Combine(Path.GetTempPath(), "na-tun-auth-" + Guid.NewGuid().ToString("N")); Directory.CreateDirectory(authRoot); try { var source = Path.Combine(authRoot, "sing-box-source"); - await File.WriteAllTextAsync(source, "sing-box-v1"); + WriteElfExecutable(source, "sing-box-v1"); + await File.WriteAllBytesAsync(Path.Combine(authRoot, "libcronet.so"), "cronet-a"u8.ToArray()); var capable = new HashSet(StringComparer.Ordinal); var commands = new FakeCommands(); var runner = new FakeRunner(request => @@ -344,7 +414,9 @@ static LinuxTunFacts Facts(bool copy, bool copyCap, bool hash, bool sourceCap, b return new ProcessResult(false, -1, string.Empty, "unexpected command"); }); var scopes = 0; - var privilege = new LinuxTunPrivilege(Path.Combine(authRoot, "data"), runner, commands, new FakeXattr(), new HonoringFilesystem()) + Func> canRun = (_, _) => Task.FromResult(true); + var privilege = new LinuxTunPrivilege(Path.Combine(authRoot, "data"), runner, commands, new FakeXattr(), new HonoringFilesystem(), + canRun: (path, token) => canRun(path, token)) { BeginAuthorization = () => { @@ -354,6 +426,7 @@ static LinuxTunFacts Facts(bool copy, bool copyCap, bool hash, bool sourceCap, b }; var first = await privilege.PrepareAsync(source); Check(first.Executable == privilege.CapableCopyPath && !first.ViaPkexec, "first TUN run uses the capable copy"); + Check(File.ReadAllBytes(Path.Combine(Path.GetDirectoryName(privilege.CapableCopyPath)!, "libcronet.so")).AsSpan().SequenceEqual("cronet-a"u8), "capable copy keeps libcronet.so"); Check(scopes == 1, "authorization shows one busy scope"); Check(runner.Calls.Any(call => call.FileName == commands.PkexecPath && call.Arguments[0] == commands.SetcapPath && call.Arguments[1] == LinuxTunAuthorization.CapabilityArguments @@ -365,11 +438,13 @@ static LinuxTunFacts Facts(bool copy, bool copyCap, bool hash, bool sourceCap, b Check(runner.Calls.Skip(callsAfterFirst).All(call => call.FileName != commands.PkexecPath), "later run does not prompt"); Check(scopes == 1, "later run has no authorization scope"); - await File.WriteAllTextAsync(source, "sing-box-v2"); + WriteElfExecutable(source, "sing-box-v2"); + await File.WriteAllBytesAsync(Path.Combine(authRoot, "libcronet.so"), "cronet-bb"u8.ToArray()); var recopied = await privilege.PrepareAsync(source); Check(recopied.Executable == privilege.CapableCopyPath, "changed binary is copied again"); Check(scopes == 2, "changed binary asks for authorization again"); - Check((await File.ReadAllTextAsync(privilege.CapableCopyPath)) == "sing-box-v2", "copy matches the new binary"); + Check(File.ReadAllBytes(privilege.CapableCopyPath).AsSpan().SequenceEqual(File.ReadAllBytes(source)), "copy matches the new binary"); + Check(File.ReadAllBytes(Path.Combine(Path.GetDirectoryName(privilege.CapableCopyPath)!, "libcronet.so")).AsSpan().SequenceEqual("cronet-bb"u8), "updated core refreshes libcronet.so"); commands.SetcapPath = null; capable.Clear(); @@ -430,6 +505,96 @@ static LinuxTunFacts Facts(bool copy, bool copyCap, bool hash, bool sourceCap, b var fromXattr = new LinuxTunPrivilege(Path.Combine(authRoot, "xattr"), runner, commands, xattrReader, new HonoringFilesystem()); var direct = await fromXattr.PrepareAsync(source); Check(direct.Executable == source && !direct.ViaPkexec, "security.capability xattr authorizes the source binary"); + + runner.Handle = request => + { + if (request.FileName == commands.GetcapPath) + { + var target = request.Arguments[0]; + var text = capable.Contains(target) ? $"{target} cap_net_admin,cap_net_bind_service=ep\n" : target + "\n"; + return new ProcessResult(true, 0, text, string.Empty); + } + + if (request.Arguments.Count > 0 && request.Arguments[0] == commands.SetcapPath) + { + capable.Add(request.Arguments[^1]); + return new ProcessResult(true, 0, string.Empty, string.Empty); + } + + return new ProcessResult(false, -1, string.Empty, "unexpected command"); + }; + commands.GetcapPath = "/usr/bin/getcap"; + commands.SetcapPath = "/usr/bin/setcap"; + commands.PkexecPath = "/usr/bin/pkexec"; + commands.PlainPkexec = false; + + var wrapped = Path.Combine(authRoot, "wrapped-sing-box"); + await File.WriteAllTextAsync(wrapped, "#!/bin/sh\nDIR=$(dirname \"$0\")\nexec /nix/store/deadbeef-glibc/lib/ld-linux-x86-64.so.2 --library-path /nix/store/deadbeef-glibc/lib \"$DIR/sing-box.bin\" \"$@\"\n"); + var wrapData = Path.Combine(authRoot, "wrap-data"); + var probed = false; + var wrapPrivilege = new LinuxTunPrivilege(wrapData, runner, commands, new FakeXattr { Read = _ => capableXattr }, new HonoringFilesystem(), + canRun: (_, _) => + { + probed = true; + return Task.FromResult(true); + }); + Check(!await wrapPrivilege.HasNetAdminAsync(wrapped), "wrapper getcap and xattr caps are ignored"); + Directory.CreateDirectory(Path.GetDirectoryName(wrapPrivilege.CapableCopyPath)!); + await File.WriteAllTextAsync(wrapPrivilege.CapableCopyPath, await File.ReadAllTextAsync(wrapped)); + await File.WriteAllTextAsync(wrapPrivilege.HashSidecarPath, Sha256File(wrapped)); + await File.WriteAllBytesAsync(Path.Combine(Path.GetDirectoryName(wrapPrivilege.CapableCopyPath)!, "libcronet.so"), "stale-cronet"u8.ToArray()); + capable.Add(wrapPrivilege.CapableCopyPath); + capable.Add(wrapped); + var callsBeforeWrap = runner.Calls.Count; + var wrappedLaunch = await wrapPrivilege.PrepareAsync(wrapped); + Check(wrappedLaunch.ViaPkexec && wrappedLaunch.Executable == wrapped && wrappedLaunch.PkexecPath == commands.PkexecPath, "wrapper core launches the original script via pkexec"); + Check(!File.Exists(wrapPrivilege.CapableCopyPath) && !File.Exists(wrapPrivilege.HashSidecarPath), "false capable copy and hash are removed"); + Check(!File.Exists(Path.Combine(Path.GetDirectoryName(wrapPrivilege.CapableCopyPath)!, "libcronet.so")), "stale companion next to the capable copy is removed"); + Check(runner.Calls.Skip(callsBeforeWrap).All(call => call.Arguments.Count == 0 || call.Arguments[0] != commands.SetcapPath), "wrapper core is not setcap'd"); + Check(!probed, "wrapper core does not trust a version probe of the capable copy"); + + commands.PkexecPath = null; + await File.WriteAllTextAsync(wrapPrivilege.CapableCopyPath, await File.ReadAllTextAsync(wrapped)); + await File.WriteAllTextAsync(wrapPrivilege.HashSidecarPath, Sha256File(wrapped)); + var wrapperMissingPkexec = false; + try + { + await wrapPrivilege.PrepareAsync(wrapped); + } + catch (TunAuthorizationException exception) + { + wrapperMissingPkexec = !exception.Cancelled && exception.Message == TunAuthorizationException.PkexecMissingMessage; + } + Check(wrapperMissingPkexec, "wrapper without pkexec keeps the missing pkexec message"); + Check(!File.Exists(wrapPrivilege.CapableCopyPath), "missing pkexec still drops the false capable copy"); + + commands.PlainPkexec = true; + var wrapperMissingSetuid = false; + try + { + await wrapPrivilege.PrepareAsync(wrapped); + } + catch (TunAuthorizationException exception) + { + wrapperMissingSetuid = exception.SetuidWrapperMissing + && exception.Message.Contains("security.polkit.enablePkexecWrapper = true;", StringComparison.Ordinal); + } + Check(wrapperMissingSetuid, "wrapper without setuid pkexec keeps the NixOS wrapper message"); + commands.PkexecPath = "/usr/bin/pkexec"; + commands.PlainPkexec = false; + + var elf = Path.Combine(authRoot, "elf-version"); + WriteElfExecutable(elf, "runs-or-not"); + var versionData = Path.Combine(authRoot, "version-data"); + var versionPrivilege = new LinuxTunPrivilege(versionData, runner, commands, new FakeXattr(), new HonoringFilesystem(), + canRun: (_, _) => Task.FromResult(false)); + Directory.CreateDirectory(Path.GetDirectoryName(versionPrivilege.CapableCopyPath)!); + File.Copy(elf, versionPrivilege.CapableCopyPath, overwrite: true); + await File.WriteAllTextAsync(versionPrivilege.HashSidecarPath, Sha256File(elf)); + capable.Add(versionPrivilege.CapableCopyPath); + var versionLaunch = await versionPrivilege.PrepareAsync(elf); + Check(versionLaunch.ViaPkexec && versionLaunch.Executable == elf && versionLaunch.PkexecPath == commands.PkexecPath, "capable copy that cannot run falls back to pkexec"); + Check(!File.Exists(versionPrivilege.CapableCopyPath) && !File.Exists(versionPrivilege.HashSidecarPath), "broken capable copy is invalidated after version fails"); Console.WriteLine("PASS linux tun authorization"); } finally @@ -437,6 +602,81 @@ static LinuxTunFacts Facts(bool copy, bool copyCap, bool hash, bool sourceCap, b Directory.Delete(authRoot, recursive: true); } +var formatRoot = Directory.CreateTempSubdirectory("na-elf").FullName; +try +{ + var present = Path.Combine(formatRoot, "ld-linux"); + await File.WriteAllTextAsync(present, "loader"); + var withLoader = Path.Combine(formatRoot, "dynamic"); + await File.WriteAllBytesAsync(withLoader, ElfWithInterpreter(present)); + Check(LinuxExecutableFormat.ReadInterpreter(withLoader) == present, "PT_INTERP path is read from the ELF"); + Check(LinuxExecutableFormat.MissingInterpreter(withLoader) is null, "present interpreter is not reported missing"); + File.Delete(present); + Check(LinuxExecutableFormat.MissingInterpreter(withLoader) == present, "absent interpreter is reported"); + var staticElf = Path.Combine(formatRoot, "static"); + var staticBytes = new byte[64]; + staticBytes[0] = 0x7F; staticBytes[1] = (byte)'E'; staticBytes[2] = (byte)'L'; staticBytes[3] = (byte)'F'; + staticBytes[4] = 2; staticBytes[5] = 1; staticBytes[6] = 1; + BinaryPrimitives.WriteUInt16LittleEndian(staticBytes.AsSpan(16), 2); + await File.WriteAllBytesAsync(staticElf, staticBytes); + Check(LinuxExecutableFormat.ReadInterpreter(staticElf) is null && LinuxExecutableFormat.MissingInterpreter(staticElf) is null, "static ELF has no interpreter"); + var script = Path.Combine(formatRoot, "script"); + await File.WriteAllTextAsync(script, "#!/bin/sh\nexec /nix/store/xx/ld-linux \"$DIR/sing-box.bin\" \"$@\"\n"); + Check(LinuxExecutableFormat.MissingInterpreter(script) is null, "a shell wrapper is not a missing PT_INTERP"); +} +finally +{ + Directory.Delete(formatRoot, recursive: true); +} + +var archiveRoot = Directory.CreateTempSubdirectory("na-archive").FullName; +try +{ + var archive = Path.Combine(archiveRoot, "sing-box.tar.gz"); + await using (var file = File.Create(archive)) + await using (var gzip = new GZipStream(file, CompressionLevel.SmallestSize, leaveOpen: true)) + await using (var tar = new TarWriter(gzip, TarEntryFormat.Pax, leaveOpen: true)) + { + await WriteTarAsync(tar, "sing-box-1.13.12-linux-amd64/LICENSE", "license"u8.ToArray()); + await WriteTarAsync(tar, "sing-box-1.13.12-linux-amd64/sing-box", "core-bytes"u8.ToArray()); + await WriteTarAsync(tar, "sing-box-1.13.12-linux-amd64/libcronet.so", "cronet-bytes"u8.ToArray()); + } + + var extracted = Path.Combine(archiveRoot, "out"); + await SingBoxCoreLayout.ExtractAsync(archive, extracted, "sing-box", CancellationToken.None); + Check(File.ReadAllText(Path.Combine(extracted, "sing-box")) == "core-bytes", "linux archive extracts sing-box"); + Check(File.ReadAllText(Path.Combine(extracted, "libcronet.so")) == "cronet-bytes", "linux archive keeps libcronet.so"); + Check(!File.Exists(Path.Combine(extracted, "LICENSE")), "archive license is not installed as a core file"); + + var muslArchive = Path.Combine(archiveRoot, "musl.tar.gz"); + await using (var file = File.Create(muslArchive)) + await using (var gzip = new GZipStream(file, CompressionLevel.SmallestSize, leaveOpen: true)) + await using (var tar = new TarWriter(gzip, TarEntryFormat.Pax, leaveOpen: true)) + await WriteTarAsync(tar, "sing-box-1.13.12-linux-amd64-musl/sing-box", "static-core"u8.ToArray()); + var muslOut = Path.Combine(archiveRoot, "musl"); + await SingBoxCoreLayout.ExtractAsync(muslArchive, muslOut, "sing-box", CancellationToken.None); + Check(File.ReadAllText(Path.Combine(muslOut, "sing-box")) == "static-core", "musl archive extracts the static executable"); + Check(!File.Exists(Path.Combine(muslOut, "libcronet.so")), "musl archive has no companion library"); + + var zip = Path.Combine(archiveRoot, "windows.zip"); + using (var zipArchive = ZipFile.Open(zip, ZipArchiveMode.Create)) + { + WriteZip(zipArchive, "sing-box-1.13.12-windows-amd64/sing-box.exe", "win-core"u8.ToArray()); + WriteZip(zipArchive, "sing-box-1.13.12-windows-amd64/libcronet.dll", "win-cronet"u8.ToArray()); + WriteZip(zipArchive, "sing-box-1.13.12-windows-amd64/LICENSE", "license"u8.ToArray()); + } + + var zipOut = Path.Combine(archiveRoot, "zip"); + await SingBoxCoreLayout.ExtractAsync(zip, zipOut, "sing-box.exe", CancellationToken.None); + Check(File.ReadAllText(Path.Combine(zipOut, "sing-box.exe")) == "win-core", "windows archive extracts sing-box.exe"); + Check(File.ReadAllText(Path.Combine(zipOut, "libcronet.dll")) == "win-cronet", "windows archive keeps libcronet.dll"); + Check(!File.Exists(Path.Combine(zipOut, "LICENSE")), "windows archive license is not installed"); +} +finally +{ + Directory.Delete(archiveRoot, recursive: true); +} + var feedback = new RecordingFeedback(); NetworkAcceleratorViewModel.PresentAuthorization(feedback, new TunAuthorizationException(TunAuthorizationException.CancelledMessage, cancelled: true)); Check(feedback.Warnings.Count == 1 && feedback.Warnings[0] == TunAuthorizationException.CancelledMessage && feedback.Errors.Count == 0, "cancelled authorization is a warning toast"); @@ -514,6 +754,71 @@ static LinuxTunFacts Facts(bool copy, bool copyCap, bool hash, bool sourceCap, b Console.WriteLine($"PASS network accelerator dashboard checks ({passed})"); +static void WriteElfExecutable(string path, string payload) +{ + var body = Encoding.UTF8.GetBytes(payload); + var bytes = new byte[64 + body.Length]; + bytes[0] = 0x7F; + bytes[1] = (byte)'E'; + bytes[2] = (byte)'L'; + bytes[3] = (byte)'F'; + bytes[4] = 2; + bytes[5] = 1; + bytes[6] = 1; + BinaryPrimitives.WriteUInt16LittleEndian(bytes.AsSpan(16), 2); + body.CopyTo(bytes, 64); + File.WriteAllBytes(path, bytes); +} + +static byte[] ElfWithInterpreter(string interpreter) +{ + var interp = Encoding.UTF8.GetBytes(interpreter + "\0"); + const int offset = 128; + var bytes = new byte[offset + interp.Length]; + bytes[0] = 0x7F; + bytes[1] = (byte)'E'; + bytes[2] = (byte)'L'; + bytes[3] = (byte)'F'; + bytes[4] = 2; + bytes[5] = 1; + bytes[6] = 1; + BinaryPrimitives.WriteUInt16LittleEndian(bytes.AsSpan(16), 2); + BinaryPrimitives.WriteUInt16LittleEndian(bytes.AsSpan(18), 62); + BinaryPrimitives.WriteUInt32LittleEndian(bytes.AsSpan(20), 1); + BinaryPrimitives.WriteUInt64LittleEndian(bytes.AsSpan(32), 64); + BinaryPrimitives.WriteUInt16LittleEndian(bytes.AsSpan(52), 64); + BinaryPrimitives.WriteUInt16LittleEndian(bytes.AsSpan(54), 56); + BinaryPrimitives.WriteUInt16LittleEndian(bytes.AsSpan(56), 1); + BinaryPrimitives.WriteUInt32LittleEndian(bytes.AsSpan(64), 3); + BinaryPrimitives.WriteUInt32LittleEndian(bytes.AsSpan(68), 4); + BinaryPrimitives.WriteUInt64LittleEndian(bytes.AsSpan(72), offset); + BinaryPrimitives.WriteUInt64LittleEndian(bytes.AsSpan(96), (ulong)interp.Length); + interp.CopyTo(bytes, offset); + return bytes; +} + +static string Sha256File(string path) +{ + using var stream = File.OpenRead(path); + return Convert.ToHexString(SHA256.HashData(stream)).ToLowerInvariant(); +} + +static void WriteZip(ZipArchive archive, string name, byte[] payload) +{ + var entry = archive.CreateEntry(name); + using var output = entry.Open(); + output.Write(payload); +} + +static async Task WriteTarAsync(TarWriter tar, string name, byte[] payload) +{ + var entry = new PaxTarEntry(TarEntryType.RegularFile, name) + { + DataStream = new MemoryStream(payload) + }; + await tar.WriteEntryAsync(entry); +} + static void Pump(Task task, int milliseconds = 5000) { var deadline = Environment.TickCount64 + milliseconds;