29 Commits
Author SHA1 Message Date
yahya-nusseibehandClaude Opus 5.5 b984398cbc Seal the launcher's secrets file with a per-install secret
Without a keychain, account-secrets.json was sealed under SHA-256 of a
constant and /etc/machine-id, which every local user can read; on a
system with none of the machine-id files the key was a constant built
into every launcher. Only the file's 0600 mode protected the Microsoft
refresh token inside, so a copy restored with wider permissions, or a
shared home, gave it away.

Format 2 (HZSECv2) adds a random 32-byte install secret to the key,
kept beside the file in account-secrets.key (0600, made at the first
write through a temporary file and a rename, under the file's lock).
The key is never the bare constant any more, and a v2 file whose
secret is missing or belongs to another install is refused like any
unreadable file, never written over.

A file sealed by an older launcher (HZSECv1) is still read with the old
key and sealed again under the new one at once.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01SJ3stK8iXVDXqoi7midcqd
2026-11-07 08:20:48 +02:00
yahya-nusseibehandClaude Opus 5.5 c76534d9f1 Update tokio to 1.53.3 and mysql to 28 (lru 0.18) for advisories
cargo audit flagged two crates in the locks:

- tokio 1.53.2 (RUSTSEC-2026-0386), reached through quinn in
  horizon-mod-deps and in the launcher's lock. Patched in 1.53.3, a
  patch release, so both locks move there.
- lru 0.16.4 (RUSTSEC-2026-0253), reached only through mysql 27 in
  horizon-mod-deps. No 0.16 release carries the fix; mysql 28 depends
  on lru 0.18, which does, so the crate mods may use moves to mysql 28
  (mysql_common 0.37, pem 4, base64 0.23 and arc-swap come with it).

`cargo check -p horizon-mod-deps --features mysql` builds, and cargo
audit no longer reports either advisory. A native mod that asks for
`mysql = "27"` now has to ask for 28, since the lock is the allow list;
docs/native-mods.md names the new version.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01SJ3stK8iXVDXqoi7midcqd
2026-11-07 07:59:08 +02:00
yahya-nusseibehandClaude Opus 5.5 bddd5ebf76 Website bug reports: GH-123 cross-references nothing either
neutralise breaks @mentions, #123 and github.com/ issue links in what
people write in the website's report form, so an anonymous report can't
put backlinks on the repository's issues. GitHub also links GH-123
(either case) to issue 123, which got through.

- At the start of a word, GH- followed by a digit gets a zero-width
  space after GH, the way @ and # already do: the text reads the same
  and links nothing. Other words (high-5, GH-pages, laugh-1) are left
  alone.
- Test: GH-12, gh-7 and (Gh-8) are neutralised; look-alikes aren't.

Found in yahya-nusseibeh's review of Horizon (L2, 7 Nov).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01SJ3stK8iXVDXqoi7midcqd
2026-11-07 07:54:10 +02:00
yahya-nusseibehandClaude Opus 5.5 07ed5dac67 Relay: a cap on open connections per address, and a per-site join budget
Two ways one client could lock every user out of the Play with Friends
relay, with no account:

(a) The only connection cap was global (4,096), counted before anything
    is read, and each connection may stay silent for the 10 s first-line
    wait: about 410 idle connections a second from one address kept the
    relay full, so hosts couldn't register and guests couldn't join.
(b) Every join, known code or not, is refused while the global
    unknown-code budget (120 a minute) is empty. Each /64 may try 5
    unknown codes per 10 minutes, so ~240 /64s keep it empty, and one
    residential /56 has 256 of them.

- Limiter::open_connection / close_connection: connections open at
  once per address key (IPv6 /64; a /48 four times as many), 64 by
  default (RELAY_MAX_OPEN_PER_ADDRESS; a host keeps one connection per
  guest, hence the margin). Counted before anything is read, or right
  after the PROXY line behind the PROXY protocol; past the cap the
  connection is closed at once, not held for the first-line wait. An
  RAII OpenSlot gives the place back however the connection ends.
- Each site (IPv4 /24, IPv6 /48) has its own unknown-code budget, 24 a
  minute with a burst of 6, checked before the global one and spent on
  every unknown code, so emptying the global budget takes many sites,
  not one home.
- docs/play-with-friends.md: the new setting and the site budget.
- Tests: the per-address cap (IPv4, IPv6 /64 and /48, off without
  per-address limits), the per-site budget (shared within a /24 and a
  /48, refilling), and a running relay closing a fifth silent
  connection at once with a cap of 4, then letting new ones in once
  the held ones go.

Found in yahya-nusseibeh's review of Horizon (H2, 7 Nov).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01SJ3stK8iXVDXqoi7midcqd
2026-11-07 07:51:55 +02:00
yahya-nusseibehandClaude Opus 5.5 bda6cb38ae Shader packs: bound what one zipped file may unpack to
Pack::read preallocated from a zip entry's declared size and inflated it
with no limit. Shader packs are zips players download, so a deflate bomb
(or a forged size field) in any file under shaders/ aborted the game
with an allocation failure as soon as shaders.properties was read: 512
MiB of spaces zip to 509 KiB and took peak RSS from 4 to 516 MiB; at
1.5 GiB the review measured 1,543 MiB.

- Nothing is allocated from the declared size; a file that declares or
  inflates to more than MAX_FILE (64 MiB, far above any .properties,
  .lang or GLSL file, with room for the largest textures) reads as
  missing, like a file the pack doesn't have. The same pack now stays
  at 4 MiB.
- Folder packs are unchanged (files on disk, not inflated).
- Test: a small zip whose shaders.properties unpacks past a 16 KiB cap
  reads as missing; the rest of the pack still reads.

Found in yahya-nusseibeh's review of Horizon (M1, 7 Nov).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01SJ3stK8iXVDXqoi7midcqd
2026-11-07 07:48:23 +02:00
yahya-nusseibehandClaude Opus 5.5 22e1582558 Shader packages: count the 8 MiB total while reading, not after
Pack::read inflated each of up to 512 zip entries to 1 MiB + 1 and kept
them all, and only Pack::from_files then checked the pack's 8 MiB total.
511 entries of zeros take 561 KiB zipped, and the mod registry reads
every submitted .hshader (index::inspect, Kind::Shader) on its blocking
pool: one upload made it hold 1 GiB (measured: peak RSS 3 -> 1,026 MiB),
a few in parallel could get it OOM-killed.

- Each entry is read with room for no more than what is left of
  MAX_TOTAL (and its own 1 MiB cap), and refused as soon as it passes
  it: "the pack is over 8 MiB", or the file named when it alone is over.
  The same package now peaks at 6 MiB.
- A folder pack counts its total the same way.
- Tests: 64 MiB of zeros in a small zip, one file over its cap, and a
  folder over the total are all refused.

Found in yahya-nusseibeh's review of Horizon (H1, 7 Nov).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01SJ3stK8iXVDXqoi7midcqd
2026-11-07 07:44:44 +02:00
yahya-nusseibehandClaude Opus 5.5 58d5f3b519 Merge main into fix/nbt-heap-quota
Brings the branch up to date and re-runs CI: verify failed once in
horizon-relay's slow_first_lines_and_too_many_connections_are_closed (a
timing test, 5.6 s against its 3 s on a loaded runner), which this
change doesn't touch.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01SJ3stK8iXVDXqoi7midcqd
2026-11-06 05:43:15 +02:00
yahya-nusseibehandClaude Opus 5.5 45af2b23b2 Network NBT: no more tags than vanilla's quota admits
Vanilla reads network NBT with NbtAccounter's 2 MiB quota, which
charges each tag an estimate of its heap size: at least 8 bytes (EndTag,
the smallest; a byte tag in a list costs 13). Horizon counted only the
bytes read, plus up to 1,000,000 nodes, so a crafted server could send a
1 MB list of byte tags that vanilla refuses and have Horizon build ~1 M
32-byte Tags from it, about 16 times the memory.

- buf::Reader::nbt caps a network NBT value at NBT_QUOTA / 8 = 262,144
  tags (NBT_MAX_TAGS). Every value vanilla accepts has at most that
  many, so nothing vanilla accepts is refused, and one value's tags now
  take at most ~8 MiB here.
- custom::validate checks component values with the same reader, so it
  follows.
- Test: a list of 262,143 byte tags (with its root, 262,144) reads; one
  more is refused although it is a few hundred KB.

A full port of NbtAccounter's per-type charges would be closer still;
this bound needs only its smallest charge, so it can't refuse what
vanilla takes.

Found in yahya-nusseibeh's review of Horizon (L2).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01SJ3stK8iXVDXqoi7midcqd
2026-11-05 19:44:32 +02:00
yahya-nusseibehandClaude Opus 5.5 e0b19571dd RSA: constant-time private-key operations
The dedicated server and a LAN host decrypt each client's
ServerboundKey with the server's private exponent. That used
square-and-multiply, which multiplies only on the exponent's set bits,
with a data-dependent final subtraction in each Montgomery product, and
no blinding: a client controls the ciphertext and can time the reply,
the classic extra-reduction channel. The key is regenerated every start
(as in vanilla) and a recovered one still needs an on-path position,
but vanilla's JCE blinds this operation.

- Mont::mul subtracts n through a mask chosen from the overflow and
  the borrow of t − n, never a branch (Big::sub_with_borrow,
  Big::select). Every product takes the same steps.
- Mont::pow_secret: a square and a multiply for every bit of the
  exponent's limbs, the product kept by a mask. RsaPublicKey::
  apply_secret uses it; RsaPrivateKey::decrypt and ::sign call that.
  Public-key verification and encryption stay on the faster pow.
- Big::from_be_bytes no longer skips zero bytes: it parses the private
  exponent on every call, and the result is the same.
- Test: pow and pow_secret agree with a reference modular power, for
  moduli near a limb boundary (the overflow case) and exponents with
  leading zero limbs. The 11 existing RSA tests (OpenSSL signature,
  known-key decryption, generated keys) pass unchanged.

No wire or parity change. Multiplicative blinding would be the more
complete fix once a modular inverse exists.

Found in yahya-nusseibeh's review of Horizon (L1).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01SJ3stK8iXVDXqoi7midcqd
2026-11-05 19:40:43 +02:00
yahya-nusseibehandClaude Opus 5.5 3b71256fcd RCON: one password guess per connection
After a refused login the connection stayed open, so within the 30 s
login window a client could send as many AUTH packets as it liked: with
the 16 connections allowed at once, thousands of guesses a second
against an RCON port on 0.0.0.0, each adding a supervisor log line.
Vanilla does the same, but this module already departs from vanilla for
safety (constant-time comparison, login deadline, client cap), and this
belongs with those.

- A refused login (wrong or empty password) answers as before, waits
  REFUSED_PAUSE (1 s) and closes the connection. Its place stays taken
  meanwhile, so the client cap also bounds how fast passwords can be
  tried: at most 16 guesses a second instead of thousands.
- A command before the login is still refused without closing, and the
  wire protocol is unchanged; standard RCON clients connect again after
  a refusal.
- Tests: a wrong and an empty password each end their connection after
  the pause; a failed login after a successful one does too. The pause
  is a Limits field so tests use 200 ms.

Found in yahya-nusseibeh's review of Horizon (L3).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01SJ3stK8iXVDXqoi7midcqd
2026-11-05 19:37:23 +02:00
yahya-nusseibehandClaude Opus 5.5 c0e2f62b35 Telemetry server: hub, intake and export off the async workers too
The server runs two async workers and keeps SQLite on tokio's blocking
pool (lib.rs, `blocking`), but /hub/vote, /hub/votes and /hub/report and
the token-protected /v1/export/* still called app.store() on the
workers. While a blocking-pool task holds the store mutex (a large batch,
the daily upkeep), two votes or reports from different addresses park
both workers on it and every handler stops answering, /healthz
included; per-address limits don't help, it takes two requests.
/hub/votes also held votes_cache's lock while waiting for the store.

- hub::vote and hub::votes run their store calls through blocking();
  votes reads the cache, releases its lock, counts on the blocking pool
  and stores the result after.
- intake::receive checks the day's count and queues the report in one
  blocking() call, under one store lock as before; report marks the
  issue filed through blocking().
- export::benchmarks and export::hardware read through blocking().
- A failed blocking task answers 503 "try again later" through each
  module's own refusal (the hub's keeps its CORS headers).

Behaviour is unchanged otherwise: the crate's 58 tests pass as they are.

Found in yahya-nusseibeh's review of Horizon (M1).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01SJ3stK8iXVDXqoi7midcqd
2026-11-05 19:32:12 +02:00
yahya-nusseibehandClaude Opus 5.5 4f49676a37 port-pack: cap every jar and pack entry it inflates
horizon-port-pack read jar entries with read_to_string/read_to_end and
no limit. zip stops an entry only at the size it declares, which can be
4 GiB (more with zip64), and the launcher runs check::check in its own
process on every Java mod a mod-browser search lists, without a click.
So one published mod with a highly compressible mods.toml could abort
the launcher of everyone whose search showed it. A 522 KB jar whose
mods.toml inflates to 512 MiB made `horizon-port-pack check` reach
594 MiB and abort; with this change it peaks at 9 MiB and refuses the
jar.

- crate::entry: read()/read_text() with a per-entry cap, and a Budget
  for a running total over one pass of an archive, so many entries
  under their cap can't add up either. Limits: 16 MiB per text entry
  (JSON, TOML, the manifest), 64 MiB per file kept as it is, 1 GiB per
  archive pass. Only bytes actually read count; an entry declaring more
  than what is left is refused unread.
- Every zip read in the crate goes through it: jar::read's metadata
  (a too-large file refuses the jar), carry's namespace scan and file
  copy, worldgen's piece counts, structure-set and biome tags,
  processors, upgrade::read_pack and import's JSON index. check.rs kept
  its 16 MiB per-class cap and now also refuses a jar whose classes
  together unpack to more than 1 GiB (a thousand entries just under the
  per-class cap was the same attack).
- Tests: the cap and the running total (declared or not, refused
  entries counting), UTF-8, and check() refusing a small jar whose
  mods.toml unpacks past 16 MiB.

No parity change: real mod files are far under these limits.

Found in yahya-nusseibeh's review of Horizon (H1).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01SJ3stK8iXVDXqoi7midcqd
2026-11-05 19:28:47 +02:00
yahya-nusseibehandClaude Opus 5.5 24987d6660 Launcher: rustls 0.23.46 for four RustSec advisories
launcher/Cargo.lock pinned rustls 0.23.45 (through ureq 2.12), which
cargo audit flags with RUSTSEC-2026-0346 (client session Debug impls
expose TLS secrets), -0347 (buffered data sent to the outer server after
an ECH rejection), -0348 (Brotli certificate decompression) and -0349
(post-quantum downgrade from an unauthenticated ServerHello), all fixed
in 0.23.46. The launcher carries Microsoft sign-in tokens over this
stack.

- `cargo update -p rustls --precise 0.23.46`: rustls alone moves.
- cargo audit on the new lockfile: no vulnerabilities (8 informational
  warnings: unmaintained or yanked crates, unchanged).

Found in yahya-nusseibeh's review of Horizon (L4).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01SJ3stK8iXVDXqoi7midcqd
2026-11-05 19:21:11 +02:00
yahya-nusseibehandClaude Opus 5.5 ce92f7e45a Launcher: open HORIZON_LAUNCHER_PROJECT once without remove_var
The mod page opened the project named by HORIZON_LAUNCHER_PROJECT (a
screenshot aid) and then called std::env::remove_var so it wouldn't open
again. By then the download, check and keyring threads are running, and
remove_var races getenv on any of them (glibc's DNS resolution
included): unsound, which is why edition 2024 makes it `unsafe`. The
launcher is on 2021, so it compiled silently.

- A static flag remembers that the page was opened this run; the
  environment is no longer written. Same behaviour: the page opens once,
  after the first search that finds it.

Found in yahya-nusseibeh's review of Horizon (L5).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01SJ3stK8iXVDXqoi7midcqd
2026-11-05 19:11:41 +02:00
yahya-nusseibehandClaude Opus 5.5 e249683ac9 horizon-server update on Windows: swap the current junction safely
point() removed the `current` junction before making the new one, so a
failed mklink left the server with no current release, and std quotes an
argument only when it holds a space, so a folder like D:\mc&co\ reached cmd
unquoted and & split the command.

- The new junction is made first as current.switching; the old one is
  removed and the new one renamed into place; if that rename fails the
  old junction is put back.
- mklink runs as `cmd /D /S /C "mklink /J "link" "target""` with
  raw_arg, both paths quoted; paths holding % or " (which keep a meaning
  inside quotes) are refused with a message.

- Test (Windows only): real junctions in a folder named with `&`:
  current moves v1 -> v2 and reads each release's file, the old release
  is untouched, no current.switching is left, and a release whose path
  holds % is refused with current unchanged.

Compiled, linted (clippy -D warnings) and tested on GitHub's
windows-latest runner: all 146 horizon-server tests, the two junction
tests among them. The Unix build and tests pass too.

Split from #9129.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01SJ3stK8iXVDXqoi7midcqd
2026-11-05 14:20:43 +02:00
yahya-nusseibehandClaude Opus 5.5 449481dd49 Relay: take PROXY protocol lines only from listed load balancers
With RELAY_PROXY_PROTOCOL=1 a PROXY line was accepted from any TCP peer,
so a client reaching the port directly could choose the address its
per-address limits count.

- RELAY_PROXY_FROM: addresses or CIDR ranges (IPv4, IPv6, IPv4-mapped
  peers handled). A connection from anywhere else is closed unanswered.
- The relay refuses to start (a clear error, exit 1) when the protocol
  is on and the list is empty, or when an entry doesn't parse, instead of
  silently dropping every connection. Config::from_env returns a Result;
  Config::check says why.
- docs/play-with-friends.md lists the setting.

Split from #9129.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01SJ3stK8iXVDXqoi7midcqd
2026-11-05 07:45:15 +02:00
yahya-nusseibehandClaude Opus 5.5 9de4d4c169 Telemetry server: refuse to start behind a proxy without the client-IP header
With HORIZON_TELEMETRY_TRUST_PROXY=1 and no
HORIZON_TELEMETRY_CLIENT_IP_HEADER, every request would count as the
proxy's address: one shared bucket for /v1/batch, deletions, the live
count and reads, so intake would stop and players' deletion requests
would be refused, with only a line on stderr to say so (martipops's
review of #9129).

- The Dockerfile sets HORIZON_TELEMETRY_CLIENT_IP_HEADER=x-real-ip, the
  header the server read first before the setting existed, so the
  address the limits count on Railway stays the same.
- TRUST_PROXY=1 without the header now refuses to start (exit 1), as the
  relay does without RELAY_PROXY_FROM: a deployment missing it fails its
  /healthz check instead of going live with one bucket.
- README: both settings, and the refusal.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01SJ3stK8iXVDXqoi7midcqd
2026-11-05 07:43:48 +02:00
yahya-nusseibehandClaude Opus 5.5 65ce92e233 Nightly: sign each build for its own version (SHA256SUMS.v2.sig)
Every Nightly was signed for the constant "horizon release nightly", and
the version a launcher installed it as came from the unsigned GitHub
title, so anyone able to edit the release could serve an older signed
Nightly as tonight's.

- nightly-build.yml signs SHA256SUMS twice with the same key(s):
  SHA256SUMS.sig for the channel, as before, for installed launchers;
  SHA256SUMS.v2.sig for nightly-<date>-<commit>, the version the title
  names (sign_release.sh ALSO_FOR). Both are verified before publishing.
- horizon-release-sign: nightly_title_version() parses the title
  strictly (no fallback to published_at); nightly_digest() takes that
  version and checks SHA256SUMS.v2.sig for exactly it; is_nightly_build();
  SIGNATURE_V2. `verify --signature <file>` checks the v2 file.
- Every installer that checks a Nightly moves in the same step: the
  launcher, horizon-server update and packaging/server/install.sh refuse
  a Nightly whose title doesn't parse and install it only with a v2
  signature for that version. install.ps1 checks no signature (only
  GitHub's SHA-256), so it has nothing to move.
- An older Nightly still installs on request as a rollback with a
  warning; the launcher's update never moves the selection back to it,
  and horizon-server prints a rollback notice for --version.
- Nightlies from before this change have no v2 signature: the refusal
  now says so and names `horizon-server rollback`, and docs/security.md
  "Nightly signatures" describes the changeover (martipops's review of
  #9129). The launcher's new messages go through trf(), in every
  language table in English until translated.
- tests/install_sh_signature.sh: a v2 Nightly installs as its version; a
  Nightly with only the channel signature, an older Nightly retitled as
  a newer one, and an unreadable title are refused (all three installed
  before this change).

Split from #9129, as martipops's review suggested.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01SJ3stK8iXVDXqoi7midcqd
2026-11-05 07:12:20 +02:00
yahya-nusseibehandClaude Opus 5.5 f2ec6394cc Launcher: cap what a shared instance or a .mrpack may unpack to
import.rs inflated every entry of a dropped .mrpack or game-folder zip
with no byte limit, so a few kilobytes of zip bomb could fill the disk
before the half-made instance was removed.

- fill() counts the bytes actually inflated across all entries (a zip's
  sizes are its own claim) and stops past MAX_IMPORT (8 GiB); the error
  removes the instance as before.
- The cap sits in fill(), which import_any() calls for both
  import_into() and main's newer import_mrpack_into() (a Java instance
  takes every path a pack carries), as martipops's review of #9129
  asked when rebasing.
- The message goes through trf(), in every language table in English
  until translated.
- Tests with a 16 KiB limit: a compressed bomb is refused and leaves
  nothing, for a shared instance and for a .mrpack; the limit is for all
  files together.

Split from #9129.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01SJ3stK8iXVDXqoi7midcqd
2026-11-05 07:05:39 +02:00
yahya-nusseibehandClaude Opus 5.5 337cb194af Mod registry: owners and owner_ids must name the same accounts
Once an entry has owner_ids, only an ID in them makes an owner, so adding
a co-owner to `owners` alone, the obvious edit, would silently lock them
out, and the refusal would list their own login among the owners
(martipops's review of #9129).

- Entry::check_fields refuses an entry whose owner_ids is non-empty and
  not as long as owners, with how to find the missing ID, so the registry
  CI catches it when the change is made.
- docs/api/owner-update-v2.md: the registry repository has no
  owner-update workflow yet, so nothing merges on its own today; and the
  backfill script runs only once a maintainer has read it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01SJ3stK8iXVDXqoi7midcqd
2026-11-05 07:00:11 +02:00
yahya-nusseibeh 7aceb1cbf9 Mod registry: decide project ownership by numeric GitHub ID
Owners were GitHub logins, and a login can be claimed by someone else once
its owner renames or deletes the account. For a project without a key, a
reclaimed login could publish an owner update that merges on its own.

- Entry gains `owner_ids` (#[serde(default)], omitted when empty). The
  form records the lister's ID for new projects (file and repository
  listings). The registry review notes any change to it.
- ownership(): when an entry has owner_ids, only an ID in them is an
  owner, so a matching login on another account is refused. Without
  owner_ids (older entries) the login matches, but the update always waits
  for a reviewer and the pull request says so.
- The automatic owner update requires an ID match.
- update_trailer is v2: `owner-update-v2` message with the submitter's
  numeric ID, and `format: v2` / `submitter_id:` lines in the trailer.
- tools/registry_backfill_owner_ids.py: one-off, dry run by default,
  resolves owners' logins to IDs, cross-checks them against the IDs the
  form recorded in the entry's commits, and leaves alone (and prints)
  any entry it can't resolve completely.
- docs/api/owner-update-v2.md: what the registry repository's
  owner-update workflow must check, and the deployment order.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01SJ3stH5q6PgMAVUSmJqKDe
(cherry picked from commit 526bfcbbe1)
2026-11-05 06:59:35 +02:00
yahya-nusseibeh 6c019a2349 Telemetry server: read the client's address from one configured header
With TRUST_PROXY=1 the rate limits were keyed on X-Real-IP, else the last
X-Forwarded-For entry, whatever the platform guarantees. Railway's edge
has let clients set X-Real-IP in the past, and puts the CDN's address
there when Fastly is in the path.

- HORIZON_TELEMETRY_CLIENT_IP_HEADER names the one header the proxy
  overwrites (x-real-ip on Railway); its last comma-separated entry is
  the client. No other header is read.
- TRUST_PROXY=1 without it counts the TCP peer, and the server prints a
  warning at start saying every client then shares one bucket; a header
  set without TRUST_PROXY is reported as ignored.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01SJ3stH5q6PgMAVUSmJqKDe
(cherry picked from commit bd420535e0)
2026-11-05 06:54:39 +02:00
yahya-nusseibeh 3483117549 Telemetry server: keep SQLite off the async workers, bound the totals' cost
The server runs two async workers and did every SQLite call on them, and
GET /v1/aggregate had no rate limit and cached per `days` (366 keys),
while DELETE /v1/installs/<any id> emptied every cache: a cheap way to
stall every handler.

- blocking(): batch storage (zstd inflate included), install deletion,
  the totals, the benchmarks summary, the live history, the upkeep and
  the live count's minute sample run on tokio's blocking pool.
- /v1/aggregate and /v1/benchmarks/summary take a per-address read
  bucket (READS_PER_HOUR, 600); uncached totals builds have a global
  budget (AGGREGATE_BUILDS_PER_HOUR, 600) and answer 503 past it.
- AGGREGATE_DAYS (e.g. 7,30,90,366) restricts the windows answered; unset
  keeps every value from 1 to 366. It is left unset by default because
  the website's own requests (private horizon-site) couldn't be checked.
- A deletion invalidates the cached totals only when it deleted rows.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01SJ3stH5q6PgMAVUSmJqKDe
(cherry picked from commit a29c16a169)
2026-11-05 06:54:38 +02:00
yahya-nusseibehandClaude Opus 5.5 c0f53a324f CI commands: authorize trusted reviewers by numeric GitHub ID
/nightly, the triage commands and /claim decided who may act by the
comment author's login. GitHub hands a renamed or deleted account's login
to whoever claims it next, so a reclaimed reviewer login could pick
unreviewed pull requests into the signed Nightly.

- trusted-reviewers.txt lines become `<id> <login>`; one shared parser
  (nightly_set.read_reviewers) for all three commands. Any other line,
  bare logins included, grants nothing and is reported on stderr.
  `paused` keeps its meaning.
- All three workflows pass AUTHOR_ID (github.event.comment.user.id);
  without it the scripts refuse. Ludovic is OWNER_ID, and his threads are
  recognised by user.id in triage.
- tools/ci/resolve_reviewer_ids.py fills in the IDs (and OWNER_ID);
  --check reports renames and deleted accounts.

No review requirement is added: a Nightly still takes any open pull
request to main, as decided on 2026-10-30 (NoReviewNeededTests).

Split from #9129, as martipops's review suggested.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01SJ3stK8iXVDXqoi7midcqd
2026-11-05 06:44:04 +02:00
yahya-nusseibehandClaude Opus 5.5 62572a0b18 Nightly: call apply() with the six arguments it takes
command() still passed read_reviewers(reviewers).logins to apply() after
c527d60b7 dropped that parameter, so every /nightly comment that got past
authorize(), status included, ended in TypeError and was never answered.

- Drop the leftover argument.
- Test: command() run as nightly-command.yml runs it, with GitHub and the
  state branch stubbed, answers /nightly status. It fails on main with the
  TypeError.

Split from #9129, as martipops's review suggested.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01SJ3stK8iXVDXqoi7midcqd
2026-11-05 06:39:53 +02:00
yahya-nusseibeh 0785a988df Relay: reserve a bridge slot at the match, give it back on every exit
max_bridges compared against bridges already running, so joins waiting
(up to attach_wait) for their host's attach all passed the check and a
burst could exceed RELAY_MAX_BRIDGES.

- BridgeSlot, taken under the lookup lock when a guest is matched with
  its host, counts toward max_bridges from then on; dropping it gives it
  back, so every way out of a join (no attach, a refused or forged
  attach, an error inside bridge(), a panic) releases it.
- counts().bridges now includes matched joins awaiting attach.
- tests/bridge_slots.rs: a waiting join holds its slot (a second guest
  gets Full); the count returns to 0 after a timeout, after a forged
  attach, and after a bridge closes; refusals never take one.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01SJ3stH5q6PgMAVUSmJqKDe
(cherry picked from commit 91d35fcae0)
2026-11-05 04:11:20 +01:00
yahya-nusseibeh e42e8b5497 Ban lists: checked arithmetic for hand-edited dates
parse_date multiplied and added the fields of banned-players.json and
banned-ips.json dates as plain i64, so a huge year, month, day or time
panicked in a debug build and wrapped in a release one, turning a ban's
expiry into an arbitrary date. Every step is now checked, years are bounded
before days_from_civil, and a date out of range is None, like one that
doesn't parse. Lenient month overflow (month 13) still works.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01SJ3stH5q6PgMAVUSmJqKDe
(cherry picked from commit 184ea41194)
2026-11-05 04:11:20 +01:00
yahya-nusseibeh 0b974a2f9c Write token files through a private temporary file and a rename
write_private (the control API's .horizon/api.json, the game's control
endpoint, the management protocol's generated key) opened the path with
create+truncate and mode 0600, which applies only when the file is new:
an existing, wider-readable file kept its permissions and got the new
token, and a link at the path was followed.

- horizon_server_files::private_file::write_private, shared by
  horizon-server and horizon-client: writes a create_new 0600 temporary
  file beside the path, syncs it and renames it over the path (a link
  there is replaced, not followed); the temporary file goes on failure.
- Tests: an old 0644 file comes out 0600; a link's target is untouched.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01SJ3stH5q6PgMAVUSmJqKDe
(cherry picked from commit 9cb9d76054)
2026-11-05 04:11:20 +01:00
yahya-nusseibeh 55345332a3 Game control endpoint: draw its token from the OS random generator
The TCP control endpoint's token (it gates full control of the running
game) was two SipHash outputs of RandomState over the time and the process
ID. RandomState's keys are OS-seeded, but std doesn't promise it is a
cryptographic generator. It now comes from getrandom::fill, as
horizon-server's and the relay's tokens do (getrandom 0.3, already in the
lockfile, added to horizon-client-lib, which compiles control.rs).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01SJ3stH5q6PgMAVUSmJqKDe
(cherry picked from commit aa89153fc0)
2026-11-05 04:11:20 +01:00