package main import ( "fmt" "strings" "github.com/bdw/ship/internal/output" "github.com/bdw/ship/internal/ssh" "github.com/bdw/ship/internal/state" "github.com/bdw/ship/internal/templates" "github.com/spf13/cobra" ) func initHostV2() { hostInitV2Cmd.Flags().String("domain", "", "Base domain for deployments (required)") hostInitV2Cmd.MarkFlagRequired("domain") hostV2Cmd.AddCommand(hostInitV2Cmd) hostV2Cmd.AddCommand(hostStatusV2Cmd) } var hostInitV2Cmd = &cobra.Command{ Use: "init USER@HOST --domain DOMAIN", Short: "Initialize a VPS for deployments", Long: `Set up a fresh VPS with Caddy, Docker, and required directories. Example: ship host init user@my-vps --domain example.com`, Args: cobra.ExactArgs(1), RunE: runHostInitV2, } func runHostInitV2(cmd *cobra.Command, args []string) error { host := args[0] domain, _ := cmd.Flags().GetString("domain") if domain == "" { output.PrintAndExit(output.Err(output.ErrInvalidArgs, "--domain is required")) } // Connect client, err := ssh.Connect(host) if err != nil { output.PrintAndExit(output.Err(output.ErrSSHConnectFailed, err.Error())) } defer client.Close() // Detect OS osRelease, err := client.Run("cat /etc/os-release") if err != nil { output.PrintAndExit(output.Err(output.ErrSSHConnectFailed, "failed to detect OS: "+err.Error())) } if !strings.Contains(osRelease, "Ubuntu") && !strings.Contains(osRelease, "Debian") { output.PrintAndExit(output.Err(output.ErrInvalidArgs, "unsupported OS (only Ubuntu and Debian are supported)")) } var installed []string // Install Caddy if needed if _, err := client.Run("which caddy"); err != nil { if err := installCaddyV2(client); err != nil { output.PrintAndExit(output.Err(output.ErrServiceFailed, "failed to install Caddy: "+err.Error())) } installed = append(installed, "caddy") } // Configure Caddy caddyfile := `{ } import /etc/caddy/sites-enabled/* ` if err := client.WriteSudoFile("/etc/caddy/Caddyfile", caddyfile); err != nil { output.PrintAndExit(output.Err(output.ErrCaddyFailed, "failed to write Caddyfile: "+err.Error())) } // Create directories dirs := []string{ "/etc/ship/env", "/etc/ship/ports", "/etc/ship/ttl", "/etc/caddy/sites-enabled", "/var/www", } for _, dir := range dirs { if _, err := client.RunSudo(fmt.Sprintf("mkdir -p %s", dir)); err != nil { output.PrintAndExit(output.Err(output.ErrServiceFailed, "failed to create directory: "+err.Error())) } } // Install Docker if _, err := client.Run("which docker"); err != nil { if err := installDockerV2(client); err != nil { output.PrintAndExit(output.Err(output.ErrServiceFailed, "failed to install Docker: "+err.Error())) } installed = append(installed, "docker") } // Install cleanup timer for TTL if err := installCleanupTimer(client); err != nil { // Non-fatal } // Enable and start services if _, err := client.RunSudo("systemctl enable --now caddy docker"); err != nil { output.PrintAndExit(output.Err(output.ErrServiceFailed, "failed to enable services: "+err.Error())) } // Save state st, err := state.Load() if err != nil { output.PrintAndExit(output.Err(output.ErrServiceFailed, "failed to load state: "+err.Error())) } hostState := st.GetHost(host) hostState.BaseDomain = domain if st.GetDefaultHost() == "" { st.SetDefaultHost(host) } if err := st.Save(); err != nil { output.PrintAndExit(output.Err(output.ErrServiceFailed, "failed to save state: "+err.Error())) } // Success output.PrintAndExit(&output.HostInitResponse{ Status: "ok", Host: host, Domain: domain, Installed: installed, }) return nil } func installCaddyV2(client *ssh.Client) error { commands := []string{ "apt-get update", "apt-get install -y debian-keyring debian-archive-keyring apt-transport-https curl gnupg", "curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' -o /tmp/caddy.gpg", "gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg < /tmp/caddy.gpg", "curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' -o /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("command failed: %s: %w", cmd, err) } } return nil } func installDockerV2(client *ssh.Client) error { commands := []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 commands { if _, err := client.RunSudo(cmd); err != nil { return fmt.Errorf("command failed: %s: %w", cmd, err) } } return nil } func installCleanupTimer(client *ssh.Client) error { // Cleanup script script := `#!/bin/bash now=$(date +%s) for f in /etc/ship/ttl/*; do [ -f "$f" ] || continue name=$(basename "$f") expires=$(cat "$f") if [ "$now" -gt "$expires" ]; then systemctl stop "$name" 2>/dev/null || true systemctl disable "$name" 2>/dev/null || true rm -f "/etc/systemd/system/${name}.service" rm -f "/etc/caddy/sites-enabled/${name}.caddy" rm -rf "/var/www/${name}" rm -rf "/var/lib/${name}" rm -f "/usr/local/bin/${name}" rm -f "/etc/ship/env/${name}.env" rm -f "/etc/ship/ports/${name}" rm -f "/etc/ship/ttl/${name}" docker rm -f "$name" 2>/dev/null || true docker rmi "$name" 2>/dev/null || true fi done systemctl daemon-reload systemctl reload caddy ` if err := client.WriteSudoFile("/usr/local/bin/ship-cleanup", script); err != nil { return err } if _, err := client.RunSudo("chmod +x /usr/local/bin/ship-cleanup"); err != nil { return err } // Timer unit timer := `[Unit] Description=Ship TTL cleanup timer [Timer] OnCalendar=hourly Persistent=true [Install] WantedBy=timers.target ` if err := client.WriteSudoFile("/etc/systemd/system/ship-cleanup.timer", timer); err != nil { return err } // Service unit service := `[Unit] Description=Ship TTL cleanup [Service] Type=oneshot ExecStart=/usr/local/bin/ship-cleanup ` if err := client.WriteSudoFile("/etc/systemd/system/ship-cleanup.service", service); err != nil { return err } // Enable timer if _, err := client.RunSudo("systemctl daemon-reload"); err != nil { return err } if _, err := client.RunSudo("systemctl enable --now ship-cleanup.timer"); err != nil { return err } return nil } var hostStatusV2Cmd = &cobra.Command{ Use: "status", Short: "Check host status", RunE: func(cmd *cobra.Command, args []string) error { st, err := state.Load() if err != nil { output.PrintAndExit(output.Err(output.ErrHostNotConfigured, err.Error())) } hostName := hostFlag if hostName == "" { hostName = st.DefaultHost } if hostName == "" { output.PrintAndExit(output.Err(output.ErrHostNotConfigured, "no host specified")) } hostConfig := st.GetHost(hostName) client, err := ssh.Connect(hostName) if err != nil { output.PrintAndExit(output.Err(output.ErrSSHConnectFailed, err.Error())) } defer client.Close() // Check services caddyStatus, _ := client.RunSudo("systemctl is-active caddy") dockerStatus, _ := client.RunSudo("systemctl is-active docker") // Print as JSON directly (custom response type) fmt.Printf(`{"status":"ok","host":%q,"domain":%q,"caddy":%t,"docker":%t}`+"\n", hostName, hostConfig.BaseDomain, strings.TrimSpace(caddyStatus) == "active", strings.TrimSpace(dockerStatus) == "active", ) return nil }, } // Preserve git setup functionality from v1 for advanced users func setupGitDeployV2(client *ssh.Client, baseDomain string, hostState *state.Host) *output.ErrorResponse { // Install git, fcgiwrap, cgit if _, err := client.RunSudo("apt-get install -y git fcgiwrap cgit"); err != nil { return output.Err(output.ErrServiceFailed, "failed to install git tools: "+err.Error()) } // Create git user client.RunSudo("useradd -r -m -d /home/git -s $(which git-shell) git") client.RunSudo("usermod -aG docker git") client.RunSudo("usermod -aG git www-data") client.RunSudo("usermod -aG www-data caddy") // Copy SSH keys 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 output.Err(output.ErrServiceFailed, "failed to setup git SSH: "+err.Error()) } } // Create /srv/git client.RunSudo("mkdir -p /srv/git") client.RunSudo("chown git:git /srv/git") // Sudoers sudoersContent := `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 output.Err(output.ErrServiceFailed, "failed to write sudoers: "+err.Error()) } client.RunSudo("chmod 440 /etc/sudoers.d/ship-git") // Vanity import template vanityHTML := `
{{$path := trimPrefix "/" (placeholder "http.request.orig_uri.path")}} {{$parts := splitList "/" $path}} {{$module := first $parts}} go get {{.Host}}/{{$module}} ` client.RunSudo("mkdir -p /opt/ship/vanity") client.WriteSudoFile("/opt/ship/vanity/index.html", vanityHTML) // cgit config codeCaddyContent, _ := templates.CodeCaddy(map[string]string{"BaseDomain": baseDomain}) client.WriteSudoFile("/etc/caddy/sites-enabled/ship-code.caddy", codeCaddyContent) cgitrcContent, _ := templates.CgitRC(map[string]string{"BaseDomain": baseDomain}) client.WriteSudoFile("/etc/cgitrc", cgitrcContent) client.WriteSudoFile("/opt/ship/cgit-header.html", templates.CgitHeader()) // Start services client.RunSudo("systemctl enable --now fcgiwrap") client.RunSudo("systemctl restart caddy") hostState.GitSetup = true return nil }