diff --git a/.env.example b/.env.example
@@ -1,12 +1,13 @@
# transtation server configuration.
#
-# NEVER COMMIT THIS FILE. Copy it to .env and edit. Every value here is
-# optional — with no .env at all the server provisions itself and works.
+# NEVER COMMIT THIS FILE. Copy it to /opt/transtation/.env (container) or to
+# /etc/transtation/server.env (native) and edit. Every value here is optional --
+# with no config file at all the server provisions itself and works.
#
-# These are CONFIG, not identity. They are read fresh on every boot, so
-# changing one and running `docker compose up -d` takes effect immediately.
-# Your Reality keypair and user UUIDs live in the data volume and are never
-# touched by anything in this file.
+# These are CONFIG, not identity. They are read fresh on every boot, so changing
+# one and restarting takes effect immediately: `docker compose up -d`, or
+# `systemctl restart transtation`. Your Reality keypair and user UUIDs live in
+# the data directory, not here, and nothing in this file touches them.
# ---------------------------------------------------------------- networking
@@ -41,7 +42,7 @@
# re-dials this site on every client connection.
# 4. Is it in the same region as the traffic? Worth 150ms of latency.
#
-# The choice is STICKY. It is stored in the data volume and never re-picked on
+# The choice is STICKY. It is stored with your identity and never re-picked on
# its own, because every share link carries `sni=` and silently changing it
# would break every client you have already configured.
#
diff --git a/README.md b/README.md
@@ -6,8 +6,8 @@ the client is a native install, because putting a machine behind a proxy means
owning its routing table and a container is the wrong shape for that.
```
-curl -fsSLO https://raw.githubusercontent.com/sqzer-x/transtation/v1.0.16/install.sh
-sha256sum install.sh # compare against the hash in the v1.0.16 release notes
+curl -fsSLO https://raw.githubusercontent.com/sqzer-x/transtation/v1.0.17/install.sh
+sha256sum install.sh # compare against the hash in the v1.0.17 release notes
less install.sh # please actually read it
sudo sh install.sh
```
@@ -28,8 +28,9 @@ Telegram bot, **use 3x-ui and stop reading here.**
transtation exists because of a narrower set of preferences:
-- **A published, digest-pinned image**, not a 2,700-line bash generator you run
- on your host.
+- **A published, digest-pinned image** if you want one, rather than a
+ 2,700-line bash generator that writes your config on the host. The native
+ path is the same program with a systemd unit, not a second codebase.
- **Zero capabilities.** The container holds no `NET_ADMIN`, mounts no
`/dev/net/tun`, and does not use host networking. WARP runs in Xray's
in-process userspace network stack.
@@ -48,8 +49,8 @@ how you feel about a container runtime on your VPS.
### In a container (default)
```
-curl -fsSLO https://raw.githubusercontent.com/sqzer-x/transtation/v1.0.16/install.sh
-sha256sum install.sh # compare against the hash in the v1.0.16 release notes
+curl -fsSLO https://raw.githubusercontent.com/sqzer-x/transtation/v1.0.17/install.sh
+sha256sum install.sh # compare against the hash in the v1.0.17 release notes
less install.sh # please actually read it
sudo sh install.sh
```
@@ -114,10 +115,13 @@ drop and clients reconnect on their own; Xray has no config reload signal.
The healthcheck only proves **outbound** traffic works. Nothing on the server
can prove inbound does. If clients time out and the server log shows nothing at
-all, open TCP 443 in your provider's firewall or security group. Note that
-Docker's published ports **bypass `ufw`** in both directions, so a ufw rule is
-neither necessary nor sufficient; Oracle Cloud additionally needs the port
-opened in the instance's own iptables.
+all, open TCP 443 in your provider's firewall or security group. Oracle Cloud
+additionally needs the port opened in the instance's own iptables.
+
+Note the difference between the two install paths here: Docker's published
+ports **bypass `ufw`** in both directions, so on the container path a ufw rule
+is neither necessary nor sufficient. The native path has no such shortcut — a
+host firewall applies to it normally and must permit the port.
---
@@ -263,8 +267,9 @@ in the client's startup banner every boot, so they are in
## Configuration
-Nothing is required. Copy `.env.example` to `/opt/transtation/.env` and set only
-what you want to change; every value is read fresh on each boot.
+Nothing is required. Copy `.env.example` to `/opt/transtation/.env` — or, on a
+native install, to `/etc/transtation/server.env` — and set only what you want to
+change; every value is read fresh on each boot.
| Variable | Default | What it does |
|---|---|---|
@@ -332,7 +337,7 @@ From a Korean VPS it picks a Korean streaming service; with `CLIENT_REGION=JP`
it picks `www.nicovideo.jp`; with no region it can determine at all, a global
streaming brand.
-**The choice is sticky.** It lives in the data volume, is included in backups,
+**The choice is sticky.** It lives with your identity, is included in backups,
and is never re-picked on its own — every share link carries `sni=`, so
silently changing it would break every client you have already handed out.
Re-picking is explicit, and says what it invalidates:
@@ -347,9 +352,9 @@ Setting `SNI=` in `.env` overrides all of it, permanently.
### Why the size of a certificate matters
**Above roughly 5200 bytes of certificate chain the REALITY handshake does not
-complete**, and the failure is silent in the worst way: the container reports
-healthy, the port listens, and every client is quietly handed the real website
-instead of your proxy. Measured end to end against Xray 26.3.27:
+complete**, and the failure is silent in the worst way: the server reports
+itself healthy, the port listens, and every client is quietly handed the real
+website instead of your proxy. Measured end to end against Xray 26.3.27:
| dest | chain | | dest | chain |
|---|---|---|---|---|
@@ -375,7 +380,8 @@ Apple and iCloud domains are rejected outright — Xray warns that they get your
IP blocked by the GFW. To inspect a candidate yourself:
```
-docker compose exec proxy xray tls ping example.com
+docker compose exec proxy xray tls ping example.com # container
+/usr/local/lib/transtation/xray tls ping example.com # native
```
---
@@ -393,7 +399,7 @@ front to do it.
| `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. |
| `--device /dev/net/tun` | no | the userspace network stack needs no TUN device |
| `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 |
-| `--network host` | no, and must not be used | it would expose the loopback SOCKS inbound the healthcheck uses, turning your server into an open relay |
+| `--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. |
For reference, a hand-rolled native Xray unit typically holds `CAP_NET_ADMIN`
*and* `CAP_NET_BIND_SERVICE` with a writable root filesystem. The `NET_ADMIN`
@@ -405,7 +411,7 @@ probes for `CAP_NET_ADMIN` and takes the kernel-TUN path when it finds it — an
device, which fails on Docker's read-only `/proc/sys` with no fallback. Pinning
it makes behaviour independent of capabilities.
-To confirm:To confirm:
+To confirm:
```
docker compose logs proxy | grep -o 'Using .* TUN' # container
@@ -465,9 +471,9 @@ It is the only thing on the box you cannot regenerate. Restoring is one command
into a fresh volume:
```
-cd /opt/transtation && docker compose down
+cd /opt/transtation && docker compose down # native: systemctl stop transtation
transtation restore /root/transtation-backup.tgz
-docker compose up -d && transtation verify
+docker compose up -d && transtation verify # native: systemctl start transtation
```
This started life as a documented `tar` one-liner and failed twice in testing,
@@ -475,9 +481,11 @@ so it is a command now. Getting it right by hand means knowing that Docker
seeds a fresh named volume with the ownership of the image directory it is
first mounted into — so unpacking with a generic image leaves `/data` owned by
root and the server refuses to start — *and* that the backup is mode 0600, so
-the unpacking container has to run as root to read it at all. `restore` also
-refuses to run while the server is up, and refuses a file that is not a
-transtation backup.
+the unpacking container has to run as root to read it at all. The native path
+has the mirror-image version of the same trap: unpacked as root, the files come
+out owned by root and the service user can no longer read its own identity.
+`restore` also refuses to run while the server is up, and refuses a file that is
+not a transtation backup.
Upgrades: re-run `install.sh` (with `server --native` if that is how you
installed), or `docker compose pull && docker compose up -d`.
@@ -488,8 +496,9 @@ the new image every boot, so schema changes come along for free.
sudo sh install.sh --uninstall
```
-It removes the containers and the compose file, and asks separately before
-deleting the data volume.
+It removes whichever server is installed — containers and compose file, or the
+systemd unit and `/usr/local/lib/transtation` — and asks separately before
+deleting your data. Binaries it did not install are left alone.
---
@@ -527,21 +536,21 @@ host's own proxy stopped, so nothing was measured through a second proxy:
- **Server on a real VPS.** `install.sh` on Ubuntu 24.04: installs Docker,
pulls and digest-pins the image, and `verify` completes a REALITY handshake
reporting `warp=on colo=NRT`.
-- **Client to server across the internet, no tunnels.** Client container in
- Korea to a Tokyo VPS with the host's own proxy stopped, so nothing was
- measured through a second proxy. Steady-state request latency ≈0.20 s,
- throughput 6.5–8.3 MB/s for a 10 MB download, and 0.9 s from `docker run` to
- the first byte through the tunnel.
+- **Client to server across the internet, no tunnels.** A client in Korea to a
+ Tokyo VPS with the host's own proxy stopped, so nothing was measured through a
+ second proxy. Steady-state request latency ≈0.20 s, throughput 6.5–8.3 MB/s
+ for a 10 MB download, and 0.9 s from starting the client to the first byte
+ through the tunnel.
- **The camouflage, from outside.** `openssl s_client` against the server on
:443 returns a genuine `C=JP, O=TVer INC., CN=*.tver.jp` certificate over
TLS 1.3 — the dest the auto-selection chose for a Japanese server, unprompted.
- **Users.** Added a second user, connected as them, revoked them; their client
stopped working and the first one kept going.
-- **Whole-host tunnel.** `MODE=tun` moved the host's egress from its ISP address
- to WARP, kept `DIRECT_SUFFIXES` domains on their real path, and `SIGKILL` of
- the container left `curl` timing out rather than leaking. `transtation-panic`
- restored direct egress with no rules, no interface and no table left behind,
- and without disturbing Tailscale's rules or other containers.
+- **Whole-host tunnel.** It moved the host's egress from its ISP address to
+ WARP, kept `DIRECT_SUFFIXES` domains on their real path, and `SIGKILL` of the
+ client left `curl` timing out rather than leaking. `transtation-panic` restored
+ direct egress with no rules, no interface and no table left behind, and without
+ disturbing Tailscale's rules or other containers.
- **The client, natively.** `install.sh client` on this laptop: verified
sing-box, systemd unit, tunnel up, host egress moved off the ISP address,
`DIRECT_SUFFIXES` domains still on their real path, other containers on the
@@ -562,8 +571,8 @@ git clone https://github.com/sqzer-x/transtation && cd transtation
sh test.sh
```
-Builds both images and runs their offline self-checks: the config renders and is
-accepted by the pinned Xray with WARP on *and* off, the `xray x25519` parser
+Builds the server image and runs its offline self-checks: the config renders and
+is accepted by the pinned Xray with WARP on *and* off, the `xray x25519` parser
survives all three historical output formats, every invariant that protects you
(no empty shortId, port 25 blocked, metadata range blocked, loopback-only health
inbound) holds, both client configs are accepted by sing-box, and malformed
@@ -572,6 +581,6 @@ never registers a WARP identity.
## License
-MIT. See [LICENSE](LICENSE). Third-party binaries redistributed in the images —
-Xray-core (MPL-2.0), wgcf (MIT), sing-box (GPL-3.0-or-later) — and their
-obligations are listed in [NOTICE](NOTICE).
+MIT. See [LICENSE](LICENSE). The third-party binaries this project ships or
+fetches — Xray-core (MPL-2.0), wgcf (MIT), sing-box (GPL-3.0-or-later) — and
+their obligations are listed in [NOTICE](NOTICE).
diff --git a/TROUBLESHOOTING.md b/TROUBLESHOOTING.md
@@ -29,9 +29,15 @@ Causes, in order of likelihood:
(5879 bytes) is the notable trap — it looks like the safest possible choice
and does not work. Check and replace:
```
+ # container
docker compose exec proxy xray tls ping www.microsoft.com # look at the chain length
echo 'SNI=dl.google.com' >> /opt/transtation/.env
cd /opt/transtation && docker compose up -d && transtation verify
+
+ # native
+ /usr/local/lib/transtation/xray tls ping www.microsoft.com
+ echo 'SNI=dl.google.com' >> /etc/transtation/server.env
+ systemctl restart transtation && transtation verify
```
Known-good: `dl.google.com` (895), `www.cloudflare.com` (2493),
`addons.mozilla.org` (2843), `www.nicovideo.jp` (3822).
@@ -47,10 +53,8 @@ Causes, in order of likelihood:
4. **Missing `fp` in a hand-built link.** sing-box, NekoBox and Hiddify hard-fail
with `uTLS is required by reality client`; Xray-based clients tolerate it.
The generated link always carries `fp=chrome`.
-5. **Your SNI stopped supporting TLS 1.3 or X25519.** Check it and pick another:
- ```
- docker compose exec proxy xray tls ping www.microsoft.com
- ```
+5. **Your SNI stopped supporting TLS 1.3 or X25519.** Check it and pick another
+ with the same `xray tls ping` command as above.
---
@@ -66,24 +70,26 @@ transtation warp register
Cloudflare rate-limits registration per source IP for about fifteen minutes, so
if you have just retried a few times, wait. If it keeps failing, either accept
-direct egress and silence the warning with `WARP=0` in `/opt/transtation/.env`,
+direct egress and silence the warning with `WARP=0` in the config file
+(`/opt/transtation/.env`, or `/etc/transtation/server.env` on a native install),
or check that this host has working outbound HTTPS.
---
-## The container is `unhealthy`, but Reality itself works
+## The container is `unhealthy` (or the native service restarts), but Reality itself works
Almost always: **your provider blocks outbound UDP/2408**. WARP registration is
TCP and succeeds; the data plane is UDP and never comes up.
```
-docker compose logs proxy | grep -o 'Using .* TUN'
+docker compose logs proxy | grep -o 'Using .* TUN' # container
+journalctl -u transtation | grep -o 'Using .* TUN' # native
```
It should print `Using gVisor TUN. NoKernelTun is set to true.` — that is the
normal, expected path and it holds even if the container was given extra
capabilities. So if you see it, the WireGuard stack is fine and the problem is
-UDP egress: set `WARP=0` in `.env` and `docker compose up -d`.
+UDP egress: set `WARP=0` in the config file and restart.
If instead it prints `Using kernel TUN`, or the container dies with a
`read-only file system` error mentioning `rp_filter`, then something removed
@@ -91,7 +97,9 @@ the `noKernelTun` pin from the generated config. That is a transtation bug —
please report it.
Docker does not restart unhealthy containers by itself. `unhealthy` is a report,
-not a self-heal.
+not a self-heal. The native install has no healthcheck at all — it is a plain
+systemd service, so `systemctl status transtation` and the log are the whole
+story there.
---
@@ -103,25 +111,40 @@ Nothing is reaching Xray. The healthcheck cannot detect this — it only proves
1. Open TCP 443 in your **provider's** firewall or security group. This is the
answer roughly nine times out of ten.
2. Oracle Cloud also needs it opened in the instance's own iptables.
-3. Docker's published ports **bypass `ufw`** in both directions, so a ufw rule
- neither helps nor hurts. Do not go looking there.
+3. On the **container** path, Docker's published ports **bypass `ufw`** in both
+ directions, so a ufw rule neither helps nor hurts — do not go looking there.
+ On the **native** path there is no such shortcut: a host firewall does apply,
+ so check `ufw status` / `firewall-cmd --list-all` too.
4. Confirm something is actually listening:
```
- docker compose ps
+ docker compose ps # container
+ systemctl status transtation # native
ss -tlnp | grep :443
```
---
-## Tun mode: the container starts and exits, or the host's traffic is untouched
+## The client runs, but the host's traffic is untouched
-Read the startup message — each impossible situation gets its own sentence.
+The installer checks for this and exits non-zero rather than claiming success:
+if your public address did not move, it says `STILL YOUR OWN ADDRESS`. Start
+with the log, which names the reason:
+
+```
+journalctl -u transtation-client -n 40
+```
| Message | Meaning |
|---|---|
-| `no /dev/net/tun in this container` | add `--device /dev/net/tun`. If the *host* has none: `sudo modprobe tun && echo tun \| sudo tee /etc/modules-load.d/tun.conf` |
-| `cannot manage host routing` | either `--cap-add NET_ADMIN` is missing, or this is rootless Docker/Podman, where tun mode cannot work at all. Use proxy mode. On Fedora/RHEL also try `sudo setsebool -P container_use_devices=true` |
-| `docker0 is not visible` | you are on bridge networking. Add `--network host`, or the tunnel comes up in the container's own namespace and your host never uses it |
+| `MODE=tun needs root` | run it with `sudo`. Creating a network interface and routing rules is not something an unprivileged process can do |
+| `no /dev/net/tun on this host` | the kernel module is not loaded: `sudo modprobe tun && echo tun \| sudo tee /etc/modules-load.d/tun.conf`. Some minimal VPS and container-based hosts (OpenVZ, LXC) do not offer it at all — use proxy mode there |
+| `iproute2 is not installed` | install it; `ip` is what creates the routes |
+| `generated config rejected by sing-box` | the share link produced something sing-box will not accept. Reissue it with `transtation show` and never hand-edit it |
+
+If you installed with `--proxy` instead of the default, this is not a fault:
+proxy mode opens `127.0.0.1:1080` and deliberately changes nothing else. Only
+programs you point at it use the tunnel. Reinstall without `--proxy` for a
+whole-host tunnel.
---
@@ -159,7 +182,7 @@ That is WARP's shared exit pool, not a bug. Either route that site around the
tunnel:
```
-docker run ... -e DIRECT_SUFFIXES=thatsite.com,othersite.net ...
+DIRECT_SUFFIXES=thatsite.com,othersite.net sudo sh install.sh client
```
or turn WARP off entirely (`WARP=0`) so you egress from your own VPS address.
@@ -171,9 +194,9 @@ or turn WARP off entirely (`WARP=0`) so you egress from your own VPS address.
If you have a backup, it is one command into a fresh volume:
```
-cd /opt/transtation && docker compose down
+cd /opt/transtation && docker compose down # native: systemctl stop transtation
transtation restore /root/transtation-backup.tgz
-docker compose up -d && transtation verify
+docker compose up -d && transtation verify # native: systemctl start transtation
```
This started life as a documented `tar` one-liner and failed twice in testing,
diff --git a/client/transtation-client b/client/transtation-client
@@ -123,13 +123,13 @@ validate() {
done
fi
- # Resolve once, here, so the container's resolver never has to look up the
+ # Resolve once, here, so the tunnel's own resolver never has to look up the
# server name mid-flight -- and so the killswitch's bootstrap rule stays
# valid for as long as the tunnel does.
case "$HOST" in
*[!0-9.]*)
_ip=$(resolve4 "$HOST") || true
- [ -n "$_ip" ] || die "cannot resolve $(q "$HOST") from inside the container"
+ [ -n "$_ip" ] || die "cannot resolve $(q "$HOST") -- check this host's DNS, or put the server's IP in the link"
printf 'resolved %s -> %s\n' "$HOST" "$_ip"
SERVER_IP=$_ip
;;
diff --git a/host/transtation-killswitch b/host/transtation-killswitch
@@ -1,13 +1,13 @@
#!/bin/sh
# transtation-killswitch -- fail-closed egress filter for MODE=tun.
#
-# Host-owned on purpose. The client container never writes host firewall state:
-# with --network host its rules would land in the host's network namespace as
-# ordinary kernel state that outlives the container, and SIGKILL / OOM / a
-# dockerd crash bypasses any cleanup trap it might install. Worse, under
-# `restart: unless-stopped` a container that applies the killswitch and then
-# fails to start would sit in a loop re-closing your network faster than you
-# can open it.
+# Separate from the client service on purpose. Firewall rules are kernel state
+# that outlives whatever created them, and SIGKILL or OOM bypasses any cleanup
+# trap the client might install -- so a service that both closes the network and
+# can die without warning is a service that can strand you. Worse, under
+# `Restart=always` one that applies the killswitch and then fails to start would
+# sit in a loop re-closing your network faster than you can open it. Turning it
+# on and off stays an explicit, separate act.
#
# transtation-killswitch on [--persist]
# transtation-killswitch off
diff --git a/install.sh b/install.sh
@@ -15,11 +15,11 @@
# last line. If the download is truncated, nothing runs.
set -eu
-VERSION=${TT_VERSION:-v1.0.16}
+VERSION=${TT_VERSION:-v1.0.17}
IMAGE_SERVER=${TT_IMAGE_SERVER:-ghcr.io/sqzer-x/transtation}
# Filled in by the release workflow. A tag is mutable -- the same stolen-token
# compromise we already concede for Xray's .dgst would let someone retag
-# v1.0.16, and the hash-verified installer you read would vouch for nothing.
+# v1.0.17, and the hash-verified installer you read would vouch for nothing.
SERVER_DIGEST=${TT_SERVER_DIGEST:-}
# sing-box ships no checksum file, so these were computed once from the official
@@ -217,17 +217,53 @@ install_server_native() {
# Every command has to run as the service user. Run as root, the script would
# create root-owned files in the state directory and the service -- which is
# not root -- would stop being able to read its own identity.
+ # backup/restore/logs are handled here rather than passed through, so that
+ # `transtation <verb>` means the same thing on both install paths -- the
+ # container wrapper below implements exactly these three the same way.
install -Dm0755 /dev/stdin /usr/local/bin/transtation <<-'WRAP'
#!/bin/sh
set -eu
- if [ "$(id -un)" = transtation ]; then
- exec env TT_DATA=/var/lib/transtation PATH=/usr/local/lib/transtation:$PATH /usr/local/lib/transtation/server "$@"
- fi
- [ "$(id -u)" = 0 ] || { echo "transtation: run this as root or as the transtation user" >&2; exit 1; }
- if command -v runuser >/dev/null 2>&1; then
- exec runuser -u transtation -- env TT_DATA=/var/lib/transtation PATH=/usr/local/lib/transtation:$PATH /usr/local/lib/transtation/server "$@"
- fi
- exec su -s /bin/sh transtation -c 'exec env TT_DATA=/var/lib/transtation PATH=/usr/local/lib/transtation:$PATH /usr/local/lib/transtation/server "$@"' -- sh "$@"
+ D=/var/lib/transtation
+ L=/usr/local/lib/transtation
+ asuser() {
+ if [ "$(id -un)" = transtation ]; then
+ env TT_DATA=$D PATH=$L:"$PATH" $L/server "$@"
+ elif command -v runuser >/dev/null 2>&1; then
+ runuser -u transtation -- env TT_DATA=$D PATH=$L:"$PATH" $L/server "$@"
+ else
+ su -s /bin/sh transtation -c "exec env TT_DATA=$D PATH=$L:\$PATH $L/server \"\$@\"" -- sh "$@"
+ fi
+ }
+ [ "$(id -un)" = transtation ] || [ "$(id -u)" = 0 ] ||
+ { echo "transtation: run this as root or as the transtation user" >&2; exit 1; }
+ case "${1:-}" in
+ backup)
+ f=${2:-/root/transtation-backup.tgz}
+ [ -e "$f" ] && { echo "$f exists -- refusing to overwrite" >&2; exit 1; }
+ # root's umask is 022 on every target distro, which would leave a
+ # world-readable tarball of your Reality private key, your WARP private
+ # key and your WARP bearer token.
+ umask 077
+ asuser backup > "$f"
+ echo "backed up to $f (mode 0600): reality key, users, WARP account."
+ ;;
+ restore)
+ f=${2:-}
+ [ -n "$f" ] && [ -r "$f" ] || { echo "usage: transtation restore <backup.tgz>" >&2; exit 1; }
+ tar tzf "$f" 2>/dev/null | grep -q '^state.env$' ||
+ { echo "$f does not look like a transtation backup" >&2; exit 1; }
+ systemctl is-active --quiet transtation &&
+ { echo "stop the server first: systemctl stop transtation" >&2; exit 1; }
+ # Unpacked as root, then handed back: the service user cannot create
+ # files it does not already own, and tar would restore archived uids
+ # that need not match this host's.
+ tar xzf "$f" -C "$D"
+ chown -R transtation:transtation "$D"
+ echo "restored. start it again: systemctl start transtation"
+ ;;
+ logs) shift; exec journalctl -u transtation "$@" ;;
+ *) asuser "$@" ;;
+ esac
WRAP
step config "wrote /etc/transtation/server.env, data in /var/lib/transtation"
diff --git a/test.sh b/test.sh
@@ -83,6 +83,48 @@ check "the compose block sets no-new-privileges" present_in_compose "no-new-priv
check "the compose block mounts the root filesystem read-only" present_in_compose "read_only"
+# The README says `transtation <verb>` means the same thing on both install
+# paths. The native wrapper was a pure passthrough once, so `backup` printed a
+# tarball of the Reality private key to the terminal and `restore` did not exist
+# -- and redirecting it by hand produced a 0644 file, because root's umask is
+# 022. Both wrappers must carry the same three verbs, and backup must be 0600.
+wrapper_native() {
+ awk '/install -Dm0755 \/dev\/stdin \/usr\/local\/bin\/transtation <<-.WRAP./{f=1;next}
+ f && /^\tWRAP$/{exit} f' install.sh | sed 's/^\t\t//'
+}
+wrapper_parity() {
+ for _v in backup restore logs; do
+ wrapper_native | grep -q "^ $_v)" || return 1
+ grep -q "^ $_v)" install.sh || return 1
+ done
+}
+wrapper_backup_is_0600() (
+ _t=$(mktemp -d) || return 1
+ trap 'rm -rf "$_t"' EXIT
+ mkdir -p "$_t/bin" "$_t/lib" "$_t/data"
+ wrapper_native | sed "s#^D=/var/lib/transtation#D=$_t/data#; s#^L=/usr/local/lib/transtation#L=$_t/lib#" \
+ >"$_t/bin/transtation"
+ printf '#!/bin/sh\ntar czf - -C "$TT_DATA" state.env\n' >"$_t/lib/server"
+ # Take the already-the-service-user branch, so no privilege drop is needed.
+ printf '#!/bin/sh\n[ "$1" = -un ] && { echo transtation; exit 0; }\nexec /usr/bin/id "$@"\n' >"$_t/bin/id"
+ chmod +x "$_t/bin/transtation" "$_t/bin/server" "$_t/lib/server" "$_t/bin/id" 2>/dev/null
+ : >"$_t/data/state.env"
+ PATH="$_t/bin:$PATH"
+ # root's umask is what made this a real hole; reproduce it.
+ umask 022
+ transtation backup "$_t/b.tgz" >/dev/null 2>&1 || return 1
+ [ "$(stat -c %a "$_t/b.tgz")" = 600 ] || return 1
+ # and it must refuse to overwrite, rather than silently replace an old one
+ transtation backup "$_t/b.tgz" >/dev/null 2>&1 && return 1
+ # a file that is not a backup must be refused before anything is unpacked
+ echo junk | gzip >"$_t/junk.tgz"
+ transtation restore "$_t/junk.tgz" >/dev/null 2>&1 && return 1
+ return 0
+)
+check "both install paths implement the same transtation verbs" wrapper_parity
+check "native backup is 0600 even under root's umask, and will not clobber" wrapper_backup_is_0600
+
+
if ! command -v docker >/dev/null 2>&1; then
echo
echo " skip image builds and runtime checks (docker not installed)"
|