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;