Files
telebit/telebitctl
2026-06-26 14:01:58 +02:00

320 lines
7.7 KiB
Bash
Executable File

#!/bin/sh
set -eu
ROOT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
SERVER_ENV="${TELEBIT_SERVER_ENV:-$ROOT_DIR/.env}"
CLIENT_ENV="${TELEBIT_CLIENT_ENV:-$ROOT_DIR/telebit-client.env}"
CLIENT_ENV_DIR="${TELEBIT_CLIENT_ENV_DIR:-$ROOT_DIR/.telebit-clients}"
TELEBIT_BIN="${TELEBIT_BIN:-$ROOT_DIR/bin/telebit}"
SIGNJWT_BIN="${SIGNJWT_BIN:-$ROOT_DIR/bin/signjwt}"
TELEBIT_RETRY_DELAY="${TELEBIT_RETRY_DELAY:-5}"
TELEBIT_RETRY_MAX="${TELEBIT_RETRY_MAX:-0}"
usage() {
cat <<'USAGE'
Usage:
telebitctl build [os] [arch]
telebitctl bind <slug>
telebitctl release <slug>
telebitctl auth refresh [slug]
telebitctl up <slug> <local-port>
Examples:
./telebitctl build
./telebitctl bind test
./telebitctl up test 80
./telebitctl auth refresh test
./telebitctl release test
USAGE
}
die() {
printf '%s\n' "$*" >&2
exit 1
}
load_server_env() {
[ -f "$SERVER_ENV" ] || die "Missing $SERVER_ENV. Copy .env.example to .env and set DOMAIN, TUNNEL_DOMAIN, and SECRET."
set -a
# shellcheck disable=SC1090
. "$SERVER_ENV"
set +a
: "${DOMAIN:?DOMAIN is required in $SERVER_ENV}"
: "${TUNNEL_DOMAIN:?TUNNEL_DOMAIN is required in $SERVER_ENV}"
: "${SECRET:?SECRET is required in $SERVER_ENV}"
VENDOR_ID="${VENDOR_ID:-renman-dev}"
MGMT_URL="${MGMT_URL:-https://$TUNNEL_DOMAIN/api}"
}
need_bin() {
[ -x "$1" ] || die "Missing executable $1. Run: ./telebitctl build"
}
admin_token() {
need_bin "$SIGNJWT_BIN"
"$SIGNJWT_BIN" \
--expires-in 5m \
--vendor-id "$VENDOR_ID" \
--secret "$SECRET" \
--machine-ppid "$SECRET" | awk '/^eyJ/ {print; exit}'
}
current_slug() {
[ -f "$CLIENT_ENV" ] || return 1
sed -n "s#^LOCALS=https:\\([^:]*\\):.*#\\1#p" "$CLIENT_ENV" | sed "s#\\.$DOMAIN\$##" | sed -n '1p'
}
current_port() {
[ -f "$CLIENT_ENV" ] || return 1
sed -n 's#^LOCALS=https:[^:]*:\([0-9][0-9]*\).*#\1#p' "$CLIENT_ENV" | sed -n '1p'
}
safe_slug() {
case "$1" in
""|*/*|*..*|.*|*-|-*|*[^A-Za-z0-9-]*)
die "Invalid slug: $1"
;;
esac
}
client_env_path() {
slug="$1"
safe_slug "$slug"
printf '%s/%s.env\n' "$CLIENT_ENV_DIR" "$slug"
}
env_port() {
env_file="$1"
[ -f "$env_file" ] || return 1
sed -n 's#^LOCALS=https:[^:]*:\([0-9][0-9]*\).*#\1#p' "$env_file" | sed -n '1p'
}
write_client_env() {
slug="$1"
shared_key="$2"
port="${3:-}"
env_file="${4:-$(client_env_path "$slug")}"
port="${port:-$(env_port "$env_file" || current_port || true)}"
port="${port:-80}"
mkdir -p "$(dirname "$env_file")"
cat > "$env_file" <<EOF
TUNNEL_RELAY_URL=wss://$TUNNEL_DOMAIN/ws
AUTH_URL=https://$TUNNEL_DOMAIN/api
ACME_HTTP_01_RELAY_URL=https://$TUNNEL_DOMAIN/api/acme-relay
ACME_RELAY_URL=https://$TUNNEL_DOMAIN/api/acme-relay
ACME_AGREE=true
VENDOR_ID=$VENDOR_ID
SECRET=$shared_key
LOCALS=https:$slug.$DOMAIN:$port
EOF
cp "$env_file" "$CLIENT_ENV"
printf 'Updated %s\n' "$env_file"
}
client_key() {
env_file="${1:-$CLIENT_ENV}"
[ -f "$env_file" ] || return 1
sed -n 's/^SECRET=//p' "$env_file" | sed -n '1p'
}
create_device() {
slug="$1"
token="$2"
response=$(curl -fsS -X POST "$MGMT_URL/devices" \
-H "Authorization: Bearer $token" \
-H "Content-Type: application/json" \
-d "{ \"slug\": \"$slug\" }")
shared_key=$(printf '%s\n' "$response" | sed -n 's/.*"shared_key":"\([^"]*\)".*/\1/p')
[ -n "$shared_key" ] || die "Could not parse shared_key from response: $response"
printf '%s\n' "$shared_key"
}
bind_slug() {
load_server_env
slug="${1:?bind requires a slug}"
env_file=$(client_env_path "$slug")
token=$(admin_token)
shared_key=$(create_device "$slug" "$token")
write_client_env "$slug" "$shared_key" "$(env_port "$env_file" || current_port || true)" "$env_file"
printf 'Bound %s.%s\n' "$slug" "$DOMAIN"
}
release_slug() {
load_server_env
slug="${1:?release requires a slug}"
env_file=$(client_env_path "$slug")
token=$(admin_token)
delete_device "$slug" "$token" true
rm -f "$env_file"
if [ "$(current_slug || true)" = "$slug" ]; then
rm -f "$CLIENT_ENV"
fi
}
delete_device() {
slug="$1"
token="$2"
strict="${3:-false}"
tmp=$(mktemp)
code=$(curl -sS -o "$tmp" -w '%{http_code}' -X DELETE "$MGMT_URL/devices/$slug" \
-H "Authorization: Bearer $token")
body=$(cat "$tmp")
rm -f "$tmp"
case "$code:$body" in
200:*|500:*E_NOT_FOUND*)
printf 'Released %s.%s\n' "$slug" "$DOMAIN"
;;
*)
[ "$strict" = "true" ] || return 0
printf '%s\n' "$body" >&2
die "Release failed with HTTP $code"
;;
esac
}
refresh_auth() {
load_server_env
slug="${1:-$(current_slug || true)}"
slug="${slug:-test}"
env_file=$(client_env_path "$slug")
token=$(admin_token)
delete_device "$slug" "$token" false >/dev/null 2>&1 || true
shared_key=$(create_device "$slug" "$token")
write_client_env "$slug" "$shared_key" "$(env_port "$env_file" || current_port || true)" "$env_file"
printf 'Bound %s.%s\n' "$slug" "$DOMAIN"
}
run_client_with_retries() {
env_file="$1"
attempts=0
stop_requested=false
child_pid=""
case "$TELEBIT_RETRY_DELAY" in
""|*[!0-9]*) die "TELEBIT_RETRY_DELAY must be a non-negative integer" ;;
esac
case "$TELEBIT_RETRY_MAX" in
""|*[!0-9]*) die "TELEBIT_RETRY_MAX must be a non-negative integer" ;;
esac
trap 'stop_requested=true; if [ -n "$child_pid" ]; then kill "$child_pid" 2>/dev/null || true; fi' INT TERM
while :; do
"$TELEBIT_BIN" --env "$env_file" &
child_pid=$!
if wait "$child_pid"; then
status=0
else
status=$?
fi
child_pid=""
if [ "$stop_requested" = "true" ]; then
exit "$status"
fi
attempts=$((attempts + 1))
if [ "$TELEBIT_RETRY_MAX" != "0" ] && [ "$attempts" -ge "$TELEBIT_RETRY_MAX" ]; then
printf 'Telebit exited with status %s after %s attempt(s); not retrying.\n' "$status" "$attempts" >&2
exit "$status"
fi
printf 'Telebit exited with status %s; retrying in %ss.\n' "$status" "$TELEBIT_RETRY_DELAY" >&2
sleep "$TELEBIT_RETRY_DELAY" || exit "$?"
done
}
up_client() {
load_server_env
need_bin "$TELEBIT_BIN"
slug="${1:?up requires a slug}"
port="${2:?up requires a local port}"
env_file=$(client_env_path "$slug")
key="$(client_key "$env_file" || true)"
if [ -z "$key" ]; then
token=$(admin_token)
key=$(create_device "$slug" "$token")
fi
write_client_env "$slug" "$key" "$port" "$env_file" >/dev/null
unset SECRET DOMAIN TUNNEL_DOMAIN MGMT_URL POSTGRES_PASSWORD TELEBIT_PORT MGMT_PORT VENDOR_ID
cd "$HOME"
run_client_with_retries "$env_file"
}
build_client() {
os="${1:-}"
arch="${2:-}"
if [ -z "$os" ]; then
case "$(uname -s)" in
Darwin) os=darwin ;;
Linux) os=linux ;;
*) die "Unsupported OS. Pass os and arch explicitly." ;;
esac
fi
if [ -z "$arch" ]; then
case "$(uname -m)" in
arm64|aarch64) arch=arm64 ;;
x86_64|amd64) arch=amd64 ;;
*) die "Unsupported arch. Pass os and arch explicitly." ;;
esac
fi
mkdir -p "$ROOT_DIR/bin"
docker build \
-f "$ROOT_DIR/Dockerfile.client" \
--build-arg "TELEBIT_REF=${TELEBIT_REF:-master}" \
--build-arg "TARGETOS=$os" \
--build-arg "TARGETARCH=$arch" \
--output "type=local,dest=$ROOT_DIR/bin" \
"$ROOT_DIR"
chmod +x "$ROOT_DIR/bin/telebit" "$ROOT_DIR/bin/signjwt"
printf 'Built bin/telebit and bin/signjwt for %s/%s\n' "$os" "$arch"
}
cmd="${1:-}"
case "$cmd" in
build)
shift
build_client "$@"
;;
bind)
shift
bind_slug "$@"
;;
release)
shift
release_slug "$@"
;;
auth)
shift
sub="${1:-}"
[ "$sub" = "refresh" ] || die "Unknown auth command: ${sub:-}. Expected: auth refresh [slug]"
shift || true
refresh_auth "$@"
;;
up)
shift
up_client "$@"
;;
-h|--help|help|"")
usage
;;
*)
usage
exit 1
;;
esac