From f5b667c80e49117c94481d49c5b0c77dbcf2804a Mon Sep 17 00:00:00 2001 From: bndw Date: Tue, 10 Feb 2026 21:29:32 -0800 Subject: Rewrite README and add SECURITY.md Document both deployment modes (git push and direct), all commands, architecture, VPS file layout, and vanity imports. Add SECURITY.md covering threat model, mitigations, and known gaps. --- README.md | 350 +++++++++++++++++++++++++++++++++++------------------------- SECURITY.md | 55 ++++++++++ 2 files changed, 262 insertions(+), 143 deletions(-) create mode 100644 SECURITY.md diff --git a/README.md b/README.md index 4394e35..54be72f 100644 --- a/README.md +++ b/README.md @@ -1,219 +1,283 @@ -# Ship - VPS Deployment CLI +# Ship -Simple CLI tool for deploying Go apps and static sites to a VPS with automatic HTTPS via Caddy. +Ship deploys apps and static sites to a VPS over SSH. It handles HTTPS certificates, port allocation, systemd services, and reverse proxying — all with zero dependencies beyond SSH access. -## Features +There are two deployment modes: -- 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) +- **Git push** — push to a bare repo on the VPS, which triggers a Docker build and deploy via post-receive hooks. Deployment config (systemd unit, Caddyfile) lives in `.ship/` in your repo and is versioned alongside code. +- **Direct** — SCP a pre-built binary or rsync a static directory to the VPS. Ship generates and installs the systemd unit and Caddy config on your behalf. -## Installation +If a base domain is configured, Ship also serves **Go vanity imports** and **git HTTPS cloning** from the same domain, so `go get yourdomain.com/foo` works with zero extra setup. -```bash -# Build the CLI -go build -o ~/bin/ship ./cmd/ship +## Install -# Or install to GOPATH -go install ./cmd/ship +``` +go install github.com/bdw/ship/cmd/ship@latest ``` -## Quick Start - -### 1. Initialize Your VPS (One-time) +Or build from source: -```bash -# Initialize a fresh VPS (this sets it as the default host) -ship host init user@your-vps-ip +``` +go build -o ship ./cmd/ship ``` -This will: -- Install Caddy -- Configure Caddy for automatic HTTPS -- Create necessary directories -- Set up the VPS for deployments +## Quick start -### 2. Deploy a Go App +### 1. Set up the VPS -```bash -# Build your app for Linux -GOOS=linux GOARCH=amd64 go build -o myapp +``` +ship host init --host user@your-vps --base-domain example.com +``` -# Deploy it -ship --binary ./myapp --domain api.example.com +This installs Caddy, Docker, git, and fcgiwrap. It creates a `git` user for push access, configures sudoers for deploy hooks, sets up vanity import serving, and enables automatic HTTPS. The host becomes the default for subsequent commands. -# With environment variables -ship --binary ./myapp --domain api.example.com \ - --env DB_HOST=localhost \ - --env API_KEY=secret +If you don't need git-push deploys or vanity imports, omit `--base-domain`: -# Or from an env file -ship --binary ./myapp --domain api.example.com \ - --env-file .env.production +``` +ship host init --host user@your-vps ``` -### 3. Deploy a Static Site +### 2. Deploy -```bash -# Build your site -npm run build +**Git push (Docker-based app):** -# Deploy it -ship --static --dir ./dist --domain example.com +``` +ship init myapp ``` -## App Requirements +This creates a bare git repo on the VPS, generates `.ship/Caddyfile` and `.ship/service` locally, initializes a local git repo if needed, and adds an `origin` remote. -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 +``` +git add .ship/ Dockerfile +git commit -m "initial deploy" +git push origin main +``` -Example: +The post-receive hook checks out code, installs `.ship/` configs, runs `docker build`, and restarts the service. If no Dockerfile is present, the push is accepted but deploy is skipped — useful for Go modules and libraries that only need vanity imports. -```go -package main +**Git push (static site):** -import ( - "flag" - "fmt" - "net/http" - "os" -) +``` +ship init mysite --static +git add .ship/ index.html +git commit -m "initial deploy" +git push origin main +``` -func main() { - port := flag.String("port", os.Getenv("PORT"), "port to listen on") - flag.Parse() +**Direct (pre-built binary):** - if *port == "" { - *port = "8080" // fallback for local dev - } +``` +GOOS=linux GOARCH=amd64 go build -o myapp +ship --binary ./myapp --domain api.example.com +``` - http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { - w.Write([]byte("Hello World")) - }) +**Direct (static site):** - addr := "127.0.0.1:" + *port - fmt.Printf("Listening on %s\n", addr) - http.ListenAndServe(addr, nil) -} +``` +ship --static --dir ./dist --domain example.com ``` ## Commands -### Host Management +### `ship init ` + +Create a bare git repo on the VPS and generate local `.ship/` config files. + +``` +ship init myapp # Docker-based app +ship init mysite --static # static site +ship init myapp --domain custom.example.com # custom domain +ship init mylib --public # publicly cloneable (for go get) +``` -```bash -# Initialize a fresh VPS (one-time setup, sets as default) -ship host init user@vps-ip +Flags: +- `--static` — initialize as a static site instead of a Docker app +- `--public` — make the repo publicly cloneable over HTTPS +- `--domain` — custom domain (default: `name.basedomain`) -# Update system packages (apt update && apt upgrade) -ship host update +### `ship deploy ` -# Check host status -ship host status +Manually rebuild and deploy a git-deployed app. Runs the same steps as the post-receive hook: checkout, install configs, docker build, restart. -# SSH into the host -ship host ssh ``` +ship deploy myapp +``` + +### `ship [deploy flags]` -### Deploy App/Site -```bash -# Go app +Deploy a pre-built binary or static directory directly. + +``` +# App ship --binary ./myapp --domain api.example.com +ship --binary ./myapp --domain api.example.com --env DB_HOST=localhost --env API_KEY=secret +ship --binary ./myapp --domain api.example.com --env-file .env.production +ship --binary ./myapp --name myapi --memory 512M --cpu 50% # Static site ship --static --dir ./dist --domain example.com -# Custom name (defaults to binary/directory name) -ship --name myapi --binary ./myapp --domain api.example.com +# Config update (no binary, just change settings) +ship --name myapi --memory 1G +ship --name myapi --env DEBUG=true ``` -### List Deployments -```bash -ship list +Flags: +- `--binary` — path to a compiled binary +- `--static` — deploy as a static site +- `--dir` — directory to deploy (default: `.`) +- `--domain` — custom domain +- `--name` — app name (default: inferred from binary or directory) +- `--env KEY=VALUE` — environment variable (repeatable) +- `--env-file` — path to a `.env` file +- `--args` — arguments passed to the binary +- `--file` — config file to upload to working directory (repeatable) +- `--memory` — memory limit (e.g., `512M`, `1G`) +- `--cpu` — CPU limit (e.g., `50%`, `200%` for 2 cores) + +### `ship list` + +List all deployments on the default host. + +``` +NAME TYPE VISIBILITY DOMAIN PORT +myapp git-app private myapp.example.com :8001 +mysite git-static public mysite.example.com +api app api.example.com :8002 ``` -### Manage Deployments -```bash -# View logs -ship logs myapp +### `ship status ` -# View status -ship status myapp +Show systemd service status for an app. -# Restart app -ship restart myapp +### `ship logs ` -# Remove deployment -ship remove myapp -``` +Show service logs (via journalctl). + +### `ship restart ` + +Restart an app's systemd service. + +### `ship remove ` -### Environment Variables -```bash -# View current env vars (secrets are masked) -ship env list myapi +Remove a deployment. Stops the service, removes files, configs, and state. -# Set env vars -ship env set myapi DB_HOST=localhost API_KEY=secret +### `ship env` -# Load from file -ship env set myapi -f .env.production +Manage environment variables for an app. -# Unset env var -ship env unset myapi API_KEY +``` +ship env list myapp # show env vars (secrets masked) +ship env set myapp KEY=VALUE # set variable(s) +ship env set myapp -f .env # load from file +ship env unset myapp KEY # remove a variable ``` -## Configuration +### `ship host` -The host you initialize becomes the default, so you don't need to specify `--host` for every command. The default host is stored in `~/.config/ship/state.json`. +Manage the VPS. -## How It Works +``` +ship host init --host user@vps --base-domain example.com # one-time setup +ship host status # uptime, disk, memory, load +ship host update # apt update && upgrade +ship host ssh # open an SSH session +ship host set-domain example.com # change base domain +``` -1. **State on Laptop**: All deployment state lives at `~/.config/ship/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 +### `ship ui` -## File Structure +Launch a local web UI for viewing deployments. -### On Laptop ``` -~/.config/ship/state.json # All deployment state (including default host) +ship ui # http://localhost:8080 +ship ui -p 3000 # custom port ``` -### On VPS +### `ship version` + +Show version, commit, and build date. + +## How it works + +### Architecture + +Ship is a client-side CLI. All state lives on your laptop at `~/.config/ship/state.json`. The VPS is configured entirely over SSH — no agent or daemon runs on the server. This means the VPS is stateless and easily recreatable from local state. + +### Git push flow + +1. `ship init` creates a bare repo at `/srv/git/.git` with a post-receive hook +2. `git push` triggers the hook, which: + - Checks out code to `/var/lib//src` + - Copies `.ship/service` to `/etc/systemd/system/.service` + - Copies `.ship/Caddyfile` to `/etc/caddy/sites-enabled/.caddy` + - Runs `docker build` (skipped if no Dockerfile) + - Restarts the systemd service +3. The Docker container runs with: + - Port bound to `127.0.0.1:` + - Env vars from `/etc/ship/env/.env` + - Persistent data volume at `/var/lib//data` (mounted as `/data`) +4. Caddy reverse-proxies HTTPS traffic to the container + +### Direct deploy flow + +1. Binary uploaded via SCP to `/usr/local/bin/` +2. A dedicated system user is created +3. Ship generates and installs a systemd unit and Caddy config +4. Service is started, Caddy is reloaded + +### Vanity imports and git cloning + +When a base domain is configured, Ship sets up the base domain to serve: + +- **Go vanity imports** — `go get example.com/myapp` returns the correct `` tag pointing to `https://example.com/myapp.git` +- **Git HTTPS cloning** — `git clone https://example.com/myapp.git` works for public repos (those created with `--public`) + +This is handled by Caddy's `templates` directive and `fcgiwrap` + `git-http-backend`, with no custom server. + +### Port allocation + +Ports are allocated automatically starting from 8001 and never reused. You don't need to track which ports are in use. + +## VPS file layout + ``` -/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/ship/env/myapp.env # Environment variables +/srv/git/.git/ # bare git repos +/srv/git/.git/hooks/post-receive # auto-deploy hook + +/var/lib//src/ # checked-out source (for docker build) +/var/lib//data/ # persistent data volume +/etc/systemd/system/.service # systemd unit +/etc/caddy/sites-enabled/.caddy # Caddy config +/etc/ship/env/.env # environment variables -/var/www/mysite/ # Static site files -/etc/caddy/sites-enabled/mysite.caddy # Caddy config +/var/www// # static site files + +/usr/local/bin/ # direct-deployed binaries + +/opt/ship/vanity/index.html # vanity import template +/etc/caddy/sites-enabled/ship-code.caddy # base domain Caddy config +/etc/sudoers.d/ship-git # sudo rules for git user +/home/git/.ssh/authorized_keys # SSH keys for git push ``` -## Security +## App requirements + +For direct-deployed Go apps, the binary must: -- 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 +1. Listen on HTTP (Caddy handles HTTPS) +2. Read the port from the `PORT` environment variable or a `--port` flag +3. Bind to `127.0.0.1` (not `0.0.0.0`) -## Supported OS +For git-deployed Docker apps, the Dockerfile should expose a service that listens on the port specified by the `PORT` environment variable. + +## Supported platforms + +VPS: Ubuntu 20.04+ or Debian 11+ + +## Security -- Ubuntu 20.04+ -- Debian 11+ +See [SECURITY.md](SECURITY.md) for the threat model, mitigations, and known gaps. ## License diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..ad04094 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,55 @@ +# Security Model & Known Gaps + +Ship is a single-user VPS deployment tool. The threat model assumes: +- You control the VPS and have root SSH access +- You trust everyone who has SSH push access (their keys are copied to the `git` user) +- The VPS runs only your own apps + +## Mitigations in place + +### App name validation +All app/project names are validated against `^[a-z][a-z0-9-]{0,62}$` before being used in shell commands, file paths, systemd units, or DNS labels. This prevents command injection via crafted names. + +### Scoped sudoers +The `git` user's sudo rules are restricted to specific paths: +- `systemctl restart/enable` only for services matching `[a-z]*` +- `cp` only from `.ship/` subdirectories to `/etc/systemd/system/` and `/etc/caddy/sites-enabled/` +- `mkdir` only under `/var/lib/` and `/var/www/` +- `chown` only for `git:git` under `/var/lib/` and `/var/www/` + +### Scoped safe.directory +Git's `safe.directory` is set only for the `www-data` user (not system-wide), preserving CVE-2022-24765 protection for other users. + +## Accepted risks (by design) + +### SSH key access = root access +The `git` user is in the `docker` group, which is root-equivalent (can mount the host filesystem). Additionally, `.ship/service` files pushed via git are installed as systemd units. Anyone with SSH push access effectively has root. This is intentional for a single-user tool. + +### Git repo visibility +Repos are private by default (not cloneable over HTTPS). Use `ship init --public` to make a repo publicly cloneable. This is controlled by the `git-daemon-export-ok` marker file in each bare repo. Only public repos are accessible via `go get` or `git clone` over HTTPS. + +### User-controlled systemd units +The `.ship/service` file in each repo is copied to `/etc/systemd/system/` on push. A malicious service file could run arbitrary commands as root. This is equivalent to the Docker access risk above. + +## Known gaps (not yet addressed) + +### SSH host key verification disabled +`ssh.InsecureIgnoreHostKey()` is used for all SSH connections, and `StrictHostKeyChecking=no` for scp/rsync. This makes connections vulnerable to MITM attacks on untrusted networks. A future improvement would use `known_hosts` verification. + +### Env files may have loose permissions +Environment files at `/etc/ship/env/{name}.env` are created via `sudo tee` and may be world-readable depending on umask. These files can contain secrets. The `deploy` flow does `chmod 600` but `ship init` does not. A future improvement would ensure consistent restrictive permissions. + +### host init is not idempotent +Running `ship host init` twice will overwrite `/etc/caddy/Caddyfile` and the base domain Caddy config, destroying any manual edits. No guard checks whether setup has already been completed. + +### No rollback on failed docker build +The post-receive hook installs `.ship/service` and `.ship/Caddyfile` before running `docker build`. If the build fails, the configs are updated but the old image is still running, creating a mismatch. The old container keeps running (due to `set -e`), but a manual restart would use the new (mismatched) unit file. + +### ship deploy vs git push ownership mismatch +`ship deploy` runs commands as root (the SSH user), while `git push` triggers the hook as the `git` user. Files checked out by `ship deploy` become root-owned, which can prevent subsequent `git push` deploys from overwriting them. + +### No concurrent push protection +Simultaneous pushes can race on the checkout directory and docker build. For single-user usage this is unlikely but not impossible. + +### Port allocation is monotonic +Ports are never reclaimed when apps are removed. After ~57,000 create/remove cycles, ports would be exhausted. Not a practical concern. -- cgit v1.2.3