#!/bin/sh # TZX Mesh installer — brings up a self-hosted control plane on a fresh Debian/Ubuntu VPS. # # curl -fsSL https://tzxmesh.com/install.sh | sh # TZX_DOMAIN=mesh.example.com TZX_OWNER_EMAIL=you@example.com sh install.sh (non-interactive) # # What it does, in order: # 1. checks root, apt, a public IPv4 and that ports 443/3478 are free # 2. installs Docker Engine + Compose plugin from Docker's repository (skipped if present) # 3. writes /opt/tzxmesh/app: docker-compose.yml, Caddyfile, config.yaml (secrets generated locally, mode 600) # 4. pulls the images and starts the stack; Caddy obtains the TLS certificate for your domain # 5. prints the console URL and the owner's initial password (shown once; change it after first sign-in) # Everything stays on your server. Re-running is safe: existing secrets and data are kept. # Source of this script and the compose files: https://github.com/salik-k/tzx-mesh-infra set -eu TZX_DIR="${TZX_DIR:-/opt/tzxmesh}" TZX_VERSION="${TZX_VERSION:-v0.1.0}" TZX_IMAGE_SERVER="${TZX_IMAGE_SERVER:-ghcr.io/salik-k/tzx-mesh-combined:$TZX_VERSION}" TZX_IMAGE_DASHBOARD="${TZX_IMAGE_DASHBOARD:-ghcr.io/salik-k/tzx-mesh-dashboard:$TZX_VERSION}" APP="$TZX_DIR/app" say() { printf '\033[32m==>\033[0m %s\n' "$*"; } warn() { printf '\033[33m!!\033[0m %s\n' "$*" >&2; } die() { printf '\033[31mxx\033[0m %s\n' "$*" >&2; exit 1; } # ---------- 1. preflight ---------- [ "$(id -u)" -eq 0 ] || die "run as root (sudo sh install.sh)" command -v apt-get >/dev/null 2>&1 || die "this installer supports Debian and Ubuntu (apt) only" . /etc/os-release 2>/dev/null || true case "${ID:-}" in debian|ubuntu) ;; *) warn "untested distribution '${ID:-unknown}'; continuing";; esac if [ -z "${TZX_DOMAIN:-}" ]; then [ -t 0 ] || die "TZX_DOMAIN is not set and no terminal to ask; pass TZX_DOMAIN=... TZX_OWNER_EMAIL=..." printf 'Domain for the console (DNS must already point here, e.g. mesh.example.com): ' read -r TZX_DOMAIN /dev/null | sed -n 's/^ip=//p' || true) dns_ip=$(getent ahostsv4 "$TZX_DOMAIN" 2>/dev/null | awk 'NR==1{print $1}' || true) if [ -n "$pub_ip" ] && [ -n "$dns_ip" ] && [ "$pub_ip" != "$dns_ip" ]; then warn "$TZX_DOMAIN resolves to $dns_ip but this server's public IP is $pub_ip" warn "TLS issuance will fail until DNS points here (or the name is proxied; use a DNS-only record)." fi for p in 443 3478; do if ss -Hltnu 2>/dev/null | awk '{print $5}' | grep -Eq "[:.]$p\$"; then die "port $p is already in use on this server"; fi done # ---------- 2. docker ---------- if ! command -v docker >/dev/null 2>&1 || ! docker compose version >/dev/null 2>&1; then say "installing Docker Engine + Compose plugin" export DEBIAN_FRONTEND=noninteractive apt-get update -qq apt-get install -y -qq ca-certificates curl gnupg openssl >/dev/null install -m 0755 -d /etc/apt/keyrings curl -fsSL "https://download.docker.com/linux/${ID}/gpg" | gpg --dearmor -o /etc/apt/keyrings/docker.gpg chmod a+r /etc/apt/keyrings/docker.gpg echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/${ID} ${VERSION_CODENAME} stable" > /etc/apt/sources.list.d/docker.list apt-get update -qq apt-get install -y -qq docker-ce docker-ce-cli containerd.io docker-compose-plugin >/dev/null systemctl enable --now docker >/dev/null 2>&1 || true else say "docker present: $(docker --version | cut -d, -f1)" fi command -v openssl >/dev/null 2>&1 || apt-get install -y -qq openssl >/dev/null # ---------- 3. files ---------- mkdir -p "$APP" "$TZX_DIR/backups" cd "$APP" umask 077 if [ ! -f .env ]; then say "generating secrets (kept only in $APP/.env, mode 600)" { echo "DOMAIN=$TZX_DOMAIN" echo "POSTGRES_USER=tzxmesh" echo "POSTGRES_PASSWORD=$(openssl rand -hex 24)" echo "POSTGRES_DB=tzxmesh" echo "NETBIRD_RELAY_AUTH_SECRET=$(openssl rand -hex 32)" echo "NETBIRD_ENCRYPTION_KEY=$(openssl rand -base64 32)" echo "NETBIRD_SESSION_COOKIE_ENCRYPTION_KEY=$(openssl rand -hex 16)" echo "OWNER_EMAIL=$TZX_OWNER_EMAIL" echo "OWNER_INITIAL_PASSWORD=$(openssl rand -base64 18 | tr -d '/+=' | cut -c1-20)" echo "BACKUP_PASSPHRASE=$(openssl rand -hex 24)" echo "CADDY_IP=172.30.0.10" echo "TZX_IMAGE_SERVER=$TZX_IMAGE_SERVER" echo "TZX_IMAGE_DASHBOARD=$TZX_IMAGE_DASHBOARD" } > .env else say "keeping existing $APP/.env" fi umask 022 # bcrypt hash of the owner password (the server stores the hash verbatim); $2a$ prefix for Go's bcrypt if [ ! -s owner.hash ]; then pw=$(sed -n 's/^OWNER_INITIAL_PASSWORD=//p' .env) docker run --rm httpd:2.4-alpine htpasswd -bnBC 10 "" "$pw" | cut -d: -f2 | sed 's/^\$2y\$/$2a$/' | tr -d '\n' > owner.hash chmod 600 owner.hash fi cat > docker-compose.yml <<'EOF' services: caddy: image: caddy:2 container_name: tzxmesh-caddy restart: unless-stopped ports: ["80:80", "443:443", "443:443/udp"] environment: - DOMAIN=${DOMAIN} volumes: - ./Caddyfile:/etc/caddy/Caddyfile:ro - caddy_data:/data - caddy_config:/config networks: tzxmesh: ipv4_address: ${CADDY_IP} depends_on: [server, dashboard] server: image: ${TZX_IMAGE_SERVER} container_name: tzxmesh-server restart: unless-stopped ports: ["3478:3478/udp"] volumes: - ./config.yaml:/etc/netbird/config.yaml:ro - server_data:/var/lib/netbird networks: [tzxmesh] depends_on: postgres: { condition: service_healthy } logging: { driver: json-file, options: { max-size: "50m", max-file: "5" } } dashboard: image: ${TZX_IMAGE_DASHBOARD} container_name: tzxmesh-dashboard restart: unless-stopped environment: - NETBIRD_MGMT_API_ENDPOINT=https://${DOMAIN} - NETBIRD_MGMT_GRPC_API_ENDPOINT=https://${DOMAIN} - AUTH_AUDIENCE=netbird-dashboard - AUTH_CLIENT_ID=netbird-dashboard - AUTH_CLIENT_SECRET= - AUTH_AUTHORITY=https://${DOMAIN}/oauth2 - USE_AUTH0=false - AUTH_SUPPORTED_SCOPES=openid profile email groups offline_access - AUTH_REDIRECT_URI=/nb-auth - AUTH_SILENT_REDIRECT_URI=/nb-silent-auth - NETBIRD_TOKEN_SOURCE=accessToken networks: [tzxmesh] postgres: image: postgres:17 container_name: tzxmesh-postgres restart: unless-stopped environment: - POSTGRES_USER=${POSTGRES_USER} - POSTGRES_PASSWORD=${POSTGRES_PASSWORD} - POSTGRES_DB=${POSTGRES_DB} healthcheck: test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"] interval: 10s timeout: 5s retries: 10 volumes: - postgres_data:/var/lib/postgresql/data - ./init-idp-db.sql:/docker-entrypoint-initdb.d/10-idp.sql:ro networks: [tzxmesh] volumes: caddy_data: {} caddy_config: {} server_data: {} postgres_data: {} networks: tzxmesh: name: tzxmesh driver: bridge ipam: config: - subnet: 172.30.0.0/24 gateway: 172.30.0.1 EOF # second database for the embedded identity provider (created on first Postgres start) cat > init-idp-db.sql <<'EOF' CREATE DATABASE tzxmesh_idp; EOF cat > Caddyfile <<'EOF' {$DOMAIN} { encode gzip @grpc path /signalexchange.SignalExchange/* /management.ManagementService/* /management.ProxyService/* reverse_proxy @grpc h2c://server:80 @backend path /relay* /ws-proxy/* /api* /oauth2* reverse_proxy @backend server:80 reverse_proxy dashboard:80 log { output file /data/access.log { roll_size 20mb roll_keep 5 } } } EOF # render config.yaml from the env file (first '=' split; values escaped for sed) env_get() { sed -n "s/^$1=//p" .env; } esc() { printf '%s' "$1" | sed -e 's/[\/&]/\\&/g'; } DOMAIN=$(env_get DOMAIN); PGU=$(env_get POSTGRES_USER); PGP=$(env_get POSTGRES_PASSWORD); PGD=$(env_get POSTGRES_DB) cat > config.yaml </dev/null; then die "could not pull $TZX_IMAGE_SERVER / $TZX_IMAGE_DASHBOARD. Public images ship with the first release; until then build from source (README in the infra repository)." fi say "starting the stack" docker compose up -d i=0 until curl -fsS -m 5 -o /dev/null "https://$DOMAIN/api/instance" 2>/dev/null || [ $i -ge 90 ]; do i=$((i+1)); sleep 2; done if curl -fsS -m 5 -o /dev/null "https://$DOMAIN/api/instance" 2>/dev/null; then say "console is answering at https://$DOMAIN" else warn "the console did not answer over TLS yet; check DNS and 'docker compose logs caddy' in $APP" fi # ---------- 5. done ---------- echo say "TZX Mesh is installed" echo " Console: https://$DOMAIN" echo " Sign in: $(env_get OWNER_EMAIL)" echo " Password: $(env_get OWNER_INITIAL_PASSWORD) (initial; change it after first sign-in)" echo " Files: $APP (.env holds the secrets, mode 600)" echo " Next: Settings → Authentication → Peer Approval on; Settings → Setup Keys → create a key; enrol a device."