summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--cmd/ship/deploy.go3
-rw-r--r--cmd/ship/deploy_cmd.go3
-rw-r--r--cmd/ship/env/list.go3
-rw-r--r--cmd/ship/env/set.go3
-rw-r--r--cmd/ship/env/unset.go3
-rw-r--r--cmd/ship/host/init.go42
-rw-r--r--cmd/ship/logs.go3
-rw-r--r--cmd/ship/remove.go3
-rw-r--r--cmd/ship/restart.go3
-rw-r--r--cmd/ship/status.go3
-rw-r--r--cmd/ship/validate.go9
-rw-r--r--internal/state/state.go13
12 files changed, 84 insertions, 7 deletions
diff --git a/cmd/ship/deploy.go b/cmd/ship/deploy.go
index 9ac754c..86d4878 100644
--- a/cmd/ship/deploy.go
+++ b/cmd/ship/deploy.go
@@ -140,6 +140,9 @@ func runDeploy(cmd *cobra.Command, args []string) error {
140 name = filepath.Base(binary) 140 name = filepath.Base(binary)
141 } 141 }
142 } 142 }
143 if err := validateName(name); err != nil {
144 return err
145 }
143 146
144 // Check if this is an update to an existing app/site 147 // Check if this is an update to an existing app/site
145 existingApp, _ := st.GetApp(host, name) 148 existingApp, _ := st.GetApp(host, name)
diff --git a/cmd/ship/deploy_cmd.go b/cmd/ship/deploy_cmd.go
index ca5c54d..ba45c4f 100644
--- a/cmd/ship/deploy_cmd.go
+++ b/cmd/ship/deploy_cmd.go
@@ -24,6 +24,9 @@ Examples:
24 24
25func runDeployGit(cmd *cobra.Command, args []string) error { 25func runDeployGit(cmd *cobra.Command, args []string) error {
26 name := args[0] 26 name := args[0]
27 if err := validateName(name); err != nil {
28 return err
29 }
27 30
28 st, err := state.Load() 31 st, err := state.Load()
29 if err != nil { 32 if err != nil {
diff --git a/cmd/ship/env/list.go b/cmd/ship/env/list.go
index ad76eb6..e94b83a 100644
--- a/cmd/ship/env/list.go
+++ b/cmd/ship/env/list.go
@@ -17,6 +17,9 @@ var listCmd = &cobra.Command{
17 17
18func runList(cmd *cobra.Command, args []string) error { 18func runList(cmd *cobra.Command, args []string) error {
19 name := args[0] 19 name := args[0]
20 if err := state.ValidateName(name); err != nil {
21 return err
22 }
20 23
21 st, err := state.Load() 24 st, err := state.Load()
22 if err != nil { 25 if err != nil {
diff --git a/cmd/ship/env/set.go b/cmd/ship/env/set.go
index e11d2c9..d4292f3 100644
--- a/cmd/ship/env/set.go
+++ b/cmd/ship/env/set.go
@@ -25,6 +25,9 @@ func init() {
25 25
26func runSet(cmd *cobra.Command, args []string) error { 26func runSet(cmd *cobra.Command, args []string) error {
27 name := args[0] 27 name := args[0]
28 if err := state.ValidateName(name); err != nil {
29 return err
30 }
28 envVars := args[1:] 31 envVars := args[1:]
29 32
30 st, err := state.Load() 33 st, err := state.Load()
diff --git a/cmd/ship/env/unset.go b/cmd/ship/env/unset.go
index 7d9a141..8292f42 100644
--- a/cmd/ship/env/unset.go
+++ b/cmd/ship/env/unset.go
@@ -18,6 +18,9 @@ var unsetCmd = &cobra.Command{
18 18
19func runUnset(cmd *cobra.Command, args []string) error { 19func runUnset(cmd *cobra.Command, args []string) error {
20 name := args[0] 20 name := args[0]
21 if err := state.ValidateName(name); err != nil {
22 return err
23 }
21 keys := args[1:] 24 keys := args[1:]
22 25
23 st, err := state.Load() 26 st, err := state.Load()
diff --git a/cmd/ship/host/init.go b/cmd/ship/host/init.go
index e1792f5..0ec573c 100644
--- a/cmd/ship/host/init.go
+++ b/cmd/ship/host/init.go
@@ -157,6 +157,13 @@ func setupGitDeploy(client *ssh.Client, baseDomain string, hostState *state.Host
157 if _, err := client.RunSudo("apt-get install -y git fcgiwrap"); err != nil { 157 if _, err := client.RunSudo("apt-get install -y git fcgiwrap"); err != nil {
158 return fmt.Errorf("error installing git/fcgiwrap: %w", err) 158 return fmt.Errorf("error installing git/fcgiwrap: %w", err)
159 } 159 }
160 // Allow git-http-backend (runs as www-data) to access repos owned by git.
161 // Scoped to www-data only, not system-wide, to preserve CVE-2022-24765 protection.
162 // www-data's home is /var/www; ensure it can write .gitconfig there.
163 client.RunSudo("chown www-data:www-data /var/www")
164 if _, err := client.RunSudo("sudo -u www-data git config --global --add safe.directory '*'"); err != nil {
165 return fmt.Errorf("error setting git safe.directory: %w", err)
166 }
160 fmt.Println(" git and fcgiwrap installed") 167 fmt.Println(" git and fcgiwrap installed")
161 168
162 fmt.Println("-> Creating git user...") 169 fmt.Println("-> Creating git user...")
@@ -165,6 +172,14 @@ func setupGitDeploy(client *ssh.Client, baseDomain string, hostState *state.Host
165 if _, err := client.RunSudo("usermod -aG docker git"); err != nil { 172 if _, err := client.RunSudo("usermod -aG docker git"); err != nil {
166 return fmt.Errorf("error adding git user to docker group: %w", err) 173 return fmt.Errorf("error adding git user to docker group: %w", err)
167 } 174 }
175 // www-data needs to read git repos for git-http-backend
176 if _, err := client.RunSudo("usermod -aG git www-data"); err != nil {
177 return fmt.Errorf("error adding www-data to git group: %w", err)
178 }
179 // caddy needs to connect to fcgiwrap socket (owned by www-data)
180 if _, err := client.RunSudo("usermod -aG www-data caddy"); err != nil {
181 return fmt.Errorf("error adding caddy to www-data group: %w", err)
182 }
168 fmt.Println(" git user created") 183 fmt.Println(" git user created")
169 184
170 fmt.Println("-> Copying SSH keys to git user...") 185 fmt.Println("-> Copying SSH keys to git user...")
@@ -192,7 +207,20 @@ func setupGitDeploy(client *ssh.Client, baseDomain string, hostState *state.Host
192 fmt.Println(" /srv/git created") 207 fmt.Println(" /srv/git created")
193 208
194 fmt.Println("-> Writing sudoers for git user...") 209 fmt.Println("-> Writing sudoers for git user...")
195 sudoersContent := `git ALL=(ALL) NOPASSWD: /bin/systemctl restart *, /bin/systemctl daemon-reload, /bin/systemctl reload caddy, /bin/systemctl enable *, /bin/cp * /etc/systemd/system/*, /bin/cp * /etc/caddy/sites-enabled/*, /bin/mkdir -p /var/lib/*, /bin/mkdir -p /var/www/*, /bin/chown * 210 sudoersContent := `# Ship git-deploy: allow post-receive hooks to install configs and manage services.
211# App names are validated to [a-z][a-z0-9-] before reaching this point.
212git ALL=(ALL) NOPASSWD: \
213 /bin/systemctl daemon-reload, \
214 /bin/systemctl reload caddy, \
215 /bin/systemctl restart [a-z]*, \
216 /bin/systemctl enable [a-z]*, \
217 /bin/cp /var/lib/*/src/.ship/service /etc/systemd/system/*.service, \
218 /bin/cp /var/lib/*/src/.ship/Caddyfile /etc/caddy/sites-enabled/*.caddy, \
219 /bin/cp /var/www/*/.ship/Caddyfile /etc/caddy/sites-enabled/*.caddy, \
220 /bin/mkdir -p /var/lib/*, \
221 /bin/mkdir -p /var/www/*, \
222 /bin/chown -R git\:git /var/lib/*, \
223 /bin/chown git\:git /var/www/*
196` 224`
197 if err := client.WriteSudoFile("/etc/sudoers.d/ship-git", sudoersContent); err != nil { 225 if err := client.WriteSudoFile("/etc/sudoers.d/ship-git", sudoersContent); err != nil {
198 return fmt.Errorf("error writing sudoers: %w", err) 226 return fmt.Errorf("error writing sudoers: %w", err)
@@ -205,7 +233,7 @@ func setupGitDeploy(client *ssh.Client, baseDomain string, hostState *state.Host
205 fmt.Println("-> Writing vanity import template...") 233 fmt.Println("-> Writing vanity import template...")
206 vanityHTML := `<!DOCTYPE html> 234 vanityHTML := `<!DOCTYPE html>
207<html><head> 235<html><head>
208{{$path := trimPrefix "/" .Req.URL.Path}} 236{{$path := trimPrefix "/" (placeholder "http.request.orig_uri.path")}}
209{{$parts := splitList "/" $path}} 237{{$parts := splitList "/" $path}}
210{{$module := first $parts}} 238{{$module := first $parts}}
211<meta name="go-import" content="{{.Host}}/{{$module}} git https://{{.Host}}/{{$module}}.git"> 239<meta name="go-import" content="{{.Host}}/{{$module}} git https://{{.Host}}/{{$module}}.git">
@@ -237,16 +265,16 @@ func setupGitDeploy(client *ssh.Client, baseDomain string, hostState *state.Host
237 if _, err := client.RunSudo("systemctl enable docker fcgiwrap"); err != nil { 265 if _, err := client.RunSudo("systemctl enable docker fcgiwrap"); err != nil {
238 return fmt.Errorf("error enabling services: %w", err) 266 return fmt.Errorf("error enabling services: %w", err)
239 } 267 }
240 if _, err := client.RunSudo("systemctl start docker fcgiwrap"); err != nil { 268 if _, err := client.RunSudo("systemctl restart docker fcgiwrap"); err != nil {
241 return fmt.Errorf("error starting services: %w", err) 269 return fmt.Errorf("error starting services: %w", err)
242 } 270 }
243 fmt.Println(" Docker and fcgiwrap started") 271 fmt.Println(" Docker and fcgiwrap started")
244 272
245 fmt.Println("-> Reloading Caddy...") 273 fmt.Println("-> Restarting Caddy...")
246 if _, err := client.RunSudo("systemctl reload caddy"); err != nil { 274 if _, err := client.RunSudo("systemctl restart caddy"); err != nil {
247 return fmt.Errorf("error reloading Caddy: %w", err) 275 return fmt.Errorf("error restarting Caddy: %w", err)
248 } 276 }
249 fmt.Println(" Caddy reloaded") 277 fmt.Println(" Caddy restarted")
250 278
251 hostState.GitSetup = true 279 hostState.GitSetup = true
252 fmt.Println(" Git deployment setup complete") 280 fmt.Println(" Git deployment setup complete")
diff --git a/cmd/ship/logs.go b/cmd/ship/logs.go
index 4d85fe1..4c58a9c 100644
--- a/cmd/ship/logs.go
+++ b/cmd/ship/logs.go
@@ -22,6 +22,9 @@ func init() {
22 22
23func runLogs(cmd *cobra.Command, args []string) error { 23func runLogs(cmd *cobra.Command, args []string) error {
24 name := args[0] 24 name := args[0]
25 if err := validateName(name); err != nil {
26 return err
27 }
25 follow, _ := cmd.Flags().GetBool("follow") 28 follow, _ := cmd.Flags().GetBool("follow")
26 lines, _ := cmd.Flags().GetInt("lines") 29 lines, _ := cmd.Flags().GetInt("lines")
27 30
diff --git a/cmd/ship/remove.go b/cmd/ship/remove.go
index 8380e87..b55d0c8 100644
--- a/cmd/ship/remove.go
+++ b/cmd/ship/remove.go
@@ -18,6 +18,9 @@ var removeCmd = &cobra.Command{
18 18
19func runRemove(cmd *cobra.Command, args []string) error { 19func runRemove(cmd *cobra.Command, args []string) error {
20 name := args[0] 20 name := args[0]
21 if err := validateName(name); err != nil {
22 return err
23 }
21 24
22 st, err := state.Load() 25 st, err := state.Load()
23 if err != nil { 26 if err != nil {
diff --git a/cmd/ship/restart.go b/cmd/ship/restart.go
index 404624a..c902adb 100644
--- a/cmd/ship/restart.go
+++ b/cmd/ship/restart.go
@@ -17,6 +17,9 @@ var restartCmd = &cobra.Command{
17 17
18func runRestart(cmd *cobra.Command, args []string) error { 18func runRestart(cmd *cobra.Command, args []string) error {
19 name := args[0] 19 name := args[0]
20 if err := validateName(name); err != nil {
21 return err
22 }
20 23
21 st, err := state.Load() 24 st, err := state.Load()
22 if err != nil { 25 if err != nil {
diff --git a/cmd/ship/status.go b/cmd/ship/status.go
index 536ec8c..4774fad 100644
--- a/cmd/ship/status.go
+++ b/cmd/ship/status.go
@@ -17,6 +17,9 @@ var statusCmd = &cobra.Command{
17 17
18func runStatus(cmd *cobra.Command, args []string) error { 18func runStatus(cmd *cobra.Command, args []string) error {
19 name := args[0] 19 name := args[0]
20 if err := validateName(name); err != nil {
21 return err
22 }
20 23
21 st, err := state.Load() 24 st, err := state.Load()
22 if err != nil { 25 if err != nil {
diff --git a/cmd/ship/validate.go b/cmd/ship/validate.go
new file mode 100644
index 0000000..00275af
--- /dev/null
+++ b/cmd/ship/validate.go
@@ -0,0 +1,9 @@
1package main
2
3import "github.com/bdw/ship/internal/state"
4
5// validateName checks that an app/project name is safe for use in shell
6// commands, file paths, systemd units, and DNS labels.
7func validateName(name string) error {
8 return state.ValidateName(name)
9}
diff --git a/internal/state/state.go b/internal/state/state.go
index 324fd34..c9aa21d 100644
--- a/internal/state/state.go
+++ b/internal/state/state.go
@@ -5,6 +5,7 @@ import (
5 "fmt" 5 "fmt"
6 "os" 6 "os"
7 "path/filepath" 7 "path/filepath"
8 "regexp"
8) 9)
9 10
10// State represents the entire local deployment state 11// State represents the entire local deployment state
@@ -27,6 +28,7 @@ type App struct {
27 Domain string `json:"domain"` 28 Domain string `json:"domain"`
28 Port int `json:"port,omitempty"` // only for type="app" or "git-app" 29 Port int `json:"port,omitempty"` // only for type="app" or "git-app"
29 Repo string `json:"repo,omitempty"` // only for git types, e.g. "/srv/git/foo.git" 30 Repo string `json:"repo,omitempty"` // only for git types, e.g. "/srv/git/foo.git"
31 Public bool `json:"public,omitempty"` // only for git types, enables HTTP clone access
30 Env map[string]string `json:"env,omitempty"` // only for type="app" or "git-app" 32 Env map[string]string `json:"env,omitempty"` // only for type="app" or "git-app"
31 Args string `json:"args,omitempty"` // only for type="app" 33 Args string `json:"args,omitempty"` // only for type="app"
32 Files []string `json:"files,omitempty"` // only for type="app" 34 Files []string `json:"files,omitempty"` // only for type="app"
@@ -38,6 +40,17 @@ const (
38 startPort = 8001 40 startPort = 8001
39) 41)
40 42
43var validName = regexp.MustCompile(`^[a-z][a-z0-9-]{0,62}$`)
44
45// ValidateName checks that a name is safe for use in shell commands,
46// file paths, systemd units, and DNS labels.
47func ValidateName(name string) error {
48 if !validName.MatchString(name) {
49 return fmt.Errorf("invalid name %q: must start with a lowercase letter, contain only lowercase letters, digits, and hyphens, and be 1-63 characters", name)
50 }
51 return nil
52}
53
41// Load reads state from ~/.config/ship/state.json 54// Load reads state from ~/.config/ship/state.json
42func Load() (*State, error) { 55func Load() (*State, error) {
43 path := statePath() 56 path := statePath()