this repo has no description
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat(infra): add automated Hetzner deployment scripts

- deploy.sh: single-command server provisioning with Hetzner, Cloudflare DNS,
Tailscale, Docker, and GitHub Actions deploy key setup
- teardown.sh: clean removal of server and DNS records
- cloud-init.yaml.tmpl: server initialization template
- sync-prompt.sh: quick system prompt sync without full deploy
- secrets.env.example: template for required secrets

Security improvements:
- Tailscale auth key written to file instead of command line (avoids logs)
- Deploy key saved to file with chmod 600 (not printed to stdout)
- IPv6 address properly extracted from /64 prefix
- Host key captured after first SSH connection

Also updates documentation and .gitignore for infra secrets.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

alice cf8db6d5 780d5ce6

+1041 -10
+7
.gitignore
··· 42 42 43 43 # System prompt (contains personal info) 44 44 prompts/SYSTEM_PROMPT.md 45 + 46 + # Infrastructure secrets 47 + infra/secrets.env 48 + infra/deploy_key_* 49 + 50 + # Environment backups (created by deploy.sh) 51 + .env.backup.*
+7 -9
Caddyfile.example
··· 1 1 # Copy to Caddyfile and customize: 2 2 # cp Caddyfile.example Caddyfile 3 3 # nano Caddyfile 4 + # 5 + # Note: deploy.sh generates this automatically with your domain 4 6 5 7 # Main app - Telegram webhook 6 - assistant.mosphere.at { 8 + assistant.yourdomain.com { 7 9 reverse_proxy app:3000 8 10 } 9 11 10 - # Letta ADE - password protected by Letta itself (LETTA_SERVER_PASSWORD) 11 - # Connect via https://app.letta.com → Self-Hosted Server 12 - letta.assistant.mosphere.at { 13 - reverse_proxy letta:8283 14 - } 15 - 16 - # Netdata is accessed via Tailscale (http://TAILSCALE_IP:19999) 17 - # No public exposure needed 12 + # Internal services accessed via Tailscale only (no public exposure): 13 + # http://TAILSCALE_IP:8283 - Letta API/ADE 14 + # http://TAILSCALE_IP:19999 - Netdata monitoring 15 + # http://TAILSCALE_IP:4001 - Anthropic Proxy (OAuth setup)
+22 -1
DEPLOY.md
··· 1 1 # Deploying to Hetzner VPS 2 2 3 - Simple deployment using Docker Compose + Caddy + GitHub Actions. 3 + ## Automated Deployment (Recommended) 4 + 5 + Single-command deployment using `infra/deploy.sh`: 6 + 7 + ```bash 8 + cd infra 9 + cp secrets.env.example secrets.env 10 + nano secrets.env # Fill in your values 11 + ./deploy.sh 12 + ``` 13 + 14 + See [infra/README.md](infra/README.md) for prerequisites (hcloud CLI, Cloudflare token, Tailscale OAuth client). 15 + 16 + The script handles: server creation, Docker setup, Tailscale, DNS, SSL, Telegram webhook, and GitHub Actions deploy key. 17 + 18 + **Only manual step:** Complete Anthropic OAuth via the URL shown in deploy output. 19 + 20 + --- 21 + 22 + ## Manual Deployment 23 + 24 + Step-by-step deployment using Docker Compose + Caddy + GitHub Actions. 4 25 5 26 ## Prerequisites 6 27
+13
README.md
··· 188 188 docker compose up -d 189 189 ``` 190 190 191 + ## Deployment 192 + 193 + Deploy to Hetzner Cloud with a single command: 194 + 195 + ```bash 196 + cd infra 197 + cp secrets.env.example secrets.env 198 + nano secrets.env # Fill in your values 199 + ./deploy.sh 200 + ``` 201 + 202 + See [DEPLOY.md](DEPLOY.md) for full deployment guide (automated and manual options). 203 + 191 204 ## Testing 192 205 193 206 ```bash
+102
infra/README.md
··· 1 + # Infrastructure Scripts 2 + 3 + Single-command deployment to Hetzner Cloud with automatic DNS, SSL, and Tailscale setup. 4 + 5 + ## Quick Start 6 + 7 + ```bash 8 + # Copy and fill in secrets 9 + cp secrets.env.example secrets.env 10 + nano secrets.env 11 + 12 + # Deploy 13 + ./deploy.sh 14 + 15 + # Complete Anthropic OAuth (URL shown in deploy output) 16 + # Then restart services 17 + ``` 18 + 19 + ## Prerequisites 20 + 21 + ### Hetzner Cloud 22 + - Install CLI: `brew install hcloud` 23 + - Create API token: https://console.hetzner.cloud → Security → API Tokens 24 + - Configure: `hcloud context create assistant` (enter token when prompted) 25 + 26 + ### Cloudflare 27 + - Create API token: https://dash.cloudflare.com/profile/api-tokens 28 + - Use "Edit zone DNS" template, scope to your zone 29 + - Get Zone ID: 30 + - Go to https://dash.cloudflare.com 31 + - Click your domain 32 + - Right sidebar → scroll to **API** section 33 + - Copy the **Zone ID** (32-char hex string) 34 + 35 + ### Tailscale 36 + 37 + Add tag to ACL policy (https://login.tailscale.com/admin/acls): 38 + ```json 39 + { 40 + "tagOwners": { 41 + "tag:server": ["your-email@example.com", "tag:server"] 42 + } 43 + } 44 + ``` 45 + 46 + Create OAuth client: 47 + - Go to https://login.tailscale.com/admin/settings/oauth 48 + - Scopes: `auth_keys` (write) 49 + - Tags: `tag:server` 50 + - Save client secret (shown only once) 51 + 52 + ## Scripts 53 + 54 + | Script | Purpose | 55 + |--------|---------| 56 + | `deploy.sh` | Full deployment: server, DNS, services | 57 + | `teardown.sh` | Delete server and DNS records | 58 + | `sync-prompt.sh` | Quick-sync system prompt without redeploy | 59 + 60 + ## After Deployment 61 + 62 + ### Complete Anthropic OAuth 63 + 64 + The deploy script outputs a URL like: 65 + ``` 66 + http://100.x.x.x:4001/auth/device 67 + ``` 68 + 69 + Open this from any device on your Tailscale network, complete the OAuth flow, then: 70 + 71 + ```bash 72 + ssh root@SERVER_IP 73 + nano /opt/assistant/.env 74 + # Set ANTHROPIC_PROXY_SESSION_ID=your_session_id 75 + docker compose -f docker-compose.yml -f docker-compose.prod.yml restart 76 + ``` 77 + 78 + ### GitHub Actions 79 + 80 + The deploy script outputs: 81 + - Deploy key (add to GitHub repo → Settings → Deploy keys) 82 + - SSH private key (add to GitHub → Settings → Secrets → `SSH_KEY`) 83 + - Server IP (add to GitHub → Settings → Secrets → `HOST`) 84 + 85 + ## Service Access 86 + 87 + | Service | Access | 88 + |---------|--------| 89 + | App | `https://assistant.yourdomain.com` (public) | 90 + | Letta | `http://TAILSCALE_IP:8283` (Tailscale only) | 91 + | Netdata | `http://TAILSCALE_IP:19999` (Tailscale only) | 92 + | Anthropic Proxy | `http://TAILSCALE_IP:4001` (Tailscale only) | 93 + 94 + ## Updating System Prompt 95 + 96 + Edit `prompts/SYSTEM_PROMPT.md` locally, then: 97 + 98 + ```bash 99 + ./infra/sync-prompt.sh 100 + ``` 101 + 102 + This uploads the prompt and restarts only the app container (fast, no rebuild).
+54
infra/cloud-init.yaml.tmpl
··· 1 + #cloud-config 2 + # Cloud-init template for assistant server 3 + # Variables like __VAR_NAME__ are substituted by deploy.sh 4 + 5 + package_update: true 6 + package_upgrade: true 7 + 8 + packages: 9 + - ca-certificates 10 + - curl 11 + - git 12 + - mosh 13 + - jq 14 + 15 + # Write Tailscale auth key to file (avoids exposing in logs) 16 + write_files: 17 + - path: /tmp/ts-auth-key 18 + permissions: '0600' 19 + content: __TS_OAUTH_SECRET__ 20 + 21 + runcmd: 22 + # Docker installation 23 + - install -m 0755 -d /etc/apt/keyrings 24 + - curl -fsSL https://download.docker.com/linux/debian/gpg -o /etc/apt/keyrings/docker.asc 25 + - chmod a+r /etc/apt/keyrings/docker.asc 26 + - echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/debian $(. /etc/os-release && echo $VERSION_CODENAME) stable" | tee /etc/apt/sources.list.d/docker.list > /dev/null 27 + - apt-get update 28 + - DEBIAN_FRONTEND=noninteractive apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin 29 + 30 + # Tailscale installation and auth 31 + - curl -fsSL https://tailscale.com/install.sh | sh 32 + - tailscale up --auth-key="$(cat /tmp/ts-auth-key)?ephemeral=false" --advertise-tags=__TS_TAG__ --hostname=assistant 33 + - rm -f /tmp/ts-auth-key 34 + 35 + # GitHub CLI installation 36 + - mkdir -p /etc/apt/keyrings 37 + - curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg | tee /etc/apt/keyrings/githubcli-archive-keyring.gpg > /dev/null 38 + - chmod go+r /etc/apt/keyrings/githubcli-archive-keyring.gpg 39 + - echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" | tee /etc/apt/sources.list.d/github-cli.list > /dev/null 40 + - apt-get update 41 + - DEBIAN_FRONTEND=noninteractive apt-get install -y gh 42 + 43 + # Clone repository 44 + - mkdir -p /opt 45 + - git clone https://github.com/__GH_REPO__.git /opt/assistant 46 + - chmod +x /opt/assistant/deploy.sh 47 + 48 + # Generate deploy key for GitHub Actions 49 + - ssh-keygen -t ed25519 -f /root/.ssh/deploy_key -N "" -C "deploy@assistant" 50 + 51 + # Signal completion 52 + - touch /opt/.cloud-init-complete 53 + 54 + final_message: "Cloud-init completed after $UPTIME seconds"
+649
infra/deploy.sh
··· 1 + #!/usr/bin/env bash 2 + # Single-command deployment for assistant to Hetzner Cloud 3 + # 4 + # Prerequisites: 5 + # - hcloud CLI installed and configured (hcloud context create assistant) 6 + # - secrets.env filled in (cp secrets.env.example secrets.env) 7 + # 8 + # Usage: 9 + # ./deploy.sh 10 + 11 + set -euo pipefail 12 + IFS=$'\n\t' 13 + 14 + # Safe temp directory with automatic cleanup 15 + scratch=$(mktemp -d -t deploy.XXXXXXXXXX) 16 + function finish { 17 + rm -rf "$scratch" 18 + } 19 + trap finish EXIT 20 + 21 + # Colors for output 22 + RED='\033[0;31m' 23 + GREEN='\033[0;32m' 24 + YELLOW='\033[0;33m' 25 + BLUE='\033[0;34m' 26 + NC='\033[0m' # No Color 27 + 28 + # Script directory 29 + SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" 30 + 31 + # Server name 32 + SERVER_NAME="assistant" 33 + 34 + ####################################### 35 + # Logging functions 36 + ####################################### 37 + log_info() { 38 + echo -e "${BLUE}[INFO]${NC} $1" 39 + } 40 + 41 + log_success() { 42 + echo -e "${GREEN}[OK]${NC} $1" 43 + } 44 + 45 + log_warn() { 46 + echo -e "${YELLOW}[WARN]${NC} $1" 47 + } 48 + 49 + log_error() { 50 + echo -e "${RED}[ERROR]${NC} $1" >&2 51 + } 52 + 53 + die() { 54 + log_error "$1" 55 + exit 1 56 + } 57 + 58 + ####################################### 59 + # Check prerequisites 60 + ####################################### 61 + check_prerequisites() { 62 + log_info "Checking prerequisites..." 63 + 64 + # Check required commands 65 + local missing=() 66 + for cmd in hcloud jq curl ssh scp; do 67 + if ! command -v "$cmd" &> /dev/null; then 68 + missing+=("$cmd") 69 + fi 70 + done 71 + 72 + if [[ ${#missing[@]} -gt 0 ]]; then 73 + die "Missing required commands: ${missing[*]}" 74 + fi 75 + 76 + # Check hcloud is configured 77 + if ! hcloud context active &> /dev/null; then 78 + die "hcloud not configured. Run: hcloud context create assistant" 79 + fi 80 + 81 + # Check secrets.env exists 82 + if [[ ! -f "$SCRIPT_DIR/secrets.env" ]]; then 83 + die "secrets.env not found. Run: cp secrets.env.example secrets.env" 84 + fi 85 + 86 + log_success "Prerequisites OK" 87 + } 88 + 89 + ####################################### 90 + # Load and validate secrets 91 + ####################################### 92 + load_secrets() { 93 + log_info "Loading secrets..." 94 + 95 + # shellcheck source=/dev/null 96 + source "$SCRIPT_DIR/secrets.env" 97 + 98 + # Required variables 99 + local required=( 100 + DOMAIN 101 + SUBDOMAIN 102 + CLOUDFLARE_API_TOKEN 103 + CLOUDFLARE_ZONE_ID 104 + TS_OAUTH_SECRET 105 + TS_TAG 106 + GH_REPO 107 + TELEGRAM_BOT_TOKEN 108 + OPENAI_API_KEY 109 + ) 110 + 111 + local missing=() 112 + for var in "${required[@]}"; do 113 + if [[ -z "${!var:-}" ]]; then 114 + missing+=("$var") 115 + fi 116 + done 117 + 118 + if [[ ${#missing[@]} -gt 0 ]]; then 119 + die "Missing required secrets: ${missing[*]}" 120 + fi 121 + 122 + # Generate optional secrets if empty 123 + if [[ -z "${ANTHROPIC_PROXY_SESSION_SECRET:-}" ]]; then 124 + ANTHROPIC_PROXY_SESSION_SECRET=$(openssl rand -hex 32) 125 + log_info "Generated ANTHROPIC_PROXY_SESSION_SECRET" 126 + fi 127 + 128 + if [[ -z "${TELEGRAM_WEBHOOK_SECRET_TOKEN:-}" ]]; then 129 + TELEGRAM_WEBHOOK_SECRET_TOKEN=$(openssl rand -hex 16) 130 + log_info "Generated TELEGRAM_WEBHOOK_SECRET_TOKEN" 131 + fi 132 + 133 + if [[ -z "${LETTA_SERVER_PASSWORD:-}" ]]; then 134 + LETTA_SERVER_PASSWORD=$(openssl rand -hex 16) 135 + log_info "Generated LETTA_SERVER_PASSWORD" 136 + fi 137 + 138 + # Defaults 139 + HETZNER_LOCATION="${HETZNER_LOCATION:-fsn1}" 140 + HETZNER_SERVER_TYPE="${HETZNER_SERVER_TYPE:-cx22}" 141 + 142 + log_success "Secrets loaded" 143 + } 144 + 145 + ####################################### 146 + # Create or get SSH key 147 + ####################################### 148 + setup_ssh_key() { 149 + log_info "Setting up SSH key..." 150 + 151 + local key_name="assistant-deploy" 152 + 153 + # Check if key exists in hcloud 154 + if hcloud ssh-key describe "$key_name" &> /dev/null; then 155 + log_info "Using existing SSH key: $key_name" 156 + SSH_KEY_NAME="$key_name" 157 + return 158 + fi 159 + 160 + # Check if local key exists 161 + local local_key="$HOME/.ssh/id_ed25519" 162 + if [[ ! -f "$local_key" ]]; then 163 + local_key="$HOME/.ssh/id_rsa" 164 + fi 165 + 166 + if [[ ! -f "$local_key" ]]; then 167 + die "No SSH key found. Create one with: ssh-keygen -t ed25519" 168 + fi 169 + 170 + # Upload to hcloud 171 + hcloud ssh-key create --name "$key_name" --public-key-from-file "${local_key}.pub" 172 + SSH_KEY_NAME="$key_name" 173 + log_success "SSH key uploaded: $key_name" 174 + } 175 + 176 + ####################################### 177 + # Check if server already exists 178 + ####################################### 179 + check_existing_server() { 180 + if hcloud server describe "$SERVER_NAME" &> /dev/null; then 181 + log_warn "Server '$SERVER_NAME' already exists" 182 + read -rp "Delete and recreate? [y/N] " confirm 183 + if [[ "$confirm" =~ ^[Yy]$ ]]; then 184 + log_info "Deleting existing server..." 185 + hcloud server delete "$SERVER_NAME" 186 + sleep 5 187 + else 188 + die "Aborted. Use teardown.sh to remove the existing server." 189 + fi 190 + fi 191 + } 192 + 193 + ####################################### 194 + # Generate cloud-init config 195 + ####################################### 196 + generate_cloud_init() { 197 + log_info "Generating cloud-init config..." 198 + 199 + local template="$SCRIPT_DIR/cloud-init.yaml.tmpl" 200 + local output="$scratch/cloud-init.yaml" 201 + 202 + if [[ ! -f "$template" ]]; then 203 + die "cloud-init template not found: $template" 204 + fi 205 + 206 + # Substitute variables 207 + sed \ 208 + -e "s|__TS_OAUTH_SECRET__|${TS_OAUTH_SECRET}|g" \ 209 + -e "s|__TS_TAG__|${TS_TAG}|g" \ 210 + -e "s|__GH_REPO__|${GH_REPO}|g" \ 211 + "$template" > "$output" 212 + 213 + CLOUD_INIT_FILE="$output" 214 + log_success "Cloud-init config generated" 215 + } 216 + 217 + ####################################### 218 + # Create server 219 + ####################################### 220 + create_server() { 221 + log_info "Creating server..." 222 + 223 + hcloud server create \ 224 + --name "$SERVER_NAME" \ 225 + --type "$HETZNER_SERVER_TYPE" \ 226 + --image debian-13 \ 227 + --location "$HETZNER_LOCATION" \ 228 + --ssh-key "$SSH_KEY_NAME" \ 229 + --user-data-from-file "$CLOUD_INIT_FILE" 230 + 231 + # Get server IPs 232 + SERVER_IP=$(hcloud server ip "$SERVER_NAME") 233 + # hcloud returns a /64 prefix (e.g., 2a01:4f8::/64), convert to usable address 234 + local ip6_raw 235 + ip6_raw=$(hcloud server ip -6 "$SERVER_NAME" | head -1) 236 + if [[ -n "$ip6_raw" && "$ip6_raw" =~ ^([^/]+):: ]]; then 237 + SERVER_IP6="${BASH_REMATCH[1]}::1" 238 + else 239 + SERVER_IP6="" 240 + fi 241 + log_success "Server created: $SERVER_IP${SERVER_IP6:+ / $SERVER_IP6}" 242 + } 243 + 244 + ####################################### 245 + # Update Cloudflare DNS 246 + ####################################### 247 + update_dns_record() { 248 + local record_type="$1" 249 + local fqdn="$2" 250 + local content="$3" 251 + local api_base="https://api.cloudflare.com/client/v4" 252 + 253 + # Check if record exists 254 + local existing 255 + existing=$(curl -s -X GET \ 256 + "$api_base/zones/$CLOUDFLARE_ZONE_ID/dns_records?type=$record_type&name=$fqdn" \ 257 + -H "Authorization: Bearer $CLOUDFLARE_API_TOKEN" \ 258 + -H "Content-Type: application/json") 259 + 260 + local record_id 261 + record_id=$(echo "$existing" | jq -r '.result[0].id // empty') 262 + 263 + if [[ -n "$record_id" ]]; then 264 + # Update existing record 265 + curl -s -X PUT \ 266 + "$api_base/zones/$CLOUDFLARE_ZONE_ID/dns_records/$record_id" \ 267 + -H "Authorization: Bearer $CLOUDFLARE_API_TOKEN" \ 268 + -H "Content-Type: application/json" \ 269 + --data "{\"type\":\"$record_type\",\"name\":\"$fqdn\",\"content\":\"$content\",\"ttl\":300,\"proxied\":false}" \ 270 + | jq -e '.success' > /dev/null 271 + log_success "DNS $record_type updated: $fqdn -> $content" 272 + else 273 + # Create new record 274 + curl -s -X POST \ 275 + "$api_base/zones/$CLOUDFLARE_ZONE_ID/dns_records" \ 276 + -H "Authorization: Bearer $CLOUDFLARE_API_TOKEN" \ 277 + -H "Content-Type: application/json" \ 278 + --data "{\"type\":\"$record_type\",\"name\":\"$fqdn\",\"content\":\"$content\",\"ttl\":300,\"proxied\":false}" \ 279 + | jq -e '.success' > /dev/null 280 + log_success "DNS $record_type created: $fqdn -> $content" 281 + fi 282 + } 283 + 284 + update_dns() { 285 + log_info "Updating Cloudflare DNS..." 286 + 287 + local fqdn="${SUBDOMAIN}.${DOMAIN}" 288 + 289 + # Create/update A record (IPv4) 290 + update_dns_record "A" "$fqdn" "$SERVER_IP" 291 + 292 + # Create/update AAAA record (IPv6) 293 + if [[ -n "$SERVER_IP6" ]]; then 294 + update_dns_record "AAAA" "$fqdn" "$SERVER_IP6" 295 + fi 296 + } 297 + 298 + ####################################### 299 + # Wait for server to be ready 300 + ####################################### 301 + wait_for_server() { 302 + log_info "Waiting for server to be ready..." 303 + 304 + local max_attempts=60 305 + local attempt=0 306 + 307 + # Wait for SSH 308 + while [[ $attempt -lt $max_attempts ]]; do 309 + if ssh -o ConnectTimeout=5 -o StrictHostKeyChecking=no -o BatchMode=yes \ 310 + "root@$SERVER_IP" "echo ok" &> /dev/null; then 311 + break 312 + fi 313 + attempt=$((attempt + 1)) 314 + echo -n "." 315 + sleep 5 316 + done 317 + echo 318 + 319 + if [[ $attempt -ge $max_attempts ]]; then 320 + die "Timeout waiting for SSH" 321 + fi 322 + 323 + # Capture host key for future connections 324 + ssh-keyscan -H "$SERVER_IP" >> ~/.ssh/known_hosts 2>/dev/null || true 325 + 326 + log_success "SSH is available" 327 + 328 + # Wait for cloud-init to complete, streaming log output in real-time 329 + log_info "Waiting for cloud-init to complete (streaming log)..." 330 + 331 + # Start tail -f in background 332 + ssh -o StrictHostKeyChecking=no "root@$SERVER_IP" \ 333 + "tail -f /var/log/cloud-init-output.log 2>/dev/null" & 334 + local tail_pid=$! 335 + 336 + # Poll for completion file 337 + attempt=0 338 + while [[ $attempt -lt $max_attempts ]]; do 339 + if ssh -o StrictHostKeyChecking=no "root@$SERVER_IP" \ 340 + "test -f /opt/.cloud-init-complete" &> /dev/null; then 341 + break 342 + fi 343 + attempt=$((attempt + 1)) 344 + sleep 5 345 + done 346 + 347 + # Stop the tail process 348 + kill "$tail_pid" 2>/dev/null || true 349 + wait "$tail_pid" 2>/dev/null || true 350 + 351 + if [[ $attempt -ge $max_attempts ]]; then 352 + die "Timeout waiting for cloud-init" 353 + fi 354 + 355 + log_success "Cloud-init completed" 356 + } 357 + 358 + ####################################### 359 + # Get Tailscale IP 360 + ####################################### 361 + get_tailscale_ip() { 362 + log_info "Getting Tailscale IP..." 363 + 364 + TAILSCALE_IP=$(ssh -o StrictHostKeyChecking=no "root@$SERVER_IP" \ 365 + "tailscale ip -4" 2>/dev/null || echo "") 366 + 367 + if [[ -z "$TAILSCALE_IP" ]]; then 368 + log_warn "Could not get Tailscale IP. Check Tailscale auth." 369 + else 370 + log_success "Tailscale IP: $TAILSCALE_IP" 371 + fi 372 + } 373 + 374 + ####################################### 375 + # Generate and upload .env file 376 + ####################################### 377 + upload_env_file() { 378 + log_info "Generating .env file..." 379 + 380 + local env_file="$scratch/.env" 381 + local fqdn="${SUBDOMAIN}.${DOMAIN}" 382 + 383 + cat > "$env_file" << EOF 384 + # Generated by deploy.sh on $(date -u +"%Y-%m-%dT%H:%M:%SZ") 385 + 386 + # === Server === 387 + PORT=3000 388 + NODE_ENV=production 389 + 390 + # === Letta === 391 + LETTA_BASE_URL=http://letta:8283 392 + LETTA_SERVER_PASSWORD=${LETTA_SERVER_PASSWORD} 393 + 394 + # === Telegram === 395 + TELEGRAM_BOT_TOKEN=${TELEGRAM_BOT_TOKEN} 396 + TELEGRAM_WEBHOOK_URL=https://${fqdn}/webhook 397 + TELEGRAM_WEBHOOK_SECRET_TOKEN=${TELEGRAM_WEBHOOK_SECRET_TOKEN} 398 + 399 + # === Anthropic Proxy === 400 + ANTHROPIC_PROXY_URL=http://anthropic-proxy:4001/v1 401 + ANTHROPIC_PROXY_SESSION_SECRET=${ANTHROPIC_PROXY_SESSION_SECRET} 402 + ANTHROPIC_PROXY_SESSION_ID= 403 + 404 + # === LiteLLM === 405 + LITELLM_URL=http://litellm:4000 406 + 407 + # === OpenAI === 408 + OPENAI_API_KEY=${OPENAI_API_KEY} 409 + 410 + # === Database === 411 + DB_PATH=/app/data/assistant.db 412 + 413 + # === Tool Webhooks === 414 + TOOL_WEBHOOK_URL=http://app:3000 415 + 416 + # === Monitoring === 417 + NETDATA_CLAIM_TOKEN=${NETDATA_CLAIM_TOKEN:-} 418 + EOF 419 + 420 + log_info "Uploading .env file..." 421 + scp -o StrictHostKeyChecking=no "$env_file" "root@$SERVER_IP:/opt/assistant/.env" 422 + log_success ".env file uploaded" 423 + } 424 + 425 + ####################################### 426 + # Generate and upload Caddyfile 427 + ####################################### 428 + upload_caddyfile() { 429 + log_info "Generating Caddyfile..." 430 + 431 + local caddyfile="$scratch/Caddyfile" 432 + local fqdn="${SUBDOMAIN}.${DOMAIN}" 433 + 434 + cat > "$caddyfile" << EOF 435 + # Generated by deploy.sh on $(date -u +"%Y-%m-%dT%H:%M:%SZ") 436 + 437 + # Main app - Telegram webhook 438 + ${fqdn} { 439 + reverse_proxy app:3000 440 + } 441 + 442 + # Netdata and Letta are accessed via Tailscale only 443 + # http://TAILSCALE_IP:19999 - Netdata 444 + # http://TAILSCALE_IP:8283 - Letta 445 + # http://TAILSCALE_IP:4001 - Anthropic Proxy (for OAuth setup) 446 + EOF 447 + 448 + log_info "Uploading Caddyfile..." 449 + scp -o StrictHostKeyChecking=no "$caddyfile" "root@$SERVER_IP:/opt/assistant/Caddyfile" 450 + log_success "Caddyfile uploaded" 451 + } 452 + 453 + ####################################### 454 + # Start services 455 + ####################################### 456 + start_services() { 457 + log_info "Pulling pre-built images..." 458 + 459 + ssh -T -o StrictHostKeyChecking=no "root@$SERVER_IP" << 'EOF' 460 + cd /opt/assistant 461 + docker compose -f docker-compose.yml -f docker-compose.prod.yml pull --ignore-buildable 462 + EOF 463 + 464 + log_success "Images pulled" 465 + log_info "Building custom images..." 466 + 467 + ssh -T -o StrictHostKeyChecking=no "root@$SERVER_IP" << 'EOF' 468 + cd /opt/assistant 469 + export DOCKER_BUILDKIT=1 470 + export COMPOSE_DOCKER_CLI_BUILD=1 471 + docker compose -f docker-compose.yml -f docker-compose.prod.yml build 472 + EOF 473 + 474 + log_success "Images built" 475 + log_info "Starting services..." 476 + 477 + ssh -T -o StrictHostKeyChecking=no "root@$SERVER_IP" << 'EOF' 478 + cd /opt/assistant 479 + docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d 480 + EOF 481 + 482 + log_success "Services started" 483 + } 484 + 485 + ####################################### 486 + # Wait for health check 487 + ####################################### 488 + wait_for_health() { 489 + log_info "Waiting for services to be healthy..." 490 + 491 + local fqdn="${SUBDOMAIN}.${DOMAIN}" 492 + local max_attempts=30 493 + local attempt=0 494 + 495 + while [[ $attempt -lt $max_attempts ]]; do 496 + if curl -sf "https://${fqdn}/health" &> /dev/null; then 497 + break 498 + fi 499 + attempt=$((attempt + 1)) 500 + # Show container health status 501 + local status 502 + status=$(ssh -o StrictHostKeyChecking=no -o ConnectTimeout=5 "root@$SERVER_IP" \ 503 + "cd /opt/assistant && docker compose -f docker-compose.yml -f docker-compose.prod.yml ps --format 'table {{.Service}}\t{{.Status}}' 2>/dev/null | tail -n +2 | tr '\n' ' '" 2>/dev/null || echo "connecting...") 504 + printf "\r %-100s" "$status" 505 + sleep 10 506 + done 507 + printf "\r%-110s\n" "" # Clear the status line 508 + 509 + if [[ $attempt -ge $max_attempts ]]; then 510 + log_warn "Health check timeout. Services may still be starting." 511 + log_info "Check logs with: ssh root@$SERVER_IP 'docker compose -f /opt/assistant/docker-compose.yml -f /opt/assistant/docker-compose.prod.yml logs -f'" 512 + else 513 + log_success "Services healthy" 514 + fi 515 + } 516 + 517 + ####################################### 518 + # Set Telegram webhook 519 + ####################################### 520 + set_telegram_webhook() { 521 + log_info "Setting Telegram webhook..." 522 + 523 + local fqdn="${SUBDOMAIN}.${DOMAIN}" 524 + local webhook_url="https://${fqdn}/webhook" 525 + 526 + local response 527 + response=$(curl -s -X POST \ 528 + "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/setWebhook" \ 529 + -H "Content-Type: application/json" \ 530 + -d "{\"url\":\"$webhook_url\",\"secret_token\":\"$TELEGRAM_WEBHOOK_SECRET_TOKEN\"}") 531 + 532 + if echo "$response" | jq -e '.ok' > /dev/null; then 533 + log_success "Telegram webhook set: $webhook_url" 534 + else 535 + log_warn "Failed to set webhook: $response" 536 + fi 537 + } 538 + 539 + ####################################### 540 + # Get deploy key for GitHub Actions 541 + ####################################### 542 + get_deploy_key() { 543 + log_info "Getting deploy key for GitHub Actions..." 544 + 545 + DEPLOY_KEY_PUBLIC=$(ssh -o StrictHostKeyChecking=no "root@$SERVER_IP" \ 546 + "cat /root/.ssh/deploy_key.pub" 2>/dev/null || echo "") 547 + 548 + # Write private key to file with restricted permissions (never print to stdout) 549 + DEPLOY_KEY_FILE="$SCRIPT_DIR/deploy_key_${SERVER_NAME}" 550 + ssh -o StrictHostKeyChecking=no "root@$SERVER_IP" \ 551 + "cat /root/.ssh/deploy_key" 2>/dev/null > "$DEPLOY_KEY_FILE" || true 552 + 553 + if [[ -s "$DEPLOY_KEY_FILE" ]]; then 554 + chmod 600 "$DEPLOY_KEY_FILE" 555 + log_success "Deploy key saved to: $DEPLOY_KEY_FILE" 556 + else 557 + rm -f "$DEPLOY_KEY_FILE" 558 + DEPLOY_KEY_FILE="" 559 + log_warn "Could not retrieve deploy key" 560 + fi 561 + } 562 + 563 + ####################################### 564 + # Print summary 565 + ####################################### 566 + print_summary() { 567 + local fqdn="${SUBDOMAIN}.${DOMAIN}" 568 + 569 + echo 570 + echo -e "${GREEN}========================================${NC}" 571 + echo -e "${GREEN} Deployment Complete!${NC}" 572 + echo -e "${GREEN}========================================${NC}" 573 + echo 574 + echo -e "${BLUE}Server:${NC}" 575 + echo " IPv4: $SERVER_IP" 576 + echo " IPv6: ${SERVER_IP6:-'(none)'}" 577 + echo " Tailscale: ${TAILSCALE_IP:-'(check Tailscale admin)'}" 578 + echo 579 + echo -e "${BLUE}Service URLs:${NC}" 580 + echo " App (public): https://${fqdn}" 581 + echo " Health check: https://${fqdn}/health" 582 + if [[ -n "${TAILSCALE_IP:-}" ]]; then 583 + echo " Letta (Tailscale): http://${TAILSCALE_IP}:8283" 584 + echo " Netdata (Tailscale): http://${TAILSCALE_IP}:19999" 585 + echo " OAuth setup (Tailscale): http://${TAILSCALE_IP}:4001/auth/device" 586 + fi 587 + echo 588 + echo -e "${YELLOW}NEXT STEP: Complete Anthropic OAuth${NC}" 589 + echo " Open this URL from any device on your Tailscale network:" 590 + if [[ -n "${TAILSCALE_IP:-}" ]]; then 591 + echo " http://${TAILSCALE_IP}:4001/auth/device" 592 + else 593 + echo " http://<TAILSCALE_IP>:4001/auth/device" 594 + fi 595 + echo 596 + echo " After completing OAuth, copy the session ID and run:" 597 + echo " ssh root@$SERVER_IP" 598 + echo " nano /opt/assistant/.env" 599 + echo " # Set ANTHROPIC_PROXY_SESSION_ID=your_session_id" 600 + echo " docker compose -f docker-compose.yml -f docker-compose.prod.yml restart" 601 + echo 602 + if [[ -n "${DEPLOY_KEY_PUBLIC:-}" ]]; then 603 + echo -e "${BLUE}GitHub Actions Setup:${NC}" 604 + echo " Add this deploy key to GitHub (Settings → Deploy keys):" 605 + echo " $DEPLOY_KEY_PUBLIC" 606 + echo 607 + echo " Add these secrets to GitHub Actions (Settings → Secrets):" 608 + echo " HOST: $SERVER_IP" 609 + if [[ -n "${DEPLOY_KEY_FILE:-}" ]]; then 610 + echo " SSH_KEY: contents of $DEPLOY_KEY_FILE" 611 + fi 612 + echo 613 + fi 614 + echo -e "${BLUE}Useful commands:${NC}" 615 + echo " SSH: ssh root@$SERVER_IP" 616 + echo " Logs: ssh root@$SERVER_IP 'cd /opt/assistant && docker compose logs -f'" 617 + echo " Restart: ssh root@$SERVER_IP 'cd /opt/assistant && docker compose -f docker-compose.yml -f docker-compose.prod.yml restart'" 618 + echo " Teardown: ./teardown.sh" 619 + echo 620 + } 621 + 622 + ####################################### 623 + # Main 624 + ####################################### 625 + main() { 626 + echo -e "${BLUE}========================================${NC}" 627 + echo -e "${BLUE} Assistant Deployment Script${NC}" 628 + echo -e "${BLUE}========================================${NC}" 629 + echo 630 + 631 + check_prerequisites 632 + load_secrets 633 + setup_ssh_key 634 + check_existing_server 635 + generate_cloud_init 636 + create_server 637 + update_dns 638 + wait_for_server 639 + get_tailscale_ip 640 + upload_env_file 641 + upload_caddyfile 642 + start_services 643 + wait_for_health 644 + set_telegram_webhook 645 + get_deploy_key 646 + print_summary 647 + } 648 + 649 + main "$@"
+37
infra/secrets.env.example
··· 1 + # Infrastructure secrets for automated deployment 2 + # Copy to secrets.env and fill in your values: 3 + # cp secrets.env.example secrets.env 4 + # 5 + # WARNING: secrets.env contains sensitive data - it's gitignored 6 + 7 + # === Hetzner === 8 + HETZNER_LOCATION=fsn1 # fsn1 (Falkenstein), nbg1 (Nuremberg), hel1 (Helsinki) 9 + HETZNER_SERVER_TYPE=cx22 # cx22 (2 vCPU, 4GB) or cx32 (4 vCPU, 8GB) 10 + 11 + # === Domain & DNS === 12 + DOMAIN=yourdomain.com # Your domain (e.g., example.com) 13 + SUBDOMAIN=assistant # Subdomain for the app (e.g., assistant.example.com) 14 + CLOUDFLARE_API_TOKEN= # API token with "Edit zone DNS" permission 15 + CLOUDFLARE_ZONE_ID= # Dashboard → your domain → right sidebar → API section 16 + 17 + # === Tailscale === 18 + TS_OAUTH_SECRET= # OAuth client secret (tskey-client-...) 19 + TS_TAG=tag:server # Tag for the device (must exist in ACLs) 20 + 21 + # === GitHub === 22 + GH_REPO=username/assistant # GitHub repo path (e.g., myuser/assistant) 23 + 24 + # === Telegram === 25 + TELEGRAM_BOT_TOKEN= # Bot token from @BotFather 26 + 27 + # === OpenAI === 28 + OPENAI_API_KEY= # OpenAI API key (for embeddings) 29 + 30 + # === Auto-generated if empty === 31 + # Leave these empty and deploy.sh will generate secure random values 32 + ANTHROPIC_PROXY_SESSION_SECRET= # 32-byte hex for session encryption 33 + TELEGRAM_WEBHOOK_SECRET_TOKEN= # 16-byte hex for webhook verification 34 + LETTA_SERVER_PASSWORD= # 16-byte hex for Letta API auth 35 + 36 + # === Optional === 37 + NETDATA_CLAIM_TOKEN= # Netdata Cloud claim token (for remote monitoring)
+49
infra/sync-prompt.sh
··· 1 + #!/usr/bin/env bash 2 + # Quick-sync system prompt to server without full deploy 3 + # 4 + # Usage: 5 + # ./sync-prompt.sh 6 + 7 + set -euo pipefail 8 + IFS=$'\n\t' 9 + 10 + # Colors 11 + GREEN='\033[0;32m' 12 + BLUE='\033[0;34m' 13 + RED='\033[0;31m' 14 + NC='\033[0m' 15 + 16 + SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" 17 + PROJECT_DIR="$(dirname "$SCRIPT_DIR")" 18 + SERVER_NAME="assistant" 19 + 20 + log_info() { echo -e "${BLUE}[INFO]${NC} $1"; } 21 + log_success() { echo -e "${GREEN}[OK]${NC} $1"; } 22 + log_error() { echo -e "${RED}[ERROR]${NC} $1" >&2; } 23 + die() { log_error "$1"; exit 1; } 24 + 25 + # Get server IP 26 + if ! command -v hcloud &> /dev/null; then 27 + die "hcloud CLI not found" 28 + fi 29 + 30 + SERVER_IP=$(hcloud server ip "$SERVER_NAME" 2>/dev/null) || die "Server '$SERVER_NAME' not found" 31 + 32 + # Check prompt file exists 33 + PROMPT_FILE="$PROJECT_DIR/prompts/SYSTEM_PROMPT.md" 34 + if [[ ! -f "$PROMPT_FILE" ]]; then 35 + die "System prompt not found: $PROMPT_FILE" 36 + fi 37 + 38 + log_info "Syncing system prompt to $SERVER_IP..." 39 + 40 + # Upload prompt 41 + scp -o StrictHostKeyChecking=no "$PROMPT_FILE" "root@$SERVER_IP:/opt/assistant/prompts/SYSTEM_PROMPT.md" 42 + log_success "Prompt uploaded" 43 + 44 + # Restart app container only (fast, no rebuild) 45 + log_info "Restarting app container..." 46 + ssh -o StrictHostKeyChecking=no "root@$SERVER_IP" \ 47 + "cd /opt/assistant && docker compose -f docker-compose.yml -f docker-compose.prod.yml restart app" 48 + 49 + log_success "Done! App restarted with new prompt."
+101
infra/teardown.sh
··· 1 + #!/usr/bin/env bash 2 + # Teardown assistant server and DNS records 3 + # 4 + # Usage: 5 + # ./teardown.sh 6 + 7 + set -euo pipefail 8 + IFS=$'\n\t' 9 + 10 + # Colors 11 + RED='\033[0;31m' 12 + GREEN='\033[0;32m' 13 + YELLOW='\033[0;33m' 14 + BLUE='\033[0;34m' 15 + NC='\033[0m' 16 + 17 + SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" 18 + SERVER_NAME="assistant" 19 + 20 + log_info() { echo -e "${BLUE}[INFO]${NC} $1"; } 21 + log_success() { echo -e "${GREEN}[OK]${NC} $1"; } 22 + log_warn() { echo -e "${YELLOW}[WARN]${NC} $1"; } 23 + log_error() { echo -e "${RED}[ERROR]${NC} $1" >&2; } 24 + die() { log_error "$1"; exit 1; } 25 + 26 + echo -e "${RED}========================================${NC}" 27 + echo -e "${RED} Assistant Teardown${NC}" 28 + echo -e "${RED}========================================${NC}" 29 + echo 30 + 31 + # Confirm 32 + echo -e "${YELLOW}This will delete:${NC}" 33 + echo " - Hetzner server: $SERVER_NAME" 34 + echo " - DNS records (if secrets.env is available)" 35 + echo 36 + read -rp "Are you sure? [y/N] " confirm 37 + if [[ ! "$confirm" =~ ^[Yy]$ ]]; then 38 + echo "Aborted." 39 + exit 0 40 + fi 41 + 42 + # Load secrets for DNS cleanup (optional) 43 + if [[ -f "$SCRIPT_DIR/secrets.env" ]]; then 44 + # shellcheck source=/dev/null 45 + source "$SCRIPT_DIR/secrets.env" 46 + fi 47 + 48 + # Delete server 49 + if hcloud server describe "$SERVER_NAME" &> /dev/null; then 50 + log_info "Deleting server..." 51 + hcloud server delete "$SERVER_NAME" 52 + log_success "Server deleted" 53 + else 54 + log_warn "Server '$SERVER_NAME' not found" 55 + fi 56 + 57 + # Delete DNS records (if we have the credentials) 58 + if [[ -n "${CLOUDFLARE_API_TOKEN:-}" ]] && [[ -n "${CLOUDFLARE_ZONE_ID:-}" ]] && [[ -n "${SUBDOMAIN:-}" ]] && [[ -n "${DOMAIN:-}" ]]; then 59 + log_info "Deleting DNS records..." 60 + 61 + fqdn="${SUBDOMAIN}.${DOMAIN}" 62 + api_base="https://api.cloudflare.com/client/v4" 63 + 64 + # Delete both A and AAAA records 65 + for record_type in A AAAA; do 66 + existing=$(curl -s -X GET \ 67 + "$api_base/zones/$CLOUDFLARE_ZONE_ID/dns_records?type=$record_type&name=$fqdn" \ 68 + -H "Authorization: Bearer $CLOUDFLARE_API_TOKEN" \ 69 + -H "Content-Type: application/json") 70 + 71 + record_id=$(echo "$existing" | jq -r '.result[0].id // empty') 72 + 73 + if [[ -n "$record_id" ]]; then 74 + curl -s -X DELETE \ 75 + "$api_base/zones/$CLOUDFLARE_ZONE_ID/dns_records/$record_id" \ 76 + -H "Authorization: Bearer $CLOUDFLARE_API_TOKEN" \ 77 + -H "Content-Type: application/json" \ 78 + | jq -e '.success' > /dev/null 79 + log_success "DNS $record_type deleted: $fqdn" 80 + fi 81 + done 82 + else 83 + log_warn "Skipping DNS cleanup (secrets not available)" 84 + fi 85 + 86 + # Delete SSH key (optional) 87 + read -rp "Delete SSH key from Hetzner? [y/N] " delete_key 88 + if [[ "$delete_key" =~ ^[Yy]$ ]]; then 89 + if hcloud ssh-key describe "assistant-deploy" &> /dev/null; then 90 + hcloud ssh-key delete "assistant-deploy" 91 + log_success "SSH key deleted" 92 + else 93 + log_warn "SSH key 'assistant-deploy' not found" 94 + fi 95 + fi 96 + 97 + echo 98 + log_success "Teardown complete" 99 + echo 100 + echo "Note: Tailscale device may still appear in your admin console." 101 + echo "Remove it manually at: https://login.tailscale.com/admin/machines"