diff --git a/README.md b/README.md
@@ -4,8 +4,8 @@ Your own VLESS + REALITY proxy, with optional Cloudflare WARP egress, in one
container on any Linux box that has Docker.
```
-curl -fsSLO https://raw.githubusercontent.com/sqzer-x/transtation/v1.0.12/install.sh
-sha256sum install.sh # compare against the hash in the v1.0.12 release notes
+curl -fsSLO https://raw.githubusercontent.com/sqzer-x/transtation/v1.0.13/install.sh
+sha256sum install.sh # compare against the hash in the v1.0.13 release notes
less install.sh # please actually read it
sudo sh install.sh
```
@@ -40,42 +40,57 @@ transtation exists because of a narrower set of preferences:
## Server
-### Install
+Two ways to run it. They are the same software with the same config; pick on
+how you feel about a container runtime on your VPS.
-Two-step form above is the recommended one. If you are going to pipe it anyway:
+### In a container (default)
```
-curl -fsSL https://raw.githubusercontent.com/sqzer-x/transtation/v1.0.12/install.sh | sudo sh
+curl -fsSLO https://raw.githubusercontent.com/sqzer-x/transtation/v1.0.13/install.sh
+sha256sum install.sh # compare against the hash in the v1.0.13 release notes
+less install.sh # please actually read it
+sudo sh install.sh
```
-The installer needs root, refuses anything other than x86_64/aarch64, installs
-Docker if it is missing, writes `/opt/transtation/{docker-compose.yml,.env}`,
-and waits for the container to report healthy. Running it again is an upgrade:
-it pulls and restarts, and never re-provisions.
+Installs Docker if it is missing, writes `/opt/transtation/{docker-compose.yml,.env}`,
+resolves the image tag to a digest and pins the compose file to it, then waits
+for the container to be healthy and proves the handshake works. Re-running it is
+how you upgrade.
-If you already run Docker and prefer to do it yourself:
+### Natively
-```yaml
-# /opt/transtation/docker-compose.yml
-services:
- proxy:
- image: ghcr.io/sqzer-x/transtation:v1.0.12
- container_name: transtation
- restart: unless-stopped
- ports: ["443:8443"]
- volumes: ["transtation-data:/data"]
- env_file: .env
- cap_drop: [ALL]
- security_opt: ["no-new-privileges:true"]
- read_only: true
- tmpfs: ["/tmp:rw,noexec,nosuid,size=16m"]
- logging:
- driver: json-file
- options: { max-size: "10m", max-file: "3" }
-volumes:
- transtation-data:
- name: transtation-data # pinned; otherwise Compose prefixes it with the directory name
```
+sudo sh install.sh server --native
+```
+
+No container runtime. It downloads Xray and wgcf, verifies both against
+upstream checksums, installs `/usr/local/bin/transtation`, keeps state in
+`/var/lib/transtation` owned by a dedicated `transtation` user, and runs under
+`transtation.service`.
+
+### Which one
+
+Measured on the same host, same binary:
+
+| | container | native |
+|---|---|---|
+| capabilities held | **none** — `CapEff 0000000000000000` | `CAP_NET_BIND_SERVICE`, and only that |
+| root filesystem | read-only | `ProtectSystem=strict` |
+| runs as | uid 10001 | `transtation` user |
+| costs | Docker daemon, ~250 MB, `ip_forward=1`, published ports bypass `ufw` | needs systemd |
+| upgrades | pull a new digest | re-run the installer |
+
+The container needs no capability at all because dockerd performs the
+privileged bind on 443 and the container listens high; natively there is
+nothing in front, so it keeps exactly one capability and nothing else is even
+reachable. Both pin and verify the same Xray release.
+
+If you have no strong feeling, use the container: one artifact, dependencies
+verified and frozen inside it, and rollback is a digest. If you would rather not
+run a container runtime on a proxy server, the native path is not a lesser
+option — it is the same program with a systemd unit instead of a compose file.
+
+Either way `transtation` is the command:
### Day to day
@@ -363,25 +378,37 @@ docker compose exec proxy xray tls ping example.com
---
-## Capabilities: none, and none needed
+## Capabilities
+
+The container holds **none at all**, verified on a running server:
+`CapEff`, `CapPrm` and `CapBnd` all `0000000000000000`, `NoNewPrivs 1`, root
+filesystem read-only. The native install holds `CAP_NET_BIND_SERVICE` and
+nothing else, because something has to bind 443 and there is no dockerd in
+front to do it.
| | Needed? | |
|---|---|---|
| `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` | no | it listens on 8443 inside; `-p 443:8443` does the privileged bind in dockerd, which also keeps this working under rootless Docker |
+| `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 |
+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`
+half is only needed for kernel-TUN WARP, which this does not use.
+
That `noKernelTun` pin is load-bearing rather than decorative. Left unset, Xray
probes for `CAP_NET_ADMIN` and takes the kernel-TUN path when it finds it — and
`createKernelTun` writes to `/proc/sys/.../rp_filter` *before* opening the
device, which fails on Docker's read-only `/proc/sys` with no fallback. Pinning
-it makes the container's behaviour independent of its capabilities.
+it makes behaviour independent of capabilities.
-To confirm:
+To confirm:To confirm:
```
-docker compose logs proxy | grep -o 'Using .* TUN' # -> Using gVisor TUN
+docker compose logs proxy | grep -o 'Using .* TUN' # container
+journalctl -u transtation | grep -o 'Using .* TUN' # native
+# both -> Using gVisor TUN
```
---
@@ -450,7 +477,8 @@ 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.
-Upgrades: re-run `install.sh`, or `docker compose pull && docker compose up -d`.
+Upgrades: re-run `install.sh` (with `server --native` if that is how you
+installed), or `docker compose pull && docker compose up -d`.
Your identity lives in the volume and survives; the config is regenerated from
the new image every boot, so schema changes come along for free.
diff --git a/install.sh b/install.sh
@@ -15,18 +15,20 @@
# last line. If the download is truncated, nothing runs.
set -eu
-VERSION=${TT_VERSION:-v1.0.12}
+VERSION=${TT_VERSION:-v1.0.13}
IMAGE_SERVER=${TT_IMAGE_SERVER:-ghcr.io/sqzer-x/transtation}
IMAGE_CLIENT=${TT_IMAGE_CLIENT:-ghcr.io/sqzer-x/transtation-client}
# 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.12, and the hash-verified installer you read would vouch for nothing.
+# v1.0.13, and the hash-verified installer you read would vouch for nothing.
SERVER_DIGEST=${TT_SERVER_DIGEST:-}
CLIENT_DIGEST=${TT_CLIENT_DIGEST:-}
# sing-box ships no checksum file, so these were computed once from the official
# release artifacts and are reviewed as part of this repo. The -musl builds are
# statically linked, so one binary runs on any distribution.
+XRAY_VERSION=${TT_XRAY_VERSION:-v26.3.27}
+WGCF_VERSION=${TT_WGCF_VERSION:-2.2.32}
SINGBOX_VERSION=${TT_SINGBOX_VERSION:-1.13.16}
SINGBOX_SHA_amd64=9ff0345fde4157a6bdab45a615668d41ccc93f6d0f361108042a48b8a49a9baa
SINGBOX_SHA_arm64=3ea951c68f2eea10fd3ee8f8cc7794c12ccc7405afa99279a79e0b41cb183adf
@@ -121,7 +123,172 @@ wait_healthy() {
return 1
}
-# ------------------------------------------------------------------ server
+# --- native server -------------------------------------------------------
+
+fetch_verified_xray() {
+ case "$(uname -m)" in
+ x86_64 | amd64) _xz=Xray-linux-64.zip; _wa=amd64 ;;
+ aarch64 | arm64) _xz=Xray-linux-arm64-v8a.zip; _wa=arm64 ;;
+ esac
+ _t=$(mktemp -d)
+ _b="https://github.com/XTLS/Xray-core/releases/download/${XRAY_VERSION}"
+ curl -fsSL -o "$_t/$_xz" "$_b/$_xz" || { rm -rf "$_t"; die "could not download Xray ${XRAY_VERSION}"; }
+ curl -fsSL -o "$_t/$_xz.dgst" "$_b/$_xz.dgst" || { rm -rf "$_t"; die "could not download the Xray checksum"; }
+ _exp=$(awk '/^SHA2-256/ {print $2}' "$_t/$_xz.dgst")
+ # Same guard as the Dockerfile: an empty expectation makes sha256sum verify
+ # nothing at all.
+ printf '%s' "$_exp" | grep -Eq '^[0-9a-f]{64}$' ||
+ { rm -rf "$_t"; die "no usable SHA2-256 line in $_xz.dgst"; }
+ ( cd "$_t" && echo "$_exp $_xz" | sha256sum -c - >/dev/null 2>&1 ) ||
+ { rm -rf "$_t"; die "Xray checksum mismatch -- refusing to install it"; }
+ command -v unzip >/dev/null 2>&1 || install_pkg unzip
+ unzip -q -j "$_t/$_xz" xray -d "$_t"
+ install -Dm0755 "$_t/xray" /usr/local/bin/xray
+
+ _w="https://github.com/ViRb3/wgcf/releases/download/v${WGCF_VERSION}"
+ curl -fsSL -o "$_t/wgcf" "$_w/wgcf_${WGCF_VERSION}_linux_${_wa}" ||
+ { rm -rf "$_t"; die "could not download wgcf"; }
+ curl -fsSL "$_w/checksums.txt" | grep " wgcf_${WGCF_VERSION}_linux_${_wa}\$" |
+ awk -v f="$_t/wgcf" '{print $1" "f}' | sha256sum -c - >/dev/null 2>&1 ||
+ { rm -rf "$_t"; die "wgcf checksum mismatch -- refusing to install it"; }
+ install -Dm0755 "$_t/wgcf" /usr/local/bin/wgcf
+ rm -rf "$_t"
+ step binaries "xray $(/usr/local/bin/xray version | head -1 | cut -d' ' -f2) and wgcf ${WGCF_VERSION}, both checksum-verified"
+}
+
+install_pkg() {
+ case "$(os_id)" in
+ alpine) apk add --no-cache "$1" >/dev/null 2>&1 ;;
+ arch | archarm | manjaro | endeavouros) pacman -S --needed --noconfirm "$1" >/dev/null 2>&1 ;;
+ *) DEBIAN_FRONTEND=noninteractive apt-get -y -qq install "$1" >/dev/null 2>&1 ||
+ dnf -y -q install "$1" >/dev/null 2>&1 || true ;;
+ esac
+}
+
+install_server_native() {
+ need_root
+ check_arch
+ say ""
+ say " transtation $VERSION -- native install, no container runtime"
+ say " https://github.com/sqzer-x/transtation"
+ say ""
+ step host "$(os_id) / $(uname -m) / root"
+ [ -d /run/systemd/system ] || die "the native install is managed by systemd, which is not running here.
+ Use the container install instead: sh install.sh"
+ command -v curl >/dev/null 2>&1 || die "curl is required"
+
+ fetch_verified_xray
+ install -Dm0755 "$(server_script)" /usr/local/lib/transtation/server
+
+ id -u transtation >/dev/null 2>&1 || useradd --system --no-create-home --shell /usr/sbin/nologin transtation 2>/dev/null ||
+ adduser -S -D -H -s /sbin/nologin transtation 2>/dev/null || true
+ install -d -o transtation -g transtation -m 0700 /var/lib/transtation
+ mkdir -p /etc/transtation
+ [ -e /etc/transtation/server.env ] || { : >/etc/transtation/server.env; chmod 600 /etc/transtation/server.env; }
+ [ -n "${PORT:-}" ] && ! grep -q '^PORT=' /etc/transtation/server.env &&
+ printf 'PORT=%s\n' "$PORT" >>/etc/transtation/server.env
+ # 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.
+ 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 /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 /usr/local/lib/transtation/server "$@"
+ fi
+ exec su -s /bin/sh transtation -c 'exec env TT_DATA=/var/lib/transtation /usr/local/lib/transtation/server "$@"' -- sh "$@"
+ WRAP
+ step config "wrote /etc/transtation/server.env, data in /var/lib/transtation"
+
+ cat >/etc/systemd/system/transtation.service <<-'EOF'
+ [Unit]
+ Description=transtation server
+ After=network-online.target
+ Wants=network-online.target
+
+ [Service]
+ Type=simple
+ User=transtation
+ Group=transtation
+ Environment=TT_DATA=/var/lib/transtation
+ EnvironmentFile=-/etc/transtation/server.env
+ ExecStart=/usr/local/lib/transtation/server run
+ Restart=on-failure
+ RestartSec=3s
+ # The container needs no capability because dockerd performs the
+ # privileged bind. Here nothing is in front of us, so we keep exactly
+ # one -- and nothing else is even reachable.
+ AmbientCapabilities=CAP_NET_BIND_SERVICE
+ CapabilityBoundingSet=CAP_NET_BIND_SERVICE
+ NoNewPrivileges=true
+ ProtectSystem=strict
+ ProtectHome=true
+ PrivateTmp=true
+ PrivateDevices=true
+ ProtectKernelTunables=true
+ ProtectControlGroups=true
+ RestrictSUIDSGID=true
+ ReadWritePaths=/var/lib/transtation
+
+ [Install]
+ WantedBy=multi-user.target
+ EOF
+ systemctl daemon-reload
+ printf ' %-14s' start
+ systemctl enable --now transtation.service >/dev/null 2>&1 || true
+ _i=0
+ while [ "$_i" -lt 40 ]; do
+ /usr/local/bin/transtation health >/dev/null 2>&1 && break
+ _i=$((_i + 1)); printf '.'; sleep 3
+ done
+ systemctl is-active --quiet transtation || {
+ say "FAILED"
+ say " journalctl -u transtation -n 40"
+ exit 1
+ }
+ say "running"
+
+ printf ' %-14s' verify
+ /usr/local/bin/transtation verify || {
+ say ""
+ say " The server is running but clients cannot complete the REALITY handshake."
+ say " echo 'SNI=dl.google.com' >> /etc/transtation/server.env"
+ say " systemctl restart transtation && transtation verify"
+ exit 1
+ }
+ say ""
+ /usr/local/bin/transtation status || true
+ cat <<-EOF
+ DO THIS NOW -- it is the only irreplaceable thing on this box:
+ transtation backup
+
+ Your link: transtation show
+ Add a user: transtation user add alice
+ Logs: journalctl -u transtation -f
+ Uninstall: sh install.sh --uninstall
+
+ >> If a client cannot connect, open the port in your provider's firewall
+ or security group. The healthcheck only proves OUTBOUND traffic works.
+
+ EOF
+}
+
+server_script() {
+ _d=$(dirname -- "$0" 2>/dev/null || echo .)
+ if [ -r "$_d/server/transtation" ]; then
+ printf '%s' "$_d/server/transtation"
+ return 0
+ fi
+ _f=$(mktemp)
+ curl -fsSL "$RAW/server/transtation" -o "$_f" || die "could not fetch the server script from $RAW"
+ printf '%s' "$_f"
+}
+
+# ------------------------------------------------------ server (container)
install_server() {
need_root
@@ -515,6 +682,19 @@ uninstall() {
*) say "volume kept: 'docker volume ls | grep transtation-data'" ;;
esac
fi
+ if [ -e /etc/systemd/system/transtation.service ]; then
+ systemctl disable --now transtation.service >/dev/null 2>&1 || true
+ rm -f /etc/systemd/system/transtation.service
+ systemctl daemon-reload 2>/dev/null || true
+ rm -rf /usr/local/bin/transtation /usr/local/lib/transtation /usr/local/bin/xray /usr/local/bin/wgcf
+ say "native server removed. Its data is still in /var/lib/transtation."
+ ask 'also delete /var/lib/transtation? That destroys your Reality key and every issued link. [y/N] '
+ say ""
+ case "$REPLY" in
+ y | Y) rm -rf /var/lib/transtation; say "data deleted." ;;
+ *) say "data kept in /var/lib/transtation" ;;
+ esac
+ fi
if [ -e /etc/systemd/system/transtation-client.service ]; then
systemctl disable --now transtation-client.service >/dev/null 2>&1 || true
rm -f /etc/systemd/system/transtation-client.service
@@ -535,7 +715,14 @@ main() {
_cmd=${1:-server}
if [ $# -gt 0 ]; then shift; fi
case "$_cmd" in
- server) install_server ;;
+ server)
+ case "${1:-}" in
+ --native) install_server_native ;;
+ '') install_server ;;
+ *) die "unknown option: $1 (expected --native)" ;;
+ esac
+ ;;
+ --native) install_server_native ;;
client) install_client "$@" ;;
--uninstall | uninstall) uninstall ;;
-h | --help)
diff --git a/server/Dockerfile b/server/Dockerfile
@@ -55,6 +55,7 @@ COPY NOTICE /NOTICE
# CIDR list replaces. Any "geoip:" or "geosite:" routing rule will fail at
# startup with file-not-found.
+ENV TT_MANAGED=docker
USER xray
VOLUME /data
EXPOSE 8443
diff --git a/server/transtation b/server/transtation
@@ -16,9 +16,26 @@ HEALTH_PORT=10808
WARP_PEER_PUBKEY='bmXOC+F1FxEMF9dyiK2H5/1SUtzH0JuVo51h2wPfgyo='
WARP_ENDPOINT_DEFAULT='162.159.192.1:2408'
TAB=$(printf '\t')
+# How this instance is supervised. The image sets TT_MANAGED=docker; a native
+# install leaves it unset and is managed by systemd. It decides how the server
+# is restarted and which file the operator edits -- and, critically, that
+# `kill -TERM 1` is only ever right inside a container. On a host, PID 1 is
+# init.
+TT_MANAGED=${TT_MANAGED:-systemd}
+restart_hint() { [ "$TT_MANAGED" = docker ] && printf 'docker compose up -d' || printf 'systemctl restart transtation'; }
+env_hint() { [ "$TT_MANAGED" = docker ] && printf '/opt/transtation/.env' || printf '/etc/transtation/server.env'; }
set_paths() {
- DATA=${TT_DATA:-/data}
+ # The container mounts its volume at /data; a native install keeps state
+ # where a system service should. Getting this wrong sends `transtation show`
+ # looking at an empty directory.
+ if [ -n "${TT_DATA:-}" ]; then
+ DATA=$TT_DATA
+ elif [ "$TT_MANAGED" = docker ]; then
+ DATA=/data
+ else
+ DATA=/var/lib/transtation
+ fi
STATE="$DATA/state.env"
USERS="$DATA/users.tsv"
CONF="$DATA/config.json"
@@ -154,7 +171,7 @@ warp_register() {
Cloudflare rate-limits registration per source IP for roughly 15 minutes.
Continuing with DIRECT egress from this VPS's own IP -- the proxy works.
retry: transtation warp register
- turn off for good: set WARP=0 in your .env and 'docker compose up -d'
+ turn off for good: set WARP=0 in $(env_hint) and '$(restart_hint)'
Any WARP identity you already had is untouched."
return 1
}
@@ -213,7 +230,7 @@ render_config() {
{
"tag": "reality-in",
"listen": "0.0.0.0",
- "port": $INTERNAL_PORT,
+ "port": ${LISTEN_PORT:-$INTERNAL_PORT},
"protocol": "vless",
"settings": {
"clients": [
@@ -276,7 +293,9 @@ render_config() {
write_config() {
_t=$(mktemp "$DATA/.config.XXXXXX")
render_config >"$_t"
- jq -e . "$_t" >/dev/null 2>&1 || { rm -f "$_t"; die "generated config is not valid JSON -- this is a transtation bug"; }
+ if command -v jq >/dev/null 2>&1; then
+ jq -e . "$_t" >/dev/null 2>&1 || { rm -f "$_t"; die "generated config is not valid JSON -- this is a transtation bug"; }
+ fi
# -format json is required, not optional: xray infers the format from the
# file extension, and this is a mktemp name with no .json suffix. Without it
# every validation fails with "Failed to get format of ..." and the server
@@ -296,20 +315,21 @@ write_runtime() {
# Derived, rewritten every boot. `show`/`status`/`health` run as separate
# `docker exec` processes and would otherwise not see an auto-detected HOST.
_t=$(mktemp "$DATA/.runtime.XXXXXX")
- printf 'HOST=%s\nPORT=%s\nSNI=%s\nDEST=%s\nWARP=%s\nWARP_READY=%s\nSNI_REASON=%s\n' \
- "$HOST" "$PORT" "$SNI" "$DEST" "${WARP:-1}" "$(warp_ready && echo 1 || echo 0)" \
- "${SNI_REASON:-unknown}" >"$_t"
+ printf 'HOST=%s\nPORT=%s\nLISTEN_PORT=%s\nSNI=%s\nDEST=%s\nWARP=%s\nWARP_READY=%s\nSNI_REASON=%s\n' \
+ "$HOST" "$PORT" "${LISTEN_PORT:-$INTERNAL_PORT}" "$SNI" "$DEST" "${WARP:-1}" \
+ "$(warp_ready && echo 1 || echo 0)" "${SNI_REASON:-unknown}" >"$_t"
chmod 600 "$_t"
mv -f "$_t" "$RUNTIME"
}
load_runtime() {
- [ -f "$RUNTIME" ] || die "server has not started yet -- run 'docker compose up -d' first"
- HOST=""; PORT=""; SNI=""; DEST=""; WARP=""; WARP_READY=""; SNI_REASON=""
+ [ -f "$RUNTIME" ] || die "server has not started yet -- run '$(restart_hint)' first"
+ HOST=""; PORT=""; LISTEN_PORT=""; SNI=""; DEST=""; WARP=""; WARP_READY=""; SNI_REASON=""
while IFS='=' read -r _k _v; do
case "$_k" in
HOST) HOST=$_v ;;
PORT) PORT=$_v ;;
+ LISTEN_PORT) LISTEN_PORT=$_v ;;
SNI) SNI=$_v ;;
DEST) DEST=$_v ;;
WARP) WARP=$_v ;;
@@ -349,17 +369,24 @@ apply() {
DEST=${_dest_env:-$SNI:443}
write_config
write_runtime
- if [ -r /proc/1/comm ] && [ "$(cat /proc/1/comm)" = xray ]; then
- # Xray has no reload signal and no config watcher, so the file is the
- # source of truth and the only way to apply it is to restart. Stopping
- # PID 1 stops the container; Docker's restart policy brings it back.
- echo "applying: stopping xray so your restart policy brings it back up."
+ # Xray has no reload signal and no config watcher, so the file is the source
+ # of truth and the only way to apply it is to restart.
+ if [ "$TT_MANAGED" = docker ]; then
+ if [ -r /proc/1/comm ] && [ "$(cat /proc/1/comm)" = xray ]; then
+ echo "applying: stopping xray so your restart policy brings it back up."
+ echo " existing connections drop and clients reconnect on their own."
+ printf ' If you started this container WITHOUT a restart policy, start it again:\n'
+ printf ' docker start %s\n' "$(hostname)"
+ kill -TERM 1
+ else
+ echo "written; takes effect the next time the container starts"
+ fi
+ elif command -v systemctl >/dev/null 2>&1 && systemctl is-active --quiet transtation 2>/dev/null; then
+ echo "applying: restarting transtation.service"
echo " existing connections drop and clients reconnect on their own."
- printf ' If you started this container WITHOUT a restart policy, start it again:\n'
- printf ' docker start %s\n' "$(hostname)"
- kill -TERM 1
+ systemctl restart transtation
else
- echo "written; takes effect the next time the container starts"
+ echo "written; takes effect the next time the server starts"
fi
}
@@ -657,7 +684,7 @@ cmd_verify() {
"settings": { "auth": "noauth", "udp": false } }],
"outbounds": [{
"protocol": "vless",
- "settings": { "vnext": [{ "address": "127.0.0.1", "port": $INTERNAL_PORT,
+ "settings": { "vnext": [{ "address": "127.0.0.1", "port": ${LISTEN_PORT:-$INTERNAL_PORT},
"users": [{ "id": "$_u", "encryption": "none", "flow": "xtls-rprx-vision" }] }] },
"streamSettings": { "network": "tcp", "security": "reality",
"realitySettings": { "fingerprint": "chrome", "serverName": "$SNI",
@@ -694,7 +721,7 @@ cmd_verify() {
complete the handshake:
xray tls ping $SNI
Then pick a smaller one (dl.google.com, www.cloudflare.com, addons.mozilla.org),
- set SNI= in your .env, and 'docker compose up -d'."
+ set SNI= in $(env_hint), and '$(restart_hint)'."
fi
printf 'REALITY handshake OK. Egress through the tunnel: %s\n' \
"$(echo "$_out" | grep -E '^(ip|colo|warp)=' | tr '\n' ' ')"
@@ -716,7 +743,7 @@ cmd_health() {
- the WireGuard stack itself is fine: this image pins "noKernelTun": true, so
it always uses Xray's in-process userspace stack no matter what
capabilities the container holds.
- fix: set WARP=0 in your .env and run 'docker compose up -d'. The proxy works
+ fix: set WARP=0 in $(env_hint) and run '$(restart_hint)'. The proxy works
without WARP; it just egresses from this VPS's own IP.
EOF
exit 1
@@ -743,10 +770,16 @@ cmd_status() {
printf ' egress UNREACHABLE -- no traffic is getting out through the proxy\n'
fi
- if [ -r /proc/1/comm ] && [ "$(cat /proc/1/comm)" = xray ]; then
- printf ' xray running\n'
+ if [ "$TT_MANAGED" = docker ]; then
+ if [ -r /proc/1/comm ] && [ "$(cat /proc/1/comm)" = xray ]; then
+ printf ' xray running\n'
+ else
+ printf ' xray NOT running as PID 1 of this container\n'
+ fi
+ elif command -v systemctl >/dev/null 2>&1 && systemctl is-active --quiet transtation 2>/dev/null; then
+ printf ' xray running under transtation.service\n'
else
- printf ' xray NOT running as PID 1\n'
+ printf ' xray NOT running\n'
fi
printf ' users %s\n' "$(awk -F'\t' 'NF>=3{printf "%s%s", (n++?", ":""), $1}' "$USERS" 2>/dev/null)"
printf ' inbound not verifiable from this host. If clients time out with no server\n'
@@ -772,7 +805,7 @@ cmd_reset() {
[ "${I_UNDERSTAND_THIS_DESTROYS_MY_KEYS:-}" = yes ] || die \
"refusing. This destroys your Reality keypair and every issued link.
If you are certain:
- docker compose run --rm -e I_UNDERSTAND_THIS_DESTROYS_MY_KEYS=yes proxy reset"
+ I_UNDERSTAND_THIS_DESTROYS_MY_KEYS=yes transtation reset"
rm -f "$STATE" "$USERS" "$CONF" "$RUNTIME" "$ACCOUNT" "$PROFILE" "$SNIFILE"
echo "identity destroyed. The next start provisions a brand new server."
}
@@ -952,6 +985,10 @@ validate_config_env() {
PORT=${PORT:-443}
printf '%s' "$PORT" | grep -Eq '^[0-9]{1,5}$' && [ "$PORT" -ge 1 ] && [ "$PORT" -le 65535 ] ||
die "PORT must be 1-65535, got: $(q "$PORT")"
+ # In a container we listen high and let dockerd perform the privileged bind,
+ # which is why the image needs no capability at all. Natively there is
+ # nothing in front of us, so we listen on the real port.
+ [ "$TT_MANAGED" = docker ] && LISTEN_PORT=$INTERNAL_PORT || LISTEN_PORT=$PORT
printf '%s' "${MIN_CLIENT_VER:-0.0.0}" | grep -Eq '^[0-9]{1,3}(\.[0-9]{1,3}){0,2}$' ||
die "MIN_CLIENT_VER must look like 0.0.0, got: $(q "${MIN_CLIENT_VER:-}")"
# Deliberately IPv4:port only, never a hostname: resolving the WARP endpoint
@@ -963,7 +1000,7 @@ validate_config_env() {
# --------------------------------------------------------------------- run
banner() {
- printf '\n transtation -- listening on 0.0.0.0:%s inside the container\n' "$INTERNAL_PORT"
+ printf '\n transtation -- listening on 0.0.0.0:%s\n' "${LISTEN_PORT:-$INTERNAL_PORT}"
printf ' server %s:%s\n' "$HOST" "$PORT"
printf ' dest / SNI %s (%s)\n' "$SNI" "${SNI_REASON:-unknown}"
if [ "${WARP:-1}" = 1 ] && ! warp_ready; then
@@ -1038,7 +1075,7 @@ cmd_run() {
[ -s "$USERS" ] || user_add "${USER_NAME:-main}"
# --- collect the WARP registration started above ---------------------
- [ -z "$_warp_pid" ] || wait "$_warp_pid" || true
+ [ -z "$_warp_pid" ] || wait "$_warp_pid" 2>/dev/null || true
write_config
write_runtime
diff --git a/test.sh b/test.sh
@@ -130,6 +130,11 @@ posix_sh_ok() {
}
check "install.sh survives dash (Debian, Ubuntu) with no arguments" posix_sh_ok debian:stable-slim dash
check "install.sh survives busybox ash (Alpine) with no arguments" posix_sh_ok alpine:3.23 sh
+posix_sh_native_ok() {
+ _o=$(docker run --rm -v "$PWD":/w:ro -u 65534 "$1" sh -c "cd /w && $2 install.sh server --native" 2>&1) || true
+ case "$_o" in *"needs root"*) return 0 ;; *) return 1 ;; esac
+}
+check "the native server path dispatches under dash" posix_sh_native_ok debian:stable-slim dash
echo
echo "== client renders configs the real sing-box accepts =="
|