#!/bin/sh # # Gray self-host bootstrap — the OUTER layer behind `curl -fsSL # https://get.layergray.com | sudo sh` (lane 84). # # This script owns the network/OS layer and then hands off: # 1. installs Tailscale via Tailscale's official installer when absent # (skipped cleanly if present, or with GRAY_SKIP_TAILSCALE=1), # 2. downloads the Gray server artifact + verifies its SHA-256 checksum, # 3. unpacks it to /opt/gray/src (one rolling .prev kept for rollback), # 4. runs the INNER installer selfhost/install.sh from the artifact, # forwarding every argument (e.g. --entitlement-pubkey, --port). # # POSIX sh; idempotent — re-running upgrades the source tree and re-runs the # (itself idempotent) inner installer. Nothing here is piped to a shell # except Tailscale's own documented install pattern; the Gray artifact is # checksum-verified before a single byte of it executes. # # curl -fsSL https://get.layergray.com | sudo sh # curl -fsSL https://get.layergray.com | sudo sh -s -- --entitlement-pubkey # curl -fsSL https://get.layergray.com | sudo sh -s -- --uninstall # curl -fsSL https://get.layergray.com | sudo sh -s -- --uninstall --purge-data --yes # set -eu BASE_URL="${GRAY_INSTALL_BASE:-https://get.layergray.com}" SRC_DIR="${GRAY_SRC_DIR:-/opt/gray/src}" PREFIX="/opt/gray" ENV_FILE="/etc/gray/server.env" VAULT_DIR="/var/lib/voice-brain" ARTIFACT="gray-selfhost.tgz" GRAY_SERVER_PORT="8848" GRAY_SERVER_UNIT="gray-server" TAILSCALE_HTTPS_PORT="${GRAY_TAILSCALE_HTTPS_PORT:-8443}" # How long to wait for the user to finish the Tailscale browser login before # falling back to the resume command. `tailscale up` blocks FOREVER by # default, and this script is run from CI and provisioning shells where that # is strictly worse than exiting with instructions. TAILSCALE_LOGIN_TIMEOUT="${GRAY_TAILSCALE_LOGIN_TIMEOUT:-10m}" # Written when setup cannot finish on its own. It FINISHES THE JOB — signs in, # publishes the address, prints address + box code — because a command that # only does half the job is the same dead end as a re-run instruction. GRAY_RESUME_CMD="/usr/local/bin/gray-finish-setup" UNINSTALL=0 PURGE_DATA=0 YES=0 ARCHIVE_DIR="" is_utf8_locale() { case "${LC_ALL:-${LC_CTYPE:-${LANG:-}}}" in *UTF-8*|*utf8*|*UTF8*) return 0 ;; *) return 1 ;; esac } if [ -t 1 ] && [ -z "${NO_COLOR:-}" ] && [ "${TERM:-}" != "dumb" ] && [ -z "${CI:-}" ]; then C_BOLD="$(printf '\033[1m')" C_RESET="$(printf '\033[0m')" C_OK="$(printf '\033[38;5;82m')" C_WARN="$(printf '\033[38;5;220m')" C_ERR="$(printf '\033[38;5;203m')" C_CYAN="$(printf '\033[38;5;51m')" C_DIM="$(printf '\033[2m')" else C_BOLD="" C_RESET="" C_OK="" C_WARN="" C_ERR="" C_CYAN="" C_DIM="" fi if [ -t 1 ] && is_utf8_locale; then G_OK="✓" G_RUN="→" G_INFO="•" G_WARN="!" G_ERR="×" else G_OK="OK" G_RUN="->" G_INFO="*" G_WARN="!" G_ERR="ERR" fi status() { printf '%s%s%s %s\n' "$C_OK" "$G_OK" "$C_RESET" "$*"; } run() { printf '%s%s%s %s\n' "$C_CYAN" "$G_RUN" "$C_RESET" "$*"; } detail() { [ "${GRAY_INSTALL_VERBOSE:-0}" = "1" ] \ && printf ' %s%s%s %s%s%s\n' "$C_DIM" "$G_INFO" "$C_RESET" "$C_DIM" "$*" "$C_RESET" return 0 } info() { printf ' %s%s%s %s%s%s\n' "$C_DIM" "$G_INFO" "$C_RESET" "$C_DIM" "$*" "$C_RESET"; } warn() { printf '%s%s%s %s\n' "$C_WARN" "$G_WARN" "$C_RESET" "$*"; } warn_detail() { printf ' %s%s%s\n' "$C_DIM" "$*" "$C_RESET"; } fail() { printf '%s%s%s %s\n' "$C_ERR" "$G_ERR" "$C_RESET" "$*" >&2; exit 1; } section() { printf '\n%s%s%s\n' "$C_BOLD" "$1" "$C_RESET"; } plan_item() { printf ' %s %s\n' "$1" "$2" } print_intro() { printf '%s%s' "$C_BOLD" "$C_CYAN" if [ -t 1 ] && is_utf8_locale; then cat <<'EOF' ██████╗ ██████╗ █████╗ ██╗ ██╗ ██╔════╝ ██╔══██╗██╔══██╗╚██╗ ██╔╝ ██║ ███╗██████╔╝███████║ ╚████╔╝ ██║ ██║██╔══██╗██╔══██║ ╚██╔╝ ╚██████╔╝██║ ██║██║ ██║ ██║ ╚═════╝ ╚═╝ ╚═╝╚═╝ ╚═╝ ╚═╝ EOF else cat <<'EOF' ____ ____ _ __ __ / ___| _ \ / \\ \ / / | | _| |_) |/ _ \\ V / | |_| | _ $_dest" } uninstall_serve_port_state() { _serve_port="$1" _serve_target_expected="$2" _serve_json="$(tailscale serve status --json 2>/dev/null || true)" if [ -n "$_serve_json" ] && command -v python3 >/dev/null 2>&1; then _serve_parsed="$( printf '%s\n' "$_serve_json" | python3 -c ' import json, sys port = sys.argv[1] target = sys.argv[2].rstrip("/") def normalize(value): return str(value or "").rstrip("/") def entry_port(name): tail = str(name).rsplit(":", 1)[-1] return tail if tail.isdigit() else "" try: data = json.load(sys.stdin) web = data.get("Web") or {} except Exception: sys.exit(2) state = "none" for name, entry in web.items(): if entry_port(name) != port: continue handlers = (entry or {}).get("Handlers") or {} handler = handlers.get("/") or handlers.get("") proxy = normalize((handler or {}).get("Proxy") if isinstance(handler, dict) else "") if proxy == target: state = "same" else: print("conflict") sys.exit(0) print(state) ' "$_serve_port" "$_serve_target_expected" 2>/dev/null || true )" case "$_serve_parsed" in same|conflict|none) printf '%s\n' "$_serve_parsed" return 0 ;; esac fi _serve_text="$(tailscale serve status 2>/dev/null || true)" if [ -n "$_serve_text" ] \ && printf '%s\n' "$_serve_text" | grep -F ":$_serve_port" >/dev/null 2>&1; then printf '%s\n' "conflict" return 0 fi printf '%s\n' "none" } clear_uninstall_tailscale_serve() { case "$TAILSCALE_HTTPS_PORT" in ''|*[!0-9]*) warn "GRAY_TAILSCALE_HTTPS_PORT must be numeric; Tailscale Serve left unchanged." return 0 ;; esac if ! command -v tailscale >/dev/null 2>&1; then detail "Tailscale not found; no Gray Serve endpoint to clear" return 0 fi _serve_target="http://127.0.0.1:$GRAY_SERVER_PORT" _serve_state="$(uninstall_serve_port_state "$TAILSCALE_HTTPS_PORT" "$_serve_target")" case "$_serve_state" in same) if tailscale serve --https="$TAILSCALE_HTTPS_PORT" off >/dev/null 2>&1; then status "Cleared Gray Tailscale Serve endpoint" else warn "Could not clear Tailscale Serve port $TAILSCALE_HTTPS_PORT; run: tailscale serve --https=$TAILSCALE_HTTPS_PORT off" fi ;; conflict) warn "Tailscale Serve port $TAILSCALE_HTTPS_PORT is not Gray's $_serve_target; left unchanged." ;; *) detail "No Gray Tailscale Serve mapping found on port $TAILSCALE_HTTPS_PORT" ;; esac } print_uninstall_plan() { printf '\n' printf '%sGray uninstall plan%s\n' "$C_BOLD" "$C_RESET" printf ' Stop/disable unit: %s\n' "$GRAY_SERVER_UNIT" printf ' Remove unit file: /etc/systemd/system/%s.service\n' "$GRAY_SERVER_UNIT" printf ' Archive runtime: %s/server\n' "$PREFIX" printf ' %s/venv\n' "$PREFIX" printf ' %s\n' "$SRC_DIR" printf ' %s.prev\n' "$SRC_DIR" printf ' %s/box-voice-venv\n' "$PREFIX" printf ' %s/box-voice-cache\n' "$PREFIX" if [ "$PURGE_DATA" = "1" ]; then printf ' Archive data: %s\n' "$ENV_FILE" printf ' %s\n' "$VAULT_DIR" else printf ' Keep data: %s\n' "$ENV_FILE" printf ' %s\n' "$VAULT_DIR" fi printf ' Tailscale: keep installed/signed in; clear only Gray Serve %s -> http://127.0.0.1:%s\n' "$TAILSCALE_HTTPS_PORT" "$GRAY_SERVER_PORT" printf ' Archive path: %s\n' "$ARCHIVE_PATH" printf '\n' } confirm_uninstall() { print_uninstall_plan [ "$YES" = "1" ] && return 0 if printf 'Continue? [y/N] ' 2>/dev/null >/dev/tty; then read answer 2>/dev/null /dev/null 2>&1; then systemctl stop "$GRAY_SERVER_UNIT" >/dev/null 2>&1 || warn "Could not stop $GRAY_SERVER_UNIT (continuing)." systemctl disable "$GRAY_SERVER_UNIT" >/dev/null 2>&1 || warn "Could not disable $GRAY_SERVER_UNIT (continuing)." else warn "systemctl not found; unit stop/disable skipped." fi clear_uninstall_tailscale_serve archive_move "/etc/systemd/system/$GRAY_SERVER_UNIT.service" "unit" archive_move "$PREFIX/server" "runtime" archive_move "$PREFIX/venv" "runtime" archive_move "$SRC_DIR" "runtime" archive_move "$SRC_DIR.prev" "runtime" # lane-192 box-voice engine (optional add-on; absent on non-voice boxes — # archive_move no-ops). Kokoro models + the engine .pth live inside # $PREFIX/server and $PREFIX/venv, already archived above. archive_move "$PREFIX/box-voice-venv" "runtime" archive_move "$PREFIX/box-voice-cache" "runtime" if [ "$PURGE_DATA" = "1" ]; then archive_move "$ENV_FILE" "data" archive_move "$VAULT_DIR" "data" else status "User data kept" detail "$ENV_FILE" detail "$VAULT_DIR" fi if command -v systemctl >/dev/null 2>&1; then systemctl daemon-reload >/dev/null 2>&1 || warn "Could not reload systemd (continuing)." fi uninstall_final_card } if [ "$UNINSTALL" = "1" ]; then if [ -x "$SRC_DIR/selfhost/install.sh" ] \ && grep -q -- '--uninstall' "$SRC_DIR/selfhost/install.sh" 2>/dev/null; then command -v bash >/dev/null 2>&1 || fail "bash is required to run the installed Gray uninstaller" GRAY_INSTALL_SRC_DIR="$SRC_DIR" bash "$SRC_DIR/selfhost/install.sh" "$@" else warn "Installed uninstaller not found; using built-in fallback." builtin_uninstall fi exit 0 fi command -v curl >/dev/null 2>&1 || fail "curl is required" command -v tar >/dev/null 2>&1 || fail "tar is required" command -v sha256sum >/dev/null 2>&1 || fail "sha256sum is required (coreutils)" command -v bash >/dev/null 2>&1 || fail "bash is required (the server installer uses it)" command -v systemctl >/dev/null 2>&1 || fail "systemd is required (the server runs as a unit)" # ── 1. Tailscale layer ─────────────────────────────────────────────────────── # The box pairs with the Gray app over your tailnet by default. The official # installer adds Tailscale's per-distro package repo and installs from it. if [ "${GRAY_SKIP_TAILSCALE:-0}" = "1" ]; then warn "Tailscale setup skipped" detail "GRAY_SKIP_TAILSCALE=1" elif command -v tailscale >/dev/null 2>&1; then status "Tailscale detected" else PKG="" for c in apt-get dnf yum zypper pacman apk; do if command -v "$c" >/dev/null 2>&1; then PKG="$c"; break; fi done [ -n "$PKG" ] || fail "no supported package manager found ($(uname -s)); \ install Tailscale manually (https://tailscale.com/download) or rerun with GRAY_SKIP_TAILSCALE=1" run "Installing Tailscale" detail "Using the official Tailscale installer for $PKG" curl -fsSL https://tailscale.com/install.sh | sh \ || fail "Tailscale install failed — install it manually, or rerun with GRAY_SKIP_TAILSCALE=1" status "Tailscale installed" fi # ── 1b. python venv preflight (lane 129) ───────────────────────────────────── # Stock Ubuntu 24.04 / Debian 12 cloud images ship python3 WITHOUT ensurepip, # so the inner installer's `python3 -m venv` step dies (lane 122, proven on # real VMs). This script owns the OS layer, so repair it here on apt distros # (python3-venv is the metapackage that pulls the matching python3.X-venv); # non-apt distros get a loud preflight error inside install.sh instead. if ! python3 -c 'import ensurepip' >/dev/null 2>&1; then if command -v apt-get >/dev/null 2>&1; then run "Installing Python venv support" export DEBIAN_FRONTEND=noninteractive apt-get update -qq >/dev/null 2>&1 || true apt-get install -y python3-venv >/dev/null \ || apt-get install -y "python3.$(python3 -c 'import sys; print(sys.version_info[1])' 2>/dev/null)-venv" >/dev/null \ || fail "could not install python3-venv — install it manually, then re-run this installer" python3 -c 'import ensurepip' >/dev/null 2>&1 \ || fail "python3-venv installed but ensurepip is still missing — \ install the python3.X-venv package matching your python3, then re-run" status "Python venv support installed" else warn "Python venv support may be missing" detail "Install your distro's python3 venv package if the next step fails" fi fi # ── 2. fetch + verify the artifact ─────────────────────────────────────────── TMP="$(mktemp -d /tmp/gray-bootstrap.XXXXXX)" trap 'rm -rf "$TMP"' EXIT INT TERM run "Downloading Gray" detail "$BASE_URL/$ARTIFACT" curl -fsSL "$BASE_URL/$ARTIFACT" -o "$TMP/$ARTIFACT" || fail "artifact download failed" curl -fsSL "$BASE_URL/$ARTIFACT.sha256" -o "$TMP/$ARTIFACT.sha256" || fail "checksum download failed" status "Downloaded Gray" (cd "$TMP" && sha256sum -c "$ARTIFACT.sha256" >/dev/null 2>&1) \ || fail "checksum mismatch — refusing to install (truncated download or tampered artifact)" status "Package verified" # ── 3. unpack (atomic swap; one rolling .prev for rollback) ────────────────── NEW="$SRC_DIR.new.$$" mkdir -p "$NEW" tar -xzf "$TMP/$ARTIFACT" -C "$NEW" || fail "unpack failed" [ -x "$NEW/selfhost/install.sh" ] || fail "artifact layout unexpected (no selfhost/install.sh)" mkdir -p "$(dirname "$SRC_DIR")" if [ -d "$SRC_DIR" ]; then rm -rf "$SRC_DIR.prev" mv "$SRC_DIR" "$SRC_DIR.prev" fi mv "$NEW" "$SRC_DIR" status "Installer unpacked" detail "Source tree: $SRC_DIR" detail "Previous tree: $SRC_DIR.prev" # ── 4. hand off to the inner installer ─────────────────────────────────────── run "Installing Gray server" GRAY_INSTALL_PARENT=bootstrap bash "$SRC_DIR/selfhost/install.sh" "$@" status "Installed Gray server" # ── 5. expose the box over Tailscale HTTPS when possible ───────────────────── tailscale_dns_name() { tailscale status --json 2>/dev/null | python3 -c ' import json, sys try: name = (json.load(sys.stdin).get("Self") or {}).get("DNSName") or "" except Exception: name = "" print(name.rstrip(".")) ' 2>/dev/null || true } serve_port_state() { serve_port="$1" serve_target_expected="$2" serve_json="$(tailscale serve status --json 2>/dev/null || true)" if [ -n "$serve_json" ]; then serve_parsed="$( printf '%s\n' "$serve_json" | python3 -c ' import json, sys port = sys.argv[1] target = sys.argv[2].rstrip("/") def normalize(value): return str(value or "").rstrip("/") def entry_port(name): tail = str(name).rsplit(":", 1)[-1] return tail if tail.isdigit() else "" try: data = json.load(sys.stdin) web = data.get("Web") or {} except Exception: sys.exit(2) state = "none" for name, entry in web.items(): if entry_port(name) != port: continue handlers = (entry or {}).get("Handlers") or {} handler = handlers.get("/") or handlers.get("") proxy = normalize((handler or {}).get("Proxy") if isinstance(handler, dict) else "") if proxy == target: state = "same" else: print("conflict") sys.exit(0) print(state) ' "$serve_port" "$serve_target_expected" 2>/dev/null || true )" case "$serve_parsed" in same|conflict|none) printf '%s\n' "$serve_parsed" return 0 ;; esac fi serve_text="$(tailscale serve status 2>/dev/null || true)" if [ -n "$serve_text" ] \ && printf '%s\n' "$serve_text" | grep -F ":$serve_port" >/dev/null 2>&1; then printf '%s\n' "conflict" return 0 fi printf '%s\n' "none" } write_resume_command() { # Values are baked in as LITERALS — this must never source the env file. cat >"$GRAY_RESUME_CMD" <&2; exit 1; } PORT="$GRAY_SERVER_PORT" HTTPS_PORT="$TAILSCALE_HTTPS_PORT" UNIT="$GRAY_SERVER_UNIT" dns_name() { tailscale status --json 2>/dev/null | python3 -c 'import json,sys try: n=(json.load(sys.stdin).get("Self") or {}).get("DNSName") or "" except Exception: n="" print(n.rstrip("."))' 2>/dev/null || true } if [ -z "\$(dns_name)" ]; then echo "Signing this box into Tailscale — open the link below, then this" echo "command continues by itself." tailscale up --qr fi NAME="\$(dns_name)" [ -n "\$NAME" ] || { echo "Still not signed into Tailscale. Run: sudo tailscale up" >&2; exit 1; } tailscale serve --yes --bg --https="\$HTTPS_PORT" "http://127.0.0.1:\$PORT" >/dev/null # The code comes from the vault, not from grepping the journal for "claim # code": the server logs a WARNING that MENTIONS the phrase without carrying # the code, and a naive grep prints that whole sentence at the user. CODE="\$(python3 - "$VAULT_DIR/selfhost_auth.json" <<'PYCODE' 2>/dev/null || true import json, re, sys try: data = json.load(open(sys.argv[1], encoding="utf-8")) except Exception: raise SystemExit(0) code = str(data.get("claim_code") or "").strip() if re.fullmatch(r"[0-9A-Fa-f]{16}", code): print(code) PYCODE )" echo "" echo "PAIR YOUR PHONE" echo " Box address https://\$NAME:\$HTTPS_PORT" if [ -n "\$CODE" ]; then echo " One-time code \$CODE" else echo " One-time code (run: journalctl -u \$UNIT | grep -i 'claim code')" fi echo "" echo "Your phone must be signed into the SAME Tailscale account." RESUME chmod 0755 "$GRAY_RESUME_CMD" 2>/dev/null || true } have_tty() { # Under `curl … | sudo sh` STDIN IS THE PIPE, so a terminal has to be # reached explicitly. No tty is a real state (cloud-init, Ansible, packer, # CI) and must never block. # # The SUBSHELL is load-bearing. `:` is a POSIX SPECIAL BUILTIN, and a # redirection failure on a special builtin makes the shell EXIT — under # `set -e` the first version of this killed the installer silently, right # after "Installed Gray server", with no card and no message (dash exits # 2, which even looked like our own honest exit code). Inside ( ) the # fatal exit is confined to the subshell and we just get a false. # confirm_uninstall() is unaffected because `printf` is not special. ( : >/dev/tty ) 2>/dev/null } attempt_tailscale_login() { # Returns 0 if the box is now logged in, 1 if the caller should fall # through to its own needs_up handling. NEVER hangs an unattended run. if [ -n "${GRAY_TAILSCALE_AUTHKEY:-}" ]; then run "Signing this box into Tailscale" detail "Using GRAY_TAILSCALE_AUTHKEY (unattended)" if tailscale up --auth-key="$GRAY_TAILSCALE_AUTHKEY" \ --timeout="$TAILSCALE_LOGIN_TIMEOUT" >/dev/null 2>&1; then status "Tailscale signed in" return 0 fi TAILSCALE_RESULT="needs_up" TAILSCALE_DETAIL="GRAY_TAILSCALE_AUTHKEY was rejected or timed out. Check the key has not expired, then run the command below to finish." warn "Tailscale HTTPS not configured" return 1 fi if ! have_tty; then TAILSCALE_RESULT="needs_up" TAILSCALE_DETAIL="This box is not signed into Tailscale, and this install has no terminal to sign in from. Run the command below to finish (it signs in, publishes the address and prints it), or re-install with GRAY_TAILSCALE_AUTHKEY= for an unattended setup." warn "Tailscale HTTPS not configured" return 1 fi section "ONE STEP LEFT — SIGN THIS BOX INTO TAILSCALE" printf '%s\n' "Gray reaches this box over Tailscale, so the box has to be" >/dev/tty printf '%s\n' "signed into your (free) Tailscale account. Open the link below," >/dev/tty printf '%s\n' "sign in, and this installer CONTINUES BY ITSELF — no need to" >/dev/tty printf '%s\n' "run anything again. Your phone must use the SAME account." >/dev/tty printf '\n' >/dev/tty detail "Waiting up to $TAILSCALE_LOGIN_TIMEOUT" # Output goes to the terminal, not the pipe: the auth URL is the whole # point and must be visible even when stdout is being captured. if tailscale up --qr --timeout="$TAILSCALE_LOGIN_TIMEOUT" >/dev/tty 2>&1; then status "Tailscale signed in" return 0 fi TAILSCALE_RESULT="needs_up" TAILSCALE_DETAIL="Sign-in did not complete within $TAILSCALE_LOGIN_TIMEOUT. Nothing is lost — run the command below to finish (it signs in, publishes the address and prints it)." warn "Tailscale HTTPS not configured" return 1 } configure_tailscale_serve() { serve_target="http://127.0.0.1:$GRAY_SERVER_PORT" BOX_ADDRESS="" BOX_HOST="" TAILSCALE_RESULT="pending" TAILSCALE_DETAIL="" TAILSCALE_MANUAL_COMMAND="sudo tailscale serve --yes --bg --https=$TAILSCALE_HTTPS_PORT $serve_target" if [ "${GRAY_SKIP_TAILSCALE:-0}" = "1" ]; then TAILSCALE_RESULT="skipped" TAILSCALE_DETAIL="Tailscale was skipped (GRAY_SKIP_TAILSCALE=1). Configure your own HTTPS route to $serve_target, or install Tailscale, run sudo tailscale up, then run the command below." warn "Tailscale HTTPS not configured" return 0 fi case "$TAILSCALE_HTTPS_PORT" in ''|*[!0-9]*) TAILSCALE_RESULT="invalid_port" TAILSCALE_DETAIL="GRAY_TAILSCALE_HTTPS_PORT must be numeric." TAILSCALE_MANUAL_COMMAND="sudo tailscale serve --yes --bg --https= $serve_target" warn "Tailscale HTTPS not configured" return 0 ;; esac if ! command -v tailscale >/dev/null 2>&1; then TAILSCALE_RESULT="unavailable" TAILSCALE_DETAIL="Tailscale is not available after install. Install Tailscale, run sudo tailscale up, then run the command below." warn "Tailscale HTTPS not configured" return 0 fi dns_name="$(tailscale_dns_name)" if [ -z "$dns_name" ]; then # THE 97% BRANCH. This used to set needs_up and return, printing "run # sudo tailscale up, then rerun this installer" — so the user finished # setup holding a box code and NO ADDRESS, and had to start over. The # installer never even TRIED to log in. `tailscale up` prints an auth # URL and BLOCKS until the browser login completes, then exits 0, so # the wait is ours to take rather than the user's to repeat. attempt_tailscale_login || return 0 dns_name="$(tailscale_dns_name)" if [ -z "$dns_name" ]; then TAILSCALE_RESULT="needs_up" TAILSCALE_DETAIL="Logged into Tailscale, but this box still has no MagicDNS name. Enable MagicDNS for your tailnet, then run the command below to finish." warn "Tailscale HTTPS not configured" return 0 fi fi serve_state="$(serve_port_state "$TAILSCALE_HTTPS_PORT" "$serve_target")" if [ "$serve_state" = "conflict" ]; then TAILSCALE_RESULT="conflict" TAILSCALE_DETAIL="Tailscale Serve port $TAILSCALE_HTTPS_PORT already has a non-Gray or unverified mapping, so it was left unchanged. Review tailscale serve status or choose another port with GRAY_TAILSCALE_HTTPS_PORT." warn "Tailscale HTTPS not configured" return 0 fi run "Configuring Tailscale HTTPS" detail "Public port: $TAILSCALE_HTTPS_PORT" if tailscale serve --yes --bg --https="$TAILSCALE_HTTPS_PORT" "$serve_target" >/dev/null 2>&1; then BOX_HOST="$dns_name" BOX_ADDRESS="https://$dns_name:$TAILSCALE_HTTPS_PORT" TAILSCALE_RESULT="ready" status "Tailscale HTTPS ready" return 0 fi TAILSCALE_RESULT="failed" TAILSCALE_DETAIL="Could not configure Tailscale HTTPS automatically. Enable MagicDNS and HTTPS certificates in Tailscale if needed, then run the command below." warn "Tailscale HTTPS not configured" return 0 } configure_tailscale_serve # The needs_up class is the one that strands people, so it gets the command # that finishes the job. The others keep their targeted advice: a user who set # GRAY_SKIP_TAILSCALE opted out, and a port conflict is not fixed by signing in. if [ "$TAILSCALE_RESULT" = "needs_up" ]; then write_resume_command TAILSCALE_MANUAL_COMMAND="sudo $GRAY_RESUME_CMD" fi wait_for_gray_status() { _up=0 _i=0 while [ "$_i" -lt 60 ]; do if curl -fsS -m 2 "http://127.0.0.1:$GRAY_SERVER_PORT/api/auth/status" >/dev/null 2>&1; then _up=1 break fi _i=$((_i + 1)) sleep 1 done [ "$_up" = "1" ] } update_rp_env_for_box_address() { [ -n "$BOX_ADDRESS" ] || return 0 _rp_host="${BOX_HOST:-}" if [ -z "$_rp_host" ]; then _rp_host="${BOX_ADDRESS#https://}" _rp_host="${_rp_host%%/*}" _rp_host="${_rp_host%%:*}" fi [ -n "$_rp_host" ] || return 0 if [ ! -f "$ENV_FILE" ]; then warn "Passkey origin not updated; env file not found: $ENV_FILE" return 0 fi _rp_update="$( python3 - "$ENV_FILE" "$_rp_host" "$BOX_ADDRESS" <<'PY' 2>/dev/null || printf 'error\n' import os import sys import tempfile from pathlib import Path path = Path(sys.argv[1]) values = { "GRAY_RP_ID": sys.argv[2], "GRAY_RP_ORIGIN": sys.argv[3], } try: text = path.read_text() except OSError: print("error") raise SystemExit(0) lines = text.splitlines() if text.endswith("\n"): trailing_newline = True else: trailing_newline = False def host_of(value): v = value.strip().strip('"').strip("'") if "://" in v: v = v.split("://", 1)[1] v = v.split("/", 1)[0].split(":", 1)[0] return v.lower().rstrip(".") def ours_to_rewrite(current, new): # Only values Gray itself wrote are ours to rewrite: unset/empty, the # localhost install default, a tailnet-derived name, or already the # target. An operator's explicit custom domain is never clobbered — # flipping RP back to the tailnet name invalidates their passkeys. if current is None or current.strip() == "" or current == new: return True host = host_of(current) return host in ("localhost", "127.0.0.1") or host.endswith(".ts.net") existing = {} for line in lines: key = line.split("=", 1)[0] if "=" in line else "" if key in values and key not in existing: existing[key] = line.split("=", 1)[1] if not all(ours_to_rewrite(existing.get(k), v) for k, v in values.items()): print("custom") raise SystemExit(0) out = [] seen = set() changed = False for line in lines: key = line.split("=", 1)[0] if "=" in line else "" if key in values: if key in seen: changed = True continue replacement = f"{key}={values[key]}" out.append(replacement) seen.add(key) if line != replacement: changed = True else: out.append(line) for key in ("GRAY_RP_ID", "GRAY_RP_ORIGIN"): if key not in seen: out.append(f"{key}={values[key]}") changed = True if not changed: print("same") raise SystemExit(0) mode = path.stat().st_mode & 0o777 if path.exists() else 0o600 parent = path.parent fd, tmp_name = tempfile.mkstemp(prefix=f".{path.name}.", suffix=".tmp", dir=str(parent)) with os.fdopen(fd, "w") as f: f.write("\n".join(out)) if trailing_newline or out: f.write("\n") os.chmod(tmp_name, mode or 0o600) os.replace(tmp_name, path) print("changed") PY )" case "$_rp_update" in changed) status "Updated passkey origin" detail "GRAY_RP_ID=$_rp_host" detail "GRAY_RP_ORIGIN=$BOX_ADDRESS" run "Restarting Gray server" if systemctl restart "$GRAY_SERVER_UNIT" >/dev/null 2>&1 && wait_for_gray_status; then status "Gray server restarted" else warn "Passkey origin updated, but $GRAY_SERVER_UNIT did not restart cleanly; check systemctl status $GRAY_SERVER_UNIT" fi ;; same) detail "Passkey origin already matches $BOX_ADDRESS" ;; custom) detail "Passkey origin left as-is (custom GRAY_RP_ID/GRAY_RP_ORIGIN in $ENV_FILE)" ;; *) warn "Passkey origin not updated; could not edit $ENV_FILE" ;; esac } update_rp_env_for_box_address claim_code_text() { claim_line="$(journalctl -u "$GRAY_SERVER_UNIT" -b --no-pager 2>/dev/null \ | grep -i 'claim code' | tail -1 || true)" claim_code="$(printf '%s\n' "$claim_line" \ | awk ' { lower = tolower($0) pos = index(lower, "claim code") if (!pos) next tail = substr($0, pos + length("claim code")) n = split(tail, parts, /[^0-9A-Fa-f]+/) for (i = 1; i <= n; i++) { if (length(parts[i]) == 16 && parts[i] ~ /^[0-9A-Fa-f][0-9A-Fa-f]*$/) { print parts[i] exit } } } ' \ | tail -1)" if [ -z "$claim_code" ]; then claim_code="$(python3 - "$VAULT_DIR/selfhost_auth.json" <<'PY' 2>/dev/null || true import json import re import sys try: with open(sys.argv[1], "r", encoding="utf-8") as fh: data = json.load(fh) except Exception: raise SystemExit(0) code = str(data.get("claim_code") or "").strip() if re.fullmatch(r"[0-9A-Fa-f]{16}", code): print(code) PY )" fi if [ -n "$claim_code" ]; then printf '%s\n' "$claim_code" elif [ -n "$claim_line" ]; then printf '%s\n' "$claim_line" | sed 's/^.*]: //' else printf 'journalctl -u %s | grep -i '"'"'claim code'"'"'\n' "$GRAY_SERVER_UNIT" fi } final_card() { claim="$(claim_code_text)" lost_cmd="journalctl -u $GRAY_SERVER_UNIT | grep -i 'claim code'" if [ -n "$BOX_ADDRESS" ]; then section "GRAY IS ONLINE" status "Server running" status "Tailscale HTTPS ready" status "Mobile pairing ready" status "Keys stay on this box" printf '\n' box_top box_title "PAIR YOUR PHONE" box_text "" box_text "Open Gray on your phone." box_text "Go to Settings -> Connect Server." box_text "Paste this address and box code into Gray:" box_text "" box_label "Box address" box_value "$BOX_ADDRESS" box_text "" box_label "One-time box code" box_value "$claim" box_bottom else # An install that cannot pair is NOT a successful install. This used # to lead with "GRAY SERVER IS ONLINE" and exit 0 — technically true # (it does run on 127.0.0.1) and useless to the person reading it, # which is why nine days of zero pairs read as silence instead of an # alarm. Lead with what is missing, and exit non-zero so anything # watching can tell. section "SETUP IS NOT FINISHED — ONE STEP LEFT" warn "Your phone cannot reach this box yet" status "Gray server is installed and running locally" printf '\n' box_top box_title "FINISH SETUP" box_text "" box_text "This box has no address your phone can reach yet." box_text "Run this to finish — it signs in, publishes the" box_text "address, and prints it:" box_text "" box_value "$TAILSCALE_MANUAL_COMMAND" box_text "" box_label "One-time box code" box_value "$claim" box_bottom printf '\n' printf '%s\n' 'Why' printf ' %s\n' "${TAILSCALE_DETAIL:-Gray reaches this box over Tailscale, which needs the box signed in.}" printf '\n' printf '%sLost box code:%s %s\n' "$C_DIM" "$C_RESET" "$lost_cmd" return 1 fi printf '%sLost box code:%s %s\n' "$C_DIM" "$C_RESET" "$lost_cmd" return 0 } final_card || exit 2