package host import ( "fmt" "strings" "github.com/bdw/ship/internal/ssh" "github.com/bdw/ship/internal/state" "github.com/bdw/ship/internal/templates" "github.com/spf13/cobra" ) var initCmd = &cobra.Command{ Use: "init", Short: "Initialize VPS (one-time setup)", Long: "Set up a fresh VPS with Caddy for automatic HTTPS and required directories", RunE: runInit, } func runInit(cmd *cobra.Command, args []string) error { st, err := state.Load() if err != nil { return fmt.Errorf("error loading state: %w", err) } host, _ := cmd.Flags().GetString("host") if host == "" { host = st.GetDefaultHost() } baseDomain, _ := cmd.Flags().GetString("base-domain") if host == "" { return fmt.Errorf("--host is required") } fmt.Printf("Initializing VPS: %s\n", host) client, err := ssh.Connect(host) if err != nil { return fmt.Errorf("error connecting to VPS: %w", err) } defer client.Close() fmt.Println("-> Detecting OS...") osRelease, err := client.Run("cat /etc/os-release") if err != nil { return fmt.Errorf("error detecting OS: %w", err) } if !strings.Contains(osRelease, "Ubuntu") && !strings.Contains(osRelease, "Debian") { return fmt.Errorf("unsupported OS (only Ubuntu and Debian are supported)") } fmt.Println(" Detected Ubuntu/Debian") fmt.Println("-> Checking for Caddy...") _, err = client.Run("which caddy") if err == nil { fmt.Println(" Caddy already installed") } else { fmt.Println(" Installing Caddy...") if err := installCaddy(client); err != nil { return err } fmt.Println(" Caddy installed") } fmt.Println("-> Configuring Caddy...") caddyfile := `{ } import /etc/caddy/sites-enabled/* ` if err := client.WriteSudoFile("/etc/caddy/Caddyfile", caddyfile); err != nil { return fmt.Errorf("error creating Caddyfile: %w", err) } fmt.Println(" Caddyfile created") fmt.Println("-> Creating directories...") if _, err := client.RunSudo("mkdir -p /etc/ship/env"); err != nil { return fmt.Errorf("error creating /etc/ship/env: %w", err) } if _, err := client.RunSudo("mkdir -p /etc/caddy/sites-enabled"); err != nil { return fmt.Errorf("error creating /etc/caddy/sites-enabled: %w", err) } fmt.Println(" Directories created") fmt.Println("-> Starting Caddy...") if _, err := client.RunSudo("systemctl enable caddy"); err != nil { return fmt.Errorf("error enabling Caddy: %w", err) } if _, err := client.RunSudo("systemctl restart caddy"); err != nil { return fmt.Errorf("error starting Caddy: %w", err) } fmt.Println(" Caddy started") fmt.Println("-> Verifying installation...") output, err := client.RunSudo("systemctl is-active caddy") if err != nil || strings.TrimSpace(output) != "active" { fmt.Println(" Warning: Caddy may not be running properly") } else { fmt.Println(" Caddy is active") } hostState := st.GetHost(host) if baseDomain != "" { hostState.BaseDomain = baseDomain fmt.Printf(" Base domain: %s\n", baseDomain) } // Git-centric deployment setup (gated on base domain) if baseDomain != "" { if err := setupGitDeploy(client, baseDomain, hostState); err != nil { return err } } if st.GetDefaultHost() == "" { st.SetDefaultHost(host) fmt.Printf(" Set %s as default host\n", host) } if err := st.Save(); err != nil { return fmt.Errorf("error saving state: %w", err) } fmt.Println("\nVPS initialized successfully!") fmt.Println("\nNext steps:") fmt.Println(" 1. Deploy an app:") fmt.Printf(" ship --binary ./myapp --domain api.example.com\n") fmt.Println(" 2. Deploy a static site:") fmt.Printf(" ship --static --dir ./dist --domain example.com\n") if baseDomain != "" { fmt.Println(" 3. Initialize a git-deployed app:") fmt.Printf(" ship init myapp\n") } return nil } func setupGitDeploy(client *ssh.Client, baseDomain string, hostState *state.Host) error { fmt.Println("-> Installing Docker...") dockerCommands := []string{ "apt-get install -y ca-certificates curl gnupg", "install -m 0755 -d /etc/apt/keyrings", "curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc", "chmod a+r /etc/apt/keyrings/docker.asc", `sh -c 'echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/ubuntu $(. /etc/os-release && echo ${VERSION_CODENAME}) stable" > /etc/apt/sources.list.d/docker.list'`, "apt-get update", "apt-get install -y docker-ce docker-ce-cli containerd.io", } for _, cmd := range dockerCommands { if _, err := client.RunSudo(cmd); err != nil { return fmt.Errorf("error installing Docker: %w", err) } } fmt.Println(" Docker installed") fmt.Println("-> Installing git and fcgiwrap...") if _, err := client.RunSudo("apt-get install -y git fcgiwrap"); err != nil { return fmt.Errorf("error installing git/fcgiwrap: %w", err) } // Allow git-http-backend (runs as www-data) to access repos owned by git. // Scoped to www-data only, not system-wide, to preserve CVE-2022-24765 protection. // www-data's home is /var/www; ensure it can write .gitconfig there. client.RunSudo("chown www-data:www-data /var/www") if _, err := client.RunSudo("sudo -u www-data git config --global --add safe.directory '*'"); err != nil { return fmt.Errorf("error setting git safe.directory: %w", err) } fmt.Println(" git and fcgiwrap installed") fmt.Println("-> Creating git user...") // Create git user (ignore error if already exists) client.RunSudo("useradd -r -m -d /home/git -s $(which git-shell) git") if _, err := client.RunSudo("usermod -aG docker git"); err != nil { return fmt.Errorf("error adding git user to docker group: %w", err) } // www-data needs to read git repos for git-http-backend if _, err := client.RunSudo("usermod -aG git www-data"); err != nil { return fmt.Errorf("error adding www-data to git group: %w", err) } // caddy needs to connect to fcgiwrap socket (owned by www-data) if _, err := client.RunSudo("usermod -aG www-data caddy"); err != nil { return fmt.Errorf("error adding caddy to www-data group: %w", err) } fmt.Println(" git user created") fmt.Println("-> Copying SSH keys to git user...") copyKeysCommands := []string{ "mkdir -p /home/git/.ssh", "cp ~/.ssh/authorized_keys /home/git/.ssh/authorized_keys", "chown -R git:git /home/git/.ssh", "chmod 700 /home/git/.ssh", "chmod 600 /home/git/.ssh/authorized_keys", } for _, cmd := range copyKeysCommands { if _, err := client.RunSudo(cmd); err != nil { return fmt.Errorf("error copying SSH keys: %w", err) } } fmt.Println(" SSH keys copied") fmt.Println("-> Creating /srv/git...") if _, err := client.RunSudo("mkdir -p /srv/git"); err != nil { return fmt.Errorf("error creating /srv/git: %w", err) } if _, err := client.RunSudo("chown git:git /srv/git"); err != nil { return fmt.Errorf("error setting /srv/git ownership: %w", err) } fmt.Println(" /srv/git created") fmt.Println("-> Writing sudoers for git user...") sudoersContent := `# Ship git-deploy: allow post-receive hooks to install configs and manage services. # App names are validated to [a-z][a-z0-9-] before reaching this point. git ALL=(ALL) NOPASSWD: \ /bin/systemctl daemon-reload, \ /bin/systemctl reload caddy, \ /bin/systemctl restart [a-z]*, \ /bin/systemctl enable [a-z]*, \ /bin/cp /var/lib/*/src/.ship/service /etc/systemd/system/*.service, \ /bin/cp /var/lib/*/src/.ship/Caddyfile /etc/caddy/sites-enabled/*.caddy, \ /bin/cp /var/www/*/.ship/Caddyfile /etc/caddy/sites-enabled/*.caddy, \ /bin/mkdir -p /var/lib/*, \ /bin/mkdir -p /var/www/*, \ /bin/chown -R git\:git /var/lib/*, \ /bin/chown git\:git /var/www/* ` if err := client.WriteSudoFile("/etc/sudoers.d/ship-git", sudoersContent); err != nil { return fmt.Errorf("error writing sudoers: %w", err) } if _, err := client.RunSudo("chmod 440 /etc/sudoers.d/ship-git"); err != nil { return fmt.Errorf("error setting sudoers permissions: %w", err) } fmt.Println(" sudoers configured") fmt.Println("-> Writing vanity import template...") vanityHTML := ` {{$path := trimPrefix "/" (placeholder "http.request.orig_uri.path")}} {{$parts := splitList "/" $path}} {{$module := first $parts}} go get {{.Host}}/{{$module}} ` if _, err := client.RunSudo("mkdir -p /opt/ship/vanity"); err != nil { return fmt.Errorf("error creating vanity directory: %w", err) } if err := client.WriteSudoFile("/opt/ship/vanity/index.html", vanityHTML); err != nil { return fmt.Errorf("error writing vanity template: %w", err) } fmt.Println(" vanity template written") fmt.Println("-> Writing base domain Caddy config...") codeCaddyContent, err := templates.CodeCaddy(map[string]string{ "BaseDomain": baseDomain, }) if err != nil { return fmt.Errorf("error generating code caddy config: %w", err) } if err := client.WriteSudoFile("/etc/caddy/sites-enabled/ship-code.caddy", codeCaddyContent); err != nil { return fmt.Errorf("error writing code caddy config: %w", err) } fmt.Println(" base domain Caddy config written") fmt.Println("-> Starting Docker and fcgiwrap...") if _, err := client.RunSudo("systemctl enable docker fcgiwrap"); err != nil { return fmt.Errorf("error enabling services: %w", err) } if _, err := client.RunSudo("systemctl restart docker fcgiwrap"); err != nil { return fmt.Errorf("error starting services: %w", err) } fmt.Println(" Docker and fcgiwrap started") fmt.Println("-> Restarting Caddy...") if _, err := client.RunSudo("systemctl restart caddy"); err != nil { return fmt.Errorf("error restarting Caddy: %w", err) } fmt.Println(" Caddy restarted") hostState.GitSetup = true fmt.Println(" Git deployment setup complete") return nil } func installCaddy(client *ssh.Client) error { commands := []string{ "apt-get update", "apt-get install -y debian-keyring debian-archive-keyring apt-transport-https curl", "curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' | sudo gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg", "curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' | sudo tee /etc/apt/sources.list.d/caddy-stable.list", "apt-get update", "apt-get install -y caddy", } for _, cmd := range commands { if _, err := client.RunSudo(cmd); err != nil { return fmt.Errorf("error running: %s: %w", cmd, err) } } return nil }