README.md (26938B)
1 # transtation
2
3 Your own VLESS + REALITY proxy with optional Cloudflare WARP egress, on any
4 Linux box. The server runs in a container or natively, whichever you prefer;
5 the client is a native install, because putting a machine behind a proxy means
6 owning its routing table and a container is the wrong shape for that.
7
8 ```
9 curl -fsSLO https://raw.githubusercontent.com/sqzer-x/transtation/v1.0.18/install.sh
10 sha256sum install.sh # compare against the hash in the v1.0.18 release notes
11 less install.sh # please actually read it
12 sudo sh install.sh
13 ```
14
15 That is the whole server install. It generates its own Reality keypair and its
16 first user, registers a WARP identity, and prints the connection details as a
17 `vless://` link with a QR of the same link.
18
19 ---
20
21 ## This is not a new idea, and you should know the alternatives
22
23 [`reality-ezpz`](https://github.com/aleskxyz/reality-ezpz) already does Reality
24 + WARP + share link + QR over Docker Compose. [`3x-ui`](https://github.com/MHSanaei/3x-ui)
25 owns the web-panel end of this space with 44k stars and does far more than this
26 does. If you want a panel, multiple users with quotas, subscription links or a
27 Telegram bot, **use 3x-ui and stop reading here.**
28
29 transtation exists because of a narrower set of preferences:
30
31 - **A published, digest-pinned image** if you want one, rather than a
32 2,700-line bash generator that writes your config on the host. The native
33 path is the same program with a systemd unit, not a second codebase.
34 - **Zero capabilities.** The container holds no `NET_ADMIN`, mounts no
35 `/dev/net/tun`, and does not use host networking. WARP runs in Xray's
36 in-process userspace network stack.
37 - **Tracking upstream.** Xray ships roughly monthly and its config schema moves.
38 If this project stops keeping up within a year it is worse than what it
39 replaced, and the right thing to do then is archive it and point at
40 reality-ezpz. That is a promise, not a disclaimer.
41
42 ---
43
44 ## Server
45
46 Two ways to run it. They are the same software with the same config; pick on
47 how you feel about a container runtime on your VPS.
48
49 ### In a container (default)
50
51 ```
52 curl -fsSLO https://raw.githubusercontent.com/sqzer-x/transtation/v1.0.18/install.sh
53 sha256sum install.sh # compare against the hash in the v1.0.18 release notes
54 less install.sh # please actually read it
55 sudo sh install.sh
56 ```
57
58 Installs Docker if it is missing, writes `/opt/transtation/{docker-compose.yml,.env}`,
59 resolves the image tag to a digest and pins the compose file to it, then waits
60 for the container to be healthy and proves the handshake works. Re-running it is
61 how you upgrade.
62
63 ### Natively
64
65 ```
66 sudo sh install.sh server --native
67 ```
68
69 No container runtime. It downloads Xray and wgcf, verifies both against
70 upstream checksums, installs `/usr/local/bin/transtation`, keeps state in
71 `/var/lib/transtation` owned by a dedicated `transtation` user, and runs under
72 `transtation.service`.
73
74 ### Which one
75
76 Measured on the same host, same binary:
77
78 | | container | native |
79 |---|---|---|
80 | capabilities held | **none** — `CapEff 0000000000000000` | `CAP_NET_BIND_SERVICE`, and only that |
81 | root filesystem | read-only | `ProtectSystem=strict` |
82 | runs as | uid 10001 | `transtation` user |
83 | costs | Docker daemon, ~250 MB, `ip_forward=1`, published ports bypass `ufw` | needs systemd |
84 | upgrades | pull a new digest | re-run the installer |
85
86 The container needs no capability at all because dockerd performs the
87 privileged bind on 443 and the container listens high; natively there is
88 nothing in front, so it keeps exactly one capability and nothing else is even
89 reachable. Both pin and verify the same Xray release.
90
91 If you have no strong feeling, use the container: one artifact, dependencies
92 verified and frozen inside it, and rollback is a digest. If you would rather not
93 run a container runtime on a proxy server, the native path is not a lesser
94 option — it is the same program with a systemd unit instead of a compose file.
95
96 Either way `transtation` is the command:
97
98 ### Day to day
99
100 ```
101 transtation show # share link + QR for every user
102 transtation status # address, SNI, egress IP, WARP state, users
103 transtation user add alice # new UUID + shortId, applied immediately
104 transtation user rm alice # revokes both
105 transtation backup # tar.gz of everything irreplaceable, mode 0600
106 transtation restore <file> # put one back (server must be stopped)
107 transtation verify # prove a client can actually complete the handshake
108 transtation logs -f
109 ```
110
111 `transtation user add` and `user rm` restart Xray to apply. Existing connections
112 drop and clients reconnect on their own; Xray has no config reload signal.
113
114 ### Open the port
115
116 The healthcheck only proves **outbound** traffic works. Nothing on the server
117 can prove inbound does. If clients time out and the server log shows nothing at
118 all, open TCP 443 in your provider's firewall or security group. Oracle Cloud
119 additionally needs the port opened in the instance's own iptables.
120
121 Note the difference between the two install paths here: Docker's published
122 ports **bypass `ufw`** in both directions, so on the container path a ufw rule
123 is neither necessary nor sufficient. The native path has no such shortcut — a
124 host firewall applies to it normally and must permit the port.
125
126 ---
127
128 ## Client
129
130 You may well not need this project's client. The server prints a `vless://`
131 link, and every platform already has a good client that takes one:
132
133 | Platform | Use |
134 |---|---|
135 | Android | v2rayNG, Hiddify |
136 | iOS | sing-box, Streisand, Shadowrocket |
137 | Windows / macOS | v2rayN, Hiddify, sing-box |
138
139 What those do not cover well is a Linux machine you want *entirely* behind the
140 proxy, with a fail-closed killswitch, managed by the init system. That is what
141 this client is for, and it installs natively — a verified static `sing-box`
142 binary, a generated config and a systemd unit. **No Docker on the client.**
143
144 Capturing a machine's traffic means owning its routing table. A container
145 handed `--network host` and `CAP_NET_ADMIN` to do that isolates nothing; it is
146 a packaging format with extra failure modes, and it excludes every phone and
147 desktop anyway.
148
149 ### Whole-host tunnel (default)
150
151 ```
152 sudo sh install.sh client
153 ```
154
155 It asks for the link, verifies and installs sing-box, writes
156 `transtation-client.service`, starts it, and then checks that this machine's
157 public address actually **changed** — a reply on its own only proves the network
158 works, which it did before you installed anything. If the address did not move,
159 the installer says so and exits non-zero.
160
161 ```
162 stop: sudo systemctl stop transtation-client
163 logs: journalctl -u transtation-client -f
164 panic: sudo transtation-panic # undo everything, needs no network
165 ```
166
167 Everything the machine sends goes through the tunnel with no per-program
168 configuration — including traffic from containers running on it.
169
170 `DIRECT_SUFFIXES` is a comma-separated list of domain suffixes that bypass the
171 tunnel entirely — for sites that block proxy IPs, or a bank that objects to a
172 foreign exit. Those domains also resolve locally rather than through the tunnel,
173 so you get the geographically correct answer before connecting.
174
175 ```
176 DIRECT_SUFFIXES=example.com,example.net sudo sh install.sh client --killswitch
177 ```
178
179 Needs Linux, root and systemd. The tunnel interface is created directly on the
180 host, so there is no rootless story to get wrong and nothing to be confused
181 about on macOS or Windows — use one of the apps above there.
182
183 ### Proxy mode — a local SOCKS5 + HTTP proxy
184
185 ```
186 sudo sh install.sh client --proxy
187 ```
188
189 **This does not put your machine behind the proxy.** It opens a proxy on
190 `127.0.0.1:1080` and nothing else. Every program that should use it has to be
191 pointed at it, one at a time. Your browser keeps using your own address until
192 you configure it; so does everything started from your desktop, and every
193 systemd service.
194
195 ```
196 export ALL_PROXY=socks5h://127.0.0.1:1080
197 export HTTPS_PROXY=http://127.0.0.1:1080 HTTP_PROXY=http://127.0.0.1:1080
198 ```
199
200 Those two lines apply to **that shell and the programs it starts**, and nothing
201 else. Measured on a host with the client running:
202
203 ```
204 curl https://api.ipify.org 198.51.100.23 your own address
205 curl https://api.ipify.org (after the exports)
206 192.0.2.7 through the proxy
207 env -i curl https://api.ipify.org 198.51.100.23 your own address
208 … and the same from any other terminal, and from anything your desktop starts
209 ```
210
211 The **`h`** in `socks5h` is load-bearing: plain `socks5://` makes curl resolve
212 DNS locally and leaks every hostname you visit. In Firefox, set the SOCKS host
213 in the connection settings and tick "Proxy DNS when using SOCKS v5" — the
214 environment variables above will not reach it.
215
216 Two more things this mode will not do even for a program you *have* pointed at
217 it: QUIC (browsers do not send UDP over SOCKS, so it silently falls back to
218 TCP), and anything that reads no proxy setting at all.
219
220 Settings, all optional:
221
222 | Variable | Default | What it does |
223 |---|---|---|
224 | `DIRECT_SUFFIXES` | *(empty)* | comma-separated domain suffixes that bypass the tunnel |
225 | `SOCKS_PORT` | `1080` | proxy mode's listening port |
226 | `TT_URI_FILE` | `/etc/transtation/uri` | where the share link is read from |
227
228 ### Killswitch and recovery
229
230 `--killswitch` installs a host-owned nftables table with a `policy drop` output
231 chain. It permits loopback, established connections, the tun interface, the
232 split-tunnel fwmark, RFC1918/CGNAT/link-local/multicast, DHCP, and your server's
233 address and port. Everything else is dropped, so nothing leaks when the tunnel
234 is down.
235
236 It is **additive** — a separate `table inet transtation` — and removing it is
237 one command. Nothing in this project ever runs `nft flush ruleset`, which on a
238 modern host would also wipe the iptables-nft tables Docker installs and silently
239 break every container on the box.
240
241 ```
242 sudo transtation-killswitch on [--persist]
243 sudo transtation-killswitch off
244 sudo transtation-killswitch status
245 ```
246
247 Persistence is opt-in. By default the killswitch dies at reboot, because reboot
248 is the first thing everyone tries when the network stops working.
249
250 **Two limits, stated plainly.** It is an output-hook chain: it covers this host's
251 own processes, and traffic *forwarded* from your other containers does not
252 traverse it. And if you test it by SIGKILLing the client, note that sing-box's
253 own ip rules at priority 9000–9010 survive — they are not bound to the
254 interface.
255
256 If your network ever dies:
257
258 ```
259 sudo transtation-panic
260 ```
261
262 Small, and it needs neither a network nor Docker. The same commands are printed
263 in the client's startup banner every boot, so they are in
264 `journalctl -u transtation-client` and in your scrollback.
265
266 ---
267
268 ## Configuration
269
270 Nothing is required. Copy `.env.example` to `/opt/transtation/.env` — or, on a
271 native install, to `/etc/transtation/server.env` — and set only what you want to
272 change; every value is read fresh on each boot.
273
274 | Variable | Default | What it does |
275 |---|---|---|
276 | `PORT` | `443` | public port |
277 | `HOST` | auto-detected | address that goes into the share link |
278 | `SNI` | *auto-selected* | the site your server impersonates — leave unset |
279 | `CLIENT_REGION` | server's country | two-letter code for where your clients connect from |
280 | `DEST` | `$SNI:443` | where the handshake is forwarded |
281 | `WARP` | `1` | `0` to egress from the VPS IP instead |
282 | `WARP_ENDPOINT` | `162.159.192.1:2408` | override if this anycast address does not handshake |
283 | `USER_NAME` | `main` | name of the user created on first boot |
284 | `MIN_CLIENT_VER` | `0.0.0` | Reality client version floor |
285 | `SKIP_DEST_CHECK` | `0` | skip the startup TLS probe of `$SNI` |
286
287 ### The dest picks itself
288
289 The single most important setting is which real site your server impersonates,
290 so transtation chooses it on first boot — the way a commercial VPN picks your
291 nearest exit rather than making you read a server list.
292
293 **The thing that gets you blocked is not a slow dest, it is an implausible
294 one.** Your clients' ISP sees their traffic labelled with this name and judges
295 the volume and shape of it against what the name implies. Hours of sustained
296 multi-gigabyte download addressed to a corporate brochure page is not something
297 a human does, and a DPI box that has already taken an interest in your server's
298 address starts dropping the connection. The same bytes pulled from a video
299 service are what everyone does all evening.
300
301 So candidates carry a curated class, and it dominates the score:
302
303 | class | what it is | weight |
304 |---|---|---|
305 | `bulk` | video, streaming, real download and CDN hosts. Sustained multi-GB transfer and hours-long connections are unremarkable. | 0 |
306 | `mixed` | media-heavy portals. Moderate sustained traffic is fine; a torrent of it is a bit odd. | +800 |
307 | `light` | corporate sites, marketing pages, DNS endpoints. Bulk transfer here looks exactly as wrong as it is. | +2500 |
308
309 The weights are in milliseconds so they compare directly against measured
310 latency, and they are deliberately lopsided: being dropped is fatal, a slower
311 dest merely adds to connection setup.
312
313 This class is a judgement about the internet, not something a probe can
314 measure — `accept-ranges` was tested for the job and does not discriminate
315 (`dl.google.com` answers `none` on its root path; `addons.mozilla.org` answers
316 `bytes`). Everything else *is* measured, from the server, in parallel:
317
318 - **hard filter** — the handshake must succeed, it must be TLS 1.3, and the
319 certificate chain must have headroom under the size where REALITY breaks.
320 Both halves earn their keep: `abema.tv` is an otherwise ideal Japanese video
321 dest that serves only TLS 1.2, and `www.hulu.com` has a 4717-byte chain.
322 - **latency** — the measured TLS handshake time, because your server re-dials
323 this site on every single client connection.
324 - **region** — worth 150 ms. The region that matters is where your *clients*
325 are, not where your server is: the ISP judging the traffic is theirs.
326 Defaults to the server's country; set `CLIENT_REGION` when they differ.
327
328 ```
329 $ transtation sni
330 sni tv.naver.com
331 why sustained heavy traffic is unremarkable here; same region as your
332 clients; 54ms TLS handshake, 3480-byte chain
333 since 2026-08-06
334 ```
335
336 From a Korean VPS it picks a Korean streaming service; with `CLIENT_REGION=JP`
337 it picks `www.nicovideo.jp`; with no region it can determine at all, a global
338 streaming brand.
339
340 **The choice is sticky.** It lives with your identity, is included in backups,
341 and is never re-picked on its own — every share link carries `sni=`, so
342 silently changing it would break every client you have already handed out.
343 Re-picking is explicit, and says what it invalidates:
344
345 ```
346 transtation sni auto # probe again and re-pick (invalidates links)
347 transtation sni <domain> # set by hand (invalidates links)
348 ```
349
350 Setting `SNI=` in `.env` overrides all of it, permanently.
351
352 ### Why the size of a certificate matters
353
354 **Above roughly 5200 bytes of certificate chain the REALITY handshake does not
355 complete**, and the failure is silent in the worst way: the server reports
356 itself healthy, the port listens, and every client is quietly handed the real
357 website instead of your proxy. Measured end to end against Xray 26.3.27:
358
359 | dest | chain | | dest | chain |
360 |---|---|---|---|---|
361 | `dl.google.com` | 895 ✓ | | `www.ibm.com` | 4331 ✓ |
362 | `one.one.one.one` | 2307 ✓ | | `www.paypal.com` | 4883 ✓ |
363 | `www.cloudflare.com` | 2493 ✓ | | `www.linkedin.com` | 5046 ✓ |
364 | `addons.mozilla.org` | 2843 ✓ | | `www.microsoft.com` | 5879 ✗ |
365 | `www.nicovideo.jp` | 3822 ✓ | | | |
366
367 The auto-selection filters on this, so you only meet it if you override `SNI`
368 yourself. When you do:
369
370 ```
371 transtation verify
372 ```
373
374 does a genuine REALITY handshake against the server's own inbound. It is the
375 only check that proves clients can actually connect — the healthcheck goes
376 through a loopback inbound and never touches REALITY at all. `install.sh` runs
377 it for you and refuses to finish if it fails.
378
379 Apple and iCloud domains are rejected outright — Xray warns that they get your
380 IP blocked by the GFW. To inspect a candidate yourself:
381
382 ```
383 docker compose exec proxy xray tls ping example.com # container
384 /usr/local/lib/transtation/xray tls ping example.com # native
385 ```
386
387 ---
388
389 ## Capabilities
390
391 The container holds **none at all**, verified on a running server:
392 `CapEff`, `CapPrm` and `CapBnd` all `0000000000000000`, `NoNewPrivs 1`, root
393 filesystem read-only. The native install holds `CAP_NET_BIND_SERVICE` and
394 nothing else, because something has to bind 443 and there is no dockerd in
395 front to do it.
396
397 | | Needed? | |
398 |---|---|---|
399 | `cap_add: NET_ADMIN` | no | the generated config pins `"noKernelTun": true`, so WARP always runs in Xray's in-process userspace stack regardless of what the container holds. Adding the capability buys nothing and widens the container's privileges. |
400 | `--device /dev/net/tun` | no | the userspace network stack needs no TUN device |
401 | `CAP_NET_BIND_SERVICE` | container: no | it listens on 8443 inside; `-p 443:8443` does the privileged bind in dockerd, which also keeps this working under rootless Docker |
402 | `--network host` | no, and must not be used | the healthcheck's SOCKS inbound is bound to `127.0.0.1`, which inside the container reaches nothing. On the host's loopback the same listener becomes an unauthenticated proxy into your tunnel for every local process and everyone with a shell on the box. |
403
404 For reference, a hand-rolled native Xray unit typically holds `CAP_NET_ADMIN`
405 *and* `CAP_NET_BIND_SERVICE` with a writable root filesystem. The `NET_ADMIN`
406 half is only needed for kernel-TUN WARP, which this does not use.
407
408 That `noKernelTun` pin is load-bearing rather than decorative. Left unset, Xray
409 probes for `CAP_NET_ADMIN` and takes the kernel-TUN path when it finds it — and
410 `createKernelTun` writes to `/proc/sys/.../rp_filter` *before* opening the
411 device, which fails on Docker's read-only `/proc/sys` with no fallback. Pinning
412 it makes behaviour independent of capabilities.
413
414 To confirm:
415
416 ```
417 docker compose logs proxy | grep -o 'Using .* TUN' # container
418 journalctl -u transtation | grep -o 'Using .* TUN' # native
419 # both -> Using gVisor TUN
420 ```
421
422 ---
423
424 ## What this does not protect you from
425
426 - **Your account.** The VPS is rented in your name with your card. Reality hides
427 *what* the traffic is, not *whose* server it is.
428 - **Traffic analysis over time.** Reality is very good against a prober that
429 connects and looks; it is not a defence against an adversary correlating flow
430 timing and volume across a link they fully observe.
431 - **The endpoint.** Sites still see a browser fingerprint, cookies, and a login.
432 - **Publishing your link.** A `vless://` link is a credential *and* a permanent
433 fingerprint of your server. Once it is public, revoke it (`transtation user rm`)
434 — the shortId goes with it.
435 - **BitTorrent.** The default routing blocks *unencrypted* BitTorrent
436 handshakes. Encrypted BT (MSE/PE, the modern default) and DHT pass straight
437 through. You have no DMCA protection here; your provider will forward the
438 notice to you.
439 - **Outbound abuse from your own box.** Port 25 is blocked by default so a
440 WARP-less install is not an open SMTP relay, and the cloud metadata range
441 `169.254.0.0/16` is blocked so a client of your proxy cannot read your
442 instance's IAM credentials. Everything else your users do leaves from your IP.
443
444 ## About WARP
445
446 With `WARP=1` (the default) traffic exits from a Cloudflare address instead of
447 your VPS's. Your server's IP never reaches the destination, and cloud-IP
448 blocklists do not apply to you.
449
450 The costs are real: a shared exit pool means more CAPTCHAs, the exit's geography
451 is Cloudflare's choice and not your VPS's, the free tier is throttled under
452 sustained load, and MTU drops to 1280. Registration also depends on an
453 undocumented third-party API. If any of that bothers you, set `WARP=0` — the
454 proxy works exactly the same, it just egresses from your own address.
455
456 If registration fails, the server **starts anyway** with direct egress and says
457 so on every boot and in `transtation status`. It never retries in a loop:
458 hammering Cloudflare's registration endpoint is how a source IP, historically a
459 whole region, gets blocked.
460
461 ---
462
463 ## Backup, upgrade, uninstall
464
465 ```
466 transtation backup # -> /root/transtation-backup.tgz, mode 0600
467 ```
468
469 That tarball holds your Reality private key, your users, and your WARP account.
470 It is the only thing on the box you cannot regenerate. Restoring is one command
471 into a fresh volume:
472
473 ```
474 cd /opt/transtation && docker compose down # native: systemctl stop transtation
475 transtation restore /root/transtation-backup.tgz
476 docker compose up -d && transtation verify # native: systemctl start transtation
477 ```
478
479 This started life as a documented `tar` one-liner and failed twice in testing,
480 so it is a command now. Getting it right by hand means knowing that Docker
481 seeds a fresh named volume with the ownership of the image directory it is
482 first mounted into — so unpacking with a generic image leaves `/data` owned by
483 root and the server refuses to start — *and* that the backup is mode 0600, so
484 the unpacking container has to run as root to read it at all. The native path
485 has the mirror-image version of the same trap: unpacked as root, the files come
486 out owned by root and the service user can no longer read its own identity.
487 `restore` also refuses to run while the server is up, and refuses a file that is
488 not a transtation backup.
489
490 Upgrades: re-run `install.sh` (with `server --native` if that is how you
491 installed), or `docker compose pull && docker compose up -d`.
492 Your identity lives in the volume and survives; the config is regenerated from
493 the new image every boot, so schema changes come along for free.
494
495 ```
496 sudo sh install.sh --uninstall
497 ```
498
499 It removes whichever server is installed — containers and compose file, or the
500 systemd unit and `/usr/local/lib/transtation` — and asks separately before
501 deleting your data. Binaries it did not install are left alone.
502
503 ---
504
505 ## About `curl | sh`
506
507 It is a real objection and this project does not wave it away:
508
509 1. **You cannot review what you pipe.** So the documented form downloads the
510 script, checks its hash against the release notes, and lets you read it. Do
511 that.
512 2. **A truncated download could execute half a script.** Everything here lives
513 in functions and `main "$@"` is the last line, so a truncated file runs
514 nothing.
515 3. **It installs Docker via `get.docker.com`**, a ~780-line third-party script,
516 as root. The installer tells you before it does, and skips the step entirely
517 if Docker is already present.
518 4. **The image tag is mutable.** The installer resolves the tag to the digest
519 it points at during installation and writes *that* into the compose file, so
520 from then on `docker compose up` can only start the exact image the install
521 verified. Re-running the installer is how you move to a newer one.
522
523 The Xray binary is verified inside the Dockerfile against the `.dgst` file from
524 the same GitHub release. That defends against a bad mirror or a truncated
525 transfer, **not** against a compromised upstream — Xray publishes no signatures.
526 sing-box publishes no checksum file at all, so its hashes are pinned in
527 `install.sh` and reviewed as part of this repo. The native server install
528 verifies Xray the same way the image does, against the `.dgst` from its own
529 release, and wgcf against upstream's `checksums.txt`.
530
531 ## What has been tested
532
533 Everything below was run, on a Korean client against an AWS Tokyo VPS with the
534 host's own proxy stopped, so nothing was measured through a second proxy:
535
536 - **Server on a real VPS.** `install.sh` on Ubuntu 24.04: installs Docker,
537 pulls and digest-pins the image, and `verify` completes a REALITY handshake
538 reporting `warp=on colo=NRT`.
539 - **Client to server across the internet, no tunnels.** A client in Korea to a
540 Tokyo VPS with the host's own proxy stopped, so nothing was measured through a
541 second proxy. Steady-state request latency ≈0.20 s, throughput 6.5–8.3 MB/s
542 for a 10 MB download, and 0.9 s from starting the client to the first byte
543 through the tunnel.
544 - **The camouflage, from outside.** `openssl s_client` against the server on
545 :443 returns a genuine `C=JP, O=TVer INC., CN=*.tver.jp` certificate over
546 TLS 1.3 — the dest the auto-selection chose for a Japanese server, unprompted.
547 - **Users.** Added a second user, connected as them, revoked them; their client
548 stopped working and the first one kept going.
549 - **Whole-host tunnel.** It moved the host's egress from its ISP address to
550 WARP, kept `DIRECT_SUFFIXES` domains on their real path, and `SIGKILL` of the
551 client left `curl` timing out rather than leaking. `transtation-panic` restored
552 direct egress with no rules, no interface and no table left behind, and without
553 disturbing Tailscale's rules or other containers.
554 - **The client, natively.** `install.sh client` on this laptop: verified
555 sing-box, systemd unit, tunnel up, host egress moved off the ISP address,
556 `DIRECT_SUFFIXES` domains still on their real path, other containers on the
557 host carried too, and `transtation-panic` putting it all back. Pointed at a
558 dead server it reports "still your own address" and exits non-zero.
559 - **arm64.** The published arm64 server image was run under emulation with a
560 client on the other architecture passing traffic through it.
561 - **Timings, measured rather than asserted.** On that VPS: 7.3 s from
562 `docker compose up` to healthy on a first boot that generates keys, picks a
563 dest and registers with WARP; 14.4 s to a proven-working proxy. Most of the
564 gap between the two is a fixed warm-up inside Xray before its first proxied
565 connection, not work this project controls.
566
567 ## Building and testing yourself
568
569 ```
570 git clone https://github.com/sqzer-x/transtation && cd transtation
571 sh test.sh
572 ```
573
574 Builds the server image and runs its offline self-checks: the config renders and
575 is accepted by the pinned Xray with WARP on *and* off, the `xray x25519` parser
576 survives all three historical output formats, every invariant that protects you
577 (no empty shortId, port 25 blocked, metadata range blocked, loopback-only health
578 inbound) holds, both client configs are accepted by sing-box, and malformed
579 share links are rejected. Everything runs with `--network none`; the test suite
580 never registers a WARP identity.
581
582 ## License
583
584 MIT. See [LICENSE](LICENSE). The third-party binaries this project ships or
585 fetches — Xray-core (MPL-2.0), wgcf (MIT), sing-box (GPL-3.0-or-later) — and
586 their obligations are listed in [NOTICE](NOTICE).