diff --git a/.github/workflows/build-test.yml b/.github/workflows/build-test.yml index 92684562b..eb74c39a4 100644 --- a/.github/workflows/build-test.yml +++ b/.github/workflows/build-test.yml @@ -46,7 +46,7 @@ jobs: - name: Install Prerequisites run: | brew update - brew install icu4c libdeflate zstd argon2 + brew install icu4c libdeflate argon2 - name: Set Library Path run: echo "DYLD_LIBRARY_PATH=/opt/homebrew/lib:$DYLD_LIBRARY_PATH" >> $GITHUB_ENV - name: Build @@ -124,12 +124,36 @@ jobs: dnf config-manager --set-enabled crb dnf install -y epel-release if: ${{ matrix.epel }} + # Runtime packages only, deliberately. Installing the -dev packages here would add the + # unversioned .so symlink and mask the very thing the binding packages now probe for, so a + # regression in versioned-SONAME resolution would sail through CI. - name: Install Prerequisites using dnf - run: dnf makecache --refresh && dnf install -y findutils libicu libdeflate-devel zstd libargon2-devel + run: dnf makecache --refresh && dnf install -y findutils libicu libdeflate libargon2 tzdata if: ${{ matrix.packageManager == 'dnf' }} + # ICU's runtime package carries the ABI version in its name (libicu70 on jammy, libicu76 on + # trixie) and has no stable alias, so match it by pattern. libicu-dev was the old way to stay + # version-independent, but it drags in the unversioned symlink and defeats the check below. - name: Install Prerequisites using apt - run: apt-get update -y && apt-get install -y curl libicu-dev libdeflate-dev zstd libargon2-dev tzdata + run: apt-get update -y && apt-get install -y curl '^libicu[0-9]+$' libdeflate0 libargon2-1 tzdata if: ${{ matrix.packageManager == 'apt' }} + # Versioned-SONAME resolution is only under test while the unversioned symlink is absent. If a + # base image or a package ever starts shipping it, every probe would succeed on the first try + # and a regression in the fallback would sail through CI, so fail loudly instead of silently + # testing nothing. + - name: Assert the unversioned .so symlinks are absent + run: | + found="" + for lib in libicuuc libicui18n libdeflate libargon2; do + hit=$(ls /usr/lib/*/"$lib".so /usr/lib64/"$lib".so 2>/dev/null || true) + if [ -n "$hit" ]; then + found="$found $hit" + fi + done + if [ -n "$found" ]; then + echo "::error::Unversioned symlinks present, so CI is no longer exercising versioned SONAME resolution:$found" + exit 1 + fi + echo "No unversioned symlinks present; versioned SONAME resolution is under test." - uses: actions/checkout@v7 with: fetch-depth: 0 # avoid shallow clone so nbgv can do its work. diff --git a/CLAUDE.md b/CLAUDE.md index 7f24661a4..924255249 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -46,6 +46,7 @@ Apply these when writing or reviewing `.cs` files under `Projects/`. | Event system | `dev-docs/events.md` | | Threading model | `dev-docs/threading-model.md` | | Server lifecycle & bootstrap phases (Configure/ConfigurePrompts/Initialize) | `dev-docs/server-lifecycle.md` | +| Platform prerequisites (ICU, tzdata, native libs per distro) | `dev-docs/platform-prerequisites.md` | | Configuration system | `dev-docs/configuration.md` | | Networking & packets | `dev-docs/networking-packets.md` | | IP bans, blocklists & allowlists (incl. unblocking a player) | `dev-docs/ip-bans-and-allowlists.md` | diff --git a/Projects/BuildTool/BuildOptions.cs b/Projects/BuildTool/BuildOptions.cs index 9aea5010d..9e1d56388 100644 --- a/Projects/BuildTool/BuildOptions.cs +++ b/Projects/BuildTool/BuildOptions.cs @@ -14,4 +14,11 @@ public sealed class BuildOptions public string? Arch { get; set; } public bool SkipPrereqs { get; set; } public bool Interactive { get; set; } + + /// + /// Report the native library prerequisites and exit. The interactive flow is the only other + /// path that runs these checks, so without this there is no way to verify a deployment target + /// from a script or a container. + /// + public bool CheckPrereqsOnly { get; set; } } diff --git a/Projects/BuildTool/Prerequisites/NativeLibraryChecker.cs b/Projects/BuildTool/Prerequisites/NativeLibraryChecker.cs index 4f84aa373..95c521c24 100644 --- a/Projects/BuildTool/Prerequisites/NativeLibraryChecker.cs +++ b/Projects/BuildTool/Prerequisites/NativeLibraryChecker.cs @@ -1,3 +1,4 @@ +using System.Runtime.InteropServices; using BuildTool.Platform; using BuildTool.Publishing; @@ -31,8 +32,10 @@ public static class NativeLibraryChecker "Linux", [ ".NET 10 Runtime — https://dotnet.microsoft.com/download/dotnet/10.0", - "Debian/Ubuntu: sudo apt-get install -y libicu-dev libdeflate-dev zstd libargon2-dev", - "Fedora/RHEL: sudo dnf install -y libicu libdeflate-devel zstd libargon2-devel", + "Debian/Ubuntu: sudo apt-get install -y libdeflate0 libargon2-1 libicuNN tzdata", + " (libicuNN varies by release, e.g. libicu76 — run build-tool --check-prereqs there for the exact name)", + " (add tzdata-legacy if the shard is configured with an alias such as US/Eastern)", + "Fedora/RHEL: sudo dnf install -y libdeflate libargon2 libicu tzdata", "CentOS: Also requires epel-release and CRB enabled" ] ), @@ -176,82 +179,59 @@ public static class NativeLibraryChecker return results; } + /// + /// Native libraries the server needs from the system on Linux, and the SONAME range to accept + /// for each. Rationale and per-distro package names: dev-docs/platform-prerequisites.md. + /// + private static readonly (string Name, int MinSoVersion, int MaxSoVersion)[] _linuxLibraries = + [ + ("libicuuc", 60, 120), + ("libicui18n", 60, 120), + ("libdeflate", 0, 9), + ("libargon2", 0, 9) + ]; + private static List CheckLinux(PlatformInfo platform) - { - return platform.PackageManager switch - { - PackageManager.Apt => CheckLinuxApt(), - PackageManager.Dnf => CheckLinuxDnf(platform), - _ => CheckLinuxGeneric(platform) - }; - } - - private static List CheckLinuxApt() { var results = new List(); - var packages = new[] { "libicu-dev", "libdeflate-dev", "zstd", "libargon2-dev" }; var missing = new List(); - foreach (var package in packages) + foreach (var (name, minSoVersion, maxSoVersion) in _linuxLibraries) { - var result = ProcessRunner.RunCaptured("dpkg", $"-l {package}"); - var installed = result.Success && result.StandardOutput.Contains("ii"); + var found = CanLoad(name, minSoVersion, maxSoVersion); - if (!installed) + if (!found) { - missing.Add(package); + missing.Add(name); } results.Add(new PrerequisiteResult { - Name = package, - Passed = installed, - Details = installed ? "Installed" : "Not installed" + Name = name, + Passed = found, + Details = found ? "Found" : "Not found" }); } - if (missing.Count > 0) + var hasTimeZoneData = HasTimeZoneData(); + if (!hasTimeZoneData) { - results.Add(new PrerequisiteResult - { - Name = "Install all missing", - Passed = false, - IsWarning = true, - Details = "Run the following command to install all missing dependencies:", - InstallCommand = $"sudo apt-get install -y {string.Join(' ', missing)}" - }); + missing.Add("tzdata"); } - return results; - } - - private static List CheckLinuxDnf(PlatformInfo platform) - { - var results = new List(); - var packages = new[] { "libicu", "libdeflate-devel", "zstd", "libargon2-devel" }; - var missing = new List(); - - foreach (var package in packages) + results.Add(new PrerequisiteResult { - var result = ProcessRunner.RunCaptured("rpm", $"-q {package}"); - var installed = result.Success; + Name = "tzdata", + Passed = hasTimeZoneData, + Details = hasTimeZoneData ? "Found" : "Not found — every zone except UTC will throw" + }); - if (!installed) - { - missing.Add(package); - } - - results.Add(new PrerequisiteResult - { - Name = package, - Passed = installed, - Details = installed ? "Installed" : "Not installed" - }); + if (missing.Count == 0) + { + return results; } - // Check if this is CentOS (needs EPEL) - var isCentOs = platform.DistroId?.Equals("centos", StringComparison.OrdinalIgnoreCase) == true; - if (isCentOs && missing.Count > 0) + if (platform.DistroId?.Equals("centos", StringComparison.OrdinalIgnoreCase) == true) { results.Add(new PrerequisiteResult { @@ -263,48 +243,131 @@ public static class NativeLibraryChecker }); } - if (missing.Count > 0) + results.Add(new PrerequisiteResult { - results.Add(new PrerequisiteResult - { - Name = "Install all missing", - Passed = false, - IsWarning = true, - Details = "Run the following command to install all missing dependencies:", - InstallCommand = $"sudo dnf install -y {string.Join(' ', missing)}" - }); - } + Name = "Install all missing", + Passed = false, + IsWarning = true, + Details = "Install the missing dependencies. The -dev/-devel packages are not required:", + InstallCommand = BuildInstallCommand(platform, missing) + }); return results; } - private static List CheckLinuxGeneric(PlatformInfo platform) + /// + /// tzdata is data, not a library, so no loader probe finds it. Asking the runtime rather than + /// stat'ing a path keeps TZDIR honoured, and the count is still accurate under + /// InvariantGlobalization, which this tool runs with — only display names degrade there. + /// + private static bool HasTimeZoneData() { - var results = new List(); - - // Use ldconfig to check for shared libraries - var ldResult = ProcessRunner.RunCaptured("ldconfig", "-p"); - var ldOutput = ldResult.Success ? ldResult.StandardOutput : ""; - - var libraries = new Dictionary + try { - ["libicu"] = "libicuuc", - ["libdeflate"] = "libdeflate", - ["zstd"] = "libzstd", - ["libargon2"] = "libargon2" - }; - - foreach (var (name, soName) in libraries) + return TimeZoneInfo.GetSystemTimeZones().Count > 1; + } + catch { - var found = ldOutput.Contains(soName, StringComparison.OrdinalIgnoreCase); - results.Add(new PrerequisiteResult - { - Name = name, - Passed = found, - Details = found ? "Found" : "Not found — install using your package manager" - }); + return false; + } + } + + /// + /// Asks the loader directly rather than querying a package database or scanning ldconfig's + /// cache, both of which answer a different question and can disagree with what dlopen will do. + /// Mirrors the binding packages' own probing: the unversioned name first, then libfoo.so.N + /// descending. Bare names go through the full loader search path, so LD_LIBRARY_PATH and + /// /etc/ld.so.conf.d still apply. + /// + private static bool CanLoad(string library, int minSoVersion, int maxSoVersion) + { + if (TryLoadAndFree($"{library}.so")) + { + return true; } - return results; + for (var soVersion = maxSoVersion; soVersion >= minSoVersion; soVersion--) + { + if (TryLoadAndFree($"{library}.so.{soVersion}")) + { + return true; + } + } + + return false; + } + + private static bool TryLoadAndFree(string candidate) + { + if (!NativeLibrary.TryLoad(candidate, out var handle)) + { + return false; + } + + NativeLibrary.Free(handle); + return true; + } + + private static string BuildInstallCommand(PlatformInfo platform, List missing) + { + switch (platform.PackageManager) + { + case PackageManager.Apt: + { + // Distinct because the two ICU libraries resolve to the same package, and + // ResolveAptIcuPackage shells out, so it is memoized rather than called per name. + var packages = missing.Select( + library => library switch + { + "libdeflate" => "libdeflate0", + "libargon2" => "libargon2-1", + "tzdata" => "tzdata", + _ => _aptIcuPackage ??= ResolveAptIcuPackage() + } + ).Distinct(); + + return $"sudo apt-get install -y {string.Join(' ', packages)}"; + } + case PackageManager.Dnf: + { + var packages = missing.Select( + library => library switch + { + "libdeflate" => "libdeflate", + "libargon2" => "libargon2", + "tzdata" => "tzdata", + _ => "libicu" + } + ).Distinct(); + + return $"sudo dnf install -y {string.Join(' ', packages)}"; + } + default: + return $"Install your distribution's runtime packages for: {string.Join(", ", missing)}"; + } + } + + private static string _aptIcuPackage; + + /// + /// ICU's apt package carries the ABI version in its name and there is no stable alias, so ask + /// apt which one this release actually ships instead of printing a name that rots. + /// + private static string ResolveAptIcuPackage() + { + var result = ProcessRunner.RunCaptured("apt-cache", "search --names-only ^libicu[0-9]+$"); + if (!result.Success) + { + return "libicu"; + } + + var best = result.StandardOutput + .Split('\n', StringSplitOptions.RemoveEmptyEntries) + .Select(line => line.Split(' ', 2)[0].Trim()) + .Where(name => name.StartsWith("libicu", StringComparison.Ordinal)) + .OrderBy(name => int.TryParse(name.AsSpan(6), out var version) ? version : 0) + .LastOrDefault(); + + return best ?? "libicu"; } } diff --git a/Projects/BuildTool/Program.cs b/Projects/BuildTool/Program.cs index 3b36066da..915839bbf 100644 --- a/Projects/BuildTool/Program.cs +++ b/Projects/BuildTool/Program.cs @@ -4,6 +4,7 @@ using BuildTool.Interactive; using BuildTool.Platform; using BuildTool.Prerequisites; using BuildTool.Publishing; +using Spectre.Console; Console.OutputEncoding = Encoding.UTF8; @@ -36,6 +37,22 @@ options.Os ??= detectedPlatform.OsRid; options.Arch ??= detectedPlatform.ArchRid; var rid = $"{options.Os}-{options.Arch}"; +if (options.CheckPrereqsOnly) +{ + // Same renderer the guided menu uses, so the two cannot drift. Spectre drops ANSI styling by + // itself when stdout is not a terminal, which is the case this flag exists for, but it also + // falls back to an 80 column width and folds anything longer. The install hints we print are + // shell commands — the CentOS one is 95 characters — and a fold puts a newline in the middle of + // a command that someone is meant to copy. Widen the profile so they stay on one line. + if (Console.IsOutputRedirected) + { + AnsiConsole.Profile.Width = 200; + } + + // Exit code is the machine-readable half: 0 when everything resolves, 1 when anything is missing. + return PrerequisiteChecker.CheckNativeLibraries(detectedPlatform, interactive: false) ? 0 : 1; +} + // Run prerequisite checks unless skipped if (!options.SkipPrereqs) { @@ -108,6 +125,12 @@ static BuildOptions ParseArguments(string[] args) hasNamedArgs = true; break; } + case "--check-prereqs": + { + options.CheckPrereqsOnly = true; + hasNamedArgs = true; + break; + } case "--interactive": { options.Interactive = true; diff --git a/Projects/Server/Server.csproj b/Projects/Server/Server.csproj index b022c584c..8ad6a610b 100644 --- a/Projects/Server/Server.csproj +++ b/Projects/Server/Server.csproj @@ -36,7 +36,7 @@ - + diff --git a/Projects/UOContent/UOContent.csproj b/Projects/UOContent/UOContent.csproj index 4f230902d..bf288e629 100644 --- a/Projects/UOContent/UOContent.csproj +++ b/Projects/UOContent/UOContent.csproj @@ -40,12 +40,12 @@ false - + - + diff --git a/README.md b/README.md index b61204743..2321fe8e3 100644 --- a/README.md +++ b/README.md @@ -87,18 +87,31 @@ dnf install -y dnf-plugins-core dnf config-manager --set-enabled crb dnf install -y epel-release # Prerequisites -dnf install -y findutils libicu libdeflate-devel zstd libargon2-devel +dnf install -y findutils libicu libdeflate libargon2 tzdata ``` ### Ubuntu, Debian, etc ```shell apt-get update -y -apt-get install -y libicu-dev libdeflate-dev zstd libargon2-dev +# The ICU runtime package carries the ABI version in its name (libicu74, libicu76, …) and has no +# stable alias, so match it by pattern rather than pinning a release-specific name. +apt-get install -y '^libicu[0-9]+$' libdeflate0 libargon2-1 tzdata ``` +Only the runtime libraries are needed — the `-dev`/`-devel` packages are not. Run +`./build-tool --check-prereqs` to check the current machine and print the exact packages your +release needs. + +`zstd` is not listed because ZstdNet bundles `libzstd` for every platform, and `liburing` is not +listed because IORingGroup issues `io_uring` syscalls directly. + +If the shard's configured time zone is a legacy alias such as `US/Eastern`, Debian 12 and Ubuntu +24.04 also need `tzdata-legacy`. See [Platform Prerequisites](dev-docs/platform-prerequisites.md) +for what each dependency is for and what breaks without it. + ## OSX Requirements ```shell -brew install icu4c libdeflate zstd argon2 +brew install icu4c libdeflate argon2 ``` ## Running the Server diff --git a/dev-docs/platform-prerequisites.md b/dev-docs/platform-prerequisites.md new file mode 100644 index 000000000..3e76d3b47 --- /dev/null +++ b/dev-docs/platform-prerequisites.md @@ -0,0 +1,159 @@ +# Platform Prerequisites + +OS-level dependencies ModernUO needs at runtime, why each one is required, and what breaks without +it. This page is about software packages, not hardware sizing. + +Run `./build-tool --check-prereqs` from the repository root to check the current machine. It prints +the exact install command for the detected distribution. + +## What is required + +| Dependency | Platform | Why | +|---|---|---| +| .NET 10 Runtime | all | — | +| ICU (`libicuuc`, `libicui18n`) | Linux, macOS | The runtime refuses to start without it; see below | +| tzdata | Linux | Time zone lookups; see below | +| `libdeflate` | all | `LibDeflate.Bindings` | +| `libargon2` | all | `Argon2.Bindings` (password hashing) | +| VC++ Redistributable v14 | Windows | Native bindings | + +Not required, despite appearances: + +- **zstd** — `ZstdNet` bundles `libzstd` for every RID. +- **liburing** — `IORingGroup` issues `io_uring` syscalls directly. It imports only `libc`, + `libSystem.dylib`, `kernel32.dll`, `kernelbase.dll` and `ws2_32.dll`. +- **`-dev` / `-devel` packages** — see "Runtime packages only" below. + +## Install + +```sh +# Debian / Ubuntu (ICU has no stable package alias, so match it by pattern) +sudo apt-get install -y '^libicu[0-9]+$' libdeflate0 libargon2-1 tzdata + +# Fedora / RHEL +sudo dnf install -y libdeflate libargon2 libicu tzdata + +# Alpine +apk add --no-cache libdeflate argon2-libs icu-libs tzdata + +# macOS +brew install icu4c libdeflate argon2 +``` + +CentOS additionally needs EPEL and CRB: + +```sh +sudo dnf install -y epel-release epel-next-release && sudo dnf config-manager --set-enabled crb +``` + +## Runtime packages only + +Only the runtime packages are needed. The `-dev`/`-devel` packages are **not** required. + +They used to be, because .NET's `DllImport` probing looks for the unversioned `libfoo.so`, and on +Linux that bare symlink ships only in the development package. The runtime package ships the +versioned SONAME (`libdeflate.so.0`, `libargon2.so.1`). The binding packages now probe the versioned +names as well, so the runtime package is sufficient. + +Anything still documenting `libicu-dev` or `libdeflate-dev` as a requirement is out of date. + +## ICU + +`Directory.Build.props` sets `InvariantGlobalization=false`, so ICU is mandatory. Without it the +runtime does **not** throw — it `FailFast`s: + +``` +Couldn't find a valid ICU package installed on the system. Please install libicu (or icu-libs) +using your package manager and try again. +``` + +That is `SIGABRT` (exit 134) and it cannot be caught. Note the process **starts cleanly and aborts +later**, at whatever line first touches a culture, so the crash rarely points at the cause. + +### Why invariant mode is not an option + +`InvariantGlobalization=true` would remove the ICU dependency, but it changes behaviour in ways that +corrupt data silently. Measured on .NET 10 with the repository's settings: + +| Behaviour | With ICU | Invariant mode | +|---|---|---| +| `new CultureInfo("de-DE")` | real culture | succeeds, returns invariant data | +| de-DE decimal separator | `,` | `.` | +| `1234.5` as de-DE | `1.234,5` | `1,234.5` | +| `string.Compare("a", "B", InvariantCulture)` | `-1` (linguistic) | `31` (ordinal) | +| sort `[b, A, a, B]` | `a, A, b, B` | `A, B, a, b` | +| `FindSystemTimeZoneById("Eastern Standard Time")` on Linux | resolves | `TimeZoneNotFoundException` | +| UTF-8 round-trip of non-ASCII | unaffected | unaffected | + +The dangerous row is the first. Because `Directory.Build.props` also sets +`PredefinedCulturesOnly=false`, constructing a culture in invariant mode **succeeds** instead of +throwing `CultureNotFoundException`, and hands back an object populated with invariant data. Number +parsing and formatting then produce wrong values with no error, and culture-sensitive sort order +silently becomes ordinal. + +Encoding is not affected — UTF-8 round-trips correctly in both modes. + +### Version floor + +The runtime accepts `libicuuc.so.60` and above (`MinICUVersion` in `pal_icushim.c`). The prerequisite +checker enforces the same floor, so a host carrying only an older ICU is reported missing rather than +passing and then aborting at startup. RHEL/CentOS 7 ships ICU 50 and is affected. + +ICU tracks its own release train, so the SONAME digit varies widely by distribution — `.so.74` on +Ubuntu 24.04, `.so.76` on Alpine, `.so.77` on Fedora, `.so.78` on openSUSE. There is no stable +package alias on Debian and Ubuntu, which is why the checker resolves the name via `apt-cache` +instead of hardcoding one. + +Only `libicuuc` and `libicui18n` are used; those are the two names +`libSystem.Globalization.Native.so` loads. `libicudata` arrives as a dependency of `libicuuc`, and +`libicuio`/`libicutu`/`libicutest` are never referenced. Every distribution ships all of them in a +single package, so installing ICU at all satisfies both. + +## tzdata + +The event scheduler resolves configured zone IDs through `TimeZoneInfo`, which reads +`/usr/share/zoneinfo` on Linux. This is separate from ICU: it is data, not a library, so no loader +probe finds it, and slim container images routinely omit it. + +Without tzdata every lookup except `UTC` throws: + +``` +TimeZoneNotFoundException: The time zone ID 'America/New_York' was not found on the local computer. +``` + +`TimeZoneInfo.GetSystemTimeZones()` returns 1 entry instead of ~419, and `TimeZoneInfo.Local` falls +back to UTC. + +### There is no per-zone subset + +Distributions do not package individual zones — it is one `tzdata` package, about 2 MB installed for +the full set. Subsetting is not worth pursuing. + +The one split that does exist is **`tzdata-legacy`** on Debian 12 and Ubuntu 24.04, which carries the +deprecated aliases. With plain `tzdata` alone: + +| Zone ID | `tzdata` | `tzdata-legacy` | +|---|---|---| +| `America/New_York` | present | — | +| `Europe/Kyiv` | present | — | +| `EST5EDT` | present | — | +| `US/Eastern` | **missing** | present | +| `Asia/Calcutta` | **missing** | present | + +So a shard configured with a legacy alias such as `US/Eastern` throws on a current Debian or Ubuntu +even though tzdata is installed. Either install `tzdata-legacy` or switch the configured value to the +canonical ID (`America/New_York`, `Asia/Kolkata`). + +`TZDIR` is honoured if the data lives somewhere non-standard. + +## How the check works + +`--check-prereqs` asks the loader directly — `NativeLibrary.TryLoad` on the unversioned name, then +`libfoo.so.N` descending through the accepted range. + +It deliberately does not consult a package database or `ldconfig -p`. Both answer a different +question than "will `dlopen` succeed": + +- Package queries need a hardcoded name, which does not exist for ICU. +- `ldconfig`'s cache can be stale, omits `LD_LIBRARY_PATH`, and carries no version information to + enforce the ICU floor against. On musl it exits successfully while producing nothing usable.