<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom"><id>https://oni.sqzer.com/transtation/tags.xml</id><title>transtation</title><subtitle>Your own VLESS + REALITY proxy with optional Cloudflare WARP egress. Server in a container or native; native client with a fail-closed killswitch.</subtitle><link rel="self" href="https://oni.sqzer.com/transtation/tags.xml"/><updated>2026-08-06T20:55:49+09:00</updated><entry><id>urn:git:6130401a3912467a1e47fe3f631526a57aa83669:tag:v1.0.18</id><title>[v1.0.18] Stop telling people to restart a server that is merely still starting</title><published>2026-08-06T20:55:49+09:00</published><updated>2026-08-06T20:55:49+09:00</updated><author><name>sqzer</name><email>110329244+sqzer-x@users.noreply.github.com</email></author><link rel="alternate" href="https://oni.sqzer.com/transtation/commit/6130401a3912467a1e47fe3f631526a57aa83669.html"/><content type="text">Stop telling people to restart a server that is merely still starting

The runtime file is written a moment into startup, after the dest probe, so
`transtation show` immediately after `systemctl start` hit the not-started
branch and was told to run `systemctl restart transtation`. Following that
advice restarts the clock and produces the same message, which is the worst
kind of wrong: actionable, confident, and a loop.

It now waits while the service is genuinely coming up, and only says that when
it is not. The distinction matters more than the wait: a stopped server still
fails immediately rather than hanging for fifteen seconds, and test.sh asserts
that, because getting the guard backwards would make every command on a down
server feel broken.

Found by racing a restore on real hardware -- the restore itself was fine and
the identity came back byte-identical, but reading the link one second later
gave advice that could not work.
</content></entry>
<entry><id>urn:git:ff4f5dedd288d28fb7a3653ce2aa651be0db1937:tag:v1.0.17</id><title>[v1.0.17] Make transtation mean the same thing on both install paths</title><published>2026-08-06T20:48:12+09:00</published><updated>2026-08-06T20:48:12+09:00</updated><author><name>sqzer</name><email>110329244+sqzer-x@users.noreply.github.com</email></author><link rel="alternate" href="https://oni.sqzer.com/transtation/commit/ff4f5dedd288d28fb7a3653ce2aa651be0db1937.html"/><content type="text">Make transtation mean the same thing on both install paths

The README says &quot;Either way transtation is the command&quot;, but the native wrapper
was a pure passthrough, so three verbs only existed on the container path.
`transtation logs` was not a command at all, `restore` did not exist, and
`backup` printed a tarball of the Reality private key to the terminal. Anyone
following the documented advice would redirect that by hand and get a 0644 file
holding the Reality private key, the WARP private key and the WARP bearer token
-- root&#39;s umask is 022 on every target distro. The container wrapper sets
umask 077 for exactly this reason.

The native wrapper now implements the same three, and test.sh asserts both the
verb parity and the 0600, reproducing root&#39;s umask so the check fails if the
guard is ever dropped again.

The documentation had drifted the same way. Troubleshooting still had a whole
section on a client container that no longer exists -- --device /dev/net/tun,
--cap-add NET_ADMIN, &quot;docker0 is not visible&quot; -- replaced with the failure modes
the native client actually reports. Backup, restore, uninstall, config paths,
dest inspection and the ufw advice were all written for the container only; ufw
in particular inverts, since published ports bypass it and a native install has
no such shortcut.

Also: the --network host warning claimed it turns the server into an open relay.
The health inbound binds 127.0.0.1, so it does not become internet-reachable --
it becomes an unauthenticated proxy for every local user. Still a reason not to
do it, but the accurate one.
</content></entry>
<entry><id>urn:git:bec76e15fe61c383e3600bfffcc9f612c62b366d:tag:v1.0.16</id><title>[v1.0.16] The first line still said this was one container</title><published>2026-08-06T20:35:08+09:00</published><updated>2026-08-06T20:35:08+09:00</updated><author><name>sqzer</name><email>110329244+sqzer-x@users.noreply.github.com</email></author><link rel="alternate" href="https://oni.sqzer.com/transtation/commit/bec76e15fe61c383e3600bfffcc9f612c62b366d.html"/><content type="text">The first line still said this was one container

It has not been for several releases. The server runs in a container or
natively, and the client is a native install because a container is the wrong
shape for something whose job is to own the routing table. The repository
description said the same thing and has been corrected too -- it is the first
sentence anyone reads.
</content></entry>
<entry><id>urn:git:bacc2072ddf81a258071cadbe3c287f61ffd01fa:tag:v1.0.15</id><title>[v1.0.15] Remove what the client rewrite left behind</title><published>2026-08-06T20:31:09+09:00</published><updated>2026-08-06T20:31:09+09:00</updated><author><name>sqzer</name><email>110329244+sqzer-x@users.noreply.github.com</email></author><link rel="alternate" href="https://oni.sqzer.com/transtation/commit/bacc2072ddf81a258071cadbe3c287f61ffd01fa.html"/><content type="text">Remove what the client rewrite left behind

install.sh still carried IMAGE_CLIENT, CLIENT_DIGEST and client_image(), all
pointing at a container package that no longer exists and is never published
again. The README&#39;s supply-chain section still sent readers to client/Dockerfile
for the sing-box hashes; that file was deleted and the hashes live in install.sh.

NOTICE described Xray and wgcf as redistributed in the image, which is only half
true now: the native server install downloads and verifies them from upstream
instead, which is a different obligation. It says both.
</content></entry>
<entry><id>urn:git:9cd85e9b0a33b2c044616594183302c3bc45e8a1:tag:v1.0.14</id><title>[v1.0.14] The native install took down an existing Xray server, twice over</title><published>2026-08-06T20:19:08+09:00</published><updated>2026-08-06T20:19:08+09:00</updated><author><name>sqzer</name><email>110329244+sqzer-x@users.noreply.github.com</email></author><link rel="alternate" href="https://oni.sqzer.com/transtation/commit/9cd85e9b0a33b2c044616594183302c3bc45e8a1.html"/><content type="text">The native install took down an existing Xray server, twice over

Found by running the native path on a VPS that already ran Xray, which is the
normal case for anyone this project is aimed at.

/usr/local/bin/xray is where the official XTLS installer and every hand-rolled
setup put the binary. The native install wrote ours over it, so the existing
service silently started running our build, and the uninstall then deleted the
file outright -- the other service failed with 203/EXEC and stayed down. wgcf
had the same problem, and the client had it waiting for sing-box.

Third-party binaries now live in /usr/local/lib/transtation, which is ours
alone. The systemd unit and the command wrapper put that directory on PATH, and
the uninstall removes only that directory. Verified by planting sentinel
binaries at both paths: they survive install and uninstall untouched, while our
own copies work. Two static checks keep it that way.

Also fixes the address the native install advertises. On a dual-stack host curl
prefers IPv6, so auto-detection returned an IPv6 literal and every share link
named an address nothing in this stack can use -- the client resolves A records
only, its DNS strategy is ipv4_only, and the killswitch refuses an IPv6 server.
Discovery is pinned to IPv4 and an IPv6 HOST is rejected with the reason. A
container had hidden this because Docker&#39;s default bridge is IPv4-only.

And the two server installs no longer stack. They own the same command and want
the same port, so installing one over the other left the first running,
orphaned and unreachable. Each refuses if the other is present.
</content></entry>
<entry><id>urn:git:f028ebe2bb3525f6dab6facffb8922b2f8f0af5a:tag:v1.0.13</id><title>[v1.0.13] The server installs natively too, not only in a container</title><published>2026-08-06T19:50:05+09:00</published><updated>2026-08-06T19:50:05+09:00</updated><author><name>sqzer</name><email>110329244+sqzer-x@users.noreply.github.com</email></author><link rel="alternate" href="https://oni.sqzer.com/transtation/commit/f028ebe2bb3525f6dab6facffb8922b2f8f0af5a.html"/><content type="text">The server installs natively too, not only in a container

Same program, same generated config, two ways to run it. `install.sh server
--native` downloads Xray and wgcf, verifies both against upstream checksums with
the same empty-expectation guard the Dockerfile uses, keeps state in
/var/lib/transtation owned by a dedicated user, and runs under a systemd unit.

Making the script work outside a container turned up three things it had been
getting away with:

- `apply()` sent SIGTERM to PID 1 to restart Xray. Inside a container that is
  Xray and the restart policy brings it back. On a host, PID 1 is init. It is
  keyed on TT_MANAGED now, which the image sets and a native install does not,
  and uses systemctl outside a container.
- The data directory was hardcoded to /data, so `transtation show` on a native
  install looked at an empty directory and reported no identity.
- Every CLI verb writes through mktemp and rename. Run as root against a state
  directory owned by the service user, that leaves root-owned files the service
  can no longer read, so the installed command drops to the transtation user
  before doing anything.

The listen port now depends on who performs the privileged bind: the container
listens high and lets dockerd bind 443, which is why it needs no capability at
all; natively there is nothing in front, so it binds the real port and the unit
carries CAP_NET_BIND_SERVICE and nothing else.

Verified end to end with no container runtime involved: install, identity and
dest selection, verify completing a REALITY handshake, user add and rm keeping
every file owned by the service user, a client connecting through it, an
outside observer seeing a genuine Tving certificate, and a clean uninstall.
</content></entry>
<entry><id>urn:git:e70837eef8bda4a218c2f6c97288ab5d76855aad:tag:v1.0.12</id><title>[v1.0.12] The client is a native install now, not a container</title><published>2026-08-06T19:17:55+09:00</published><updated>2026-08-06T19:17:55+09:00</updated><author><name>sqzer</name><email>110329244+sqzer-x@users.noreply.github.com</email></author><link rel="alternate" href="https://oni.sqzer.com/transtation/commit/e70837eef8bda4a218c2f6c97288ab5d76855aad.html"/><content type="text">The client is a native install now, not a container

Shipping the client as a container never made sense and the README said so
itself: a container given --network host and CAP_NET_ADMIN &quot;provides no
isolation whatsoever&quot;, and &quot;for a permanent desktop setup the native sing-box
package is the better answer&quot;. The project was distributing the thing it
recommended against and not distributing the thing it recommended. Phones and
desktops -- most of the people who would use a proxy -- cannot run a container
at all, and on Linux the container covered only rootful Docker, where the
native binary is better anyway. The killswitch and the panic script were host
scripts from the first commit, which was the tell.

`install.sh client` now installs a verified static sing-box, a generated config
and a systemd unit, with the whole-host tunnel as the default. Proxy mode
survives behind --proxy. The client image, its Dockerfile and its GHCR package
are gone, so there is one image to build and publish instead of two.

The self-test for tun mode was wrong in a way the container version shared: it
accepted any reply as success, so it passed while routing was still settling
and the answer had gone out the ordinary way -- reporting success on a machine
that was not tunnelled. It now records the address before the tunnel exists and
requires it to have moved, and says &quot;still your own address&quot; and exits non-zero
when it has not. Confirmed in both directions against a live server and a dead
one.

NOTICE changes with it: sing-box is no longer redistributed. The installer
fetches the official release and verifies it against a hash in this repository,
which is a different obligation from shipping the binary, and the file now says
so.
</content></entry>
<entry><id>urn:git:594fdc49e451a272800505d478b62a66db56d91d:tag:v1.0.11</id><title>[v1.0.11] Say plainly that proxy mode does not put your machine behind the proxy</title><published>2026-08-06T18:59:41+09:00</published><updated>2026-08-06T18:59:41+09:00</updated><author><name>sqzer</name><email>110329244+sqzer-x@users.noreply.github.com</email></author><link rel="alternate" href="https://oni.sqzer.com/transtation/commit/594fdc49e451a272800505d478b62a66db56d91d.html"/><content type="text">Say plainly that proxy mode does not put your machine behind the proxy

Someone reading the README could reasonably install the server and the client,
see the installer report an egress IP in Tokyo, and believe their machine was
now behind the proxy. It is not. Measured on a host with the client running:

    curl https://api.ipify.org                      198.51.100.23  own address
    curl https://api.ipify.org  (after the exports) 192.0.2.7   proxied
    env -i curl https://api.ipify.org               198.51.100.23  own address
    … and the same from any other terminal, and from anything the desktop starts

The old wording contributed to that. &quot;Not covered: QUIC and any application
that ignores proxy environment variables&quot; reads as though everything else is
covered, when in fact nothing is until each program is pointed at the proxy --
and the two export lines reach only the shell they are typed into. Firefox in
particular never sees them.

Proxy mode now opens by saying it does not put the machine behind the proxy and
pointing at tun mode for that, shows the measurement above, and reframes the
limitations so they no longer imply a default that does not exist. The
installer says the same thing in the same words, and labels its self-test
result &quot;through the proxy&quot; rather than presenting an address that is not yours
as though it were.
</content></entry>
<entry><id>urn:git:baeb91ca9dfea9c052773ccff92ea054c135ce72:tag:v1.0.10</id><title>[v1.0.10] Make the README&#39;s two promises true, and correct four stale claims</title><published>2026-08-06T18:24:38+09:00</published><updated>2026-08-06T18:24:38+09:00</updated><author><name>sqzer</name><email>110329244+sqzer-x@users.noreply.github.com</email></author><link rel="alternate" href="https://oni.sqzer.com/transtation/commit/baeb91ca9dfea9c052773ccff92ea054c135ce72.html"/><content type="text">Make the README&#39;s two promises true, and correct four stale claims

Adversarial pass over the user-facing docs. Two of them were promises the
project did not keep.

The first line of the README tells you to check install.sh against a hash in
the release notes. There were no releases and therefore no hash. The publish
workflow now creates the release and puts the installer&#39;s sha256 in it, so the
instruction can be followed.

The curl-pipe-sh section claimed release builds pin the image by digest. They
never did: the digest variables were always empty and the installer said so in
its own output, which nobody reconciled with the README. Rather than delete the
claim, the installer now earns it -- it resolves the tag to the digest it points
at during installation and rewrites the compose file to that digest, so from
then on `docker compose up` can only start the image the install verified. Re-
running the installer is still how you upgrade, because the line is rewritten
every run.

Corrected: transtation-panic is not six lines any more; the tested section said
&quot;healthy in 12s&quot; three paragraphs above a measured 7.3s; arm64 was described as
only running its selftest when it had been run as a server with a client
passing traffic through it; and rootless Docker and Docker Desktop were listed
as working when only rootless Podman was exercised.

Also fixes the client&#39;s panic banner, whose box drawing was misaligned and
whose `$p` and `$(seq ...)` were being expanded by the heredoc instead of
printed, so the commands it told you to run at 3am were wrong.
</content></entry>
<entry><id>urn:git:799bb33eaa17c9c8c6c26b1f1772e9afa05b5c01:tag:v1.0.9</id><title>[v1.0.9] Document the client&#39;s settings, and drop the list of untested platforms</title><published>2026-08-06T17:54:01+09:00</published><updated>2026-08-06T17:54:01+09:00</updated><author><name>sqzer</name><email>110329244+sqzer-x@users.noreply.github.com</email></author><link rel="alternate" href="https://oni.sqzer.com/transtation/commit/799bb33eaa17c9c8c6c26b1f1772e9afa05b5c01.html"/><content type="text">Document the client&#39;s settings, and drop the list of untested platforms

SOCKS_PORT, TT_URI and TT_URI_FILE were read by the client and documented
nowhere; the client now has the same kind of settings table the server has.

The README no longer enumerates the platforms this was not run on. It still
says exactly what was run, which is the part a reader can act on -- whether it
works on their own machine is something they will find out by running it there,
and the honest list was turning into a list of things nobody had promised.
</content></entry>
<entry><id>urn:git:5c32a23129a433d96d8255d1ec350eaa8635f301:tag:v1.0.8</id><title>[v1.0.8] The README told people to install a version five releases old</title><published>2026-08-06T16:42:28+09:00</published><updated>2026-08-06T16:42:28+09:00</updated><author><name>sqzer</name><email>110329244+sqzer-x@users.noreply.github.com</email></author><link rel="alternate" href="https://oni.sqzer.com/transtation/commit/5c32a23129a433d96d8255d1ec350eaa8635f301.html"/><content type="text">The README told people to install a version five releases old

The version appears in the installer default and in every documented command,
and bumping it was a hand-run sed. It drifted: the README pointed at v1.0.2
while the current release was v1.0.7, so anyone following the instructions got
a build without the warp register fix, the client self-test fix or the
container hardening.

Fixed, and gated so it cannot happen again. test.sh checks that every version
string in install.sh and README.md agrees with the installer&#39;s own default, and
CI additionally requires that they equal the tag being released -- so a release
whose documentation points somewhere else fails before any image is published.
</content></entry>
<entry><id>urn:git:4e35adf9138371b524fc4c2b36c391b22f783228:tag:v1.0.7</id><title>[v1.0.7] Security review: sanitise attacker-controlled text, tighten the container</title><published>2026-08-06T16:39:15+09:00</published><updated>2026-08-06T16:39:15+09:00</updated><author><name>sqzer</name><email>110329244+sqzer-x@users.noreply.github.com</email></author><link rel="alternate" href="https://oni.sqzer.com/transtation/commit/4e35adf9138371b524fc4c2b36c391b22f783228.html"/><content type="text">Security review: sanitise attacker-controlled text, tighten the container

Findings came from probing the running server from outside and from attacking
the client with hostile links.

Terminal escape injection. Values taken from a share link were quoted back in
error messages unmodified, so a crafted link painted escape sequences straight
onto the operator&#39;s terminal -- confirmed with a payload that rendered as
coloured text in the failure message. Every untrusted value now passes through
a filter that strips non-printable characters and caps the length at 64, on
both sides. A 5000-character field now produces a 59-byte message.

Container hardening. The process already held zero effective capabilities, but
the bounding set was full and no-new-privileges was off, so a future change
could have quietly re-acquired privileges. The compose file now drops ALL
capabilities, sets no-new-privileges, mounts the root filesystem read-only and
gives /tmp a small noexec tmpfs. Verified on a live server: CapEff and CapBnd
both 0000000000000000, NoNewPrivs 1, writes to / rejected, and the server still
boots, verifies and adds users normally.

What the probing found intact, for the record. From outside, the server is
indistinguishable from the site it borrows: identical certificate, identical
TLS 1.3 and TLS 1.2 behaviour, identical handshake failure on a wrong SNI,
identical silence on non-TLS input, identical ALPN, and a completed session
returns the real site&#39;s HTTP 200 byte for byte. Only ports 22 and 443 are
reachable; the loopback SOCKS inbound the healthcheck uses is not. An
authenticated user cannot reach cloud metadata over IPv4 or its IPv6 alias, the
server&#39;s own loopback, any RFC1918 or CGNAT range, or port 25 -- over TCP or
UDP -- while ordinary traffic and port 587 work.
</content></entry>
<entry><id>urn:git:0440983d41b1024edc5548c56e0f093cbd7d9a52:tag:v1.0.6</id><title>[v1.0.6] Two bugs found by running the paths that had never been run</title><published>2026-08-06T16:19:19+09:00</published><updated>2026-08-06T16:19:19+09:00</updated><author><name>sqzer</name><email>110329244+sqzer-x@users.noreply.github.com</email></author><link rel="alternate" href="https://oni.sqzer.com/transtation/commit/0440983d41b1024edc5548c56e0f093cbd7d9a52.html"/><content type="text">Two bugs found by running the paths that had never been run

transtation warp register destroyed a working identity. It deleted the live
profile and then attempted registration, so a rate-limited retry -- the single
most likely outcome of running the command by hand, since Cloudflare limits
registration per source IP for about fifteen minutes -- left the server with no
WARP configuration at all, and it degraded to direct egress on its next restart
without saying so. Registration now writes to temporary files and promotes them
only on success. Proved by planting a profile, forcing a rate-limited retry,
and confirming both the profile and the account file survive byte for byte.

The client installer could never report a failure. Its self-test piped curl
into grep into tr with the fallback message attached to the end of the
pipeline, so it hung off tr, which always succeeds. A client passing no traffic
whatsoever still finished with a success banner, which is exactly what happened
when the path was finally run. The result is captured and tested now, retried a
few times for slow links, and a client that is not working exits non-zero with
somewhere to look. Verified both directions: exit 1 against a dead server,
exit 0 against a live one.

Also points the documented install at the release that contains these fixes.

v1.0.4 and v1.0.5 were tagged from the wrong commit and are identical to
v1.0.3. They are left in place rather than moved: re-pointing a published tag
is the hazard this project&#39;s own digest pinning exists to avoid, and their
images are already on the registry.
</content></entry>
<entry><id>urn:git:4946b648a1ace9b7d2f9a11ada7cb2860410faa3:tag:v1.0.5</id><title>[v1.0.5] README: replace the throughput figure with numbers from the clean run</title><published>2026-08-06T15:59:25+09:00</published><updated>2026-08-06T15:59:25+09:00</updated><author><name>sqzer</name><email>110329244+sqzer-x@users.noreply.github.com</email></author><link rel="alternate" href="https://oni.sqzer.com/transtation/commit/4946b648a1ace9b7d2f9a11ada7cb2860410faa3.html"/><content type="text">README: replace the throughput figure with numbers from the clean run

Measured with the VPS host proxy stopped, so nothing went through a second
proxy: 0.20s steady-state request latency, 6.5-8.3 MB/s, 0.9s from docker run
to the first byte, 7.3s to healthy and 14.4s to verified on a first boot. The
note about the warm-up is there so the gap between healthy and verified does
not read as something still to be optimised away.
</content></entry>
<entry><id>urn:git:4946b648a1ace9b7d2f9a11ada7cb2860410faa3:tag:v1.0.4</id><title>[v1.0.4] README: replace the throughput figure with numbers from the clean run</title><published>2026-08-06T15:59:25+09:00</published><updated>2026-08-06T15:59:25+09:00</updated><author><name>sqzer</name><email>110329244+sqzer-x@users.noreply.github.com</email></author><link rel="alternate" href="https://oni.sqzer.com/transtation/commit/4946b648a1ace9b7d2f9a11ada7cb2860410faa3.html"/><content type="text">README: replace the throughput figure with numbers from the clean run

Measured with the VPS host proxy stopped, so nothing went through a second
proxy: 0.20s steady-state request latency, 6.5-8.3 MB/s, 0.9s from docker run
to the first byte, 7.3s to healthy and 14.4s to verified on a first boot. The
note about the warm-up is there so the gap between healthy and verified does
not read as something still to be optimised away.
</content></entry>
<entry><id>urn:git:b36a9530cd5014ca754c3fc8c50f17b00e1e5cbe:tag:v1.0.3</id><title>[v1.0.3] Measure the hot paths, then change only what the measurements justify</title><published>2026-08-06T15:54:08+09:00</published><updated>2026-08-06T15:54:08+09:00</updated><author><name>sqzer</name><email>110329244+sqzer-x@users.noreply.github.com</email></author><link rel="alternate" href="https://oni.sqzer.com/transtation/commit/b36a9530cd5014ca754c3fc8c50f17b00e1e5cbe.html"/><content type="text">Measure the hot paths, then change only what the measurements justify

Baseline first, on a Korean client against a real server. There is no
super-linear algorithm anywhere in this project -- every loop runs over a
handful of users, ten dest candidates or eleven routing priorities. The costs
are serialised I/O, a fixed sleep, and one process per key.

What changed, with numbers:

- WARP registration (~4.0s) and dest selection (~1.05s) are independent, so
  registration now starts first and is collected after the dest is chosen.
  Cold start to healthy: 11.7s -&gt; 6.5s.
- sni_probe made two round trips per candidate: `xray tls ping` for the
  technical filter, then curl to time the handshake that ping had just
  performed. It now times the ping. One fewer connection per candidate, to
  third-party sites, on first boot.
- The state files were read with one `sed` per key. They are read once each
  with the shell now: 50 `transtation status` calls, 2919ms -&gt; 1743ms. This is
  the path the healthcheck takes every five minutes for the life of the server.
- transtation-panic probed every priority in its range up to twenty times per
  address family -- 440 `ip` invocations worst case -- because several rules can
  share a priority. It lists the rules and deletes what is there: 134ms -&gt; 76ms,
  bounded by reality rather than by a guess.

What did NOT improve, stated because it is the number that matters: the time
from `docker run` to a verified working proxy is 13.9s before and 13.8s after.
Reordering made the container report healthy sooner, but the remainder is a
fixed warm-up inside xray before its first proxied connection succeeds, and no
amount of reordering removes it.

`transtation verify` was rewritten twice and put back. Racing to the earliest
possible request is slower, not faster: an attempt made before the probe client
is ready sits on a ~5s backoff, so the 2s settle beats every alternative for
the one call that matters, the one install.sh makes. The retries around it are
for slower hosts, not for speed, and the comment says so.
</content></entry>
<entry><id>urn:git:93db4d9b3585a57f9b070ff1d746b723d6f9b7ba:tag:v1.0.2</id><title>[v1.0.2] panic left routing rules behind, found by running it on a real host</title><published>2026-08-06T15:05:34+09:00</published><updated>2026-08-06T15:05:34+09:00</updated><author><name>sqzer</name><email>110329244+sqzer-x@users.noreply.github.com</email></author><link rel="alternate" href="https://oni.sqzer.com/transtation/commit/93db4d9b3585a57f9b070ff1d746b723d6f9b7ba.html"/><content type="text">panic left routing rules behind, found by running it on a real host

sing-box installs several ip rules that share a priority -- on this host two
sat at 9003 -- and `ip rule del priority N` removes exactly one. The sweep made
a single pass per priority, so after `transtation-panic` reported success three
rules were still there, including a detached one pointing at the deleted tun
interface. Every copy of the sweep is fixed: the host script, the panic verb in
the client image, the copy install.sh writes, the banner the client prints on
every boot, and the manual commands in TROUBLESHOOTING.

Verified live end to end afterwards, against the production Tokyo server:
egress moved 198.51.100.23 -&gt; 192.0.2.7, naver stayed on its real Korean
address through the split tunnel, SIGKILLing the client left curl timing out
rather than leaking, and panic restored direct egress with zero rules, no tun
interface and no nftables table left -- while Tailscale rules at 52xx were
untouched and another container kept working.
</content></entry>
<entry><id>urn:git:15e8e4f89a3fb5c9615a50cacd3c933dd25bec7e:tag:v1.0.1</id><title>[v1.0.1] The installer never ran on Debian or Ubuntu</title><published>2026-08-06T14:46:34+09:00</published><updated>2026-08-06T14:46:34+09:00</updated><author><name>sqzer</name><email>110329244+sqzer-x@users.noreply.github.com</email></author><link rel="alternate" href="https://oni.sqzer.com/transtation/commit/15e8e4f89a3fb5c9615a50cacd3c933dd25bec7e.html"/><content type="text">The installer never ran on Debian or Ubuntu

Traced on a real Ubuntu VPS: the script printed nothing and exited 2, at

    + main
    + shift

`shift` with no positional parameters is a special-builtin error, and POSIX
says a shell exits on those. dash -- /bin/sh on Debian and Ubuntu, the primary
target -- does exactly that, so `sh install.sh` with no arguments died before
the first line of output. The `|| true` next to it never had a chance. bash is
forgiving here and bash is /bin/sh on the machine every earlier test ran on,
which is precisely why nothing caught it.

The regression test runs the real installer as a non-root user under dash in
debian:stable-slim and under busybox ash in alpine, and requires the &quot;needs
root&quot; message rather than merely a non-zero exit, so a silent death fails it.

Also refuses an IPv6 server address in the killswitch instead of splicing it
into an `ip daddr` rule, which nft rejects -- leaving the host with no
killswitch at all rather than a broken one. transtation is IPv4-only end to
end: the client resolves with getent ahostsv4 and the tun DNS strategy is
ipv4_only.
</content></entry>
<entry><id>urn:git:9bc8c1632ec758b29f1bb2d549af45690dc177f8:tag:v1.0.0</id><title>[v1.0.0] Adversarial review: fix a config-injection hole and a broken restore path</title><published>2026-08-06T14:09:07+09:00</published><updated>2026-08-06T14:09:07+09:00</updated><author><name>sqzer</name><email>110329244+sqzer-x@users.noreply.github.com</email></author><link rel="alternate" href="https://oni.sqzer.com/transtation/commit/9bc8c1632ec758b29f1bb2d549af45690dc177f8.html"/><content type="text">Adversarial review: fix a config-injection hole and a broken restore path

Findings came from attacking the thing rather than reading it, so each one has
a regression test that fails without the fix.

Share links controlled the client config. flow= and fp= were interpolated into
the generated sing-box document without validation, next to a comment claiming
every value was validated. A crafted link injecting &quot;insecure&quot;:true rendered a
config that sing-box ACCEPTED (exit 0), and one injecting a second &quot;server&quot; key
put an attacker address in the document. A link is something somebody hands you
in a chat or as a QR code, so this is remote input. Both are allowlisted now,
along with SOCKS_PORT, DIRECT_SUFFIXES, MIN_CLIENT_VER and WARP_ENDPOINT, which
reach JSON the same way from the environment. Server-side validation moved into
validate_config_env so the selftest can attack it directly.

The documented disaster-recovery path did not work, in three separate ways.
Docker seeds a fresh named volume with the ownership of the image directory it
is first mounted into, so unpacking with alpine left /data owned by root and
the server refused to start. The backup is mode 0600, so the unpacking
container could not read it as a non-root user. And Compose prefixes volume
names with the project directory, so every documented command addressed
transtation-data while the real volume was &lt;dir&gt;_transtation-data -- a restore
would silently populate a brand new volume, the server would provision a fresh
identity, and every issued link would be dead with no error anywhere. The
volume name is pinned, and restore is a verb with guardrails: it refuses a file
that is not a backup and refuses to run while the server is up.

Also: the CI job would have gone red on its first run, because test.sh treats
shellcheck warnings as failures and there were six. All six files are clean
now. The uninstall prompt aborted the script where there is no controlling
terminal -- [ -r /dev/tty ] passes even then and the open fails with ENXIO --
and it now falls back to stdin and defaults to keeping the volume. Compose
writes progress to stderr, so the installer was leaking it. PORT set in the
environment is persisted to .env, or the published port and the port in the
share link disagree.

install.sh had never actually been executed before this review. It has now been
run end to end, along with backup, total volume loss, restore, and uninstall.
</content></entry>
</feed>
