#!/usr/bin/env bash # # Forgejo Self-Hosting Setup Wizard # Interactive setup script for deploying Forgejo on Scaleway or Hetzner # # This wizard guides you through: # 1. Checking dependencies # 2. Configuring secrets (Ansible Vault) # 3. Setting up cloud provider credentials # 4. Creating infrastructure (Terraform/Terragrunt) # 5. Configuring Ansible inventory # 6. Deploying Forgejo with proper Tailscale/UFW ordering # # Works on: macOS, Debian, Ubuntu # set -euo pipefail # ============================================================================== # Configuration # ============================================================================== SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" ANSIBLE_DIR="${SCRIPT_DIR}/ansible" TERRAFORM_DIR="${SCRIPT_DIR}/terraform" INVENTORY_FILE="${ANSIBLE_DIR}/inventory/production/hosts.yml" SECRETS_FILE="${ANSIBLE_DIR}/playbooks/vars/secrets.yml" SECRETS_EXAMPLE="${ANSIBLE_DIR}/playbooks/vars/secrets.yml.example" STATE_FILE="${SCRIPT_DIR}/.wizard-state" # Point Ansible to our config file export ANSIBLE_CONFIG="${ANSIBLE_DIR}/ansible.cfg" # State tracking (compatible with bash 3.x - no associative arrays) VAULT_PASSWORD="" load_state() { # State is read directly from file when needed (bash 3.x compatible) true } save_state() { local key="$1" local value="$2" # Create file if it doesn't exist if [[ ! -f "$STATE_FILE" ]]; then echo "# Wizard state - auto-generated" >"$STATE_FILE" fi # Remove existing key if present, then add new value if grep -q "^${key}=" "$STATE_FILE" 2>/dev/null; then # Key exists, update it (macOS/Linux compatible) local temp_file="${STATE_FILE}.tmp" grep -v "^${key}=" "$STATE_FILE" >"$temp_file" echo "${key}=${value}" >>"$temp_file" mv "$temp_file" "$STATE_FILE" else # Key doesn't exist, append it echo "${key}=${value}" >>"$STATE_FILE" fi } get_state() { local key="$1" if [[ -f "$STATE_FILE" ]]; then # Use || true to prevent exit on grep finding no matches (exit code 1) grep "^${key}=" "$STATE_FILE" 2>/dev/null | cut -d'=' -f2- | head -1 || true fi } # Check if a step is marked as "done" is_step_done() { [[ "$(get_state "$1")" == "done" ]] } # Check if a step has any value set (for steps that store actual values like provider, domain) has_state() { local value value="$(get_state "$1")" [[ -n "$value" ]] } # ============================================================================== # Colors and Formatting (works on macOS and Linux) # ============================================================================== # Check if terminal supports colors if [[ -t 1 ]] && command -v tput &>/dev/null && [[ $(tput colors 2>/dev/null || echo 0) -ge 8 ]]; then RED=$(tput setaf 1) GREEN=$(tput setaf 2) YELLOW=$(tput setaf 3) BLUE=$(tput setaf 4) MAGENTA=$(tput setaf 5) CYAN=$(tput setaf 6) BOLD=$(tput bold) DIM=$(tput dim) RESET=$(tput sgr0) else RED="" GREEN="" YELLOW="" BLUE="" MAGENTA="" CYAN="" BOLD="" DIM="" RESET="" fi # ============================================================================== # Helper Functions # ============================================================================== print_banner() { clear echo "${CYAN}${BOLD}" echo "╔═══════════════════════════════════════════════════════════════════════════╗" echo "║ ║" echo "║ ███████╗ ██████╗ ██████╗ ██████╗ ███████╗ ██╗ ██████╗ ║" echo "║ ██╔════╝██╔═══██╗██╔══██╗██╔════╝ ██╔════╝ ██║██╔═══██╗ ║" echo "║ █████╗ ██║ ██║██████╔╝██║ ███╗█████╗ ██║██║ ██║ ║" echo "║ ██╔══╝ ██║ ██║██╔══██╗██║ ██║██╔══╝ ██ ██║██║ ██║ ║" echo "║ ██║ ╚██████╔╝██║ ██║╚██████╔╝███████╗╚█████╔╝╚██████╔╝ ║" echo "║ ╚═╝ ╚═════╝ ╚═╝ ╚═╝ ╚═════╝ ╚══════╝ ╚════╝ ╚═════╝ ║" echo "║ ║" echo "║ Self-Hosting Setup Wizard (by Dumontix) ║" echo "║ ║" echo "╚═══════════════════════════════════════════════════════════════════════════╝" echo "${RESET}" echo "" } print_step() { local step_num="$1" local step_title="$2" # Clear screen for each new step (cleaner experience) clear # Show mini header echo "${CYAN}${BOLD}Forgejo Setup Wizard${RESET}" echo "" echo "${BLUE}${BOLD}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${RESET}" echo "${BLUE}${BOLD} Step ${step_num}: ${step_title}${RESET}" echo "${BLUE}${BOLD}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${RESET}" echo "" } print_info() { echo "${CYAN}ℹ ${RESET}$1" } print_success() { echo "${GREEN}✓ ${RESET}$1" } print_warning() { echo "${YELLOW}⚠ ${RESET}$1" } print_error() { echo "${RED}✗ ${RESET}$1" } print_action() { echo "${MAGENTA}▶ ${RESET}$1" } press_enter() { echo "" echo -n "${DIM}Press Enter to continue...${RESET}" read -r &2 else echo -n "${YELLOW}? ${RESET}${prompt}: " >&2 fi read -r result &2 read -rs result &2 # New line after hidden input echo "$result" } ask_choice() { local prompt="$1" shift local options=("$@") local choice # Output menu to stderr so it's not captured by $() echo "${YELLOW}? ${RESET}${prompt}" >&2 for i in "${!options[@]}"; do echo " ${CYAN}$((i + 1))${RESET}) ${options[$i]}" >&2 done echo -n " Enter choice [1-${#options[@]}]: " >&2 # Read from /dev/tty to ensure we read from terminal read -r choice /dev/null; then openssl rand -base64 "$1" | tr -d '\n' elif [[ -r /dev/urandom ]]; then head -c "$1" /dev/urandom | base64 | tr -d '\n' | head -c "$1" else print_error "Cannot generate random password" exit 1 fi } open_editor() { local file="$1" local editor="${EDITOR:-${VISUAL:-}}" # Try to find a suitable editor if [[ -z "$editor" ]]; then for cmd in nano vim vi; do if command -v "$cmd" &>/dev/null; then editor="$cmd" break fi done fi if [[ -z "$editor" ]]; then print_error "No editor found. Please set \$EDITOR environment variable." print_info "For now, please edit the file manually: ${file}" press_enter return 1 fi print_action "Opening ${file} in ${editor}..." "$editor" "$file" } run_command() { local description="$1" shift print_action "${description}..." echo "${DIM} \$ $*${RESET}" if "$@"; then print_success "${description} completed" return 0 else print_error "${description} failed" return 1 fi } # ============================================================================== # Step Functions # ============================================================================== check_dependencies() { print_step "1" "Checking Dependencies" local missing=() local deps=(terraform terragrunt ansible ansible-playbook ansible-vault make ssh ssh-keygen) for dep in "${deps[@]}"; do if command -v "$dep" &>/dev/null; then print_success "$dep found: $(command -v "$dep")" else print_error "$dep not found" missing+=("$dep") fi done echo "" if [[ ${#missing[@]} -gt 0 ]]; then echo "" print_error "Missing dependencies: ${missing[*]}" echo "" print_info "Please install the missing tools:" echo "" echo " ${BOLD}macOS (with Homebrew):${RESET}" echo " brew install terraform terragrunt ansible make" echo "" echo " ${BOLD}Ubuntu/Debian:${RESET}" echo " sudo apt update" echo " sudo apt install make ansible" echo " # Install terraform and terragrunt from their websites" echo "" echo " ${BOLD}on a Python virtualenv:${RESET} (likely can be done with UV, I am not sure how)" echo " python3 -m venv .venv;. .venv/bin/activate" echo " python3 -m pip install ansible" echo " # Install terraform and terragrunt from their websites" echo "" exit 1 fi print_success "All dependencies satisfied!" } select_ssh_key() { print_step "1b" "Select SSH Key" # Find all SSH keys (private keys without .pub extension that have a matching .pub) local ssh_keys=() local key_names=() for key in ~/.ssh/id_* ~/.ssh/*.pem; do # Skip if glob didn't match [[ -e "$key" ]] || continue # Skip public keys [[ "$key" == *.pub ]] && continue # Skip known_hosts and config files [[ "$key" == *known_hosts* ]] && continue [[ "$key" == *config* ]] && continue [[ "$key" == *authorized_keys* ]] && continue # Check if it's a file (not directory) [[ -f "$key" ]] || continue ssh_keys+=("$key") # Get a friendly name (just the filename) key_names+=("$(basename "$key")") done if [[ ${#ssh_keys[@]} -eq 0 ]]; then print_warning "No SSH keys found in ~/.ssh/" if ask_yes_no "Generate a new SSH key?"; then local key_name key_name=$(ask_input "Key name" "id_ed25519") ssh-keygen -t ed25519 -f ~/.ssh/"$key_name" -N "" SSH_KEY=~/.ssh/"$key_name" print_success "SSH key generated: ${SSH_KEY}" else print_error "SSH key is required for server access" exit 1 fi elif [[ ${#ssh_keys[@]} -eq 1 ]]; then SSH_KEY="${ssh_keys[0]}" print_info "Using only available SSH key: ${SSH_KEY}" else print_info "Found ${#ssh_keys[@]} SSH keys:" echo "" # Display keys with numbers local i=1 for key in "${ssh_keys[@]}"; do local pub_key="${key}.pub" local key_info="" if [[ -f "$pub_key" ]]; then # Extract comment/email from public key key_info=$(awk '{print $3}' "$pub_key" 2>/dev/null || true) fi if [[ -n "$key_info" ]]; then echo " ${CYAN}${i}${RESET}) $(basename "$key") ${DIM}(${key_info})${RESET}" else echo " ${CYAN}${i}${RESET}) $(basename "$key")" fi i=$((i + 1)) done echo "" local choice echo -n " Select SSH key [1-${#ssh_keys[@]}]: " >&2 read -r choice /dev/null | grep -q '^\$ANSIBLE_VAULT' } read_vault_secret() { local file="$1" local key="$2" local password="$3" # Decrypt and extract value using ansible-vault echo "$password" | ansible-vault view "$file" --vault-password-file=/dev/stdin 2>/dev/null | grep "^${key}:" | sed "s/^${key}:[[:space:]]*//" | tr -d '"' | tr -d "'" } configure_secrets() { print_step "3" "Configure Secrets" # Check if secrets file exists and is encrypted if [[ -f "$SECRETS_FILE" ]] && is_vault_encrypted "$SECRETS_FILE"; then print_info "Encrypted secrets file found: ${SECRETS_FILE}" echo "" local choice choice=$(ask_choice "What would you like to do?" "Use existing secrets (enter vault password to view admin password)" "Generate new secrets (overwrites existing)") if [[ "$choice" == "0" ]]; then # Read existing secrets print_info "Enter your Ansible Vault password to read existing secrets." echo "" local vault_pass vault_pass=$(ask_secret "Vault password") if [[ -z "$vault_pass" ]]; then print_error "No password entered" return 1 fi # Verify password by trying to view the file local admin_password admin_password=$(read_vault_secret "$SECRETS_FILE" "vault_forgejo_admin_password" "$vault_pass") if [[ -z "$admin_password" ]]; then print_error "Could not decrypt secrets. Wrong password?" if ask_yes_no "Try again?" "y"; then configure_secrets return $? fi return 1 fi # Show admin password echo "" echo "${GREEN}${BOLD}┌─────────────────────────────────────────────────────────────────┐${RESET}" echo "${GREEN}${BOLD}│ Secrets decrypted successfully! │${RESET}" echo "${GREEN}${BOLD}│ │${RESET}" echo "${GREEN}${BOLD}│ Admin Username: admin │${RESET}" echo "${GREEN}${BOLD}│ Admin Password: ${admin_password} ${RESET}" echo "${GREEN}${BOLD}│ │${RESET}" echo "${GREEN}${BOLD}└─────────────────────────────────────────────────────────────────┘${RESET}" echo "" print_success "Using existing secrets" return 0 fi # Fall through to generate new secrets print_warning "Existing secrets will be overwritten." fi print_info "Secrets are stored encrypted with Ansible Vault." print_info "You'll need to remember your vault password!" echo "" # Copy example file if [[ -f "$SECRETS_EXAMPLE" ]]; then cp "$SECRETS_EXAMPLE" "$SECRETS_FILE" else print_error "Secrets example file not found: ${SECRETS_EXAMPLE}" exit 1 fi # Generate passwords print_action "Generating secure passwords..." local db_password local admin_password local secret_key local internal_token local jwt_secret local metrics_token db_password=$(generate_password 32) admin_password=$(generate_password 24) secret_key=$(generate_password 48) internal_token=$(generate_password 48) jwt_secret=$(generate_password 32) metrics_token=$(generate_password 24) # Update secrets file if [[ "$(uname)" == "Darwin" ]]; then # macOS sed requires different syntax sed -i '' "s|CHANGE_ME_STRONG_PASSWORD_HERE|${db_password}|g" "$SECRETS_FILE" sed -i '' "s|CHANGE_ME_ADMIN_PASSWORD_HERE|${admin_password}|g" "$SECRETS_FILE" sed -i '' "s|CHANGE_ME_SECRET_KEY_64_CHARS_MINIMUM_XXXXXXXXXXXXXXXXX|${secret_key}|g" "$SECRETS_FILE" sed -i '' "s|CHANGE_ME_INTERNAL_TOKEN_XXXXXXXXXXXXXXXXXXXXXXXXX|${internal_token}|g" "$SECRETS_FILE" sed -i '' "s|CHANGE_ME_JWT_SECRET_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX|${jwt_secret}|g" "$SECRETS_FILE" sed -i '' "s|CHANGE_ME_METRICS_TOKEN_XXXXXXXXX|${metrics_token}|g" "$SECRETS_FILE" else # Linux sed sed -i "s|CHANGE_ME_STRONG_PASSWORD_HERE|${db_password}|g" "$SECRETS_FILE" sed -i "s|CHANGE_ME_ADMIN_PASSWORD_HERE|${admin_password}|g" "$SECRETS_FILE" sed -i "s|CHANGE_ME_SECRET_KEY_64_CHARS_MINIMUM_XXXXXXXXXXXXXXXXX|${secret_key}|g" "$SECRETS_FILE" sed -i "s|CHANGE_ME_INTERNAL_TOKEN_XXXXXXXXXXXXXXXXXXXXXXXXX|${internal_token}|g" "$SECRETS_FILE" sed -i "s|CHANGE_ME_JWT_SECRET_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX|${jwt_secret}|g" "$SECRETS_FILE" sed -i "s|CHANGE_ME_METRICS_TOKEN_XXXXXXXXX|${metrics_token}|g" "$SECRETS_FILE" fi print_success "Passwords generated and saved" # Show admin password echo "" echo "${YELLOW}${BOLD}┌─────────────────────────────────────────────────────────────────┐${RESET}" echo "${YELLOW}${BOLD}│ IMPORTANT: Save your admin password! │${RESET}" echo "${YELLOW}${BOLD}│ │${RESET}" echo "${YELLOW}${BOLD}│ Admin Username: admin │${RESET}" echo "${YELLOW}${BOLD}│ Admin Password: ${admin_password} │${RESET}" echo "${YELLOW}${BOLD}│ │${RESET}" echo "${YELLOW}${BOLD}│ You can change this later in the Forgejo web interface. │${RESET}" echo "${YELLOW}${BOLD}└─────────────────────────────────────────────────────────────────┘${RESET}" echo "" # Encrypt with Ansible Vault print_info "Now you need to encrypt the secrets file with Ansible Vault." print_info "Choose a strong password and remember it!" echo "" if ansible-vault encrypt "$SECRETS_FILE"; then print_success "Secrets encrypted successfully" else print_error "Failed to encrypt secrets" exit 1 fi } configure_cloud_credentials() { print_step "4" "Configure Cloud Provider Credentials" if [[ "$PROVIDER" == "scaleway" ]]; then print_info "You need Scaleway API credentials." print_info "Get them from: https://console.scaleway.com/iam/api-keys" echo "" if [[ -z "${SCW_ACCESS_KEY:-}" ]]; then SCW_ACCESS_KEY=$(ask_input "Scaleway Access Key") export SCW_ACCESS_KEY else print_success "SCW_ACCESS_KEY already set: ${SCW_ACCESS_KEY:0:8}..." fi if [[ -z "${SCW_SECRET_KEY:-}" ]]; then SCW_SECRET_KEY=$(ask_input "Scaleway Secret Key") export SCW_SECRET_KEY else print_success "SCW_SECRET_KEY already set" fi echo "" print_info "Project ID determines where resources are created." print_info "Find it at: https://console.scaleway.com/project/settings" print_info " (Click on the project you want, then Settings → Project ID)" echo "" if [[ -n "${SCW_DEFAULT_PROJECT_ID:-}" ]]; then print_info "Current project ID: ${SCW_DEFAULT_PROJECT_ID}" if ask_yes_no "Use this project?" "y"; then print_success "Using existing project ID" else SCW_DEFAULT_PROJECT_ID=$(ask_input "Scaleway Project ID") export SCW_DEFAULT_PROJECT_ID fi else SCW_DEFAULT_PROJECT_ID=$(ask_input "Scaleway Project ID") export SCW_DEFAULT_PROJECT_ID fi else # hetzner print_info "You need a Hetzner Cloud API token." print_info "Get it from: https://console.hetzner.cloud/ → Security → API Tokens" echo "" if [[ -z "${HCLOUD_TOKEN:-}" ]]; then HCLOUD_TOKEN=$(ask_input "Hetzner API Token") export HCLOUD_TOKEN else print_success "HCLOUD_TOKEN already set" fi fi print_success "Cloud credentials configured" } configure_domain() { print_step "5" "Configure Domain" print_info "Your Forgejo instance needs a domain name." print_info "Example: git.yourdomain.com" echo "" DOMAIN=$(ask_input "Enter your domain" "git.example.com") print_info "After deployment, you'll need to create DNS records:" echo "" echo " ${DOMAIN} IN A " echo " ${DOMAIN} IN AAAA " echo "" # Create inventory file from example if it doesn't exist local inventory_example="${ANSIBLE_DIR}/inventory/production/hosts.yml.example" if [[ ! -f "$INVENTORY_FILE" ]] && [[ -f "$inventory_example" ]]; then print_action "Creating inventory file from example..." cp "$inventory_example" "$INVENTORY_FILE" fi # Update inventory file if [[ -f "$INVENTORY_FILE" ]]; then if [[ "$(uname)" == "Darwin" ]]; then sed -i '' "s|forgejo_domain:.*|forgejo_domain: ${DOMAIN}|g" "$INVENTORY_FILE" else sed -i "s|forgejo_domain:.*|forgejo_domain: ${DOMAIN}|g" "$INVENTORY_FILE" fi print_success "Domain configured in inventory" else print_error "Could not find or create inventory file" return 1 fi export DOMAIN } create_infrastructure() { print_step "6" "Create Infrastructure" print_info "This will create the following resources on ${PROVIDER}:" echo "" if [[ "$PROVIDER" == "scaleway" ]]; then echo " - DEV1-M compute instance" echo " - 50GB block storage volume" echo " - Security group" echo " - Reserved IP address" echo " - SSH key registration" else echo " - CPX21 compute instance" echo " - Block volume" echo " - Firewall rules" echo " - SSH key registration" fi echo "" if ! ask_yes_no "Create infrastructure now?"; then print_warning "Skipping infrastructure creation" return 0 fi cd "$TERRAFORM_DIR/$PROVIDER/compute" # Update terraform.tfvars with the SSH public key and domain local tfvars_file="terraform.tfvars" if [[ -n "${SSH_PUBLIC_KEY:-}" ]]; then print_action "Configuring SSH key in Terraform..." # Create or update terraform.tfvars with the SSH key if [[ -f "$tfvars_file" ]]; then # Remove existing ssh_public_key line if present grep -v '^ssh_public_key' "$tfvars_file" >"${tfvars_file}.tmp" || true mv "${tfvars_file}.tmp" "$tfvars_file" fi # Append SSH public key echo "" >>"$tfvars_file" echo "# SSH public key (added by setup wizard)" >>"$tfvars_file" echo "ssh_public_key = \"${SSH_PUBLIC_KEY}\"" >>"$tfvars_file" print_success "SSH public key configured" else print_warning "No SSH public key available - you may need to add it manually to your cloud provider" fi # Update domain in tfvars if set if [[ -n "${DOMAIN:-}" ]]; then if [[ -f "$tfvars_file" ]]; then if [[ "$(uname)" == "Darwin" ]]; then sed -i '' "s|^domain_name = .*|domain_name = \"${DOMAIN}\"|" "$tfvars_file" else sed -i "s|^domain_name = .*|domain_name = \"${DOMAIN}\"|" "$tfvars_file" fi fi fi # Initialize run_command "Initializing Terraform" terragrunt init # Plan print_action "Planning infrastructure..." terragrunt plan echo "" if ! ask_yes_no "Apply this plan?"; then print_warning "Infrastructure creation cancelled" return 1 fi # Apply run_command "Creating infrastructure" terragrunt apply -auto-approve # Get outputs echo "" print_success "Infrastructure created!" echo "" SERVER_IP=$(terragrunt output -raw server_ip 2>/dev/null || terragrunt output -raw server_ipv4 2>/dev/null || echo "") if [[ -n "$SERVER_IP" ]]; then echo "${GREEN}${BOLD}┌─────────────────────────────────────────────────────────────────┐${RESET}" echo "${GREEN}${BOLD}│ Server IP: ${SERVER_IP} ${RESET}" echo "${GREEN}${BOLD}└─────────────────────────────────────────────────────────────────┘${RESET}" echo "" # Update inventory if [[ "$(uname)" == "Darwin" ]]; then sed -i '' "s|ansible_host:.*|ansible_host: ${SERVER_IP}|g" "$INVENTORY_FILE" else sed -i "s|ansible_host:.*|ansible_host: ${SERVER_IP}|g" "$INVENTORY_FILE" fi print_success "Server IP updated in inventory" export SERVER_IP fi echo "" print_warning "IMPORTANT: Create DNS records now!" echo "" echo " ${DOMAIN} IN A ${SERVER_IP}" echo "" print_info "DNS propagation may take a few minutes." press_enter cd "$SCRIPT_DIR" } wait_for_server() { print_step "7" "Waiting for Server" print_info "Waiting for server to be ready..." print_info "Using SSH key: ${SSH_KEY}" local max_attempts=30 local attempt=1 while [[ $attempt -le $max_attempts ]]; do echo -n " Attempt ${attempt}/${max_attempts}: " if ssh -i "$SSH_KEY" -o ConnectTimeout=5 -o StrictHostKeyChecking=no -o BatchMode=yes "root@${SERVER_IP}" "echo 'Server ready'" 2>/dev/null; then echo "" print_success "Server is ready!" return 0 fi echo "waiting..." sleep 10 ((attempt++)) done print_error "Server not reachable after ${max_attempts} attempts" print_info "You may need to wait longer or check your cloud provider console" if ask_yes_no "Continue anyway?"; then return 0 fi return 1 } deploy_base() { print_step "8" "Deploy Base Configuration" print_info "This will deploy:" echo " - System packages and configuration" echo " - Docker" echo " - PostgreSQL database" echo " - Redis cache" echo " - Forgejo application" echo " - Caddy web server (HTTPS)" echo " - Tailscale VPN" echo "" print_warning "Tailscale will be installed but NOT UFW yet!" print_info "We'll configure UFW after you authenticate Tailscale." echo "" if ! ask_yes_no "Start deployment?"; then return 1 fi # Deploy everything except UFW (run from ansible dir to pick up ansible.cfg) cd "$ANSIBLE_DIR" print_action "Running Ansible deployment (without UFW)..." ansible-playbook \ -i "inventory/production/hosts.yml" \ "playbooks/deploy.yml" \ --ask-vault-pass \ --skip-tags "ufw,firewall" cd "$SCRIPT_DIR" print_success "Base deployment completed!" } configure_tailscale() { print_step "9" "Configure Tailscale VPN" echo "" echo "${YELLOW}${BOLD}╔═══════════════════════════════════════════════════════════════════════════╗${RESET}" echo "${YELLOW}${BOLD}║ TAILSCALE AUTHENTICATION REQUIRED ║${RESET}" echo "${YELLOW}${BOLD}╚═══════════════════════════════════════════════════════════════════════════╝${RESET}" echo "" print_info "Tailscale is installed but needs to be authenticated." print_info "This connects your server to your private Tailscale network." echo "" print_warning "After this step, SSH will ONLY be accessible via Tailscale!" echo "" echo "You have two options:" echo "" echo " ${BOLD}Option 1: Interactive (recommended)${RESET}" echo " 1. SSH into the server: ssh -i ${SSH_KEY} root@${SERVER_IP}" echo " 2. Run: sudo tailscale up --ssh" echo " 3. Open the URL shown in your browser to authenticate" echo "" echo " ${BOLD}Option 2: Auth Key (for automation)${RESET}" echo " 1. Generate a key at: https://login.tailscale.com/admin/settings/keys" echo " 2. SSH into the server: ssh -i ${SSH_KEY} root@${SERVER_IP}" echo " 3. Run: sudo tailscale up --authkey=tskey-auth-XXXXX" echo "" if ask_yes_no "Open SSH session to configure Tailscale now?"; then print_action "Opening SSH session..." print_info "Run: sudo tailscale up --ssh" print_info "Then exit the SSH session when done." echo "" ssh -i "$SSH_KEY" -o StrictHostKeyChecking=no "root@${SERVER_IP}" || true fi echo "" print_warning "VERIFY: Is Tailscale authenticated and connected?" echo "" if ! ask_yes_no "Have you successfully authenticated Tailscale?"; then print_error "Tailscale must be authenticated before enabling UFW!" print_info "Without Tailscale, you will be locked out of your server." echo "" if ! ask_yes_no "Try again?"; then print_warning "Skipping UFW configuration. Your server SSH is still publicly accessible." print_info "Run 'make deploy-tags TAGS=ufw' later after configuring Tailscale." return 1 fi configure_tailscale return $? fi print_success "Tailscale configured!" # Verify Tailscale connection print_action "Verifying Tailscale connection..." local tailscale_ip tailscale_ip=$(ssh -i "$SSH_KEY" -o StrictHostKeyChecking=no "root@${SERVER_IP}" "tailscale ip -4" 2>/dev/null || echo "") if [[ -n "$tailscale_ip" ]]; then echo "" echo "${GREEN}${BOLD}┌─────────────────────────────────────────────────────────────────┐${RESET}" echo "${GREEN}${BOLD}│ Tailscale IP: ${tailscale_ip} ${RESET}" echo "${GREEN}${BOLD}│ │${RESET}" echo "${GREEN}${BOLD}│ After UFW is enabled, use this IP for SSH: │${RESET}" echo "${GREEN}${BOLD}│ ssh root@${tailscale_ip} ${RESET}" echo "${GREEN}${BOLD}└─────────────────────────────────────────────────────────────────┘${RESET}" echo "" export TAILSCALE_IP="$tailscale_ip" fi return 0 } enable_firewall() { print_step "10" "Enable UFW Firewall" print_warning "This will enable the UFW firewall with the following rules:" echo "" echo " ${GREEN}PUBLIC ACCESS:${RESET}" echo " - Port 80 (HTTP) - Caddy/Let's Encrypt" echo " - Port 443 (HTTPS) - Forgejo web interface" echo "" echo " ${YELLOW}TAILSCALE-ONLY ACCESS:${RESET}" echo " - Port 22 (SSH) - System administration" echo " - Port 2222 (Git SSH) - Git clone/push/pull" echo " - All other internal services" echo "" print_warning "After this, public SSH will be blocked!" print_info "You will need to use Tailscale to access SSH." echo "" if ! ask_yes_no "Enable UFW firewall now?"; then print_warning "Skipping UFW. Your SSH is still publicly accessible." print_info "Run 'make deploy-tags TAGS=ufw' later to enable UFW." return 0 fi # Run from ansible dir to pick up ansible.cfg cd "$ANSIBLE_DIR" print_action "Enabling UFW firewall..." ansible-playbook \ -i "inventory/production/hosts.yml" \ "playbooks/deploy.yml" \ --ask-vault-pass \ --tags "ufw,firewall" cd "$SCRIPT_DIR" print_success "UFW firewall enabled!" # Verify we can still connect via Tailscale if [[ -n "${TAILSCALE_IP:-}" ]]; then print_action "Verifying Tailscale SSH access..." if ssh -i "$SSH_KEY" -o ConnectTimeout=10 -o StrictHostKeyChecking=no "root@${TAILSCALE_IP}" "echo 'Tailscale SSH works!'" 2>/dev/null; then print_success "Tailscale SSH access confirmed!" else print_warning "Could not verify Tailscale SSH. Please test manually." fi fi } show_completion() { print_step "✓" "Setup Complete!" echo "" echo "${GREEN}${BOLD}╔═══════════════════════════════════════════════════════════════════════════╗${RESET}" echo "${GREEN}${BOLD}║ ║${RESET}" echo "${GREEN}${BOLD}║ 🎉 Congratulations! Your Forgejo instance is ready! ║${RESET}" echo "${GREEN}${BOLD}║ ║${RESET}" echo "${GREEN}${BOLD}╚═══════════════════════════════════════════════════════════════════════════╝${RESET}" echo "" echo "${CYAN}${BOLD}Access Your Forgejo:${RESET}" echo "" echo " Web Interface: https://${DOMAIN}" echo " Admin Username: admin" echo " Admin Password: (the one shown earlier)" echo "" echo "${CYAN}${BOLD}SSH Access (via Tailscale):${RESET}" echo "" if [[ -n "${TAILSCALE_IP:-}" ]]; then echo " ssh -i ${SSH_KEY} root@${TAILSCALE_IP}" else echo " ssh -i ${SSH_KEY} root@" fi echo "" echo "${CYAN}${BOLD}Git Clone URLs:${RESET}" echo "" echo " HTTPS: https://${DOMAIN}/user/repo.git" echo " SSH: git@:user/repo.git" echo "" echo "${CYAN}${BOLD}Useful Commands:${RESET}" echo "" echo " make status # Check service status" echo " make logs # View Forgejo logs" echo " make backup # Create backup" echo " make update # Update Forgejo" echo "" echo "${CYAN}${BOLD}Documentation:${RESET}" echo "" echo " docs/CONFIGURATION.md - All configuration options" echo " docs/OPERATIONS.md - Operations guide" echo " README.md - Quick reference" echo "" echo "${YELLOW}${BOLD}Next Steps:${RESET}" echo "" echo " 1. Wait for DNS propagation (check: dig ${DOMAIN})" echo " 2. Access https://${DOMAIN} in your browser" echo " 3. Log in with admin credentials" echo " 4. Configure 2FA for the admin account" echo " 5. Create your first repository!" echo "" } # ============================================================================== # Main # ============================================================================== show_resume_status() { echo "" echo "${CYAN}${BOLD}Previous session detected. Completed steps:${RESET}" echo "" # Check each step individually (some use is_step_done, some use has_state) # Steps that store "done": dependencies, secrets, cloud_credentials, infrastructure, server_ready, base_deploy, tailscale, firewall # Steps that store values: provider, domain, server_ip if is_step_done "dependencies"; then echo " ${GREEN}✓${RESET} Check Dependencies" else echo " ${DIM}○${RESET} Check Dependencies" fi if has_state "ssh_key"; then echo " ${GREEN}✓${RESET} SSH Key ($(basename "$(get_state ssh_key)"))" else echo " ${DIM}○${RESET} SSH Key" fi if has_state "provider"; then echo " ${GREEN}✓${RESET} Choose Provider ($(get_state provider))" else echo " ${DIM}○${RESET} Choose Provider" fi if is_step_done "secrets"; then echo " ${GREEN}✓${RESET} Configure Secrets" else echo " ${DIM}○${RESET} Configure Secrets" fi if is_step_done "cloud_credentials"; then echo " ${GREEN}✓${RESET} Cloud Credentials" else echo " ${DIM}○${RESET} Cloud Credentials" fi if has_state "domain"; then echo " ${GREEN}✓${RESET} Configure Domain ($(get_state domain))" else echo " ${DIM}○${RESET} Configure Domain" fi if is_step_done "infrastructure"; then echo " ${GREEN}✓${RESET} Create Infrastructure" else echo " ${DIM}○${RESET} Create Infrastructure" fi if is_step_done "server_ready"; then echo " ${GREEN}✓${RESET} Wait for Server" else echo " ${DIM}○${RESET} Wait for Server" fi if is_step_done "base_deploy"; then echo " ${GREEN}✓${RESET} Base Deployment" else echo " ${DIM}○${RESET} Base Deployment" fi if is_step_done "tailscale"; then echo " ${GREEN}✓${RESET} Tailscale Setup" else echo " ${DIM}○${RESET} Tailscale Setup" fi if is_step_done "firewall"; then echo " ${GREEN}✓${RESET} Enable Firewall" else echo " ${DIM}○${RESET} Enable Firewall" fi # Show server IP if set local saved_ip saved_ip=$(get_state "server_ip") if [[ -n "$saved_ip" ]]; then echo "" echo "${CYAN}${BOLD}Server IP:${RESET} ${saved_ip}" fi echo "" } main() { # Load previous state if exists load_state print_banner # Check if resuming if [[ -f "$STATE_FILE" ]]; then show_resume_status echo "" local choice choice=$(ask_choice "What would you like to do?" "Continue from where you left off" "Start fresh (reset all progress)") if [[ "$choice" == "1" ]]; then # Reset state rm -f "$STATE_FILE" print_info "Starting fresh..." echo "" else # Restore saved values PROVIDER=$(get_state "provider") DOMAIN=$(get_state "domain") SERVER_IP=$(get_state "server_ip") TAILSCALE_IP=$(get_state "tailscale_ip") SSH_KEY=$(get_state "ssh_key") SSH_PUBLIC_KEY=$(get_state "ssh_public_key") export PROVIDER DOMAIN SERVER_IP TAILSCALE_IP SSH_KEY SSH_PUBLIC_KEY print_info "Resuming previous session..." echo "" fi else echo "This wizard will guide you through setting up your own Forgejo instance." echo "It takes about 10-15 minutes to complete." echo "" if ! ask_yes_no "Ready to begin?"; then echo "Setup cancelled." exit 0 fi fi # Step 1: Dependencies if ! is_step_done "dependencies"; then check_dependencies save_state "dependencies" "done" press_enter else print_info "Skipping dependencies check (already completed)" fi # Step 1b: SSH Key selection (stores path, not "done") if ! has_state "ssh_key"; then select_ssh_key save_state "ssh_key" "$SSH_KEY" # Store public key content for terraform (base64 encoded to handle newlines/spaces) if [[ -n "${SSH_PUBLIC_KEY:-}" ]]; then save_state "ssh_public_key" "$SSH_PUBLIC_KEY" fi press_enter else SSH_KEY=$(get_state "ssh_key") SSH_PUBLIC_KEY=$(get_state "ssh_public_key") export SSH_KEY SSH_PUBLIC_KEY print_info "Using SSH key: ${SSH_KEY}" fi # Step 2: Provider (stores actual value, not "done") if ! has_state "provider"; then choose_provider save_state "provider" "$PROVIDER" press_enter else print_info "Skipping provider selection (using: ${PROVIDER})" fi # Step 3: Secrets if ! is_step_done "secrets"; then configure_secrets save_state "secrets" "done" press_enter else print_info "Skipping secrets configuration (already completed)" fi # Step 4: Cloud Credentials # Skip cloud credentials entirely if infrastructure is already created # (remaining steps are Ansible-only and don't need cloud provider access) if is_step_done "infrastructure"; then print_info "Skipping cloud credentials (infrastructure already exists)" elif ! is_step_done "cloud_credentials"; then configure_cloud_credentials save_state "cloud_credentials" "done" press_enter else print_info "Skipping cloud credentials (already configured)" # Re-prompt for credentials since they're not persisted (security) configure_cloud_credentials fi # Step 5: Domain (stores actual value, not "done") if ! has_state "domain"; then configure_domain save_state "domain" "$DOMAIN" press_enter else print_info "Skipping domain configuration (using: ${DOMAIN})" fi # Step 6: Infrastructure if ! is_step_done "infrastructure"; then create_infrastructure if [[ -n "${SERVER_IP:-}" ]]; then save_state "infrastructure" "done" save_state "server_ip" "$SERVER_IP" fi else print_info "Skipping infrastructure creation (already created)" fi # Ensure we have server IP if [[ -z "${SERVER_IP:-}" ]]; then SERVER_IP=$(ask_input "Enter server IP address") save_state "server_ip" "$SERVER_IP" export SERVER_IP fi # Step 7: Wait for Server if ! is_step_done "server_ready"; then wait_for_server save_state "server_ready" "done" press_enter else print_info "Skipping server wait (already verified)" fi # Step 8: Base Deployment if ! is_step_done "base_deploy"; then deploy_base save_state "base_deploy" "done" press_enter else print_info "Skipping base deployment (already deployed)" fi # Step 9: Tailscale if ! is_step_done "tailscale"; then if configure_tailscale; then save_state "tailscale" "done" if [[ -n "${TAILSCALE_IP:-}" ]]; then save_state "tailscale_ip" "$TAILSCALE_IP" fi fi else print_info "Skipping Tailscale configuration (already configured)" fi # Step 10: Firewall if ! is_step_done "firewall"; then if is_step_done "tailscale"; then enable_firewall save_state "firewall" "done" else print_warning "Skipping firewall - Tailscale must be configured first" fi else print_info "Skipping firewall setup (already enabled)" fi show_completion # Clean up state file on successful completion if is_step_done "firewall" || is_step_done "tailscale"; then if ask_yes_no "Setup complete! Remove progress tracking file?" "y"; then rm -f "$STATE_FILE" fi fi } # Run main function main "$@"