From 13c2f9cffa624fdf498f3b61fab9d809b92e026e Mon Sep 17 00:00:00 2001 From: bndw Date: Sun, 28 Dec 2025 09:21:08 -0800 Subject: init --- .gitignore | 19 ++ DESIGN_SPEC.md | 586 ++++++++++++++++++++++++++++++++++++++++ README.md | 221 +++++++++++++++ go.mod | 7 + go.sum | 6 + internal/config/config.go | 65 +++++ internal/ssh/client.go | 357 ++++++++++++++++++++++++ internal/state/state.go | 146 ++++++++++ internal/templates/templates.go | 82 ++++++ templates/app.caddy.tmpl | 3 + templates/service.tmpl | 17 ++ templates/static.caddy.tmpl | 5 + test/example-website/about.html | 42 +++ test/example-website/index.html | 46 ++++ test/example-website/style.css | 156 +++++++++++ 15 files changed, 1758 insertions(+) create mode 100644 .gitignore create mode 100644 DESIGN_SPEC.md create mode 100644 README.md create mode 100644 go.mod create mode 100644 go.sum 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 create mode 100644 templates/app.caddy.tmpl create mode 100644 templates/service.tmpl create mode 100644 templates/static.caddy.tmpl create mode 100644 test/example-website/about.html create mode 100644 test/example-website/index.html create mode 100644 test/example-website/style.css diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c23b850 --- /dev/null +++ b/.gitignore @@ -0,0 +1,19 @@ +# Binaries +deploy +*.exe +*.dll +*.so +*.dylib + +# Test binary +*.test + +# Go workspace file +go.work + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ diff --git a/DESIGN_SPEC.md b/DESIGN_SPEC.md new file mode 100644 index 0000000..e8bb197 --- /dev/null +++ b/DESIGN_SPEC.md @@ -0,0 +1,586 @@ +# VPS Deployment CLI - Design Spec + +## Overview +Standalone CLI tool for deploying Go apps and static sites to a VPS with automatic HTTPS via Caddy. + +## Server Setup (One-time) + +### Build and Install the Deploy CLI on Your Laptop +```bash +# Clone and build the deploy CLI +git clone deploy +cd deploy +go build -o ~/bin/deploy ./cmd/deploy +# Or: go install ./cmd/deploy (if ~/go/bin is in your PATH) + +# Initialize a fresh VPS (one-time) +deploy init --host user@your-vps-ip + +# The CLI will SSH into the VPS and: +# - Detect OS (Ubuntu/Debian supported) +# - Install Caddy from official repository +# - Configure Caddy to import `/etc/caddy/sites-enabled/*` +# - Create `/etc/deploy/env/` directory for env files +# - Create `/etc/caddy/sites-enabled/` directory +# - Enable and start Caddy service +# - Verify installation +# +# State is stored locally at ~/.config/deploy/state.json +``` + +## CLI Commands + +All commands run from your laptop and use `--host` to specify the VPS. + +### Initialize VPS (One-time Setup) +```bash +# On a fresh VPS, run this once to install all dependencies +deploy init --host user@your-vps-ip + +# This will SSH to the VPS and: +# - Install Caddy +# - Configure Caddy to use sites-enabled pattern +# - Create /etc/deploy/env/ directory for env files +# - Enable and start Caddy +# +# State is stored locally at ~/.config/deploy/state.json +``` + +### Deploy Go App +```bash +# Build locally, then deploy +deploy --host user@vps-ip --domain api.example.com + +# With custom name (defaults to directory name or binary name) +deploy --host user@vps-ip --name myapi --domain api.example.com + +# Deploy specific binary +deploy --host user@vps-ip --binary ./myapp --domain api.example.com + +# With environment variables +deploy --host user@vps-ip --domain api.example.com --env DB_HOST=localhost --env API_KEY=secret + +# Or from env file +deploy --host user@vps-ip --domain api.example.com --env-file .env.production +``` + +### Deploy Static Site +```bash +# Deploy current directory +deploy --host user@vps-ip --static --domain example.com + +# Deploy specific directory +deploy --host user@vps-ip --static --dir ./dist --domain example.com + +# With custom name +deploy --host user@vps-ip --static --name mysite --dir ./build --domain example.com +``` + +### Management Commands +```bash +# List all deployed apps/sites +deploy list --host user@vps-ip + +# Remove deployment +deploy remove myapp --host user@vps-ip + +# View logs +deploy logs myapp --host user@vps-ip + +# Restart app +deploy restart myapp --host user@vps-ip + +# View status +deploy status myapp --host user@vps-ip + +# Set/update environment variables +deploy env myapi --host user@vps-ip --set DB_HOST=localhost --set API_KEY=secret + +# View environment variables (secrets are masked) +deploy env myapi --host user@vps-ip + +# Load from file +deploy env myapi --host user@vps-ip --file .env.production + +# Remove specific env var +deploy env myapi --host user@vps-ip --unset API_KEY +``` + +### Optional: Config File +```bash +# Create ~/.config/deploy/config to avoid typing --host every time +mkdir -p ~/.config/deploy +cat > ~/.config/deploy/config < ~/.config/deploy/config + +# Done! VPS is ready for deployments +``` + +### Go Apps +```bash +# 1. Build locally +GOOS=linux GOARCH=amd64 go build -o myapp + +# 2. Deploy from laptop (with config file) +deploy --binary ./myapp --domain api.example.com + +# Or specify host explicitly +deploy --host user@vps-ip --binary ./myapp --domain api.example.com +# CLI will: scp binary to VPS, set up systemd, configure Caddy, start service +``` + +### Static Sites +```bash +# 1. Build locally +npm run build + +# 2. Deploy from laptop +deploy --static --dir ./dist --domain example.com +# CLI will: rsync files to VPS, configure Caddy +``` + +## App Requirements + +### Go Apps Must: +- Listen on HTTP (standard `http.ListenAndServe`) +- Accept port via env var `PORT` or flag `--port` +- Bind to `localhost` or `127.0.0.1` only (Caddy will handle external traffic) + +**Example main.go:** +```go +package main + +import ( + "flag" + "fmt" + "net/http" + "os" +) + +func main() { + port := flag.String("port", os.Getenv("PORT"), "port to listen on") + flag.Parse() + + if *port == "" { + *port = "8080" // fallback for local dev + } + + http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte("Hello World")) + }) + + addr := "127.0.0.1:" + *port + fmt.Printf("Listening on %s\n", addr) + http.ListenAndServe(addr, nil) +} +``` + +## CLI Implementation + +### How It Works +The CLI is a single binary that runs on your laptop and orchestrates deployments via SSH: + +1. **Connect**: Uses SSH to connect to VPS (via `--host` flag or config file) +2. **Transfer**: Copies files to VPS using scp (binaries) or rsync (static sites) +3. **Execute**: Runs commands on VPS over SSH to: + - Create system users + - Install systemd units + - Generate Caddy configs + - Manage services +4. **Verify**: Checks deployment status and returns results + +### Package Structure +``` +deploy/ +├── cmd/deploy/ +│ └── main.go # CLI entry point +├── internal/ +│ ├── ssh/ +│ │ └── ssh.go # SSH connection & command execution +│ ├── init/ +│ │ └── init.go # VPS initialization logic +│ ├── app/ +│ │ └── app.go # Go app deployment logic +│ ├── static/ +│ │ └── static.go # Static site deployment logic +│ ├── systemd/ +│ │ └── systemd.go # Systemd operations +│ ├── caddy/ +│ │ └── caddy.go # Caddy config generation +│ └── config/ +│ └── config.go # Deployment metadata & port allocation +└── templates/ + ├── service.tmpl # Systemd unit template + ├── app.caddy.tmpl # Caddy config for apps + └── static.caddy.tmpl # Caddy config for static sites +``` + +### Deployment State (Laptop) +All deployment state stored locally at `~/.config/deploy/state.json`: +```json +{ + "hosts": { + "user@vps-ip": { + "next_port": 8003, + "apps": { + "myapi": { + "type": "app", + "domain": "api.example.com", + "port": 8001, + "env": { + "DB_HOST": "localhost", + "DB_PORT": "5432", + "API_KEY": "secret123", + "ENVIRONMENT": "production" + } + }, + "anotherapp": { + "type": "app", + "domain": "another.example.com", + "port": 8002, + "env": {} + }, + "mysite": { + "type": "static", + "domain": "example.com" + } + } + } + } +} +``` + +### Environment Files (VPS) +Environment variables written to `/etc/deploy/env/{appname}.env` on VPS for systemd to read: +```bash +# /etc/deploy/env/myapi.env (generated from state.json) +PORT=8001 +DB_HOST=localhost +DB_PORT=5432 +API_KEY=secret123 +ENVIRONMENT=production +``` + +### CLI Behavior + +**Host Resolution:** +- CLI runs on your laptop, connects to VPS via SSH +- Host specified via `--host user@vps-ip` flag +- Or read from `~/.config/deploy/config` file if present +- All operations execute on remote VPS over SSH +- Files transferred via scp/rsync + +**Init Command:** +- Detects OS (Ubuntu 20.04+, Debian 11+) +- Checks if Caddy is already installed (skip if present) +- Installs Caddy via official APT repository +- Creates `/etc/caddy/Caddyfile` with `import /etc/caddy/sites-enabled/*` +- Creates directory structure: `/etc/deploy/env/`, `/etc/caddy/sites-enabled/` +- Enables and starts Caddy +- Runs health check (verify Caddy is running) +- Initializes local state file at `~/.config/deploy/state.json` if not present +- Outputs success message with next steps + +**Smart Defaults:** +- App name: infer from `--name` flag, or binary filename, or current directory +- Binary: use `--binary` flag to specify path, or look for binary in current directory +- Port: auto-allocate next available port (starting from 8001) +- Working directory: `/var/lib/{appname}` +- System user: `{appname}` +- Binary location: `/usr/local/bin/{appname}` + +**Port Management:** +- Automatically allocates next available port from 8001+ +- Tracks allocations in local `~/.config/deploy/state.json` +- User can override with `--port` flag if desired +- Prevents port conflicts + +**Validation:** +- Check if domain is already in use (check local state) +- Verify binary exists and is executable (for apps) +- Confirm Caddy is installed and running +- Check for port conflicts (check local state) +- Require root/sudo on VPS + +**State Management:** +- All deployment state lives on laptop at `~/.config/deploy/state.json` +- VPS is "dumb" - just runs Caddy, systemd, and deployed apps +- `deploy list` is instant (no SSH needed, reads local state) +- Easy to recreate VPS from scratch by redeploying all apps from local state +- Can manage multiple VPSes from one laptop +- State backed up with your laptop backups +- Trade-off: State can drift if VPS is manually modified (user should use CLI only) + +### Installation Steps (Go Apps) + +All steps executed remotely on VPS via SSH: + +1. Detect app name from `--name` flag, or binary filename, or current directory +2. SCP binary from laptop to VPS temp directory +3. Allocate port (auto-assign next available or use `--port` override) +4. Create system user (e.g., `myapp`) +5. Create working directory (`/var/lib/myapp`) +6. Copy binary to `/usr/local/bin/myapp` +7. Create env file at `/etc/deploy/env/myapp.env` with PORT and any user-provided vars +8. Set env file permissions (0600, owned by app user) +9. Generate systemd unit at `/etc/systemd/system/myapp.service` with EnvironmentFile +10. Generate Caddy config at `/etc/caddy/sites-enabled/myapp.caddy` pointing to localhost:port +11. Update local state at `~/.config/deploy/state.json` (on laptop) +12. Enable and start service +13. Reload Caddy + +### Installation Steps (Static Sites) + +All steps executed remotely on VPS via SSH: + +1. Detect site name from `--name` flag or directory name +2. Rsync files from laptop to VPS `/var/www/{sitename}` +3. Set ownership to `www-data:www-data` +4. Generate Caddy config at `/etc/caddy/sites-enabled/{sitename}.caddy` +5. Update local state at `~/.config/deploy/state.json` (on laptop) +6. Reload Caddy + +## File Structure + +### On Laptop +``` +~/.config/deploy/state.json # All deployment state (apps, ports, env vars) +~/.config/deploy/config # Optional: default host configuration +``` + +### On VPS +``` +/usr/local/bin/myapp # Go binary +/var/lib/myapp/ # Working directory +/etc/systemd/system/myapp.service # Systemd unit +/etc/caddy/sites-enabled/myapp.caddy # Caddy config +/etc/deploy/env/myapp.env # Environment variables (0600 permissions) + +/var/www/mysite/ # Static site files +/etc/caddy/sites-enabled/mysite.caddy # Caddy config +``` + +## Templates + +### systemd unit (`service.tmpl`) +```ini +[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 +``` + +### Caddy config for Go apps (`app.caddy.tmpl`) +``` +{{.Domain}} { + reverse_proxy 127.0.0.1:{{.Port}} +} +``` + +### Caddy config for static sites (`static.caddy.tmpl`) +``` +{{.Domain}} { + root * {{.RootDir}} + file_server + encode gzip +} +``` + +## Optional Helper Module + +For apps that want minimal boilerplate: + +```go +// github.com/yourusername/deploy/httpserver + +package httpserver + +import ( + "flag" + "fmt" + "net/http" + "os" +) + +// ListenAndServe starts HTTP server on PORT env var or --port flag +func ListenAndServe(handler http.Handler) error { + port := flag.String("port", os.Getenv("PORT"), "port to listen on") + flag.Parse() + + if *port == "" { + *port = "8080" // fallback for local dev + } + + addr := "127.0.0.1:" + *port + fmt.Printf("Listening on %s\n", addr) + return http.ListenAndServe(addr, handler) +} +``` + +## Example Usage Scenarios + +### Scenario 1: New Go API (Fresh VPS) +```bash +# ONE-TIME: Initialize fresh VPS from your laptop +deploy init --host user@your-vps-ip +# Installs Caddy, sets up directory structure + +# Optional: Save host to config +mkdir -p ~/.config/deploy +echo "host: user@your-vps-ip" > ~/.config/deploy/config + +# Develop locally +mkdir myapi && cd myapi +go mod init myapi + +# Write standard HTTP app +cat > main.go <<'EOF' +package main +import ( + "flag" + "fmt" + "net/http" + "os" +) +func main() { + port := flag.String("port", os.Getenv("PORT"), "port to listen on") + flag.Parse() + if *port == "" { + *port = "8080" + } + + http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte("Hello World")) + }) + + addr := "127.0.0.1:" + *port + fmt.Printf("Listening on %s\n", addr) + http.ListenAndServe(addr, nil) +} +EOF + +# Build and deploy from laptop +GOOS=linux GOARCH=amd64 go build +deploy --binary ./myapi --domain api.example.com +# CLI: SCPs binary to VPS, creates systemd service, configures Caddy + +# Later, add environment variables from laptop +deploy env myapi --set DB_HOST=localhost --set DB_PORT=5432 +# Service automatically restarts to pick up new env vars +``` + +### Scenario 2: React/Vue/etc Static Site +```bash +# Build +npm run build + +# Deploy from laptop +deploy --static --dir ./dist --domain example.com --name mysite +# CLI rsyncs files to VPS, configures Caddy +``` + +### Scenario 3: Update Existing Deployment +```bash +# Rebuild +GOOS=linux GOARCH=amd64 go build + +# Redeploy (same command, it will update) +deploy --binary ./myapi --domain api.example.com +# CLI detects existing deployment, stops service, updates binary, restarts +# Keeps same port allocation +``` + +### Scenario 4: Multiple Apps +```bash +# Deploy first app from laptop +deploy --binary api1 --domain api1.example.com # Gets port 8001 + +# Deploy second app +deploy --binary api2 --domain api2.example.com # Gets port 8002 + +# Deploy third app +deploy --binary api3 --domain api3.example.com # Gets port 8003 + +# View all deployments from laptop +deploy list +# Output: +# api1 app api1.example.com :8001 running +# api2 app api2.example.com :8002 running +# api3 app api3.example.com :8003 running +``` + +## Security Considerations + +- Run each Go app as dedicated system user +- Use systemd security hardening (NoNewPrivileges, PrivateTmp, ProtectSystem) +- Static sites served as www-data +- Caddy automatically handles TLS cert management +- Environment files stored at `/etc/deploy/env/{app}.env` with 0600 permissions +- Env files owned by the app's system user +- `deploy env` command masks sensitive values when displaying (shows `API_KEY=***`) +- Consider using external secret management for production (out of scope for v1) + +## Features + +✓ Single command deployment from anywhere +✓ Automatic HTTPS via Caddy + Let's Encrypt +✓ Automatic port allocation (no manual tracking needed) +✓ Standard HTTP - write apps the normal way +✓ Environment variable management (set via CLI or .env files) +✓ Secure env file storage (0600 permissions) +✓ Systemd process management with auto-restart +✓ Support for multiple apps/sites on one VPS +✓ Manage multiple VPSes from one laptop +✓ State stored locally (VPS is stateless and easily recreatable) +✓ Security hardening by default +✓ Update existing deployments with same command +✓ Clean removal with `deploy remove` +✓ View logs/status of any deployment +✓ Minimal app requirements (just accept PORT env/flag) + +## Out of Scope (for v1) + +- Database setup/management +- Secrets encryption at rest (env files are protected by file permissions) +- Secret rotation automation +- Log rotation (systemd journald handles this) +- Monitoring/alerting +- Blue/green or zero-downtime deployments +- Automatic updates/CI integration +- Custom systemd unit modifications +- Non-Caddy web servers diff --git a/README.md b/README.md new file mode 100644 index 0000000..8125bc9 --- /dev/null +++ b/README.md @@ -0,0 +1,221 @@ +# Deploy - VPS Deployment CLI + +Simple CLI tool for deploying Go apps and static sites to a VPS with automatic HTTPS via Caddy. + +## Features + +- Single command deployment from your laptop +- Automatic HTTPS via Caddy + Let's Encrypt +- Automatic port allocation (no manual tracking) +- Environment variable management +- Systemd process management with auto-restart +- Support for multiple apps/sites on one VPS +- State stored locally (VPS is stateless and easily recreatable) +- Zero dependencies on VPS (just installs Caddy) + +## Installation + +```bash +# Build the CLI +go build -o ~/bin/deploy ./cmd/deploy + +# Or install to GOPATH +go install ./cmd/deploy +``` + +## Quick Start + +### 1. Initialize Your VPS (One-time) + +```bash +# Initialize a fresh VPS +deploy init --host user@your-vps-ip +``` + +This will: +- Install Caddy +- Configure Caddy for automatic HTTPS +- Create necessary directories +- Set up the VPS for deployments + +### 2. Deploy a Go App + +```bash +# Build your app for Linux +GOOS=linux GOARCH=amd64 go build -o myapp + +# Deploy it +deploy deploy --host user@vps-ip --binary ./myapp --domain api.example.com + +# With environment variables +deploy deploy --host user@vps-ip --binary ./myapp --domain api.example.com \ + --env DB_HOST=localhost \ + --env API_KEY=secret + +# Or from an env file +deploy deploy --host user@vps-ip --binary ./myapp --domain api.example.com \ + --env-file .env.production +``` + +### 3. Deploy a Static Site + +```bash +# Build your site +npm run build + +# Deploy it +deploy deploy --host user@vps-ip --static --dir ./dist --domain example.com +``` + +## App Requirements + +Your Go app must: +1. Listen on HTTP (not HTTPS - Caddy handles that) +2. Accept port via `--port` flag or `PORT` environment variable +3. Bind to `localhost` or `127.0.0.1` only + +Example: + +```go +package main + +import ( + "flag" + "fmt" + "net/http" + "os" +) + +func main() { + port := flag.String("port", os.Getenv("PORT"), "port to listen on") + flag.Parse() + + if *port == "" { + *port = "8080" // fallback for local dev + } + + http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte("Hello World")) + }) + + addr := "127.0.0.1:" + *port + fmt.Printf("Listening on %s\n", addr) + http.ListenAndServe(addr, nil) +} +``` + +## Commands + +### Initialize VPS +```bash +deploy init --host user@vps-ip +``` + +### Deploy App/Site +```bash +# Go app +deploy deploy --host user@vps-ip --binary ./myapp --domain api.example.com + +# Static site +deploy deploy --host user@vps-ip --static --dir ./dist --domain example.com + +# Custom name (defaults to binary/directory name) +deploy deploy --host user@vps-ip --name myapi --binary ./myapp --domain api.example.com +``` + +### List Deployments +```bash +deploy list --host user@vps-ip +``` + +### Manage Deployments +```bash +# View logs +deploy logs myapp --host user@vps-ip + +# View status +deploy status myapp --host user@vps-ip + +# Restart app +deploy restart myapp --host user@vps-ip + +# Remove deployment +deploy remove myapp --host user@vps-ip +``` + +### Environment Variables +```bash +# View current env vars (secrets are masked) +deploy env myapi --host user@vps-ip + +# Set env vars +deploy env myapi --host user@vps-ip --set DB_HOST=localhost --set API_KEY=secret + +# Load from file +deploy env myapi --host user@vps-ip --file .env.production + +# Unset env var +deploy env myapi --host user@vps-ip --unset API_KEY +``` + +## Configuration + +Create `~/.config/deploy/config` to avoid typing `--host` every time: + +``` +host: user@your-vps-ip +``` + +Then you can omit the `--host` flag: + +```bash +deploy list +deploy deploy --binary ./myapp --domain api.example.com +``` + +## How It Works + +1. **State on Laptop**: All deployment state lives at `~/.config/deploy/state.json` on your laptop +2. **SSH Orchestration**: The CLI uses SSH to run commands on your VPS +3. **File Transfer**: Binaries transferred via SCP, static sites via rsync +4. **Caddy for HTTPS**: Caddy automatically handles HTTPS certificates +5. **Systemd for Processes**: Apps run as systemd services with auto-restart +6. **Dumb VPS**: The VPS is stateless - you can recreate it by redeploying from local state + +## File Structure + +### On Laptop +``` +~/.config/deploy/state.json # All deployment state +~/.config/deploy/config # Optional: default host +``` + +### On VPS +``` +/usr/local/bin/myapp # Go binary +/var/lib/myapp/ # Working directory +/etc/systemd/system/myapp.service # Systemd unit +/etc/caddy/sites-enabled/myapp.caddy # Caddy config +/etc/deploy/env/myapp.env # Environment variables + +/var/www/mysite/ # Static site files +/etc/caddy/sites-enabled/mysite.caddy # Caddy config +``` + +## Security + +- Each Go app runs as dedicated system user +- Systemd security hardening enabled (NoNewPrivileges, PrivateTmp) +- Static sites served as www-data +- Caddy automatically manages TLS certificates +- Environment files stored with 0600 permissions +- Secrets masked when displaying environment variables + +## Supported OS + +- Ubuntu 20.04+ +- Debian 11+ + +## License + +MIT diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..0d29deb --- /dev/null +++ b/go.mod @@ -0,0 +1,7 @@ +module github.com/bdw/deploy + +go 1.21 + +require golang.org/x/crypto v0.31.0 + +require golang.org/x/sys v0.28.0 // indirect diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..7fa4005 --- /dev/null +++ b/go.sum @@ -0,0 +1,6 @@ +golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U= +golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= +golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA= +golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/term v0.27.0 h1:WP60Sv1nlK1T6SupCHbXzSaN0b9wUmsPoRS9b61A23Q= +golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM= 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 +} diff --git a/templates/app.caddy.tmpl b/templates/app.caddy.tmpl new file mode 100644 index 0000000..505d1d9 --- /dev/null +++ b/templates/app.caddy.tmpl @@ -0,0 +1,3 @@ +{{.Domain}} { + reverse_proxy 127.0.0.1:{{.Port}} +} diff --git a/templates/service.tmpl b/templates/service.tmpl new file mode 100644 index 0000000..87389f0 --- /dev/null +++ b/templates/service.tmpl @@ -0,0 +1,17 @@ +[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 diff --git a/templates/static.caddy.tmpl b/templates/static.caddy.tmpl new file mode 100644 index 0000000..d04f6b0 --- /dev/null +++ b/templates/static.caddy.tmpl @@ -0,0 +1,5 @@ +{{.Domain}} { + root * {{.RootDir}} + file_server + encode gzip +} diff --git a/test/example-website/about.html b/test/example-website/about.html new file mode 100644 index 0000000..93cba92 --- /dev/null +++ b/test/example-website/about.html @@ -0,0 +1,42 @@ + + + + + + About - Deploy Test + + + +
+ +
+ +
+
+

About This Site

+

This is a test website created to demonstrate the deploy tool's static site deployment capabilities.

+ +

Features

+
    +
  • Simple HTML/CSS structure
  • +
  • Multiple pages for testing navigation
  • +
  • Responsive design
  • +
  • Clean and minimal styling
  • +
+ +

Deployment Command

+
./deploy deploy --host peerfile --static --dir ./test/example-website --domain example.com
+
+
+ +
+

© 2025 Deploy Test. Built for testing purposes.

+
+ + diff --git a/test/example-website/index.html b/test/example-website/index.html new file mode 100644 index 0000000..735ae73 --- /dev/null +++ b/test/example-website/index.html @@ -0,0 +1,46 @@ + + + + + + Example Website - Deploy Test + + + +
+ +
+ +
+
+

Welcome to the Example Website

+

This is a simple static website for testing the deploy tool.

+
+ +
+
+

Fast Deployment

+

Deploy your static sites in seconds with a single command.

+
+
+

HTTPS Enabled

+

Automatic SSL certificates with Caddy.

+
+
+

Simple Management

+

Easy-to-use CLI for managing your deployments.

+
+
+
+ + + + diff --git a/test/example-website/style.css b/test/example-website/style.css new file mode 100644 index 0000000..da7fd1c --- /dev/null +++ b/test/example-website/style.css @@ -0,0 +1,156 @@ +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; + line-height: 1.6; + color: #333; + background: #f5f5f5; +} + +header { + background: #2c3e50; + color: white; + padding: 1rem 0; + box-shadow: 0 2px 5px rgba(0,0,0,0.1); +} + +nav { + max-width: 1200px; + margin: 0 auto; + padding: 0 2rem; + display: flex; + justify-content: space-between; + align-items: center; +} + +nav h1 { + font-size: 1.5rem; +} + +nav ul { + list-style: none; + display: flex; + gap: 2rem; +} + +nav a { + color: white; + text-decoration: none; + transition: opacity 0.3s; +} + +nav a:hover { + opacity: 0.8; +} + +main { + max-width: 1200px; + margin: 2rem auto; + padding: 0 2rem; +} + +.hero { + background: white; + padding: 3rem; + border-radius: 8px; + box-shadow: 0 2px 10px rgba(0,0,0,0.1); + text-align: center; + margin-bottom: 2rem; +} + +.hero h2 { + font-size: 2.5rem; + margin-bottom: 1rem; + color: #2c3e50; +} + +.hero p { + font-size: 1.2rem; + color: #666; +} + +.features { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); + gap: 2rem; + margin: 2rem 0; +} + +.feature { + background: white; + padding: 2rem; + border-radius: 8px; + box-shadow: 0 2px 10px rgba(0,0,0,0.1); +} + +.feature h3 { + color: #2c3e50; + margin-bottom: 0.5rem; +} + +.feature p { + color: #666; +} + +.about { + background: white; + padding: 3rem; + border-radius: 8px; + box-shadow: 0 2px 10px rgba(0,0,0,0.1); +} + +.about h2 { + color: #2c3e50; + margin-bottom: 1rem; +} + +.about h3 { + color: #34495e; + margin-top: 2rem; + margin-bottom: 0.5rem; +} + +.about ul { + margin-left: 2rem; + margin-bottom: 1rem; +} + +.about pre { + background: #f5f5f5; + padding: 1rem; + border-radius: 4px; + overflow-x: auto; + margin-top: 1rem; +} + +.about code { + font-family: 'Courier New', monospace; + font-size: 0.9rem; +} + +footer { + background: #2c3e50; + color: white; + text-align: center; + padding: 2rem; + margin-top: 4rem; +} + +@media (max-width: 768px) { + nav { + flex-direction: column; + gap: 1rem; + } + + .hero h2 { + font-size: 2rem; + } + + .features { + grid-template-columns: 1fr; + } +} -- cgit v1.2.3