From f0dfabe5b7f1f8d23169c6e62a2f0c27bd6c5463 Mon Sep 17 00:00:00 2001 From: bndw Date: Sat, 14 Feb 2026 07:56:22 -0800 Subject: Add cgit web interface for browsing repos Adds cgit as a web frontend for browsing git repositories. Visiting the base domain now shows a cgit repo index with trees, commits, diffs, and blame views. Public repos (marked with git-daemon-export-ok) are browsable and cloneable over HTTPS. - Install cgit during host init - Configure cgit with dark theme and base domain integration - Add cgit CGI handler to base domain Caddyfile - Update README to emphasize git-centric workflow with cgit frontend --- README.md | 202 ++++++++-------------------------------- SECURITY.md | 2 +- cmd/ship/host/init.go | 24 ++++- internal/templates/templates.go | 91 +++++++++++++++++- 4 files changed, 151 insertions(+), 168 deletions(-) diff --git a/README.md b/README.md index 54be72f..cdd4127 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,14 @@ # Ship -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. +Ship turns a VPS into a self-hosted git server with a web frontend — similar to running cgit on your own domain. Visiting your base domain in a browser shows a cgit repo index; clicking through shows trees, commit logs, diffs, and blame. Public repos are cloneable over HTTPS. If you host Go code, `go get` works with your domain out of the box. This is the read-only, public-facing side, and it works exactly the way cgit users expect. -There are two deployment modes: +The difference is what happens on the write side. When you `git push` over SSH, Ship doesn't just update the bare repo — it builds and deploys your code. A post-receive hook checks out the repo, runs `docker build`, installs a systemd service and Caddy reverse-proxy config, and restarts the app. Your deployment config (the systemd unit, the Caddyfile) lives in `.ship/` in your repo and is versioned alongside your code. Push to main and it's live; push to any other branch and nothing happens. -- **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. +Not every repo needs to be a running service. If there's no Dockerfile, the push is accepted but the deploy step is skipped — the repo just sits there as a browsable, cloneable library. This makes Ship useful for Go modules that only need vanity imports and a public source view, alongside apps that need the full build-and-deploy pipeline. The same base domain serves both. -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. +Ship also supports direct deploys (SCP a binary or rsync a static directory) for cases where git push isn't the right fit. + +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. ## Install @@ -29,13 +30,7 @@ go build -o ship ./cmd/ship ship host init --host user@your-vps --base-domain 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. - -If you don't need git-push deploys or vanity imports, omit `--base-domain`: - -``` -ship host init --host user@your-vps -``` +This installs Caddy, Docker, git, fcgiwrap, and cgit. It creates a `git` user for push access, configures sudoers for deploy hooks, and enables automatic HTTPS. The host becomes the default for subsequent commands. ### 2. Deploy @@ -43,18 +38,11 @@ ship host init --host user@your-vps ``` ship init myapp -``` - -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. - -``` git add .ship/ Dockerfile git commit -m "initial deploy" git push origin main ``` -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. - **Git push (static site):** ``` @@ -64,19 +52,26 @@ git commit -m "initial deploy" git push origin main ``` -**Direct (pre-built binary):** +**Git push (library / Go module):** ``` -GOOS=linux GOARCH=amd64 go build -o myapp -ship --binary ./myapp --domain api.example.com +ship init mylib --public +git add . +git commit -m "initial" +git push origin main ``` -**Direct (static site):** +No Dockerfile, so nothing is deployed — the repo is just browsable and cloneable at `https://example.com/mylib`. + +**Direct (pre-built binary):** ``` -ship --static --dir ./dist --domain example.com +GOOS=linux GOARCH=amd64 go build -o myapp +ship --binary ./myapp --domain api.example.com ``` +You can version control `.ship/` or add it to `.gitignore` — it's your choice. + ## Commands ### `ship init ` @@ -84,193 +79,76 @@ ship --static --dir ./dist --domain example.com 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 # 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) +ship init mylib --public # publicly cloneable (for go get) ``` -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`) - ### `ship deploy ` -Manually rebuild and deploy a git-deployed app. Runs the same steps as the post-receive hook: checkout, install configs, docker build, restart. - -``` -ship deploy myapp -``` +Manually rebuild and deploy a git-deployed app. ### `ship [deploy flags]` 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 --binary ./myapp --domain api.example.com --env DB_HOST=localhost ship --static --dir ./dist --domain example.com - -# Config update (no binary, just change settings) -ship --name myapi --memory 1G -ship --name myapi --env DEBUG=true +ship --name myapi --memory 512M --cpu 50% ``` -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) +Flags: `--binary`, `--static`, `--dir`, `--domain`, `--name`, `--env`, `--env-file`, `--args`, `--file`, `--memory`, `--cpu` ### `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 -``` - -### `ship status ` - -Show systemd service status for an app. - -### `ship logs ` - -Show service logs (via journalctl). - -### `ship restart ` - -Restart an app's systemd service. - -### `ship remove ` +### `ship status/logs/restart/remove ` -Remove a deployment. Stops the service, removes files, configs, and state. +Manage a deployment's systemd service. ### `ship env` -Manage environment variables for an app. - ``` -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 +ship env list myapp +ship env set myapp KEY=VALUE +ship env unset myapp KEY ``` ### `ship host` -Manage the VPS. - ``` -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 +ship host init --host user@vps --base-domain example.com +ship host status +ship host update +ship host ssh ``` ### `ship ui` Launch a local web UI for viewing deployments. -``` -ship ui # http://localhost:8080 -ship ui -p 3000 # custom port -``` - -### `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 ``` /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// # static site files - -/usr/local/bin/ # direct-deployed binaries - -/opt/ship/vanity/index.html # vanity import template +/etc/systemd/system/.service # systemd unit +/etc/caddy/sites-enabled/.caddy # per-app Caddy config /etc/caddy/sites-enabled/ship-code.caddy # base domain Caddy config +/etc/cgitrc # cgit configuration +/etc/ship/env/.env # environment variables /etc/sudoers.d/ship-git # sudo rules for git user +/opt/ship/vanity/index.html # vanity import template /home/git/.ssh/authorized_keys # SSH keys for git push ``` -## App requirements - -For direct-deployed Go apps, the binary must: - -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`) - -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+ diff --git a/SECURITY.md b/SECURITY.md index ad04094..2d7a96e 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -26,7 +26,7 @@ Git's `safe.directory` is set only for the `www-data` user (not system-wide), pr 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. +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. The cgit web interface respects the same model — it is configured with `export-ok=git-daemon-export-ok`, so only public repos are browsable. ### 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. diff --git a/cmd/ship/host/init.go b/cmd/ship/host/init.go index 0ec573c..cfa2795 100644 --- a/cmd/ship/host/init.go +++ b/cmd/ship/host/init.go @@ -153,18 +153,19 @@ func setupGitDeploy(client *ssh.Client, baseDomain string, hostState *state.Host } fmt.Println(" Docker installed") - fmt.Println("-> Installing git and fcgiwrap...") - if _, err := client.RunSudo("apt-get install -y git fcgiwrap"); err != nil { - return fmt.Errorf("error installing git/fcgiwrap: %w", err) + fmt.Println("-> Installing git, fcgiwrap, and cgit...") + if _, err := client.RunSudo("apt-get install -y git fcgiwrap cgit"); err != nil { + return fmt.Errorf("error installing git/fcgiwrap/cgit: %w", err) } // Allow git-http-backend (runs as www-data) to access repos owned by git. // Scoped to www-data only, not system-wide, to preserve CVE-2022-24765 protection. // www-data's home is /var/www; ensure it can write .gitconfig there. + client.RunSudo("mkdir -p /var/www") client.RunSudo("chown www-data:www-data /var/www") if _, err := client.RunSudo("sudo -u www-data git config --global --add safe.directory '*'"); err != nil { return fmt.Errorf("error setting git safe.directory: %w", err) } - fmt.Println(" git and fcgiwrap installed") + fmt.Println(" git, fcgiwrap, and cgit installed") fmt.Println("-> Creating git user...") // Create git user (ignore error if already exists) @@ -261,6 +262,21 @@ git ALL=(ALL) NOPASSWD: \ } fmt.Println(" base domain Caddy config written") + fmt.Println("-> Writing cgit config...") + cgitrcContent, err := templates.CgitRC(map[string]string{ + "BaseDomain": baseDomain, + }) + if err != nil { + return fmt.Errorf("error generating cgitrc: %w", err) + } + if err := client.WriteSudoFile("/etc/cgitrc", cgitrcContent); err != nil { + return fmt.Errorf("error writing cgitrc: %w", err) + } + if err := client.WriteSudoFile("/opt/ship/cgit-header.html", templates.CgitHeader()); err != nil { + return fmt.Errorf("error writing cgit header: %w", err) + } + fmt.Println(" cgit config written") + fmt.Println("-> Starting Docker and fcgiwrap...") if _, err := client.RunSudo("systemctl enable docker fcgiwrap"); err != nil { return fmt.Errorf("error enabling services: %w", err) diff --git a/internal/templates/templates.go b/internal/templates/templates.go index 8f25f8f..b68a504 100644 --- a/internal/templates/templates.go +++ b/internal/templates/templates.go @@ -185,8 +185,24 @@ var codeCaddyTemplate = `{{.BaseDomain}} { } } + @cgitassets path /cgit/* + handle @cgitassets { + root * /usr/share/cgit + uri strip_prefix /cgit + file_server + } + handle { - respond "not found" 404 + reverse_proxy unix//run/fcgiwrap.socket { + transport fastcgi { + env SCRIPT_FILENAME /usr/lib/cgit/cgit.cgi + env QUERY_STRING {query} + env REQUEST_METHOD {method} + env PATH_INFO {path} + env HTTP_HOST {host} + env SERVER_NAME {host} + } + } } } ` @@ -239,6 +255,79 @@ func CodeCaddy(data map[string]string) (string, error) { return renderTemplate("code-caddy", codeCaddyTemplate, data) } +var cgitrcTemplate = `virtual-root=/ +css=/cgit/cgit.css +logo=/cgit/cgit.png +header=/opt/ship/cgit-header.html +scan-path=/srv/git/ +export-ok=git-daemon-export-ok +enable-http-clone=0 +clone-url=https://{{.BaseDomain}}/$CGIT_REPO_URL +root-title={{.BaseDomain}} +root-desc= +remove-suffix=.git +` + +var cgitHeaderTemplate = ` +` + +// CgitRC generates the /etc/cgitrc config file +func CgitRC(data map[string]string) (string, error) { + return renderTemplate("cgitrc", cgitrcTemplate, data) +} + +// CgitHeader generates the cgit header HTML file (dark theme) +func CgitHeader() string { + return cgitHeaderTemplate +} + // DockerService generates a systemd unit for a Docker-based app func DockerService(data map[string]string) (string, error) { return renderTemplate("docker-service", dockerServiceTemplate, data) -- cgit v1.2.3