diff options
Diffstat (limited to 'cmd/ship/host_v2.go')
| -rw-r--r-- | cmd/ship/host_v2.go | 445 |
1 files changed, 445 insertions, 0 deletions
diff --git a/cmd/ship/host_v2.go b/cmd/ship/host_v2.go new file mode 100644 index 0000000..b19c376 --- /dev/null +++ b/cmd/ship/host_v2.go | |||
| @@ -0,0 +1,445 @@ | |||
| 1 | package main | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "encoding/json" | ||
| 5 | "fmt" | ||
| 6 | "os" | ||
| 7 | "os/exec" | ||
| 8 | "path/filepath" | ||
| 9 | "strings" | ||
| 10 | |||
| 11 | "github.com/bdw/ship/internal/output" | ||
| 12 | "github.com/bdw/ship/internal/ssh" | ||
| 13 | "github.com/bdw/ship/internal/state" | ||
| 14 | "github.com/bdw/ship/internal/templates" | ||
| 15 | "github.com/spf13/cobra" | ||
| 16 | ) | ||
| 17 | |||
| 18 | func initHostV2() { | ||
| 19 | hostInitV2Cmd.Flags().String("domain", "", "Base domain for deployments (required)") | ||
| 20 | hostInitV2Cmd.MarkFlagRequired("domain") | ||
| 21 | |||
| 22 | hostV2Cmd.AddCommand(hostInitV2Cmd) | ||
| 23 | hostV2Cmd.AddCommand(hostStatusV2Cmd) | ||
| 24 | } | ||
| 25 | |||
| 26 | var hostInitV2Cmd = &cobra.Command{ | ||
| 27 | Use: "init USER@HOST --domain DOMAIN", | ||
| 28 | Short: "Initialize a VPS for deployments", | ||
| 29 | Long: `Set up a fresh VPS with Caddy, Docker, and required directories. | ||
| 30 | |||
| 31 | Example: | ||
| 32 | ship host init user@my-vps --domain example.com`, | ||
| 33 | Args: cobra.ExactArgs(1), | ||
| 34 | RunE: runHostInitV2, | ||
| 35 | } | ||
| 36 | |||
| 37 | func runHostInitV2(cmd *cobra.Command, args []string) error { | ||
| 38 | host := args[0] | ||
| 39 | domain, _ := cmd.Flags().GetString("domain") | ||
| 40 | |||
| 41 | if domain == "" { | ||
| 42 | output.PrintAndExit(output.Err(output.ErrInvalidArgs, "--domain is required")) | ||
| 43 | } | ||
| 44 | |||
| 45 | // Ensure SSH key exists | ||
| 46 | keyPath, pubkey, err := ensureSSHKey() | ||
| 47 | if err != nil { | ||
| 48 | output.PrintAndExit(output.Err(output.ErrSSHAuthFailed, "failed to setup SSH key: "+err.Error())) | ||
| 49 | } | ||
| 50 | |||
| 51 | // Try to connect first (to verify key is authorized) | ||
| 52 | client, err := ssh.Connect(host) | ||
| 53 | if err != nil { | ||
| 54 | // Connection failed - provide helpful error with pubkey | ||
| 55 | resp := map[string]interface{}{ | ||
| 56 | "status": "error", | ||
| 57 | "code": "SSH_AUTH_FAILED", | ||
| 58 | "message": "SSH connection failed. Add this public key to your VPS authorized_keys:", | ||
| 59 | "public_key": pubkey, | ||
| 60 | "key_path": keyPath, | ||
| 61 | "host": host, | ||
| 62 | "setup_command": fmt.Sprintf("ssh-copy-id -i %s %s", keyPath, host), | ||
| 63 | } | ||
| 64 | printJSON(resp) | ||
| 65 | os.Exit(output.ExitSSHFailed) | ||
| 66 | } | ||
| 67 | defer client.Close() | ||
| 68 | |||
| 69 | // Detect OS | ||
| 70 | osRelease, err := client.Run("cat /etc/os-release") | ||
| 71 | if err != nil { | ||
| 72 | output.PrintAndExit(output.Err(output.ErrSSHConnectFailed, "failed to detect OS: "+err.Error())) | ||
| 73 | } | ||
| 74 | |||
| 75 | if !strings.Contains(osRelease, "Ubuntu") && !strings.Contains(osRelease, "Debian") { | ||
| 76 | output.PrintAndExit(output.Err(output.ErrInvalidArgs, "unsupported OS (only Ubuntu and Debian are supported)")) | ||
| 77 | } | ||
| 78 | |||
| 79 | var installed []string | ||
| 80 | |||
| 81 | // Install Caddy if needed | ||
| 82 | if _, err := client.Run("which caddy"); err != nil { | ||
| 83 | if err := installCaddyV2(client); err != nil { | ||
| 84 | output.PrintAndExit(output.Err(output.ErrServiceFailed, "failed to install Caddy: "+err.Error())) | ||
| 85 | } | ||
| 86 | installed = append(installed, "caddy") | ||
| 87 | } | ||
| 88 | |||
| 89 | // Configure Caddy | ||
| 90 | caddyfile := `{ | ||
| 91 | } | ||
| 92 | |||
| 93 | import /etc/caddy/sites-enabled/* | ||
| 94 | ` | ||
| 95 | if err := client.WriteSudoFile("/etc/caddy/Caddyfile", caddyfile); err != nil { | ||
| 96 | output.PrintAndExit(output.Err(output.ErrCaddyFailed, "failed to write Caddyfile: "+err.Error())) | ||
| 97 | } | ||
| 98 | |||
| 99 | // Create directories | ||
| 100 | dirs := []string{ | ||
| 101 | "/etc/ship/env", | ||
| 102 | "/etc/ship/ports", | ||
| 103 | "/etc/ship/ttl", | ||
| 104 | "/etc/caddy/sites-enabled", | ||
| 105 | "/var/www", | ||
| 106 | } | ||
| 107 | for _, dir := range dirs { | ||
| 108 | if _, err := client.RunSudo(fmt.Sprintf("mkdir -p %s", dir)); err != nil { | ||
| 109 | output.PrintAndExit(output.Err(output.ErrServiceFailed, "failed to create directory: "+err.Error())) | ||
| 110 | } | ||
| 111 | } | ||
| 112 | |||
| 113 | // Install Docker | ||
| 114 | if _, err := client.Run("which docker"); err != nil { | ||
| 115 | if err := installDockerV2(client); err != nil { | ||
| 116 | output.PrintAndExit(output.Err(output.ErrServiceFailed, "failed to install Docker: "+err.Error())) | ||
| 117 | } | ||
| 118 | installed = append(installed, "docker") | ||
| 119 | } | ||
| 120 | |||
| 121 | // Install cleanup timer for TTL | ||
| 122 | if err := installCleanupTimer(client); err != nil { | ||
| 123 | // Non-fatal | ||
| 124 | } | ||
| 125 | |||
| 126 | // Enable and start services | ||
| 127 | if _, err := client.RunSudo("systemctl enable --now caddy docker"); err != nil { | ||
| 128 | output.PrintAndExit(output.Err(output.ErrServiceFailed, "failed to enable services: "+err.Error())) | ||
| 129 | } | ||
| 130 | |||
| 131 | // Save state | ||
| 132 | st, err := state.Load() | ||
| 133 | if err != nil { | ||
| 134 | output.PrintAndExit(output.Err(output.ErrServiceFailed, "failed to load state: "+err.Error())) | ||
| 135 | } | ||
| 136 | |||
| 137 | hostState := st.GetHost(host) | ||
| 138 | hostState.BaseDomain = domain | ||
| 139 | |||
| 140 | if st.GetDefaultHost() == "" { | ||
| 141 | st.SetDefaultHost(host) | ||
| 142 | } | ||
| 143 | |||
| 144 | if err := st.Save(); err != nil { | ||
| 145 | output.PrintAndExit(output.Err(output.ErrServiceFailed, "failed to save state: "+err.Error())) | ||
| 146 | } | ||
| 147 | |||
| 148 | // Success | ||
| 149 | output.PrintAndExit(&output.HostInitResponse{ | ||
| 150 | Status: "ok", | ||
| 151 | Host: host, | ||
| 152 | Domain: domain, | ||
| 153 | Installed: installed, | ||
| 154 | }) | ||
| 155 | |||
| 156 | return nil | ||
| 157 | } | ||
| 158 | |||
| 159 | func installCaddyV2(client *ssh.Client) error { | ||
| 160 | commands := []string{ | ||
| 161 | "apt-get update", | ||
| 162 | "apt-get install -y debian-keyring debian-archive-keyring apt-transport-https curl gnupg", | ||
| 163 | "curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' -o /tmp/caddy.gpg", | ||
| 164 | "gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg < /tmp/caddy.gpg", | ||
| 165 | "curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' -o /etc/apt/sources.list.d/caddy-stable.list", | ||
| 166 | "apt-get update", | ||
| 167 | "apt-get install -y caddy", | ||
| 168 | } | ||
| 169 | |||
| 170 | for _, cmd := range commands { | ||
| 171 | if _, err := client.RunSudo(cmd); err != nil { | ||
| 172 | return fmt.Errorf("command failed: %s: %w", cmd, err) | ||
| 173 | } | ||
| 174 | } | ||
| 175 | return nil | ||
| 176 | } | ||
| 177 | |||
| 178 | func installDockerV2(client *ssh.Client) error { | ||
| 179 | commands := []string{ | ||
| 180 | "apt-get install -y ca-certificates curl gnupg", | ||
| 181 | "install -m 0755 -d /etc/apt/keyrings", | ||
| 182 | "curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc", | ||
| 183 | "chmod a+r /etc/apt/keyrings/docker.asc", | ||
| 184 | `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'`, | ||
| 185 | "apt-get update", | ||
| 186 | "apt-get install -y docker-ce docker-ce-cli containerd.io", | ||
| 187 | } | ||
| 188 | |||
| 189 | for _, cmd := range commands { | ||
| 190 | if _, err := client.RunSudo(cmd); err != nil { | ||
| 191 | return fmt.Errorf("command failed: %s: %w", cmd, err) | ||
| 192 | } | ||
| 193 | } | ||
| 194 | return nil | ||
| 195 | } | ||
| 196 | |||
| 197 | func installCleanupTimer(client *ssh.Client) error { | ||
| 198 | // Cleanup script | ||
| 199 | script := `#!/bin/bash | ||
| 200 | now=$(date +%s) | ||
| 201 | for f in /etc/ship/ttl/*; do | ||
| 202 | [ -f "$f" ] || continue | ||
| 203 | name=$(basename "$f") | ||
| 204 | expires=$(cat "$f") | ||
| 205 | if [ "$now" -gt "$expires" ]; then | ||
| 206 | systemctl stop "$name" 2>/dev/null || true | ||
| 207 | systemctl disable "$name" 2>/dev/null || true | ||
| 208 | rm -f "/etc/systemd/system/${name}.service" | ||
| 209 | rm -f "/etc/caddy/sites-enabled/${name}.caddy" | ||
| 210 | rm -rf "/var/www/${name}" | ||
| 211 | rm -rf "/var/lib/${name}" | ||
| 212 | rm -f "/usr/local/bin/${name}" | ||
| 213 | rm -f "/etc/ship/env/${name}.env" | ||
| 214 | rm -f "/etc/ship/ports/${name}" | ||
| 215 | rm -f "/etc/ship/ttl/${name}" | ||
| 216 | docker rm -f "$name" 2>/dev/null || true | ||
| 217 | docker rmi "$name" 2>/dev/null || true | ||
| 218 | fi | ||
| 219 | done | ||
| 220 | systemctl daemon-reload | ||
| 221 | systemctl reload caddy | ||
| 222 | ` | ||
| 223 | if err := client.WriteSudoFile("/usr/local/bin/ship-cleanup", script); err != nil { | ||
| 224 | return err | ||
| 225 | } | ||
| 226 | if _, err := client.RunSudo("chmod +x /usr/local/bin/ship-cleanup"); err != nil { | ||
| 227 | return err | ||
| 228 | } | ||
| 229 | |||
| 230 | // Timer unit | ||
| 231 | timer := `[Unit] | ||
| 232 | Description=Ship TTL cleanup timer | ||
| 233 | |||
| 234 | [Timer] | ||
| 235 | OnCalendar=hourly | ||
| 236 | Persistent=true | ||
| 237 | |||
| 238 | [Install] | ||
| 239 | WantedBy=timers.target | ||
| 240 | ` | ||
| 241 | if err := client.WriteSudoFile("/etc/systemd/system/ship-cleanup.timer", timer); err != nil { | ||
| 242 | return err | ||
| 243 | } | ||
| 244 | |||
| 245 | // Service unit | ||
| 246 | service := `[Unit] | ||
| 247 | Description=Ship TTL cleanup | ||
| 248 | |||
| 249 | [Service] | ||
| 250 | Type=oneshot | ||
| 251 | ExecStart=/usr/local/bin/ship-cleanup | ||
| 252 | ` | ||
| 253 | if err := client.WriteSudoFile("/etc/systemd/system/ship-cleanup.service", service); err != nil { | ||
| 254 | return err | ||
| 255 | } | ||
| 256 | |||
| 257 | // Enable timer | ||
| 258 | if _, err := client.RunSudo("systemctl daemon-reload"); err != nil { | ||
| 259 | return err | ||
| 260 | } | ||
| 261 | if _, err := client.RunSudo("systemctl enable --now ship-cleanup.timer"); err != nil { | ||
| 262 | return err | ||
| 263 | } | ||
| 264 | |||
| 265 | return nil | ||
| 266 | } | ||
| 267 | |||
| 268 | var hostStatusV2Cmd = &cobra.Command{ | ||
| 269 | Use: "status", | ||
| 270 | Short: "Check host status", | ||
| 271 | RunE: func(cmd *cobra.Command, args []string) error { | ||
| 272 | st, err := state.Load() | ||
| 273 | if err != nil { | ||
| 274 | output.PrintAndExit(output.Err(output.ErrHostNotConfigured, err.Error())) | ||
| 275 | } | ||
| 276 | |||
| 277 | hostName := hostFlag | ||
| 278 | if hostName == "" { | ||
| 279 | hostName = st.DefaultHost | ||
| 280 | } | ||
| 281 | if hostName == "" { | ||
| 282 | output.PrintAndExit(output.Err(output.ErrHostNotConfigured, "no host specified")) | ||
| 283 | } | ||
| 284 | |||
| 285 | hostConfig := st.GetHost(hostName) | ||
| 286 | |||
| 287 | client, err := ssh.Connect(hostName) | ||
| 288 | if err != nil { | ||
| 289 | output.PrintAndExit(output.Err(output.ErrSSHConnectFailed, err.Error())) | ||
| 290 | } | ||
| 291 | defer client.Close() | ||
| 292 | |||
| 293 | // Check services | ||
| 294 | caddyStatus, _ := client.RunSudo("systemctl is-active caddy") | ||
| 295 | dockerStatus, _ := client.RunSudo("systemctl is-active docker") | ||
| 296 | |||
| 297 | // Print as JSON directly (custom response type) | ||
| 298 | fmt.Printf(`{"status":"ok","host":%q,"domain":%q,"caddy":%t,"docker":%t}`+"\n", | ||
| 299 | hostName, | ||
| 300 | hostConfig.BaseDomain, | ||
| 301 | strings.TrimSpace(caddyStatus) == "active", | ||
| 302 | strings.TrimSpace(dockerStatus) == "active", | ||
| 303 | ) | ||
| 304 | return nil | ||
| 305 | }, | ||
| 306 | } | ||
| 307 | |||
| 308 | // Preserve git setup functionality from v1 for advanced users | ||
| 309 | func setupGitDeployV2(client *ssh.Client, baseDomain string, hostState *state.Host) *output.ErrorResponse { | ||
| 310 | // Install git, fcgiwrap, cgit | ||
| 311 | if _, err := client.RunSudo("apt-get install -y git fcgiwrap cgit"); err != nil { | ||
| 312 | return output.Err(output.ErrServiceFailed, "failed to install git tools: "+err.Error()) | ||
| 313 | } | ||
| 314 | |||
| 315 | // Create git user | ||
| 316 | client.RunSudo("useradd -r -m -d /home/git -s $(which git-shell) git") | ||
| 317 | client.RunSudo("usermod -aG docker git") | ||
| 318 | client.RunSudo("usermod -aG git www-data") | ||
| 319 | client.RunSudo("usermod -aG www-data caddy") | ||
| 320 | |||
| 321 | // Copy SSH keys | ||
| 322 | copyKeysCommands := []string{ | ||
| 323 | "mkdir -p /home/git/.ssh", | ||
| 324 | "cp ~/.ssh/authorized_keys /home/git/.ssh/authorized_keys", | ||
| 325 | "chown -R git:git /home/git/.ssh", | ||
| 326 | "chmod 700 /home/git/.ssh", | ||
| 327 | "chmod 600 /home/git/.ssh/authorized_keys", | ||
| 328 | } | ||
| 329 | for _, cmd := range copyKeysCommands { | ||
| 330 | if _, err := client.RunSudo(cmd); err != nil { | ||
| 331 | return output.Err(output.ErrServiceFailed, "failed to setup git SSH: "+err.Error()) | ||
| 332 | } | ||
| 333 | } | ||
| 334 | |||
| 335 | // Create /srv/git | ||
| 336 | client.RunSudo("mkdir -p /srv/git") | ||
| 337 | client.RunSudo("chown git:git /srv/git") | ||
| 338 | |||
| 339 | // Sudoers | ||
| 340 | sudoersContent := `git ALL=(ALL) NOPASSWD: \ | ||
| 341 | /bin/systemctl daemon-reload, \ | ||
| 342 | /bin/systemctl reload caddy, \ | ||
| 343 | /bin/systemctl restart [a-z]*, \ | ||
| 344 | /bin/systemctl enable [a-z]*, \ | ||
| 345 | /bin/cp /var/lib/*/src/.ship/service /etc/systemd/system/*.service, \ | ||
| 346 | /bin/cp /var/lib/*/src/.ship/Caddyfile /etc/caddy/sites-enabled/*.caddy, \ | ||
| 347 | /bin/cp /var/www/*/.ship/Caddyfile /etc/caddy/sites-enabled/*.caddy, \ | ||
| 348 | /bin/mkdir -p /var/lib/*, \ | ||
| 349 | /bin/mkdir -p /var/www/*, \ | ||
| 350 | /bin/chown -R git\:git /var/lib/*, \ | ||
| 351 | /bin/chown git\:git /var/www/* | ||
| 352 | ` | ||
| 353 | if err := client.WriteSudoFile("/etc/sudoers.d/ship-git", sudoersContent); err != nil { | ||
| 354 | return output.Err(output.ErrServiceFailed, "failed to write sudoers: "+err.Error()) | ||
| 355 | } | ||
| 356 | client.RunSudo("chmod 440 /etc/sudoers.d/ship-git") | ||
| 357 | |||
| 358 | // Vanity import template | ||
| 359 | vanityHTML := `<!DOCTYPE html> | ||
| 360 | <html><head> | ||
| 361 | {{$path := trimPrefix "/" (placeholder "http.request.orig_uri.path")}} | ||
| 362 | {{$parts := splitList "/" $path}} | ||
| 363 | {{$module := first $parts}} | ||
| 364 | <meta name="go-import" content="{{.Host}}/{{$module}} git https://{{.Host}}/{{$module}}.git"> | ||
| 365 | </head> | ||
| 366 | <body>go get {{.Host}}/{{$module}}</body> | ||
| 367 | </html> | ||
| 368 | ` | ||
| 369 | client.RunSudo("mkdir -p /opt/ship/vanity") | ||
| 370 | client.WriteSudoFile("/opt/ship/vanity/index.html", vanityHTML) | ||
| 371 | |||
| 372 | // cgit config | ||
| 373 | codeCaddyContent, _ := templates.CodeCaddy(map[string]string{"BaseDomain": baseDomain}) | ||
| 374 | client.WriteSudoFile("/etc/caddy/sites-enabled/ship-code.caddy", codeCaddyContent) | ||
| 375 | |||
| 376 | cgitrcContent, _ := templates.CgitRC(map[string]string{"BaseDomain": baseDomain}) | ||
| 377 | client.WriteSudoFile("/etc/cgitrc", cgitrcContent) | ||
| 378 | client.WriteSudoFile("/opt/ship/cgit-header.html", templates.CgitHeader()) | ||
| 379 | |||
| 380 | // Start services | ||
| 381 | client.RunSudo("systemctl enable --now fcgiwrap") | ||
| 382 | client.RunSudo("systemctl restart caddy") | ||
| 383 | |||
| 384 | hostState.GitSetup = true | ||
| 385 | return nil | ||
| 386 | } | ||
| 387 | |||
| 388 | // ensureSSHKey checks for an existing SSH key or generates a new one. | ||
| 389 | // Returns the key path, public key contents, and any error. | ||
| 390 | func ensureSSHKey() (keyPath string, pubkey string, err error) { | ||
| 391 | home, err := os.UserHomeDir() | ||
| 392 | if err != nil { | ||
| 393 | return "", "", err | ||
| 394 | } | ||
| 395 | |||
| 396 | // Check common key locations | ||
| 397 | keyPaths := []string{ | ||
| 398 | filepath.Join(home, ".ssh", "id_ed25519"), | ||
| 399 | filepath.Join(home, ".ssh", "id_rsa"), | ||
| 400 | filepath.Join(home, ".ssh", "id_ecdsa"), | ||
| 401 | } | ||
| 402 | |||
| 403 | for _, kp := range keyPaths { | ||
| 404 | pubPath := kp + ".pub" | ||
| 405 | if _, err := os.Stat(kp); err == nil { | ||
| 406 | if _, err := os.Stat(pubPath); err == nil { | ||
| 407 | // Key exists, read public key | ||
| 408 | pub, err := os.ReadFile(pubPath) | ||
| 409 | if err != nil { | ||
| 410 | continue | ||
| 411 | } | ||
| 412 | return kp, strings.TrimSpace(string(pub)), nil | ||
| 413 | } | ||
| 414 | } | ||
| 415 | } | ||
| 416 | |||
| 417 | // No key found, generate one | ||
| 418 | keyPath = filepath.Join(home, ".ssh", "id_ed25519") | ||
| 419 | sshDir := filepath.Dir(keyPath) | ||
| 420 | |||
| 421 | // Ensure .ssh directory exists | ||
| 422 | if err := os.MkdirAll(sshDir, 0700); err != nil { | ||
| 423 | return "", "", fmt.Errorf("failed to create .ssh directory: %w", err) | ||
| 424 | } | ||
| 425 | |||
| 426 | // Generate key | ||
| 427 | cmd := exec.Command("ssh-keygen", "-t", "ed25519", "-f", keyPath, "-N", "", "-C", "ship") | ||
| 428 | if err := cmd.Run(); err != nil { | ||
| 429 | return "", "", fmt.Errorf("failed to generate SSH key: %w", err) | ||
| 430 | } | ||
| 431 | |||
| 432 | // Read public key | ||
| 433 | pub, err := os.ReadFile(keyPath + ".pub") | ||
| 434 | if err != nil { | ||
| 435 | return "", "", fmt.Errorf("failed to read public key: %w", err) | ||
| 436 | } | ||
| 437 | |||
| 438 | return keyPath, strings.TrimSpace(string(pub)), nil | ||
| 439 | } | ||
| 440 | |||
| 441 | // printJSON outputs a value as JSON to stdout | ||
| 442 | func printJSON(v interface{}) { | ||
| 443 | enc := json.NewEncoder(os.Stdout) | ||
| 444 | enc.Encode(v) | ||
| 445 | } | ||
