aboutsummaryrefslogtreecommitdiffstats
path: root/cmd/ship/host_v2.go
diff options
context:
space:
mode:
Diffstat (limited to 'cmd/ship/host_v2.go')
-rw-r--r--cmd/ship/host_v2.go445
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 @@
1package main
2
3import (
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
18func 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
26var 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
31Example:
32 ship host init user@my-vps --domain example.com`,
33 Args: cobra.ExactArgs(1),
34 RunE: runHostInitV2,
35}
36
37func 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
93import /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
159func 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
178func 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
197func installCleanupTimer(client *ssh.Client) error {
198 // Cleanup script
199 script := `#!/bin/bash
200now=$(date +%s)
201for 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
219done
220systemctl daemon-reload
221systemctl 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]
232Description=Ship TTL cleanup timer
233
234[Timer]
235OnCalendar=hourly
236Persistent=true
237
238[Install]
239WantedBy=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]
247Description=Ship TTL cleanup
248
249[Service]
250Type=oneshot
251ExecStart=/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
268var 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
309func 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.
390func 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
442func printJSON(v interface{}) {
443 enc := json.NewEncoder(os.Stdout)
444 enc.Encode(v)
445}