summaryrefslogtreecommitdiffstats
path: root/internal
diff options
context:
space:
mode:
authorbndw <ben@bdw.to>2025-12-28 09:21:08 -0800
committerbndw <ben@bdw.to>2025-12-28 09:21:08 -0800
commit13c2f9cffa624fdf498f3b61fab9d809b92e026e (patch)
tree4b25205ccd05e9e887376c10edb2f4069ea1d9d4 /internal
init
Diffstat (limited to 'internal')
-rw-r--r--internal/config/config.go65
-rw-r--r--internal/ssh/client.go357
-rw-r--r--internal/state/state.go146
-rw-r--r--internal/templates/templates.go82
4 files changed, 650 insertions, 0 deletions
diff --git a/internal/config/config.go b/internal/config/config.go
new file mode 100644
index 0000000..8651aa8
--- /dev/null
+++ b/internal/config/config.go
@@ -0,0 +1,65 @@
1package config
2
3import (
4 "bufio"
5 "os"
6 "path/filepath"
7 "strings"
8)
9
10// Config represents the user's configuration
11type Config struct {
12 Host string
13}
14
15// Load reads config from ~/.config/deploy/config
16func Load() (*Config, error) {
17 path := configPath()
18
19 // If file doesn't exist, return empty config
20 if _, err := os.Stat(path); os.IsNotExist(err) {
21 return &Config{}, nil
22 }
23
24 file, err := os.Open(path)
25 if err != nil {
26 return nil, err
27 }
28 defer file.Close()
29
30 cfg := &Config{}
31 scanner := bufio.NewScanner(file)
32 for scanner.Scan() {
33 line := strings.TrimSpace(scanner.Text())
34 if line == "" || strings.HasPrefix(line, "#") {
35 continue
36 }
37
38 parts := strings.SplitN(line, ":", 2)
39 if len(parts) != 2 {
40 continue
41 }
42
43 key := strings.TrimSpace(parts[0])
44 value := strings.TrimSpace(parts[1])
45
46 switch key {
47 case "host":
48 cfg.Host = value
49 }
50 }
51
52 if err := scanner.Err(); err != nil {
53 return nil, err
54 }
55
56 return cfg, nil
57}
58
59func configPath() string {
60 home, err := os.UserHomeDir()
61 if err != nil {
62 return ".deploy-config"
63 }
64 return filepath.Join(home, ".config", "deploy", "config")
65}
diff --git a/internal/ssh/client.go b/internal/ssh/client.go
new file mode 100644
index 0000000..1cd336c
--- /dev/null
+++ b/internal/ssh/client.go
@@ -0,0 +1,357 @@
1package ssh
2
3import (
4 "bufio"
5 "bytes"
6 "fmt"
7 "net"
8 "os"
9 "os/exec"
10 "path/filepath"
11 "strings"
12
13 "golang.org/x/crypto/ssh"
14 "golang.org/x/crypto/ssh/agent"
15)
16
17// Client represents an SSH connection to a remote host
18type Client struct {
19 host string
20 client *ssh.Client
21}
22
23// sshConfig holds SSH configuration for a host
24type sshConfig struct {
25 Host string
26 HostName string
27 User string
28 Port string
29 IdentityFile string
30}
31
32// Connect establishes an SSH connection to the remote host
33// Supports both SSH config aliases (e.g., "myserver") and user@host format
34func Connect(host string) (*Client, error) {
35 var user, addr string
36 var identityFile string
37
38 // Try to read SSH config first
39 cfg, err := readSSHConfig(host)
40 if err == nil && cfg.HostName != "" {
41 // Use SSH config
42 user = cfg.User
43 addr = cfg.HostName
44 if cfg.Port != "" {
45 addr = addr + ":" + cfg.Port
46 } else {
47 addr = addr + ":22"
48 }
49 identityFile = cfg.IdentityFile
50 } else {
51 // Fall back to parsing user@host format
52 parts := strings.SplitN(host, "@", 2)
53 if len(parts) != 2 {
54 return nil, fmt.Errorf("host '%s' not found in SSH config and not in user@host format", host)
55 }
56 user = parts[0]
57 addr = parts[1]
58
59 // Add default port if not specified
60 if !strings.Contains(addr, ":") {
61 addr = addr + ":22"
62 }
63 }
64
65 // Build authentication methods
66 var authMethods []ssh.AuthMethod
67
68 // Try identity file from SSH config first
69 if identityFile != "" {
70 if authMethod, err := publicKeyFromFile(identityFile); err == nil {
71 authMethods = append(authMethods, authMethod)
72 }
73 }
74
75 // Try SSH agent
76 if authMethod, err := sshAgent(); err == nil {
77 authMethods = append(authMethods, authMethod)
78 }
79
80 // Try default key files
81 if authMethod, err := publicKeyFile(); err == nil {
82 authMethods = append(authMethods, authMethod)
83 }
84
85 if len(authMethods) == 0 {
86 return nil, fmt.Errorf("no SSH authentication method available")
87 }
88
89 config := &ssh.ClientConfig{
90 User: user,
91 Auth: authMethods,
92 HostKeyCallback: ssh.InsecureIgnoreHostKey(), // TODO: Consider using known_hosts
93 }
94
95 client, err := ssh.Dial("tcp", addr, config)
96 if err != nil {
97 return nil, fmt.Errorf("failed to connect to %s: %w", host, err)
98 }
99
100 return &Client{
101 host: host,
102 client: client,
103 }, nil
104}
105
106// Close closes the SSH connection
107func (c *Client) Close() error {
108 return c.client.Close()
109}
110
111// Run executes a command on the remote host and returns the output
112func (c *Client) Run(cmd string) (string, error) {
113 session, err := c.client.NewSession()
114 if err != nil {
115 return "", err
116 }
117 defer session.Close()
118
119 var stdout, stderr bytes.Buffer
120 session.Stdout = &stdout
121 session.Stderr = &stderr
122
123 if err := session.Run(cmd); err != nil {
124 return "", fmt.Errorf("command failed: %w\nstderr: %s", err, stderr.String())
125 }
126
127 return stdout.String(), nil
128}
129
130// RunSudo executes a command with sudo on the remote host
131func (c *Client) RunSudo(cmd string) (string, error) {
132 return c.Run("sudo " + cmd)
133}
134
135// Upload copies a local file to the remote host using scp
136func (c *Client) Upload(localPath, remotePath string) error {
137 // Use external scp command for simplicity
138 // Format: scp -o StrictHostKeyChecking=no localPath user@host:remotePath
139 cmd := exec.Command("scp", "-o", "StrictHostKeyChecking=no", localPath, c.host+":"+remotePath)
140
141 var stderr bytes.Buffer
142 cmd.Stderr = &stderr
143
144 if err := cmd.Run(); err != nil {
145 return fmt.Errorf("scp failed: %w\nstderr: %s", err, stderr.String())
146 }
147
148 return nil
149}
150
151// UploadDir copies a local directory to the remote host using rsync
152func (c *Client) UploadDir(localDir, remoteDir string) error {
153 // Use rsync for directory uploads
154 // Format: rsync -avz --delete localDir/ user@host:remoteDir/
155 localDir = strings.TrimSuffix(localDir, "/") + "/"
156 remoteDir = strings.TrimSuffix(remoteDir, "/") + "/"
157
158 cmd := exec.Command("rsync", "-avz", "--delete",
159 "-e", "ssh -o StrictHostKeyChecking=no",
160 localDir, c.host+":"+remoteDir)
161
162 var stderr bytes.Buffer
163 cmd.Stderr = &stderr
164
165 if err := cmd.Run(); err != nil {
166 return fmt.Errorf("rsync failed: %w\nstderr: %s", err, stderr.String())
167 }
168
169 return nil
170}
171
172// WriteFile creates a file with the given content on the remote host
173func (c *Client) WriteFile(remotePath, content string) error {
174 session, err := c.client.NewSession()
175 if err != nil {
176 return err
177 }
178 defer session.Close()
179
180 // Use cat to write content to file
181 cmd := fmt.Sprintf("cat > %s", remotePath)
182 session.Stdin = strings.NewReader(content)
183
184 var stderr bytes.Buffer
185 session.Stderr = &stderr
186
187 if err := session.Run(cmd); err != nil {
188 return fmt.Errorf("write file failed: %w\nstderr: %s", err, stderr.String())
189 }
190
191 return nil
192}
193
194// WriteSudoFile creates a file with the given content using sudo
195func (c *Client) WriteSudoFile(remotePath, content string) error {
196 session, err := c.client.NewSession()
197 if err != nil {
198 return err
199 }
200 defer session.Close()
201
202 // Use sudo tee to write content to file
203 cmd := fmt.Sprintf("sudo tee %s > /dev/null", remotePath)
204 session.Stdin = strings.NewReader(content)
205
206 var stderr bytes.Buffer
207 session.Stderr = &stderr
208
209 if err := session.Run(cmd); err != nil {
210 return fmt.Errorf("write file with sudo failed: %w\nstderr: %s", err, stderr.String())
211 }
212
213 return nil
214}
215
216// readSSHConfig reads and parses the SSH config file for a given host
217func readSSHConfig(host string) (*sshConfig, error) {
218 home, err := os.UserHomeDir()
219 if err != nil {
220 return nil, err
221 }
222
223 configPath := filepath.Join(home, ".ssh", "config")
224 file, err := os.Open(configPath)
225 if err != nil {
226 return nil, err
227 }
228 defer file.Close()
229
230 cfg := &sshConfig{}
231 var currentHost string
232 var matchedHost bool
233
234 scanner := bufio.NewScanner(file)
235 for scanner.Scan() {
236 line := strings.TrimSpace(scanner.Text())
237
238 // Skip comments and empty lines
239 if line == "" || strings.HasPrefix(line, "#") {
240 continue
241 }
242
243 fields := strings.Fields(line)
244 if len(fields) < 2 {
245 continue
246 }
247
248 key := strings.ToLower(fields[0])
249 value := fields[1]
250
251 // Expand ~ in paths
252 if strings.HasPrefix(value, "~/") {
253 value = filepath.Join(home, value[2:])
254 }
255
256 switch key {
257 case "host":
258 currentHost = value
259 if currentHost == host {
260 matchedHost = true
261 cfg.Host = host
262 } else {
263 matchedHost = false
264 }
265 case "hostname":
266 if matchedHost {
267 cfg.HostName = value
268 }
269 case "user":
270 if matchedHost {
271 cfg.User = value
272 }
273 case "port":
274 if matchedHost {
275 cfg.Port = value
276 }
277 case "identityfile":
278 if matchedHost {
279 cfg.IdentityFile = value
280 }
281 }
282 }
283
284 if err := scanner.Err(); err != nil {
285 return nil, err
286 }
287
288 if cfg.Host == "" {
289 return nil, fmt.Errorf("host %s not found in SSH config", host)
290 }
291
292 return cfg, nil
293}
294
295// sshAgent returns an auth method using SSH agent
296func sshAgent() (ssh.AuthMethod, error) {
297 socket := os.Getenv("SSH_AUTH_SOCK")
298 if socket == "" {
299 return nil, fmt.Errorf("SSH_AUTH_SOCK not set")
300 }
301
302 conn, err := net.Dial("unix", socket)
303 if err != nil {
304 return nil, fmt.Errorf("failed to connect to SSH agent: %w", err)
305 }
306
307 agentClient := agent.NewClient(conn)
308 return ssh.PublicKeysCallback(agentClient.Signers), nil
309}
310
311// publicKeyFromFile returns an auth method from a specific private key file
312func publicKeyFromFile(keyPath string) (ssh.AuthMethod, error) {
313 key, err := os.ReadFile(keyPath)
314 if err != nil {
315 return nil, err
316 }
317
318 signer, err := ssh.ParsePrivateKey(key)
319 if err != nil {
320 return nil, err
321 }
322
323 return ssh.PublicKeys(signer), nil
324}
325
326// publicKeyFile returns an auth method using a private key file
327func publicKeyFile() (ssh.AuthMethod, error) {
328 home, err := os.UserHomeDir()
329 if err != nil {
330 return nil, err
331 }
332
333 // Try common key locations
334 keyPaths := []string{
335 filepath.Join(home, ".ssh", "id_rsa"),
336 filepath.Join(home, ".ssh", "id_ed25519"),
337 filepath.Join(home, ".ssh", "id_ecdsa"),
338 }
339
340 for _, keyPath := range keyPaths {
341 if _, err := os.Stat(keyPath); err == nil {
342 key, err := os.ReadFile(keyPath)
343 if err != nil {
344 continue
345 }
346
347 signer, err := ssh.ParsePrivateKey(key)
348 if err != nil {
349 continue
350 }
351
352 return ssh.PublicKeys(signer), nil
353 }
354 }
355
356 return nil, fmt.Errorf("no SSH private key found")
357}
diff --git a/internal/state/state.go b/internal/state/state.go
new file mode 100644
index 0000000..c7c7dd4
--- /dev/null
+++ b/internal/state/state.go
@@ -0,0 +1,146 @@
1package state
2
3import (
4 "encoding/json"
5 "fmt"
6 "os"
7 "path/filepath"
8)
9
10// State represents the entire local deployment state
11type State struct {
12 Hosts map[string]*Host `json:"hosts"`
13}
14
15// Host represents deployment state for a single VPS
16type Host struct {
17 NextPort int `json:"next_port"`
18 Apps map[string]*App `json:"apps"`
19}
20
21// App represents a deployed application or static site
22type App struct {
23 Type string `json:"type"` // "app" or "static"
24 Domain string `json:"domain"`
25 Port int `json:"port,omitempty"` // only for type="app"
26 Env map[string]string `json:"env,omitempty"` // only for type="app"
27}
28
29const (
30 startPort = 8001
31)
32
33// Load reads state from ~/.config/deploy/state.json
34func Load() (*State, error) {
35 path := statePath()
36
37 // If file doesn't exist, return empty state
38 if _, err := os.Stat(path); os.IsNotExist(err) {
39 return &State{
40 Hosts: make(map[string]*Host),
41 }, nil
42 }
43
44 data, err := os.ReadFile(path)
45 if err != nil {
46 return nil, fmt.Errorf("failed to read state file: %w", err)
47 }
48
49 var state State
50 if err := json.Unmarshal(data, &state); err != nil {
51 return nil, fmt.Errorf("failed to parse state file: %w", err)
52 }
53
54 // Initialize maps if nil
55 if state.Hosts == nil {
56 state.Hosts = make(map[string]*Host)
57 }
58
59 return &state, nil
60}
61
62// Save writes state to ~/.config/deploy/state.json
63func (s *State) Save() error {
64 path := statePath()
65
66 // Ensure directory exists
67 dir := filepath.Dir(path)
68 if err := os.MkdirAll(dir, 0755); err != nil {
69 return fmt.Errorf("failed to create config directory: %w", err)
70 }
71
72 data, err := json.MarshalIndent(s, "", " ")
73 if err != nil {
74 return fmt.Errorf("failed to marshal state: %w", err)
75 }
76
77 if err := os.WriteFile(path, data, 0600); err != nil {
78 return fmt.Errorf("failed to write state file: %w", err)
79 }
80
81 return nil
82}
83
84// GetHost returns the host state, creating it if it doesn't exist
85func (s *State) GetHost(host string) *Host {
86 if s.Hosts[host] == nil {
87 s.Hosts[host] = &Host{
88 NextPort: startPort,
89 Apps: make(map[string]*App),
90 }
91 }
92 if s.Hosts[host].Apps == nil {
93 s.Hosts[host].Apps = make(map[string]*App)
94 }
95 return s.Hosts[host]
96}
97
98// AllocatePort returns the next available port for a host
99func (s *State) AllocatePort(host string) int {
100 h := s.GetHost(host)
101 port := h.NextPort
102 h.NextPort++
103 return port
104}
105
106// AddApp adds or updates an app in the state
107func (s *State) AddApp(host, name string, app *App) {
108 h := s.GetHost(host)
109 h.Apps[name] = app
110}
111
112// RemoveApp removes an app from the state
113func (s *State) RemoveApp(host, name string) error {
114 h := s.GetHost(host)
115 if _, exists := h.Apps[name]; !exists {
116 return fmt.Errorf("app %s not found", name)
117 }
118 delete(h.Apps, name)
119 return nil
120}
121
122// GetApp returns an app from the state
123func (s *State) GetApp(host, name string) (*App, error) {
124 h := s.GetHost(host)
125 app, exists := h.Apps[name]
126 if !exists {
127 return nil, fmt.Errorf("app %s not found", name)
128 }
129 return app, nil
130}
131
132// ListApps returns all apps for a host
133func (s *State) ListApps(host string) map[string]*App {
134 h := s.GetHost(host)
135 return h.Apps
136}
137
138// statePath returns the path to the state file
139func statePath() string {
140 home, err := os.UserHomeDir()
141 if err != nil {
142 // Fallback to current directory (should rarely happen)
143 return ".deploy-state.json"
144 }
145 return filepath.Join(home, ".config", "deploy", "state.json")
146}
diff --git a/internal/templates/templates.go b/internal/templates/templates.go
new file mode 100644
index 0000000..d90163d
--- /dev/null
+++ b/internal/templates/templates.go
@@ -0,0 +1,82 @@
1package templates
2
3import (
4 "bytes"
5 "text/template"
6)
7
8var serviceTemplate = `[Unit]
9Description={{.Name}}
10After=network.target
11
12[Service]
13Type=simple
14User={{.User}}
15WorkingDirectory={{.WorkDir}}
16EnvironmentFile={{.EnvFile}}
17ExecStart={{.BinaryPath}} --port={{.Port}}
18Restart=always
19RestartSec=5s
20NoNewPrivileges=true
21PrivateTmp=true
22
23[Install]
24WantedBy=multi-user.target
25`
26
27var appCaddyTemplate = `{{.Domain}} {
28 reverse_proxy 127.0.0.1:{{.Port}}
29}
30`
31
32var staticCaddyTemplate = `{{.Domain}} {
33 root * {{.RootDir}}
34 file_server
35 encode gzip
36}
37`
38
39// SystemdService generates a systemd service unit file
40func SystemdService(data map[string]string) (string, error) {
41 tmpl, err := template.New("service").Parse(serviceTemplate)
42 if err != nil {
43 return "", err
44 }
45
46 var buf bytes.Buffer
47 if err := tmpl.Execute(&buf, data); err != nil {
48 return "", err
49 }
50
51 return buf.String(), nil
52}
53
54// AppCaddy generates a Caddy config for a Go app
55func AppCaddy(data map[string]string) (string, error) {
56 tmpl, err := template.New("caddy").Parse(appCaddyTemplate)
57 if err != nil {
58 return "", err
59 }
60
61 var buf bytes.Buffer
62 if err := tmpl.Execute(&buf, data); err != nil {
63 return "", err
64 }
65
66 return buf.String(), nil
67}
68
69// StaticCaddy generates a Caddy config for a static site
70func StaticCaddy(data map[string]string) (string, error) {
71 tmpl, err := template.New("caddy").Parse(staticCaddyTemplate)
72 if err != nil {
73 return "", err
74 }
75
76 var buf bytes.Buffer
77 if err := tmpl.Execute(&buf, data); err != nil {
78 return "", err
79 }
80
81 return buf.String(), nil
82}