#!/usr/bin/env bash # QUIC plane: install sing-box and serve whichever of Hysteria2 / TUIC has ports # configured in config.env, each with a direct and a WARP-clean-exit inbound. # # Additive by design -- safe to run on a node already serving the six TCP inbounds: # * sing-box is its own service on its own UDP ports; 3x-ui / Xray / nginx are # never touched, and nothing here writes to 3x-ui's database or nginx's config. # * The one shared resource is the acme.sh wildcard cert. sing-box reads it at # startup only, so the renew hook is EXTENDED (nginx reload + sing-box restart) # rather than replaced -- otherwise the QUIC plane quietly serves an expired # cert about 60 days later. # # Ports: set PORT_HY2_DIRECT / PORT_HY2_WARP and/or PORT_TUIC_DIRECT / PORT_TUIC_WARP. # Unset pairs are skipped. Putting a Hysteria2 inbound on UDP 443 is deliberate: it # reads as ordinary HTTP/3 traffic to that host, and it does not collide with nginx, # which holds TCP 443 (different protocol, different socket). # # Remember the firewall has two layers on some providers: this script opens the UDP # ports in ufw, but a cloud security group (AWS/GCP/Azure) must allow them too. . "$(dirname "$0")/_lib.sh" HERE="$(cd "$(dirname "$0")" && pwd)" require_vars NODE_PASSWORD SUB_COVER DOMAIN_ROOT [ -n "${PORT_HY2_DIRECT:-}${PORT_HY2_WARP:-}${PORT_TUIC_DIRECT:-}${PORT_TUIC_WARP:-}" ] || { echo "ERROR: no QUIC ports set in config.env (PORT_HY2_* / PORT_TUIC_*)" >&2; exit 1; } # TUIC authenticates with uuid+password, so it needs XUI_UUID. if [ -n "${PORT_TUIC_DIRECT:-}${PORT_TUIC_WARP:-}" ]; then require_vars XUI_UUID; fi COVER="$SUB_COVER.$DOMAIN_ROOT" CERTDIR="/etc/nginx/ssl/$DOMAIN_ROOT" echo "== install sing-box ==" run 'if ! command -v sing-box >/dev/null 2>&1; then export DEBIAN_FRONTEND=noninteractive for i in $(seq 1 60); do fuser /var/lib/dpkg/lock-frontend >/dev/null 2>&1 || break; sleep 10; done mkdir -p /etc/apt/keyrings curl -fsSL https://sing-box.app/gpg.key -o /etc/apt/keyrings/sagernet.asc chmod a+r /etc/apt/keyrings/sagernet.asc echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/sagernet.asc] https://deb.sagernet.org/ * *" > /etc/apt/sources.list.d/sagernet.list apt-get update -qq apt-get install -y -qq sing-box >/dev/null || { echo "ERROR: sing-box install failed"; exit 1; } fi sing-box version | head -1' echo "== verify the cert the QUIC plane will serve ==" run "[ -s '$CERTDIR/fullchain.pem' ] && [ -s '$CERTDIR/privkey.pem' ] || { echo 'ERROR: wildcard cert missing at $CERTDIR — run 03-sign-*-cert.sh first'; exit 1; } echo cert-present" echo "== generate config ==" push "$HERE/13-singbox-config.py" /root/_singbox_config.py run "COVER='$COVER' CERT_DIR='$CERTDIR' WARP_SOCKS_PORT='${WARP_SOCKS_PORT:-40000}' \ NODE_PASSWORD='$NODE_PASSWORD' XUI_UUID='${XUI_UUID:-}' \ PORT_HY2_DIRECT='${PORT_HY2_DIRECT:-}' PORT_HY2_WARP='${PORT_HY2_WARP:-}' \ HY2_OBFS_PASSWORD='${HY2_OBFS_PASSWORD:-}' \ HY2_UP_MBPS='${HY2_UP_MBPS:-500}' HY2_DOWN_MBPS='${HY2_DOWN_MBPS:-500}' \ PORT_TUIC_DIRECT='${PORT_TUIC_DIRECT:-}' PORT_TUIC_WARP='${PORT_TUIC_WARP:-}' \ TUIC_CC='${TUIC_CC:-bbr}' \ python3 /root/_singbox_config.py rm -f /root/_singbox_config.py sing-box check -c /etc/sing-box/config.json && echo config-valid" echo "== open the UDP ports in ufw ==" UDP_PORTS="${PORT_HY2_DIRECT:-} ${PORT_HY2_WARP:-} ${PORT_TUIC_DIRECT:-} ${PORT_TUIC_WARP:-}" run "for p in $UDP_PORTS; do [ -n \"\$p\" ] && ufw allow \$p/udp >/dev/null 2>&1; done ufw status | grep -E '/udp' || echo 'no udp rules (is ufw active?)'" echo "== enable + start ==" run 'systemctl enable sing-box >/dev/null 2>&1 || true systemctl restart sing-box sleep 4 systemctl is-active sing-box ss -ulnp | grep sing-box || echo "WARN: sing-box has no UDP sockets"' echo "== extend the cert-renew hook to restart sing-box ==" # Resolve acme.sh on the box rather than writing `~/.acme.sh`: on a sudo-user login # the tilde expands in the *login* shell (HOME=/home/) before sudo runs, so a # root-installed acme.sh is missed and the hook silently never gets updated. run "ACME=\$(for c in /root/.acme.sh/acme.sh \"\$HOME/.acme.sh/acme.sh\"; do [ -x \"\$c\" ] && echo \"\$c\" && break; done) [ -n \"\$ACME\" ] || { echo 'ERROR: acme.sh not found — cannot update the renew hook'; exit 1; } echo \"using \$ACME\" \$ACME --install-cert -d '$DOMAIN_ROOT' --ecc \ --key-file '$CERTDIR/privkey.pem' \ --fullchain-file '$CERTDIR/fullchain.pem' \ --reloadcmd 'systemctl reload nginx; systemctl restart sing-box' 2>&1 | tail -2" echo echo "NOTE: on AWS/GCP/Azure the cloud security group must also allow inbound UDP:$UDP_PORTS"