Commit graph hyperhive/nix/modules
Author SHA1 Message Date
atlas
43776afbfd fix(matrix): order hive-matrix container start after the gateway (resolver)
Per operator review on the PR: when the network module is on, the
matrix container's resolver is the dnsmasq in the gateway container, so
order the matrix container start after the gateway container. This is
robustness for tuwunel's lazy federation lookups, not a boot
requirement — the boot fix is the resolv.conf nameserver line (the
failure was a parse error on an empty resolv.conf, not connectivity).
Soft 'after' (not 'requires') keeps lifecycles decoupled; network.enable
asserts gateway.enable so the gateway container unit always exists.
2026-06-06 11:02:25 +02:00
atlas
38f2435767 fix(matrix): give hive-matrix container a DNS resolver so tuwunel can boot
tuwunel hard-fails to start when /etc/resolv.conf has no nameserver
line (Failed to configure DNS resolver: no nameservers found in
config -> exit 1 -> systemd start-limit). The declarative
containers.hive-matrix generates its own resolv.conf via resolvconf
and, unlike agent containers whose resolv.conf is written by
hive-c0re's lifecycle, has no nameserver source -> it comes up empty
(just 'options edns0'). Defaulting network.enable on surfaced this:
the host DNS moved to the bridge dnsmasq but the container was never
pointed at it, so the homeserver could not boot, taking down matrix
for all agents.

Point the container at the hive resolver (the dnsmasq the network
module runs at bridgeIp) when the network module is enabled; the
container always shares the host netns (privateNetwork = false) so it
reaches bridgeIp whether or not isolateContainers is set. With the
network module off, inherit the host resolv.conf.
2026-06-06 10:50:55 +02:00
atlas
48b7c24265 fix(nix): emit explicit port in .well-known/matrix/server delegation
The matrix server-server spec resolves a portless m.server delegated
host to the federation default port 8448 (not 443 — the URL convention
that https implies 443 does not apply to the m.server field). The
gateway reused publicPortSuffix, which intentionally drops :443 for the
client base_url, so .well-known/matrix/server advertised the bare
gateway host. Peers then federated to <gatewayHost>:8448, which nothing
listens on, while the federation endpoint actually lives on the gateway
443 vhost (/_matrix/ proxied to tuwunel). Always emit the explicit port
in the federation delegation so peers reach the live 443 endpoint.
2026-06-06 00:37:07 +02:00
damocles
e029944cb3 docs: specify certFingerprint format and generation recipe 2026-06-05 23:38:54 +02:00
damocles
c5c47b29b4 fix: own /run/hive-agent as hive-core so the unprivileged daemon can create per-agent socket dirs on first launch 2026-06-05 23:30:50 +02:00
damocles
4c8e916ae6 fix: add /run/lock to hive-priv ReadWritePaths so nixos-container can lock on first launch 2026-06-05 23:20:35 +02:00
atlas
4f446269ae fix: re-run hive-ci-prefetch on every container restart via partOf
The hive-ci-prefetch oneshot has RemainAfterExit=true and is wired to the
container only via wantedBy + before. Once it runs successfully it stays
'active (exited)' indefinitely, so systemd skips it on subsequent container
restarts. The runner-token file it wrote is never refreshed.

This breaks the runner after its first registration: the token written on
the first successful boot is either a placeholder (forge-core-token wasn't
ready yet) or a registration token that has since been consumed/rotated.
On the next container restart prefetch does not re-run, the stale token
persists, and the in-container register service fails with
'invalid_argument: runner registration token not found' — exactly the
symptom in the field (worked briefly, two orphan runners registered, then
permanently offline).

Add partOf = [ nixos-container@hive-ci.service ] so a container stop/restart
propagates to the prefetch unit, forcing it to re-run and fetch a fresh
registration token before the container comes back up. before= still orders
it ahead of the container start within the same transaction.
2026-06-05 00:39:13 +02:00
atlas
0a3a956d28 fix: add /var/lib/hyperhive to hive-priv ReadWritePaths
WriteAgentForgeToken and WriteAgentMatrixToken write agent state files
under /var/lib/hyperhive/agents/<name>/state/. The hive-priv service
has ProtectSystem=strict which makes all of /var read-only by default,
but /var/lib/hyperhive was missing from ReadWritePaths, causing EROFS
on every token write.

Closes #1274.
2026-06-04 15:35:46 +02:00
damocles
c2d023ba86 fix(#1269): add requires+after hive-c0re.socket to service unit 2026-06-04 15:16:39 +02:00
damocles
911dc5227b fix(#1263): repeat security headers in locations with their own add_header 2026-06-04 14:35:18 +02:00
damocles
41eb3f806c refactor: remove hyperhive.role option — there is only one role: agent 2026-06-04 14:31:44 +02:00
atlas
5ec1306c7b feat(gateway): assert hsts.enable requires TLS
Addresses mara's review: add a NixOS assertion that rejects
hsts.enable = true when no TLS mode is configured. HSTS over
plain HTTP is silently ignored by browsers; the assertion turns
a silent misconfiguration into a build-time error.
2026-06-04 13:50:22 +02:00
atlas
5264ef7d5e feat(gateway): add gateway.hsts.enable option, disabled by default
HSTS was unconditionally tied to hasTls. This is risky: enabling it on a
deployment that later loses TLS locks browsers out until max-age expires.

Add three options under services.hyperhive.gateway.hsts:
  enable          — bool, default false
  maxAge          — seconds, default 31536000 (1 year)
  includeSubDomains — bool, default true

HSTS header is now only emitted when hsts.enable = true.
2026-06-04 13:50:22 +02:00
atlas
18941848dc feat(gateway): add security headers to all vhosts
X-Frame-Options, X-Content-Type-Options, Referrer-Policy at server
scope on _, forge, and matrix vhosts. HSTS added when TLS is active.

nginx inheritance rule: locations with their own add_header (CORS API
endpoints like /.well-known/matrix/client, /_matrix/) are unaffected —
they already carry the headers they need. HTML-serving and proxy
locations pick the security headers up automatically.
2026-06-04 13:50:22 +02:00
atlas
dacd83f278 fix: wait for forge-core-token in prefetch instead of bailing early
On first boot (or after a wipe) hive-c0re writes forge-core-token only
after the forge container starts and the admin is provisioned. This
lags hive-c0re.service becoming active. The previous code bailed
immediately with TOKEN=placeholder if the token file was absent,
causing the runner to fail registration with 'token not found'.

Fix: merge both waits (core-token file appearance + forge API ready)
into a single 60s retry loop. The early-bail path is removed; the
script only exits cleanly if .runner is valid (writes placeholder)
or a fresh registration token is obtained.

When .runner exists but core-token is absent after 60s, we keep the
existing credentials (safe — the runner holds valid creds; next boot
will validate properly).

Closes #1224 (which tracks #1221).
2026-06-03 22:50:01 +02:00
atlas
9209094397 fix: move core-token out of hive-ci container (host-side prefetch service) 2026-06-03 22:18:53 +02:00
atlas
4d1ce6f313 fix: use path= (ExecSearchPath) to add nix to hive-ci runner PATH 2026-06-03 21:39:47 +02:00
damocles
d1fbb4aef8 feat(#1184): make agent CPU quota and memory limit configurable 2026-06-03 21:39:14 +02:00
atlas
4f80253101 fix: use lib.mkForce on hive-ci runner PATH to resolve conflicting definition 2026-06-03 21:37:27 +02:00
atlas
181535650b fix(#1199): add nix to hive-ci PATH for runner job execution
The gitea-actions-runner's host-scheme job processes use the
service's environment PATH, not the NixOS login-shell PATH.
Without this, 'nix flake check' and similar steps fail with
'nix: command not found'.

- add pkgs.nix to environment.systemPackages (ensures the binary
  is in /run/current-system/sw/bin)
- set explicit PATH on gitea-runner-hive service covering both
  /run/current-system/sw/bin and /nix/var/nix/profiles/default/bin
2026-06-03 21:25:05 +02:00
damocles
9aa624d310 fix(#1159): set WorkingDirectory=/var/lib/hive-priv for hive-priv service
With ProtectSystem=strict (added in the previous commit), / is read-only
inside hive-priv. nix creates a temp result symlink in its cwd; without
WorkingDirectory that cwd is / (systemd default), causing:

  error: creating symlink /.tmp.tmp-...: Read-only file system

Point WorkingDirectory at the StateDirectory (/var/lib/hive-priv) which is
always writable under strict. Nix drops its temp symlink there instead.
2026-06-03 17:44:37 +02:00
atlas
0e3df82504 feat(#972): tighten hive-priv systemd sandbox — ProtectSystem=strict + ReadWritePaths
Replace ProtectSystem=false with ProtectSystem=strict now that privsep
is complete (issue #702 closed) and hive-c0re runs as the non-root
hive-core user.

ReadWritePaths carves out the six paths hive-priv must write to at
runtime; everything else is read-only:

  /etc/nixos-containers   — writes <container>.conf (bind-mounts,
                            network isolation, nspawn flags)
  /run/hive               — fallback socket bind when LISTEN_FDS absent
  /run/hive-agent         — chown/chmod per-agent socket directories
  /run/systemd            — container@ drop-ins (resource limits) and
                            machinectl/systemd-machined machine state
  /var/lib/nixos-containers — container rootfs (nixos-container script)
  /nix                    — nix store + profile updates during
                            container create/update

PrivateTmp=true (already set) keeps /tmp private.
StateDirectory=hive-priv (already set) keeps /var/lib/hive-priv writable
for nix's fetch/eval cache (HOME points there).

nix flake check --no-build passes.
2026-06-03 16:57:01 +02:00
atlas
d2298e183b fix(#1155): enable recommendedTlsSettings + recommendedGzipSettings in gateway nginx 2026-06-03 16:48:33 +02:00
atlas
fb93cbf5c2 feat: add tls.acme mode — nginx inside container manages Let's Encrypt
per mara's suggestion: instead of bind-mounting operator certs, let
nginx handle ACME directly inside the gateway container.

- tls.acme.enable: lets nginx obtain + renew via HTTP-01 challenge
- tls.acme.email: ACME account contact (required when enable=true)
- security.acme in container config when acme.enable
- hasTls includes acme.enable → https, httpsPort listen, firewall
- mutual exclusion assertions: acme vs selfSignedTls vs certDir
- docs/gateway.md: four-mode TLS table + ACME section

typical setup:
  selfSignedTls = false; openFirewall = true;
  tls.acme = { enable = true; email = "admin@example.com"; };
2026-06-03 16:46:52 +02:00
atlas
44122c66de feat(#594): gateway operator-cert TLS mode (tls.certDir)
add services.hyperhive.gateway.tls.certDir option: operators with a
CA-signed cert (Let's Encrypt, corporate CA) point at the ACME output
dir instead of using the auto-generated self-signed cert.

- tls.certDir: host path bind-mounted r/o at /run/hive-tls/ in gateway
- tls.certName / tls.keyName: filenames within certDir (default: cert.pem / key.pem, matches nixpkgs security.acme layout)
- hasTls = selfSignedTls || certDir != null: publicScheme=https in both cases
- assertion: selfSignedTls=true + certDir set together is an error
- openFirewall: httpsPort opened in both TLS modes
- docs/gateway.md: TLS modes table + operator-cert section
- docs updated in swarm.md peer config reference in the cert TLS section

when using operator cert, swarm peers can omit certFingerprint —
standard CA bundle handles trust automatically.
2026-06-03 16:46:52 +02:00
atlas
a2a96490d3 fix: assert gateway.enable when isolateContainers + forge.enable
isolated agents reach forge via http://forge.<domain> → nginx.
without the gateway there is nothing on port 80 to serve that
hostname. assert early rather than fail silently at runtime.

addresses argus yellow note on PR #1150.
2026-06-03 16:33:10 +02:00
atlas
806d0e4a61 fix: use forge domain URL + open 80/443 for isolated agents
when isolateContainers=true, isolated agents have dnsmasq as their
resolver — forge.<domain> resolves to bridgeIp. route HIVE_FORGE_URL
through nginx on port 80 instead of exposing the raw forge port.

- HIVE_FORGE_URL: http://<forge.domain> when isolated (nginx proxies)
- bridge firewall: open 80+443 for agents to reach nginx (gateway)
- remove forge-specific httpPort rule (no longer needed)
- update docs/gateway.md + docs/network.md

per mara's review comment on PR #1150.
2026-06-03 16:33:10 +02:00
atlas
c97120f016 fix: forge URL + firewall for isolateContainers=true
When containers run in private netns (isolateContainers=true), host
loopback is unreachable so HIVE_FORGE_URL=http://127.0.0.1:3000 breaks.

- nix/modules/hive-network.nix: when isolateContainers is on + forge
  is enabled, open forge.httpPort on the bridge interface so agents
  can reach forgejo at bridgeIp:httpPort (forgejo binds 0.0.0.0)
- nix/modules/hive-c0re.nix: HIVE_FORGE_URL switches to bridge IP
  when network.enable && isolateContainers; loopback path retained
  when isolateContainers=false
- docs/network.md: add Forge access + Forge URL rows to effects table
- docs/gateway.md: rewrite HIVE_FORGE_URL section for both modes
2026-06-03 16:33:10 +02:00
atlas
9e63c9c1d0 fix: wireguard address option default + clarify peer mesh inclusion doc 2026-06-03 15:19:16 +02:00
atlas
89609aaa6a feat(#569): wireguard inter-hive mesh option
Add opt-in WireGuard mesh support to services.hyperhive.swarm:

- swarm.peers.<domain>.wireguardPublicKey — peer's wg public key
- swarm.peers.<domain>.wireguardEndpoint  — peer's UDP endpoint (optional)
- swarm.peers.<domain>.wireguardAddress   — peer's mesh IP with prefix

- swarm.wireguard.enable           — bring up wg-hive interface
- swarm.wireguard.privateKeyFile   — path to host's wg private key
- swarm.wireguard.address          — this host's mesh IP/prefix
- swarm.wireguard.listenPort       — UDP listen port (default 51820)
- swarm.wireguard.persistentKeepalive — keepalive seconds (default 25)

When enabled, generates networking.wireguard.interfaces.wg-hive with
one peer entry per mesh-enabled swarm.peers entry. Opens listenPort
UDP on the host firewall. Adds wireguard_address to HYPERHIVE_PEERS
JSON so hive-c0re can use mesh IPs for intra-swarm routing.

Assertions guard against enable=true without privateKeyFile or address.

Also refactors networking.firewall.allowedTCPPortRanges from the
nested attrset form (which conflicted with the new allowedUDPPorts
line) to the per-attribute form.

docs/swarm.md: adds WireGuard setup section with key generation
commands, two-hive config example, NAT/keepalive notes.
2026-06-03 15:19:16 +02:00
atlas
b89c5f5334 network: fix stale assertion messages; move prose to docs/network.md 2026-06-03 11:19:29 +02:00
atlas
3db51deace network: default network.enable to services.hyperhive.enable 2026-06-03 11:19:29 +02:00
atlas
8e50ddf016 network: revert isolateContainers default to false 2026-06-03 11:19:29 +02:00
atlas
a141d157ba network: default isolateContainers to true 2026-06-03 11:19:29 +02:00
atlas
c1b40ef819 network: fix dead assertion + document HIVE_NETWORK_SUBNET host-address
Two argus review fixups:

1. Move isolateContainers assertion to an unconditional `lib.mkIf
   cfg.isolateContainers` arm. The prior placement inside
   `mkIf (enable && isolateContainers)` was dead code — the assertion
   could never fire because both flags were already true by the time the
   block activated. Now `isolateContainers=true; enable=false;` raises
   a NixOS assertion error at eval time.

2. Add comment on HIVE_NETWORK_SUBNET noting it carries the host-side
   bridge IP (e.g. "10.42.0.1/24"), not the canonical network address
   ("10.42.0.0/24"). Rust consumer must normalize via bitwise AND before
   subnet membership checks or address arithmetic.
2026-06-03 11:19:29 +02:00
atlas
39b4c65922 network: add isolateContainers option for #14 netns isolation
Adds `services.hyperhive.network.isolateContainers` (bool, default
false). When enabled alongside `network.enable`, activates:

- IP forwarding + NAT masquerade so isolated agents reach the internet
- nftables DROP rule blocking bridge-subnet → loopback (defence-in-depth
  against compromised agent reaching the c0re dashboard)
- `HIVE_NETWORK_ISOLATION`, `HIVE_NETWORK_BRIDGE`, `HIVE_NETWORK_SUBNET`
  injected into the hive-c0re service env; the Rust lifecycle reads these
  to set `PRIVATE_NETWORK`, `LOCAL_ADDRESS`, and `HOST_BRIDGE` in each
  agent container's conf

Config block rewritten as `lib.mkMerge [...]` — the prior `lib.mkIf //
lib.mkIf` pattern was invalid nix (mkIf returns a tagged value, not an
attrset; // on it is a type error). See docs/network.md for full design.
2026-06-03 11:19:29 +02:00
müde
d96a924d77 fix(#702): set git safe.directory for cross-user repo reads
Privsep splits repo ownership: hive-priv (root) fetches the hive-core-owned
meta/applied repos via nix, and hive-c0re (hive-core) fetches the agent-owned
proposed-config repos. git/libgit2's dubious-ownership guard rejects both
('repository path is not owned by current user'), failing every rebuild.
Install a root-trusted gitconfig with safe.directory=* in each service's
HOME; both already have read access — this only satisfies the guard.
2026-06-03 00:52:42 +02:00
müde
d272803d58 fix(#702): spare agent creds from migration chown; give nix a writable HOME
Two privsep follow-ups, both in the hive-c0re service:

1. The migration 'chown -R hive-core /var/lib/hyperhive' (which only
   started running once ExecStartPre stopped failing 203/EXEC) stomped every
   agent's bind-mounted creds — agents/<name>/{claude,state,harness,config}
   are owned by the per-agent/manager users — logging all agents out with no
   way back in. Scope it to everything *except* agents/, plus the agents/
   dir node itself so c0re can still create new per-agent subdirs. Each
   container's hive-agent-user-migrate activation chowns the contents back.

2. nix (prebuild 'nix build', flake-check, meta eval in c0re; nixos-container
   update->nix in priv) writes its cache under $HOME/.cache. Both services
   run as users with no home -> HOME=/var/empty (unwritable) -> Lix cache
   init fails, rebuilds error out. Set HOME to each service's StateDirectory
   (adding one for hive-priv).
2026-06-03 00:33:15 +02:00
müde
e7b6896e4c fix(#702): give hive-priv a PATH; route forge/matrix list through priv
hive-priv had no PATH, so the nixos-container it runs for every container
op (incl. lifecycle::list) failed ENOENT. build_all swallows that into an
empty list — the 'no managed containers' symptom. Give the helper the
minimal set nixos-container shells out to (nixos-container, nix, util-linux,
e2fsprogs) on top of the systemd/coreutils/findutils already in the unit
PATH, rather than the whole system profile.

Also route forge/matrix is_present() through priv_client::list_containers
instead of spawning nixos-container directly from unprivileged hive-c0re.
2026-06-03 00:05:17 +02:00
müde
1e4066b9cb fix(#702): hive-c0re ExecStartPre fails 203/EXEC — coreutils has no sh
The state-migration chown invoked ${pkgs.coreutils}/bin/sh, which does not
exist (coreutils ships chown, not sh), so ExecStartPre exited 203/EXEC on
every boot. hive-c0re hit its start limit and never came up, so the gateway
returned 502 Bad Gateway. Call chown directly with the +- prefix (run as
root, tolerate failure) instead of going through a shell for '|| true'.
2026-06-02 23:49:51 +02:00
damocles
0bec3af933 feat(#702): drop hive-c0re from root to hive-core user (privsep phase 2) 2026-06-02 22:45:12 +02:00
atlas
ed6f40ba40 fix(#1061): suppress tuwunel default displayname suffix
Tuwunel 1.6.1 appends " 💕" to every new user's display name via the
new_user_displayname_suffix config option. Set it to empty string so
agent and operator matrix accounts get clean names.

Existing users (atlas, mara, root) need a one-time display name reset.
Atlas's was already corrected via the client API.
2026-06-02 11:18:38 +02:00
damocles
9d11e5b6d6 fix(#1052): use systemctl reload nginx instead of systemd-run 2026-06-02 01:00:48 +02:00
damocles
637b170cc5 fix(#1052): use full nginx path in systemd-run reload (exit 203 = EXEC) 2026-06-02 00:52:56 +02:00
atlas
9d816431dc fix(#981): validate runner credentials on every boot, purge stale .runner
hive-ci-register.service now runs unconditionally on every boot (not
just when .runner is absent). Before fetching a registration token it
validates existing .runner credentials via the forge admin API:
- 200: runner still registered, write dummy token and exit
- 404: runner deleted from forge, purge .runner and re-register
- 000: forge unreachable, keep credentials (runner surfaces the error)
- other non-200 or malformed .runner: purge and re-register

Removes ConditionPathExists so stale credentials from a wiped forge
no longer block the runner indefinitely. Updates docs/ci.md to match.
2026-06-02 00:27:47 +02:00
atlas
167b4fa1f3 refactor(gateway): fixed htpasswd path, drop htpasswdFile option
Remove the custom htpasswdFile option and bind-mount. The htpasswd file
now lives at the fixed path /var/lib/hyperhive/gateway/gateway.htpasswd
on the host, which is already exposed inside the container at
/run/hive-state/gateway.htpasswd via the existing gateway state
bind-mount — no extra bind-mount needed.

A tmpfiles rule pre-creates the file so nginx can open it even before
any users exist (empty file → all requests return 401, which is correct).

hivectl gateway commands default --file to the standard path so
`hivectl gateway create-user alice` just works without any flags.
2026-06-02 00:26:10 +02:00
atlas
ba1d096391 fix(gateway): interpolate actual htpasswdFile path in 401 page
Per argus review: the hardcoded /etc/hyperhive/gateway.htpasswd
example was wrong for operators with a custom htpasswdFile path.

Move the unauthorized.html from the static agentErrorPagesDir derivation
into a pkgs.writeText inside the lib.optionalAttrs guard where
cfg.auth.htpasswdFile is in scope and statically known non-null.
The rendered page now shows the operator's actual configured path.
2026-06-02 00:26:10 +02:00
atlas
a248c6bee1 feat(gateway): custom 401 page explaining how to add users
When HTTP Basic auth is enabled and credentials are absent or rejected,
nginx serves a Catppuccin-styled 401 page that tells the operator which
hivectl command to run to create a user. Uses error_page 401 =401 so
the browser still receives a 401 status (login dialog fires on first
visit) while getting a human-readable body when the dialog is dismissed.

The exact-match location (= /__hive_auth_unauthorized) beats location /
in nginx's prefix ordering so the internal subrequest does not loop back
through auth_basic.
2026-06-02 00:26:10 +02:00
atlas
db50da570a refactor(#1003): nixpkgs + nixpkgs-unstable as top-level meta inputs
Per mara's direction: both nixpkgs and nixpkgs-unstable are now
top-level meta flake inputs with explicit store-path URLs.  Hyperhive
follows them rather than the other way around:

  inputs.nixpkgs.url            = "path:${pkgs.path}";
  inputs.nixpkgs-unstable.url   = "path:${nixpkgs-unstable}";
  inputs.hyperhive.url          = "...";
  inputs.hyperhive.inputs.nixpkgs.follows            = "nixpkgs";
  inputs.hyperhive.inputs.nixpkgs-unstable.follows   = "nixpkgs-unstable";

New NixOS host options (auto-set at build time, overridable):
  services.hyperhive.c0re.nixpkgsFlake
    default: "path:${pkgs.path}" — host's evaluated nixpkgs.
  services.hyperhive.c0re.nixpkgsUnstableFlake
    default: "path:${nixpkgs-unstable}" from hyperhive's flake.nix —
    the channel that carries claude-code.  Operators can override to
    track a different unstable snapshot.

Legacy fallback (both args empty) preserved for backward compat.
Two new Rust tests cover the full-URL and fallback paths.
2026-06-01 23:30:04 +02:00
atlas
fe5a41288d feat(#1003): inject pkgs.path into meta flake as explicit nixpkgs.url
meta flake was using `nixpkgs.follows = "hyperhive/nixpkgs"` but
`hyperhive` is a store-path input, so nix resolves hyperhive's own
pinned lock rather than the host's follows-substituted version.
When an operator sets `inputs.hyperhive.inputs.nixpkgs.follows =
"nixpkgs"` in their host flake, the meta flake was silently ignoring
it and using hyperhive's pinned nixpkgs instead.

Fix: hive-c0re.nix injects `--nixpkgs-flake path:${pkgs.path}` into
the daemon's ExecStart. `pkgs` IS the host's nixpkgs when follows is
set; otherwise it's hyperhive's own pin — so the meta flake gets the
right nixpkgs in both cases. render_flake emits `nixpkgs.url = "..."`
(explicit) when nixpkgs_flake is non-empty, falling back to the old
`follows` form when empty for backward compat.
2026-06-01 23:29:54 +02:00