Three small things together:
1. relinkHomeManager was pinning the versioned symlink name to
'home-manager-24-link'. That breaks on every HM major-version bump:
HM 25 will move the alias to 'home-manager-25-link' and the script
will silently stop relinking. Derive the name from one hop of the
stable 'home-manager' symlink, with a guard against the missing case
so a fallback never accidentally rewrites the profiles/ directory
itself.
2. programs.opencode.web.environmentFile now reads from
xlib.dirs.opencode-server-env (added previously in users.nix + dirs.nix).
The sops materialization and the systemd EnvironmentFile can no
longer silently desync.
3. Document the oh-my-openagent 2026-07-opencode-config-unification
migration trap that logs 'Migration backup path already exists' on
every startup: the backup path embeds the content-hashed store path,
which stays valid in /nix/store across HM activations, so the
deterministic collision never resolves itself. Recovery is
'rm -rf ~/.omo/migration-backup-*' to let omo retry; if it keeps
failing on the same path the plugin version probably expects a new
schema and this file needs changes.
Also trim the over-explained [Service] / serviceConfig comment: the
home-manager attrset-union behavior is general, not specific to this
unit, so the explanation got shorter without losing the invariant.
The path '/home/<user>/.config/opencode/server.env' was duplicated
between users.nix (sops materialization) and home/modules/opencode.nix
(programs.opencode.web.environmentFile). Drift between the two was a
silent auth-bypass vector: if one moved, the systemd unit would either
fail to find OPENCODE_SERVER_PASSWORD or skip EnvironmentFile entirely.
Single source in lib/xlib/dirs.nix; both call sites now read from it.
Sapphira: HTTP reverse proxy serves panel/sub on x.zeroq.su;
no xray stream on 443 and no 8443 stream either (8443 is directly
exposed by podman as 0.0.0.0:8443:8443/tcp).
Otreca: stream on 443 routes by SNI (panel via pubray1.zeroq.su,
xray default) and 8443 is direct 0.0.0.0:8443.
Modules/containers/3x-ui.nix:
- basePorts restored: '0.0.0.0:8443:8443/tcp' (was '127.0.0.1:15380:8443/tcp')
- realityPorts restored (was 'lib.optional ... "127.0.0.1:15380:443/tcp"')
- image restored: ':latest' (was ':v3.9.0')
Modules/server/nginx.nix:
- removed 8443 streamConfig for xray (the one b0191bc added)
- removed 8443 from allowedTCPPorts
Other files (configurations/{server,vds,wsl}.nix, home/modules/opencode.nix)
left alone — they contain SSH firewall / builder / opencode web changes
unrelated to nginx + ports that the user asked to revert.
The systemd unit on the otreca VDS carried two -p flags that bind
the same host port 127.0.0.1:15380:
-p 127.0.0.1:15380:8443/tcp # from basePorts
-p 127.0.0.1:15380:443/tcp # from realityPorts (when reality443Forwarding=true)
podman 5.x tries to bind 127.05 in each -p flag and the second
fails with EADDRINUSE, even though no process is visible in ss —
the bind happens at the proxy level before the container starts:
Error: cannot listen on the TCP port: listen tcp4 127.0.0.1:15380:
bind: address already in use
Symptom on otreca: podman-3xui_app.service hits start-limit-hit
after 5 rapid retries.
The 15380:443 mapping is dead code: the container's only Reality
inbound listens on 8443, and nginx stream already routes host:443
to 127.0.0.1:15380 via SNI (modules/server/nginx.nix streamConfig).
reality443Forwarding remains a host option for configurations to
declare intent; the broken port-mapping generation is replaced with
an empty list.
Revert the b0191bc 'otreca vds: pin 3x-ui:v3.8.5 + nginx stream + ssh
tailscale-only + patch-3xui-xray-config' changes:
- 3x-ui.nix: back to :latest image, direct 0.0.0.0:8443 port mapping,
remove migrateScript + patchScript and their systemd units/timer.
- vds.nix: re-open 22/tcp on public (openFirewall = true); remove the
tailscale0-only port rule.
- nginx.nix: drop the 8443 stream proxy.
- Remove modules/containers/3x-ui-migration-notes.md.
Reason: those changes, once applied on otreca, left the 3x-ui container
in a start-limit-hit loop (bind 127.0.0.1:15380: address already in use,
nothing visible in ss - probably a stale TIME_WAIT or slirp4netns port
from a prior container that never released).
The 3x-ui container config was hardcoded for vds: it mounted the LE
cert for pubray1.zeroq.su and published host:15380→container:443 for
Xray REALITY. The server imports the same module but for x.zeroq.su
(no REALITY inbound, no cert needed by 3x-ui itself yet).
Add two options so each device picks what it needs:
- xlib.services.3x-ui.certDomain: domain whose LE cert is mounted
at /root/cert/{fullchain,key}.pem. null means no cert mount.
- xlib.services.3x-ui.reality443Forwarding: when true, also publish
host:15380→container:443 for nginx stream SNI-routed REALITY.
vds sets both. Server sets only certDomain (kept harmless; nginx
still terminates TLS for x.zeroq.su, so the mounted cert is unused
until/unless 3x-ui is reconfigured to terminate TLS itself).
All Xray REALITY clients already connect to VDS_IP via
pubray1.zeroq.su (or any of its subdomains). Removing the explicit
pubrayx1.zeroq.su → xray rule means the default route catches it.
This way we only have to publish one domain (pubray1.zeroq.su)
in subscriptions instead of two.
Companion change in x-ui.db (separate runbook step): subURI set
to https://pubray1.zeroq.su/subs/ so regenerated subscriptions
emit URLs under pubray1.zeroq.su, not x.zeroq.su.
nginx stream + ssl_preread reads the ClientHello SNI and forwards the
raw TCP stream (no TLS termination) to either:
- 3x-ui panel on 127.0.0.1:2049 (SNI=pubray1.zeroq.su)
- Xray on 127.0.0.1:15380 (SNI=pubrayx1.zeroq.su or default)
podman maps host:15380 → container:443 so Xray inside sees the client
on port 443 (matching its REALITY config) even though the host-side
port from podman's perspective is 15380. Host:2049 still maps to
container:2049 — 3x-ui now terminates TLS itself using the Let's
Encrypt cert mounted from /var/lib/acme/pubray1.zeroq.su/.
x-ui.db: webCertFile, webKeyFile and webDomain set so the panel
answers HTTPS on 2049. nginx no longer owns a server block on 443 —
only an ACME-only vhost for cert renewal.
REALITY inbound on container:443 still needs to be created via the
panel UI (the xrayTemplateConfig doesn't have it yet). The host-side
and routing plumbing is ready for it.