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