From 13c2f9cffa624fdf498f3b61fab9d809b92e026e Mon Sep 17 00:00:00 2001 From: bndw Date: Sun, 28 Dec 2025 09:21:08 -0800 Subject: init --- internal/config/config.go | 65 ++++++++ internal/ssh/client.go | 357 ++++++++++++++++++++++++++++++++++++++++ internal/state/state.go | 146 ++++++++++++++++ internal/templates/templates.go | 82 +++++++++ 4 files changed, 650 insertions(+) create mode 100644 internal/config/config.go create mode 100644 internal/ssh/client.go create mode 100644 internal/state/state.go create mode 100644 internal/templates/templates.go (limited to 'internal') 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 @@ +package config + +import ( + "bufio" + "os" + "path/filepath" + "strings" +) + +// Config represents the user's configuration +type Config struct { + Host string +} + +// Load reads config from ~/.config/deploy/config +func Load() (*Config, error) { + path := configPath() + + // If file doesn't exist, return empty config + if _, err := os.Stat(path); os.IsNotExist(err) { + return &Config{}, nil + } + + file, err := os.Open(path) + if err != nil { + return nil, err + } + defer file.Close() + + cfg := &Config{} + scanner := bufio.NewScanner(file) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + + parts := strings.SplitN(line, ":", 2) + if len(parts) != 2 { + continue + } + + key := strings.TrimSpace(parts[0]) + value := strings.TrimSpace(parts[1]) + + switch key { + case "host": + cfg.Host = value + } + } + + if err := scanner.Err(); err != nil { + return nil, err + } + + return cfg, nil +} + +func configPath() string { + home, err := os.UserHomeDir() + if err != nil { + return ".deploy-config" + } + return filepath.Join(home, ".config", "deploy", "config") +} 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 @@ +package ssh + +import ( + "bufio" + "bytes" + "fmt" + "net" + "os" + "os/exec" + "path/filepath" + "strings" + + "golang.org/x/crypto/ssh" + "golang.org/x/crypto/ssh/agent" +) + +// Client represents an SSH connection to a remote host +type Client struct { + host string + client *ssh.Client +} + +// sshConfig holds SSH configuration for a host +type sshConfig struct { + Host string + HostName string + User string + Port string + IdentityFile string +} + +// Connect establishes an SSH connection to the remote host +// Supports both SSH config aliases (e.g., "myserver") and user@host format +func Connect(host string) (*Client, error) { + var user, addr string + var identityFile string + + // Try to read SSH config first + cfg, err := readSSHConfig(host) + if err == nil && cfg.HostName != "" { + // Use SSH config + user = cfg.User + addr = cfg.HostName + if cfg.Port != "" { + addr = addr + ":" + cfg.Port + } else { + addr = addr + ":22" + } + identityFile = cfg.IdentityFile + } else { + // Fall back to parsing user@host format + parts := strings.SplitN(host, "@", 2) + if len(parts) != 2 { + return nil, fmt.Errorf("host '%s' not found in SSH config and not in user@host format", host) + } + user = parts[0] + addr = parts[1] + + // Add default port if not specified + if !strings.Contains(addr, ":") { + addr = addr + ":22" + } + } + + // Build authentication methods + var authMethods []ssh.AuthMethod + + // Try identity file from SSH config first + if identityFile != "" { + if authMethod, err := publicKeyFromFile(identityFile); err == nil { + authMethods = append(authMethods, authMethod) + } + } + + // Try SSH agent + if authMethod, err := sshAgent(); err == nil { + authMethods = append(authMethods, authMethod) + } + + // Try default key files + if authMethod, err := publicKeyFile(); err == nil { + authMethods = append(authMethods, authMethod) + } + + if len(authMethods) == 0 { + return nil, fmt.Errorf("no SSH authentication method available") + } + + config := &ssh.ClientConfig{ + User: user, + Auth: authMethods, + HostKeyCallback: ssh.InsecureIgnoreHostKey(), // TODO: Consider using known_hosts + } + + client, err := ssh.Dial("tcp", addr, config) + if err != nil { + return nil, fmt.Errorf("failed to connect to %s: %w", host, err) + } + + return &Client{ + host: host, + client: client, + }, nil +} + +// Close closes the SSH connection +func (c *Client) Close() error { + return c.client.Close() +} + +// Run executes a command on the remote host and returns the output +func (c *Client) Run(cmd string) (string, error) { + session, err := c.client.NewSession() + if err != nil { + return "", err + } + defer session.Close() + + var stdout, stderr bytes.Buffer + session.Stdout = &stdout + session.Stderr = &stderr + + if err := session.Run(cmd); err != nil { + return "", fmt.Errorf("command failed: %w\nstderr: %s", err, stderr.String()) + } + + return stdout.String(), nil +} + +// RunSudo executes a command with sudo on the remote host +func (c *Client) RunSudo(cmd string) (string, error) { + return c.Run("sudo " + cmd) +} + +// Upload copies a local file to the remote host using scp +func (c *Client) Upload(localPath, remotePath string) error { + // Use external scp command for simplicity + // Format: scp -o StrictHostKeyChecking=no localPath user@host:remotePath + cmd := exec.Command("scp", "-o", "StrictHostKeyChecking=no", localPath, c.host+":"+remotePath) + + var stderr bytes.Buffer + cmd.Stderr = &stderr + + if err := cmd.Run(); err != nil { + return fmt.Errorf("scp failed: %w\nstderr: %s", err, stderr.String()) + } + + return nil +} + +// UploadDir copies a local directory to the remote host using rsync +func (c *Client) UploadDir(localDir, remoteDir string) error { + // Use rsync for directory uploads + // Format: rsync -avz --delete localDir/ user@host:remoteDir/ + localDir = strings.TrimSuffix(localDir, "/") + "/" + remoteDir = strings.TrimSuffix(remoteDir, "/") + "/" + + cmd := exec.Command("rsync", "-avz", "--delete", + "-e", "ssh -o StrictHostKeyChecking=no", + localDir, c.host+":"+remoteDir) + + var stderr bytes.Buffer + cmd.Stderr = &stderr + + if err := cmd.Run(); err != nil { + return fmt.Errorf("rsync failed: %w\nstderr: %s", err, stderr.String()) + } + + return nil +} + +// WriteFile creates a file with the given content on the remote host +func (c *Client) WriteFile(remotePath, content string) error { + session, err := c.client.NewSession() + if err != nil { + return err + } + defer session.Close() + + // Use cat to write content to file + cmd := fmt.Sprintf("cat > %s", remotePath) + session.Stdin = strings.NewReader(content) + + var stderr bytes.Buffer + session.Stderr = &stderr + + if err := session.Run(cmd); err != nil { + return fmt.Errorf("write file failed: %w\nstderr: %s", err, stderr.String()) + } + + return nil +} + +// WriteSudoFile creates a file with the given content using sudo +func (c *Client) WriteSudoFile(remotePath, content string) error { + session, err := c.client.NewSession() + if err != nil { + return err + } + defer session.Close() + + // Use sudo tee to write content to file + cmd := fmt.Sprintf("sudo tee %s > /dev/null", remotePath) + session.Stdin = strings.NewReader(content) + + var stderr bytes.Buffer + session.Stderr = &stderr + + if err := session.Run(cmd); err != nil { + return fmt.Errorf("write file with sudo failed: %w\nstderr: %s", err, stderr.String()) + } + + return nil +} + +// readSSHConfig reads and parses the SSH config file for a given host +func readSSHConfig(host string) (*sshConfig, error) { + home, err := os.UserHomeDir() + if err != nil { + return nil, err + } + + configPath := filepath.Join(home, ".ssh", "config") + file, err := os.Open(configPath) + if err != nil { + return nil, err + } + defer file.Close() + + cfg := &sshConfig{} + var currentHost string + var matchedHost bool + + scanner := bufio.NewScanner(file) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + + // Skip comments and empty lines + if line == "" || strings.HasPrefix(line, "#") { + continue + } + + fields := strings.Fields(line) + if len(fields) < 2 { + continue + } + + key := strings.ToLower(fields[0]) + value := fields[1] + + // Expand ~ in paths + if strings.HasPrefix(value, "~/") { + value = filepath.Join(home, value[2:]) + } + + switch key { + case "host": + currentHost = value + if currentHost == host { + matchedHost = true + cfg.Host = host + } else { + matchedHost = false + } + case "hostname": + if matchedHost { + cfg.HostName = value + } + case "user": + if matchedHost { + cfg.User = value + } + case "port": + if matchedHost { + cfg.Port = value + } + case "identityfile": + if matchedHost { + cfg.IdentityFile = value + } + } + } + + if err := scanner.Err(); err != nil { + return nil, err + } + + if cfg.Host == "" { + return nil, fmt.Errorf("host %s not found in SSH config", host) + } + + return cfg, nil +} + +// sshAgent returns an auth method using SSH agent +func sshAgent() (ssh.AuthMethod, error) { + socket := os.Getenv("SSH_AUTH_SOCK") + if socket == "" { + return nil, fmt.Errorf("SSH_AUTH_SOCK not set") + } + + conn, err := net.Dial("unix", socket) + if err != nil { + return nil, fmt.Errorf("failed to connect to SSH agent: %w", err) + } + + agentClient := agent.NewClient(conn) + return ssh.PublicKeysCallback(agentClient.Signers), nil +} + +// publicKeyFromFile returns an auth method from a specific private key file +func publicKeyFromFile(keyPath string) (ssh.AuthMethod, error) { + key, err := os.ReadFile(keyPath) + if err != nil { + return nil, err + } + + signer, err := ssh.ParsePrivateKey(key) + if err != nil { + return nil, err + } + + return ssh.PublicKeys(signer), nil +} + +// publicKeyFile returns an auth method using a private key file +func publicKeyFile() (ssh.AuthMethod, error) { + home, err := os.UserHomeDir() + if err != nil { + return nil, err + } + + // Try common key locations + keyPaths := []string{ + filepath.Join(home, ".ssh", "id_rsa"), + filepath.Join(home, ".ssh", "id_ed25519"), + filepath.Join(home, ".ssh", "id_ecdsa"), + } + + for _, keyPath := range keyPaths { + if _, err := os.Stat(keyPath); err == nil { + key, err := os.ReadFile(keyPath) + if err != nil { + continue + } + + signer, err := ssh.ParsePrivateKey(key) + if err != nil { + continue + } + + return ssh.PublicKeys(signer), nil + } + } + + return nil, fmt.Errorf("no SSH private key found") +} 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 @@ +package state + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" +) + +// State represents the entire local deployment state +type State struct { + Hosts map[string]*Host `json:"hosts"` +} + +// Host represents deployment state for a single VPS +type Host struct { + NextPort int `json:"next_port"` + Apps map[string]*App `json:"apps"` +} + +// App represents a deployed application or static site +type App struct { + Type string `json:"type"` // "app" or "static" + Domain string `json:"domain"` + Port int `json:"port,omitempty"` // only for type="app" + Env map[string]string `json:"env,omitempty"` // only for type="app" +} + +const ( + startPort = 8001 +) + +// Load reads state from ~/.config/deploy/state.json +func Load() (*State, error) { + path := statePath() + + // If file doesn't exist, return empty state + if _, err := os.Stat(path); os.IsNotExist(err) { + return &State{ + Hosts: make(map[string]*Host), + }, nil + } + + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("failed to read state file: %w", err) + } + + var state State + if err := json.Unmarshal(data, &state); err != nil { + return nil, fmt.Errorf("failed to parse state file: %w", err) + } + + // Initialize maps if nil + if state.Hosts == nil { + state.Hosts = make(map[string]*Host) + } + + return &state, nil +} + +// Save writes state to ~/.config/deploy/state.json +func (s *State) Save() error { + path := statePath() + + // Ensure directory exists + dir := filepath.Dir(path) + if err := os.MkdirAll(dir, 0755); err != nil { + return fmt.Errorf("failed to create config directory: %w", err) + } + + data, err := json.MarshalIndent(s, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal state: %w", err) + } + + if err := os.WriteFile(path, data, 0600); err != nil { + return fmt.Errorf("failed to write state file: %w", err) + } + + return nil +} + +// GetHost returns the host state, creating it if it doesn't exist +func (s *State) GetHost(host string) *Host { + if s.Hosts[host] == nil { + s.Hosts[host] = &Host{ + NextPort: startPort, + Apps: make(map[string]*App), + } + } + if s.Hosts[host].Apps == nil { + s.Hosts[host].Apps = make(map[string]*App) + } + return s.Hosts[host] +} + +// AllocatePort returns the next available port for a host +func (s *State) AllocatePort(host string) int { + h := s.GetHost(host) + port := h.NextPort + h.NextPort++ + return port +} + +// AddApp adds or updates an app in the state +func (s *State) AddApp(host, name string, app *App) { + h := s.GetHost(host) + h.Apps[name] = app +} + +// RemoveApp removes an app from the state +func (s *State) RemoveApp(host, name string) error { + h := s.GetHost(host) + if _, exists := h.Apps[name]; !exists { + return fmt.Errorf("app %s not found", name) + } + delete(h.Apps, name) + return nil +} + +// GetApp returns an app from the state +func (s *State) GetApp(host, name string) (*App, error) { + h := s.GetHost(host) + app, exists := h.Apps[name] + if !exists { + return nil, fmt.Errorf("app %s not found", name) + } + return app, nil +} + +// ListApps returns all apps for a host +func (s *State) ListApps(host string) map[string]*App { + h := s.GetHost(host) + return h.Apps +} + +// statePath returns the path to the state file +func statePath() string { + home, err := os.UserHomeDir() + if err != nil { + // Fallback to current directory (should rarely happen) + return ".deploy-state.json" + } + return filepath.Join(home, ".config", "deploy", "state.json") +} 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 @@ +package templates + +import ( + "bytes" + "text/template" +) + +var serviceTemplate = `[Unit] +Description={{.Name}} +After=network.target + +[Service] +Type=simple +User={{.User}} +WorkingDirectory={{.WorkDir}} +EnvironmentFile={{.EnvFile}} +ExecStart={{.BinaryPath}} --port={{.Port}} +Restart=always +RestartSec=5s +NoNewPrivileges=true +PrivateTmp=true + +[Install] +WantedBy=multi-user.target +` + +var appCaddyTemplate = `{{.Domain}} { + reverse_proxy 127.0.0.1:{{.Port}} +} +` + +var staticCaddyTemplate = `{{.Domain}} { + root * {{.RootDir}} + file_server + encode gzip +} +` + +// SystemdService generates a systemd service unit file +func SystemdService(data map[string]string) (string, error) { + tmpl, err := template.New("service").Parse(serviceTemplate) + if err != nil { + return "", err + } + + var buf bytes.Buffer + if err := tmpl.Execute(&buf, data); err != nil { + return "", err + } + + return buf.String(), nil +} + +// AppCaddy generates a Caddy config for a Go app +func AppCaddy(data map[string]string) (string, error) { + tmpl, err := template.New("caddy").Parse(appCaddyTemplate) + if err != nil { + return "", err + } + + var buf bytes.Buffer + if err := tmpl.Execute(&buf, data); err != nil { + return "", err + } + + return buf.String(), nil +} + +// StaticCaddy generates a Caddy config for a static site +func StaticCaddy(data map[string]string) (string, error) { + tmpl, err := template.New("caddy").Parse(staticCaddyTemplate) + if err != nil { + return "", err + } + + var buf bytes.Buffer + if err := tmpl.Execute(&buf, data); err != nil { + return "", err + } + + return buf.String(), nil +} -- cgit v1.2.3