install.sh (30874B)
1 #!/bin/sh
2 # transtation installer.
3 #
4 # sh install.sh install the server
5 # sh install.sh client install the client, proxy mode
6 # sh install.sh client --tun install the client, whole-host tunnel
7 # sh install.sh client --tun --killswitch ... and a fail-closed egress filter
8 # sh install.sh --uninstall remove whichever is installed here
9 #
10 # `set -eu`, deliberately NOT `-o pipefail`: /bin/sh is dash on Debian and
11 # Ubuntu -- the primary target -- and `curl | sh` ignores the shebang, so
12 # pipefail would abort this script on its first line.
13 #
14 # Everything of substance lives in functions, and main() is invoked on the very
15 # last line. If the download is truncated, nothing runs.
16 set -eu
17
18 VERSION=${TT_VERSION:-v1.0.18}
19 IMAGE_SERVER=${TT_IMAGE_SERVER:-ghcr.io/sqzer-x/transtation}
20 # Filled in by the release workflow. A tag is mutable -- the same stolen-token
21 # compromise we already concede for Xray's .dgst would let someone retag
22 # v1.0.18, and the hash-verified installer you read would vouch for nothing.
23 SERVER_DIGEST=${TT_SERVER_DIGEST:-}
24
25 # sing-box ships no checksum file, so these were computed once from the official
26 # release artifacts and are reviewed as part of this repo. The -musl builds are
27 # statically linked, so one binary runs on any distribution.
28 XRAY_VERSION=${TT_XRAY_VERSION:-v26.3.27}
29 WGCF_VERSION=${TT_WGCF_VERSION:-2.2.32}
30 SINGBOX_VERSION=${TT_SINGBOX_VERSION:-1.13.16}
31 SINGBOX_SHA_amd64=9ff0345fde4157a6bdab45a615668d41ccc93f6d0f361108042a48b8a49a9baa
32 SINGBOX_SHA_arm64=3ea951c68f2eea10fd3ee8f8cc7794c12ccc7405afa99279a79e0b41cb183adf
33
34 # Our copies of third-party binaries live here, never in /usr/local/bin. That
35 # is where the official Xray installer and hand-rolled setups put theirs, and
36 # writing there means overwriting somebody's working server -- and then deleting
37 # it on uninstall. Which is exactly what happened once.
38 PRIVLIB=/usr/local/lib/transtation
39
40 DIR=${TT_DIR:-/opt/transtation}
41 URI_DIR=/etc/transtation
42 RAW=https://raw.githubusercontent.com/sqzer-x/transtation/$VERSION
43
44 say() { printf '%s\n' "$*"; }
45 # Under `curl | sh` stdin is the script itself, so a bare `read` swallows the
46 # rest of this file. Under cron or CI there is no /dev/tty at all. Try the
47 # terminal, fall back to stdin, and treat "neither" as no answer.
48 ask() {
49 printf '%s' "$1"
50 REPLY=""
51 # `[ -r /dev/tty ]` is not enough: the node exists and looks readable even
52 # with no controlling terminal, and opening it then fails with ENXIO and
53 # prints to stderr. Try the open for real, suppress it, fall back to stdin.
54 if { read -r REPLY </dev/tty; } 2>/dev/null && [ -n "$REPLY" ]; then
55 return 0
56 fi
57 read -r REPLY 2>/dev/null || REPLY=""
58 }
59 step() { printf ' %-14s%s\n' "$1" "$2"; }
60 die() { printf '\ntranstation: %s\n\n' "$*" >&2; exit 1; }
61
62 server_image() { printf '%s:%s%s' "$IMAGE_SERVER" "$VERSION" "${SERVER_DIGEST:+@$SERVER_DIGEST}"; }
63
64 need_root() {
65 [ "$(id -u)" = 0 ] || die "this needs root. Re-run as root, or with sudo:
66 curl -fsSLO $RAW/install.sh && sudo sh install.sh"
67 }
68
69 check_arch() {
70 case "$(uname -m)" in
71 x86_64 | amd64 | aarch64 | arm64) ;;
72 *) die "unsupported architecture: $(uname -m). transtation ships amd64 and arm64 images only." ;;
73 esac
74 }
75
76 os_id() {
77 # shellcheck disable=SC1091
78 [ -r /etc/os-release ] && . /etc/os-release && printf '%s' "${ID:-unknown}" || printf 'unknown'
79 }
80
81 install_docker() {
82 if command -v docker >/dev/null 2>&1 && docker compose version >/dev/null 2>&1; then
83 step docker "$(docker --version | cut -d, -f1), compose $(docker compose version --short)"
84 return 0
85 fi
86 say ""
87 step docker "not found -- installing"
88 case "$(os_id)" in
89 alpine)
90 # get.docker.com hard-fails on Alpine.
91 apk add --no-cache docker docker-cli-compose >/dev/null
92 rc-update add docker default >/dev/null 2>&1 || true
93 service docker start >/dev/null 2>&1 || true
94 ;;
95 arch | archarm | manjaro | endeavouros)
96 # get.docker.com hard-fails on Arch too.
97 pacman -S --needed --noconfirm docker docker-compose >/dev/null
98 systemctl enable --now docker >/dev/null 2>&1 || true
99 ;;
100 *)
101 say " NOTE: this runs https://get.docker.com, a ~780-line third-party"
102 say " script, as root. We do not review it. If you would rather not,"
103 say " install Docker yourself and run this installer again."
104 curl -fsSL https://get.docker.com | sh >/dev/null
105 systemctl enable --now docker >/dev/null 2>&1 || true
106 ;;
107 esac
108 command -v docker >/dev/null 2>&1 || die "Docker installation failed. Install it yourself and re-run."
109 docker compose version >/dev/null 2>&1 || die "the Docker Compose plugin is missing. Install docker-compose-plugin and re-run."
110 step docker "$(docker --version | cut -d, -f1), compose $(docker compose version --short)"
111 say " note: installing Docker sets net.ipv4.ip_forward=1 on this host."
112 }
113
114 wait_healthy() {
115 _name=$1 _limit=${2:-120} _t=0
116 while [ "$_t" -lt "$_limit" ]; do
117 case "$(docker inspect -f '{{.State.Health.Status}}' "$_name" 2>/dev/null || echo none)" in
118 healthy) printf 'healthy (%ss)\n' "$_t"; return 0 ;;
119 unhealthy | none) ;;
120 esac
121 sleep 3
122 _t=$((_t + 3))
123 printf '.'
124 done
125 printf 'TIMED OUT (%ss)\n' "$_limit"
126 return 1
127 }
128
129 # --- native server -------------------------------------------------------
130
131 # The two server installs both own /usr/local/bin/transtation and both want the
132 # same port. Installing one over the other leaves the first running and
133 # orphaned -- still holding a port, still serving its own identity, no longer
134 # reachable by the command. Refuse instead.
135 refuse_other_server() {
136 case "$1" in
137 container)
138 [ -e /etc/systemd/system/transtation.service ] &&
139 die "a native server is already installed here.
140 Remove it first: sh install.sh --uninstall
141 Or keep it -- it is the same software."
142 ;;
143 native)
144 { [ -e "$DIR/docker-compose.yml" ] || docker ps -a --format '{{.Names}}' 2>/dev/null | grep -qx transtation; } &&
145 die "a containerised server is already installed here.
146 Remove it first: sh install.sh --uninstall
147 Or keep it -- it is the same software."
148 ;;
149 esac
150 return 0
151 }
152
153
154 fetch_verified_xray() {
155 case "$(uname -m)" in
156 x86_64 | amd64) _xz=Xray-linux-64.zip; _wa=amd64 ;;
157 aarch64 | arm64) _xz=Xray-linux-arm64-v8a.zip; _wa=arm64 ;;
158 esac
159 _t=$(mktemp -d)
160 _b="https://github.com/XTLS/Xray-core/releases/download/${XRAY_VERSION}"
161 curl -fsSL -o "$_t/$_xz" "$_b/$_xz" || { rm -rf "$_t"; die "could not download Xray ${XRAY_VERSION}"; }
162 curl -fsSL -o "$_t/$_xz.dgst" "$_b/$_xz.dgst" || { rm -rf "$_t"; die "could not download the Xray checksum"; }
163 _exp=$(awk '/^SHA2-256/ {print $2}' "$_t/$_xz.dgst")
164 # Same guard as the Dockerfile: an empty expectation makes sha256sum verify
165 # nothing at all.
166 printf '%s' "$_exp" | grep -Eq '^[0-9a-f]{64}$' ||
167 { rm -rf "$_t"; die "no usable SHA2-256 line in $_xz.dgst"; }
168 ( cd "$_t" && echo "$_exp $_xz" | sha256sum -c - >/dev/null 2>&1 ) ||
169 { rm -rf "$_t"; die "Xray checksum mismatch -- refusing to install it"; }
170 command -v unzip >/dev/null 2>&1 || install_pkg unzip
171 unzip -q -j "$_t/$_xz" xray -d "$_t"
172 install -Dm0755 "$_t/xray" "$PRIVLIB/xray"
173
174 _w="https://github.com/ViRb3/wgcf/releases/download/v${WGCF_VERSION}"
175 curl -fsSL -o "$_t/wgcf" "$_w/wgcf_${WGCF_VERSION}_linux_${_wa}" ||
176 { rm -rf "$_t"; die "could not download wgcf"; }
177 curl -fsSL "$_w/checksums.txt" | grep " wgcf_${WGCF_VERSION}_linux_${_wa}\$" |
178 awk -v f="$_t/wgcf" '{print $1" "f}' | sha256sum -c - >/dev/null 2>&1 ||
179 { rm -rf "$_t"; die "wgcf checksum mismatch -- refusing to install it"; }
180 install -Dm0755 "$_t/wgcf" "$PRIVLIB/wgcf"
181 rm -rf "$_t"
182 step binaries "xray $("$PRIVLIB/xray" version | head -1 | cut -d' ' -f2) and wgcf ${WGCF_VERSION}, both checksum-verified, in $PRIVLIB"
183 }
184
185 install_pkg() {
186 case "$(os_id)" in
187 alpine) apk add --no-cache "$1" >/dev/null 2>&1 ;;
188 arch | archarm | manjaro | endeavouros) pacman -S --needed --noconfirm "$1" >/dev/null 2>&1 ;;
189 *) DEBIAN_FRONTEND=noninteractive apt-get -y -qq install "$1" >/dev/null 2>&1 ||
190 dnf -y -q install "$1" >/dev/null 2>&1 || true ;;
191 esac
192 }
193
194 install_server_native() {
195 need_root
196 check_arch
197 refuse_other_server native
198 say ""
199 say " transtation $VERSION -- native install, no container runtime"
200 say " https://github.com/sqzer-x/transtation"
201 say ""
202 step host "$(os_id) / $(uname -m) / root"
203 [ -d /run/systemd/system ] || die "the native install is managed by systemd, which is not running here.
204 Use the container install instead: sh install.sh"
205 command -v curl >/dev/null 2>&1 || die "curl is required"
206
207 fetch_verified_xray
208 install -Dm0755 "$(server_script)" "$PRIVLIB/server"
209
210 id -u transtation >/dev/null 2>&1 || useradd --system --no-create-home --shell /usr/sbin/nologin transtation 2>/dev/null ||
211 adduser -S -D -H -s /sbin/nologin transtation 2>/dev/null || true
212 install -d -o transtation -g transtation -m 0700 /var/lib/transtation
213 mkdir -p /etc/transtation
214 [ -e /etc/transtation/server.env ] || { : >/etc/transtation/server.env; chmod 600 /etc/transtation/server.env; }
215 [ -n "${PORT:-}" ] && ! grep -q '^PORT=' /etc/transtation/server.env &&
216 printf 'PORT=%s\n' "$PORT" >>/etc/transtation/server.env
217 # Every command has to run as the service user. Run as root, the script would
218 # create root-owned files in the state directory and the service -- which is
219 # not root -- would stop being able to read its own identity.
220 # backup/restore/logs are handled here rather than passed through, so that
221 # `transtation <verb>` means the same thing on both install paths -- the
222 # container wrapper below implements exactly these three the same way.
223 install -Dm0755 /dev/stdin /usr/local/bin/transtation <<-'WRAP'
224 #!/bin/sh
225 set -eu
226 D=/var/lib/transtation
227 L=/usr/local/lib/transtation
228 asuser() {
229 if [ "$(id -un)" = transtation ]; then
230 env TT_DATA=$D PATH=$L:"$PATH" $L/server "$@"
231 elif command -v runuser >/dev/null 2>&1; then
232 runuser -u transtation -- env TT_DATA=$D PATH=$L:"$PATH" $L/server "$@"
233 else
234 su -s /bin/sh transtation -c "exec env TT_DATA=$D PATH=$L:\$PATH $L/server \"\$@\"" -- sh "$@"
235 fi
236 }
237 [ "$(id -un)" = transtation ] || [ "$(id -u)" = 0 ] ||
238 { echo "transtation: run this as root or as the transtation user" >&2; exit 1; }
239 case "${1:-}" in
240 backup)
241 f=${2:-/root/transtation-backup.tgz}
242 [ -e "$f" ] && { echo "$f exists -- refusing to overwrite" >&2; exit 1; }
243 # root's umask is 022 on every target distro, which would leave a
244 # world-readable tarball of your Reality private key, your WARP private
245 # key and your WARP bearer token.
246 umask 077
247 asuser backup > "$f"
248 echo "backed up to $f (mode 0600): reality key, users, WARP account."
249 ;;
250 restore)
251 f=${2:-}
252 [ -n "$f" ] && [ -r "$f" ] || { echo "usage: transtation restore <backup.tgz>" >&2; exit 1; }
253 tar tzf "$f" 2>/dev/null | grep -q '^state.env$' ||
254 { echo "$f does not look like a transtation backup" >&2; exit 1; }
255 systemctl is-active --quiet transtation &&
256 { echo "stop the server first: systemctl stop transtation" >&2; exit 1; }
257 # Unpacked as root, then handed back: the service user cannot create
258 # files it does not already own, and tar would restore archived uids
259 # that need not match this host's.
260 tar xzf "$f" -C "$D"
261 chown -R transtation:transtation "$D"
262 echo "restored. start it again: systemctl start transtation"
263 ;;
264 logs) shift; exec journalctl -u transtation "$@" ;;
265 *) asuser "$@" ;;
266 esac
267 WRAP
268 step config "wrote /etc/transtation/server.env, data in /var/lib/transtation"
269
270 cat >/etc/systemd/system/transtation.service <<-'EOF'
271 [Unit]
272 Description=transtation server
273 After=network-online.target
274 Wants=network-online.target
275
276 [Service]
277 Type=simple
278 User=transtation
279 Group=transtation
280 Environment=TT_DATA=/var/lib/transtation
281 Environment=PATH=/usr/local/lib/transtation:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
282 EnvironmentFile=-/etc/transtation/server.env
283 ExecStart=/usr/local/lib/transtation/server run
284 Restart=on-failure
285 RestartSec=3s
286 # The container needs no capability because dockerd performs the
287 # privileged bind. Here nothing is in front of us, so we keep exactly
288 # one -- and nothing else is even reachable.
289 AmbientCapabilities=CAP_NET_BIND_SERVICE
290 CapabilityBoundingSet=CAP_NET_BIND_SERVICE
291 NoNewPrivileges=true
292 ProtectSystem=strict
293 ProtectHome=true
294 PrivateTmp=true
295 PrivateDevices=true
296 ProtectKernelTunables=true
297 ProtectControlGroups=true
298 RestrictSUIDSGID=true
299 ReadWritePaths=/var/lib/transtation
300
301 [Install]
302 WantedBy=multi-user.target
303 EOF
304 systemctl daemon-reload
305 printf ' %-14s' start
306 systemctl enable --now transtation.service >/dev/null 2>&1 || true
307 _i=0
308 while [ "$_i" -lt 40 ]; do
309 /usr/local/bin/transtation health >/dev/null 2>&1 && break
310 _i=$((_i + 1)); printf '.'; sleep 3
311 done
312 systemctl is-active --quiet transtation || {
313 say "FAILED"
314 say " journalctl -u transtation -n 40"
315 exit 1
316 }
317 say "running"
318
319 printf ' %-14s' verify
320 /usr/local/bin/transtation verify || {
321 say ""
322 say " The server is running but clients cannot complete the REALITY handshake."
323 say " echo 'SNI=dl.google.com' >> /etc/transtation/server.env"
324 say " systemctl restart transtation && transtation verify"
325 exit 1
326 }
327 say ""
328 /usr/local/bin/transtation status || true
329 cat <<-EOF
330 DO THIS NOW -- it is the only irreplaceable thing on this box:
331 transtation backup
332
333 Your link: transtation show
334 Add a user: transtation user add alice
335 Logs: journalctl -u transtation -f
336 Uninstall: sh install.sh --uninstall
337
338 >> If a client cannot connect, open the port in your provider's firewall
339 or security group. The healthcheck only proves OUTBOUND traffic works.
340
341 EOF
342 }
343
344 server_script() {
345 _d=$(dirname -- "$0" 2>/dev/null || echo .)
346 if [ -r "$_d/server/transtation" ]; then
347 printf '%s' "$_d/server/transtation"
348 return 0
349 fi
350 _f=$(mktemp)
351 curl -fsSL "$RAW/server/transtation" -o "$_f" || die "could not fetch the server script from $RAW"
352 printf '%s' "$_f"
353 }
354
355 # ------------------------------------------------------ server (container)
356
357 install_server() {
358 need_root
359 check_arch
360 refuse_other_server container
361 say ""
362 say " transtation $VERSION -- your own VLESS + REALITY proxy"
363 say " https://github.com/sqzer-x/transtation"
364 say ""
365 step host "$(os_id) / $(uname -m) / root"
366 install_docker
367
368 mkdir -p "$DIR"
369 if [ -f "$DIR/docker-compose.yml" ]; then
370 step config "already present at $DIR -- reusing it (this is an upgrade, not a reinstall)"
371 else
372 cat >"$DIR/docker-compose.yml" <<-EOF
373 services:
374 proxy:
375 image: $(server_image)
376 container_name: transtation
377 restart: unless-stopped
378 ports: ["\${PORT:-443}:8443"]
379 volumes: ["transtation-data:/data"]
380 env_file: .env
381 # Hardening. The process already holds zero effective capabilities
382 # (verified: CapEff 0000000000000000), so these mostly shrink what a
383 # future change could quietly re-acquire.
384 cap_drop: [ALL]
385 security_opt: ["no-new-privileges:true"]
386 read_only: true
387 tmpfs: ["/tmp:rw,noexec,nosuid,size=16m"]
388 logging:
389 driver: json-file
390 options: { max-size: "10m", max-file: "3" }
391 # Do NOT add cap_add: NET_ADMIN or devices: /dev/net/tun here.
392 # Both BREAK the WARP outbound. See README, "Capabilities".
393 volumes:
394 transtation-data:
395 # Pinned. Without an explicit name Docker Compose prefixes it with
396 # the project name, which comes from the directory -- so the volume
397 # would be called something else on every host and every documented
398 # backup, restore and uninstall command would silently address a
399 # volume that does not exist.
400 name: transtation-data
401 EOF
402 : >"$DIR/.env"
403 chmod 600 "$DIR/.env"
404 # If PORT came from the environment, write it down. Compose would honour
405 # it for the published port while the container -- which reads only
406 # env_file -- would still think it is 443, and the share link would name
407 # the wrong port.
408 [ -n "${PORT:-}" ] && printf 'PORT=%s\n' "$PORT" >>"$DIR/.env"
409 step config "wrote $DIR/docker-compose.yml and $DIR/.env"
410 fi
411
412 install_wrapper
413 step wrapper "installed /usr/local/bin/transtation"
414
415 printf ' %-14s' image
416 docker pull -q "$(server_image)" >/dev/null 2>&1 || true
417 # Resolve the tag to the digest it points at *now* and pin the compose file
418 # to that. A tag is mutable; once this is written, `docker compose up` can
419 # only ever start the exact image this install verified. Rewritten on every
420 # run so re-running the installer is still how you upgrade.
421 _ref=$(docker image inspect --format '{{if .RepoDigests}}{{index .RepoDigests 0}}{{end}}' "$(server_image)" 2>/dev/null)
422 case "$_ref" in
423 *@sha256:*)
424 sed -i "s|^\( *image: \).*|\1$_ref|" "$DIR/docker-compose.yml"
425 say "pinned to ${_ref#*@}"
426 ;;
427 *)
428 say "pulled $(server_image) (no digest available; the compose file pins by tag)"
429 ;;
430 esac
431
432 printf ' %-14s' start
433 docker compose -f "$DIR/docker-compose.yml" --project-directory "$DIR" up -d >/dev/null 2>&1
434 if ! wait_healthy transtation 120; then
435 say ""
436 say " The container is up but not healthy yet. Most common causes:"
437 say " - the WARP tunnel is not passing traffic (some providers block outbound"
438 say " UDP/2408 even though registration, which is TCP, succeeded). Fix:"
439 say " sed -i 's/^WARP=1/WARP=0/' $DIR/.env || echo WARP=0 >> $DIR/.env"
440 say " cd $DIR && docker compose up -d"
441 say " - no outbound HTTPS from this host at all."
442 say ""
443 say " Diagnose with: transtation status and transtation logs"
444 exit 1
445 fi
446
447 # The healthcheck proves egress, but it goes through a loopback inbound and
448 # never touches REALITY -- so a dest whose certificate chain is too large
449 # produces a permanently "healthy" server that no client can connect to.
450 # This is the only check that catches it.
451 printf ' %-14s' verify
452 if transtation verify; then :; else
453 say ""
454 say " The server is running but clients cannot complete the REALITY handshake."
455 say " Fix the dest and try again:"
456 say " echo 'SNI=dl.google.com' >> $DIR/.env"
457 say " cd $DIR && docker compose up -d && transtation verify"
458 exit 1
459 fi
460
461 say ""
462 transtation status || true
463 _port=$(sed -n 's/^PORT=//p' "$DIR/.env" 2>/dev/null | tail -1)
464 cat <<-EOF
465 DO THIS NOW -- it is the only irreplaceable thing on this box:
466 transtation backup
467
468 Your link: transtation show
469 Add a user: transtation user add alice
470 Uninstall: sh install.sh --uninstall
471
472 >> If a client cannot connect, open TCP ${_port:-443} in your provider's
473 firewall or security group. The healthcheck above only proves OUTBOUND
474 traffic works; inbound cannot be verified from this host. Note that
475 Docker's published ports bypass ufw in both directions.
476
477 EOF
478 }
479
480 install_wrapper() {
481 cat >/usr/local/bin/transtation <<-EOF
482 #!/bin/sh
483 set -eu
484 C="docker compose -f $DIR/docker-compose.yml --project-directory $DIR"
485 case "\${1:-}" in
486 backup)
487 f=\${2:-/root/transtation-backup.tgz}
488 [ -e "\$f" ] && { echo "\$f exists -- refusing to overwrite" >&2; exit 1; }
489 # root's umask is 022 on every target distro, which would make a
490 # world-readable tarball of your Reality private key, your WARP private
491 # key and your WARP bearer token.
492 umask 077
493 \$C exec -T proxy transtation backup > "\$f"
494 echo "backed up to \$f (mode 0600): reality key, users, WARP account."
495 ;;
496 restore)
497 f=\${2:-}
498 [ -n "\$f" ] && [ -r "\$f" ] || { echo "usage: transtation restore <backup.tgz>" >&2; exit 1; }
499 tar tzf "\$f" 2>/dev/null | grep -q '^state.env$' || { echo "\$f does not look like a transtation backup" >&2; exit 1; }
500 if \$C ps -q proxy 2>/dev/null | grep -q .; then
501 echo "stop the server first: cd $DIR && docker compose down" >&2; exit 1
502 fi
503 # --user 0 so it can read a 0600 archive; the transtation image so that
504 # Docker seeds a fresh volume with the right /data ownership; tar then
505 # restores each file's archived uid.
506 docker run --rm --user 0 \\
507 -v transtation-data:/data \\
508 -v "\$(cd "\$(dirname "\$f")" && pwd)":/b:ro \\
509 --entrypoint sh $(server_image) \\
510 -c "tar xzf /b/\$(basename "\$f") -C /data"
511 echo "restored. start it again: cd $DIR && docker compose up -d"
512 ;;
513 logs) shift; exec \$C logs "\$@" ;;
514 up|down|pull|restart) exec \$C "\$@" ;;
515 *) exec \$C exec proxy transtation "\$@" ;;
516 esac
517 EOF
518 chmod 0755 /usr/local/bin/transtation
519 }
520
521 # ------------------------------------------------------------------ client
522
523 # Run from a git checkout, or piped from the internet -- fetch what is missing.
524 client_script() {
525 _d=$(dirname -- "$0" 2>/dev/null || echo .)
526 if [ -r "$_d/client/transtation-client" ]; then
527 printf '%s' "$_d/client/transtation-client"
528 return 0
529 fi
530 _f=$(mktemp)
531 curl -fsSL "$RAW/client/transtation-client" -o "$_f" ||
532 die "could not fetch the client from $RAW"
533 printf '%s' "$_f"
534 }
535
536 install_panic() {
537 install -Dm0755 /dev/stdin /usr/local/sbin/transtation-panic <<-'EOF'
538 #!/bin/sh
539 set -u
540 TUN_IFACE=${TUN_IFACE:-tt0}
541 nft delete table inet transtation 2>/dev/null
542 nft delete table ip6 transtation 2>/dev/null
543 for f in -4 -6; do
544 ip $f rule list 2>/dev/null | sed -n 's/^\(90[0-9][0-9]\):.*/\1/p' | while read -r p; do
545 ip $f rule del priority "$p" 2>/dev/null
546 done
547 done
548 ip route flush table 2022 2>/dev/null
549 ip -6 route flush table 2022 2>/dev/null
550 ip link del "$TUN_IFACE" 2>/dev/null
551 systemctl stop transtation-client 2>/dev/null
552 echo "killswitch removed, policy routing flushed. You are back on direct egress."
553 EOF
554 }
555
556 install_sing_box() {
557 command -v sing-box >/dev/null 2>&1 && [ -z "${TT_FORCE_SINGBOX:-}" ] && {
558 step sing-box "$(sing-box version | head -1) (already installed)"
559 SINGBOX=$(command -v sing-box)
560 return 0
561 }
562 case "$(uname -m)" in
563 x86_64 | amd64) _a=amd64; _sum=$SINGBOX_SHA_amd64 ;;
564 aarch64 | arm64) _a=arm64; _sum=$SINGBOX_SHA_arm64 ;;
565 esac
566 _n="sing-box-${SINGBOX_VERSION}-linux-${_a}-musl"
567 _t=$(mktemp -d)
568 curl -fsSL -o "$_t/sb.tgz" \
569 "https://github.com/SagerNet/sing-box/releases/download/v${SINGBOX_VERSION}/${_n}.tar.gz" ||
570 die "could not download sing-box ${SINGBOX_VERSION}"
571 echo "$_sum $_t/sb.tgz" | sha256sum -c - >/dev/null 2>&1 ||
572 { rm -rf "$_t"; die "sing-box checksum mismatch -- refusing to install it"; }
573 tar xzf "$_t/sb.tgz" -C "$_t"
574 install -Dm0755 "$_t/$_n/sing-box" "$PRIVLIB/sing-box"
575 rm -rf "$_t"
576 SINGBOX=$PRIVLIB/sing-box
577 step sing-box "$($SINGBOX version | head -1) (verified, statically linked)"
578 }
579
580 install_client() {
581 need_root
582 check_arch
583 _mode=tun _ks=0 _uri=""
584 for a in "$@"; do
585 case "$a" in
586 --tun) _mode=tun ;;
587 --proxy) _mode=proxy ;;
588 --killswitch) _ks=1 ;;
589 vless://*) _uri=$a ;;
590 *) die "unknown option: $a" ;;
591 esac
592 done
593
594 say ""
595 say " transtation client $VERSION"
596 say ""
597
598 if [ -z "$_uri" ] && [ ! -r "$URI_DIR/uri" ]; then
599 say " paste the link from your server (run 'transtation show' there):"
600 ask ' > '
601 _uri=$REPLY
602 [ -n "$_uri" ] || die "no link given. Pass it as an argument instead:
603 sh install.sh client 'vless://...'"
604 fi
605 if [ -n "$_uri" ]; then
606 mkdir -p "$URI_DIR"
607 umask 077
608 printf '%s\n' "$_uri" >"$URI_DIR/uri"
609 chmod 600 "$URI_DIR/uri"
610 step link "stored at $URI_DIR/uri (0600)"
611 else
612 step link "reusing $URI_DIR/uri"
613 fi
614
615 install_sing_box
616 install -Dm0755 "$(client_script)" /usr/local/bin/transtation-client
617 install_panic
618 step scripts "installed /usr/local/bin/transtation-client and /usr/local/sbin/transtation-panic"
619
620 # Taken before the tunnel exists, so the self-test can tell "the tunnel is
621 # carrying traffic" from "a request happened to succeed". Without this the
622 # check passes while routing is still settling and the answer came out the
623 # ordinary way -- observed, and reported as success.
624 _before=$(curl -fsS --max-time 8 https://api.ipify.org 2>/dev/null || true)
625
626 [ -d /run/systemd/system ] || die "this needs systemd to manage the client.
627 Without it, run it yourself:
628 MODE=$_mode /usr/local/bin/transtation-client run"
629
630 cat >/etc/systemd/system/transtation-client.service <<-EOF
631 [Unit]
632 Description=transtation client ($_mode)
633 After=network-online.target
634 Wants=network-online.target
635
636 [Service]
637 Type=simple
638 Environment=MODE=$_mode
639 Environment=TT_SINGBOX=$SINGBOX
640 ${DIRECT_SUFFIXES:+Environment=DIRECT_SUFFIXES=$DIRECT_SUFFIXES}
641 ExecStart=/usr/local/bin/transtation-client run
642 Restart=on-failure
643 RestartSec=3s
644 RuntimeDirectory=transtation
645 RuntimeDirectoryMode=0700
646 # Root is needed to own the routing table in tun mode; the bounding set
647 # keeps that to what sing-box actually uses.
648 CapabilityBoundingSet=CAP_NET_ADMIN CAP_NET_RAW CAP_NET_BIND_SERVICE CAP_DAC_READ_SEARCH
649 NoNewPrivileges=true
650 ProtectHome=true
651 ProtectSystem=strict
652 ReadWritePaths=/run/transtation
653
654 [Install]
655 WantedBy=multi-user.target
656 EOF
657 systemctl daemon-reload
658 systemctl enable --now transtation-client.service >/dev/null 2>&1 || true
659 step service "transtation-client.service enabled ($_mode mode)"
660
661 if [ "$_ks" = 1 ]; then
662 [ "$_mode" = tun ] || die "--killswitch only makes sense with the whole-host tunnel"
663 curl -fsSL "$RAW/host/transtation-killswitch" -o /usr/local/sbin/transtation-killswitch ||
664 die "could not fetch the killswitch script from $RAW"
665 chmod 0755 /usr/local/sbin/transtation-killswitch
666 /usr/local/sbin/transtation-killswitch on
667 fi
668
669 printf ' %-14s' self-test
670 _probe=""
671 _n=0
672 while [ "$_n" -lt 15 ]; do
673 if [ "$_mode" = tun ]; then
674 _trace=$(curl -fsS --max-time 8 https://cloudflare.com/cdn-cgi/trace 2>/dev/null || true)
675 _ip=$(printf '%s' "$_trace" | sed -n 's/^ip=//p')
676 # The address has to have MOVED. A reply on its own only proves the
677 # network works, which it did before any of this was installed.
678 if [ -n "$_ip" ] && [ -n "$_before" ] && [ "$_ip" != "$_before" ]; then
679 _probe=$(printf '%s' "$_trace" | grep -E '^(ip|warp|colo)=' | tr '\n' ' ')
680 elif [ -n "$_ip" ] && [ -z "$_before" ]; then
681 _probe="$(printf '%s' "$_trace" | grep -E '^(ip|warp|colo)=' | tr '\n' ' ')(could not compare: no address before install)"
682 fi
683 else
684 _probe=$(curl -fsS --max-time 8 --socks5-hostname 127.0.0.1:1080 https://cloudflare.com/cdn-cgi/trace 2>/dev/null |
685 grep -E '^(ip|warp|colo)=' | tr '\n' ' ')
686 fi
687 [ -n "$_probe" ] && break
688 _n=$((_n + 1))
689 sleep 2
690 done
691 if [ -n "$_probe" ]; then
692 [ "$_mode" = tun ] && say "$_probe" || say "through the proxy: $_probe"
693 else
694 [ "$_mode" = tun ] && say "STILL YOUR OWN ADDRESS (${_before:-unknown})" || say "NO TRAFFIC"
695 say ""
696 say " The client is running but traffic is not going through the tunnel. Look at:"
697 say " journalctl -u transtation-client -n 40"
698 [ "$_mode" = tun ] && say " sudo transtation-panic # undo the tunnel and killswitch"
699 exit 1
700 fi
701
702 if [ "$_mode" = tun ]; then
703 cat <<-'EOF'
704
705 Every program on this machine now goes through the tunnel, with no
706 per-program configuration.
707
708 stop: sudo systemctl stop transtation-client
709 logs: journalctl -u transtation-client -f
710 panic: sudo transtation-panic # undo everything, needs no network
711
712 EOF
713 else
714 cat <<-'EOF'
715
716 THIS DOES NOT PUT YOUR MACHINE BEHIND THE PROXY. It opens a proxy on
717 127.0.0.1:1080 and nothing else; each program has to be pointed at it.
718 Re-run with --tun for the whole machine.
719
720 export ALL_PROXY=socks5h://127.0.0.1:1080 HTTPS_PROXY=http://127.0.0.1:1080 HTTP_PROXY=http://127.0.0.1:1080
721 Those apply to that shell and what it starts, and nothing else.
722
723 EOF
724 fi
725 }
726
727 # --------------------------------------------------------------- uninstall
728
729 uninstall() {
730 need_root
731 if [ -d "$DIR" ]; then
732 docker compose -f "$DIR/docker-compose.yml" --project-directory "$DIR" down >/dev/null 2>&1 || true
733 rm -f "$DIR/docker-compose.yml" "$DIR/.env" /usr/local/bin/transtation
734 rmdir "$DIR" 2>/dev/null || true
735 say "server removed."
736 ask 'also delete the data volume? That destroys your Reality key and every issued link. [y/N] '
737 say ""
738 case "$REPLY" in
739 y | Y)
740 if docker volume rm transtation-data >/dev/null 2>&1; then
741 say "volume deleted."
742 else
743 say "could not delete the volume (in use, or already gone):"
744 docker volume ls --format ' {{.Name}}' | grep transtation || say " none found"
745 fi
746 ;;
747 *) say "volume kept: 'docker volume ls | grep transtation-data'" ;;
748 esac
749 fi
750 if [ -e /etc/systemd/system/transtation.service ]; then
751 systemctl disable --now transtation.service >/dev/null 2>&1 || true
752 rm -f /etc/systemd/system/transtation.service
753 systemctl daemon-reload 2>/dev/null || true
754 rm -rf /usr/local/bin/transtation "$PRIVLIB"
755 say "native server removed. Its data is still in /var/lib/transtation."
756 ask 'also delete /var/lib/transtation? That destroys your Reality key and every issued link. [y/N] '
757 say ""
758 case "$REPLY" in
759 y | Y) rm -rf /var/lib/transtation; say "data deleted." ;;
760 *) say "data kept in /var/lib/transtation" ;;
761 esac
762 fi
763 if [ -e /etc/systemd/system/transtation-client.service ]; then
764 systemctl disable --now transtation-client.service >/dev/null 2>&1 || true
765 rm -f /etc/systemd/system/transtation-client.service
766 systemctl daemon-reload 2>/dev/null || true
767 rm -f /usr/local/bin/transtation-client
768 rm -f "$PRIVLIB/sing-box"
769 rmdir "$PRIVLIB" 2>/dev/null || true
770 say "client removed."
771 fi
772 rm -f /usr/local/sbin/transtation-panic
773 say "done. $URI_DIR was left in place; remove it yourself if you are finished with it."
774 }
775
776 main() {
777 # `shift` with no positional parameters is a special-builtin error, and a
778 # POSIX shell exits on those -- `|| true` does not save you. dash is
779 # /bin/sh on Debian and Ubuntu, so `sh install.sh` with no arguments used
780 # to die here, silently, on the primary target. bash (Arch's /bin/sh) is
781 # forgiving, which is exactly why local testing never saw it.
782 _cmd=${1:-server}
783 if [ $# -gt 0 ]; then shift; fi
784 case "$_cmd" in
785 server)
786 case "${1:-}" in
787 --native) install_server_native ;;
788 '') install_server ;;
789 *) die "unknown option: $1 (expected --native)" ;;
790 esac
791 ;;
792 --native) install_server_native ;;
793 client) install_client "$@" ;;
794 --uninstall | uninstall) uninstall ;;
795 -h | --help)
796 sed -n '2,10p' "$0" 2>/dev/null || say "see https://github.com/sqzer-x/transtation"
797 ;;
798 *) die "unknown command: $_cmd (expected: server, client, --uninstall)" ;;
799 esac
800 }
801
802 main "$@"