RustForge

rustforge

RustForge: the read-only, cgit-style git web host serving this site

authorTyler Fowler <tyler@localhost>2026-10-31 18:11:52 -0700
commit0cdbd22f2e0592b42ddbd472d3b22edf1d4c16db
parent1bfcd03e62242fa9786fcccd7231a84df89d3393
treebrowse files at this commit
Add deployment for the RustForge container

- deploy/forge.service: systemd unit running as user forge with
  Restart=on-failure and sandboxing (ProtectSystem=strict, read-only
  /srv/git, NoNewPrivileges, PrivateTmp, no capabilities)
- deploy/forge.toml.example: production config listening on 0.0.0.0:8090,
  serving bare mirrors in /srv/git; clone_base_url left as a TODO
- scripts/sync-mirrors.sh: publish working repos without adding remotes to
  them, via private bare mirrors in /workspace/.forge-mirrors and rsync
  over ssh (objects first, then refs), then chown to forge
- scripts/deploy.sh: build locally, install binary and unit, restart
- DEPLOY.md plus a Deployment section in the README
ADEPLOY.md+87 -0
MREADME.md+9 -0
Adeploy/forge.service+44 -0
Adeploy/forge.toml.example+23 -0
Ascripts/deploy.sh+29 -0
Ascripts/sync-mirrors.sh+87 -0

6 files changed, 279 insertions, 0 deletions

@@ -0,0 +1,87 @@+# Deploying forge to RustForge++RustForge is a Debian 13 (trixie) Proxmox LXC on Hetzner:+`ssh -p 2222 rust@88.99.212.146`, private IP **172.16.10.10**.+forge listens on `0.0.0.0:8090` inside the container. TLS and the public ports+80/443 belong to a separate proxy-manager container (e.g. Nginx Proxy Manager).++## Proxy manager setting++Point the proxy host at:++```+http://172.16.10.10:8090+```++Scheme `http`, forward host `172.16.10.10`, port `8090`. Websockets and+caching are not needed. Leave request buffering off if you can, so large clones+stream. Once the domain exists, set `clone_base_url` in `/etc/forge/forge.toml`+(it's a TODO there) and run `sudo systemctl restart forge`.++## Layout on the server++| Path | What | Owner |+|------|------|-------|+| `/opt/forge/bin/forge` | the binary, built on the dev box (CSS is compiled in, no static files needed) | root, 755 |+| `/opt/forge/README.md` | docs | root |+| `/etc/forge/forge.toml` | config (from `deploy/forge.toml.example`) | root:forge, 640 |+| `/etc/systemd/system/forge.service` | unit (from `deploy/forge.service`) | root |+| `/srv/git/<name>.git` | bare mirrors that forge serves | forge:forge |++The service runs as the system user `forge` (no login shell, home `/srv/git`).+It has `Restart=on-failure` and a read-only view of the whole filesystem+(`ProtectSystem=strict`, `NoNewPrivileges`, `PrivateTmp`, no capabilities).+Logs go to `journalctl -u forge`.++Packages installed on the server: `git` (forge runs `git upload-pack` for+clones), `ca-certificates`, `rsync` (receiving end of the mirror sync), `curl`+(for checks), and `unattended-upgrades` (Debian security updates).++## First-time setup (already done)++```sh+sudo apt-get update && sudo apt-get install -y --no-install-recommends git ca-certificates rsync curl unattended-upgrades+sudo useradd --system --user-group --home-dir /srv/git --no-create-home --shell /usr/sbin/nologin forge+sudo install -d -m 755 -o forge -g forge /srv/git+```++## Updating forge++From the dev box (it never builds on the server):++```sh+scripts/deploy.sh            # build --release, copy binary + unit, restart+scripts/deploy.sh --config   # same, and overwrite /etc/forge/forge.toml from the example+```++## Syncing the repos++The working repos in `/workspace` must never get a git remote or be pushed+from (other agents work in them). `scripts/sync-mirrors.sh` publishes them+anyway:++1. It keeps a private bare mirror per repo in `/workspace/.forge-mirrors/<name>.git`+   (`git clone --mirror` once, then `git remote update --prune` *inside the mirror*).+   Only branches and tags are published.+2. It rsyncs the mirror over ssh to `/srv/git/<name>.git`, objects first and refs+   second, so a half-finished sync never points at missing objects. The+   server keeps its own `config` and `hooks`.+3. It runs `chown -R forge:forge` on the server.++```sh+scripts/sync-mirrors.sh              # all repos+scripts/sync-mirrors.sh rust-kernel  # just one+```++It is idempotent, so run it whenever you want the site updated (or from cron).+To add a repo, add `name=/path` to `REPOS` in the script and a `[[repo]]` entry+to `/etc/forge/forge.toml`, then restart forge. `/workspace/rustbelt-os` is+already in the list and is skipped until it exists.++## Checks++```sh+curl -s -o /dev/null -w '%{http_code}\n' localhost:8090/+git clone http://127.0.0.1:8090/rust-kernel /tmp/x+systemctl status forge+```
@@ -132,6 +132,15 @@ a bare repo in a temp dir and checks every page type, pagination, escaping and path traversal attempts. It then starts a real server and runs `git clone` (protocol v0 and v2) plus a `git push` that must be refused. +## Deployment++forge runs on the RustForge container behind a proxy manager. The binary is+built here and copied over (`scripts/deploy.sh`), and repos are published as bare+mirrors (`scripts/sync-mirrors.sh`), so the working repos never need a remote.+The proxy target is `http://172.16.10.10:8090`. Everything is in+[DEPLOY.md](DEPLOY.md), including the systemd unit (`deploy/forge.service`)+and the production config (`deploy/forge.toml.example`).+ ## Roadmap  - **Push over SSH**: `git-receive-pack` behind an SSH server with per-user keys (e.g. the `russh` crate, or `authorized_keys` with a forced command).
@@ -0,0 +1,44 @@+# systemd unit for forge. Install to /etc/systemd/system/forge.service, then:+#   sudo systemctl daemon-reload && sudo systemctl enable --now forge+[Unit]+Description=forge - read-only cgit-style git web host+Documentation=file:///opt/forge/README.md+After=network-online.target+Wants=network-online.target++[Service]+Type=simple+User=forge+Group=forge+ExecStart=/opt/forge/bin/forge /etc/forge/forge.toml+WorkingDirectory=/srv/git+Environment=HOME=/srv/git+Restart=on-failure+RestartSec=2s++# --- hardening -------------------------------------------------------------+# forge only *reads* repos (git upload-pack never writes), so the whole+# filesystem is read-only to it; /tmp is a private, empty tmpfs.+NoNewPrivileges=true+ProtectSystem=strict+ReadOnlyPaths=/srv/git /etc/forge /opt/forge+ProtectHome=true+PrivateTmp=true+PrivateDevices=true+ProtectKernelTunables=true+ProtectKernelModules=true+ProtectKernelLogs=true+ProtectControlGroups=true+ProtectClock=true+ProtectHostname=true+RestrictSUIDSGID=true+RestrictRealtime=true+RestrictNamespaces=true+LockPersonality=true+RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX+CapabilityBoundingSet=+AmbientCapabilities=+UMask=0077++[Install]+WantedBy=multi-user.target
@@ -0,0 +1,23 @@+# Production config for the RustForge container (copied to /etc/forge/forge.toml).+# Repos are bare mirrors kept up to date by scripts/sync-mirrors.sh.++[server]+# Listen on all interfaces so the proxy manager container can reach us at+# http://172.16.10.10:8090. TLS is terminated by the proxy, not here.+listen = "0.0.0.0:8090"+site_title = "RustForge"++# TODO(tyler): set this once the public domain is known, e.g.+#   clone_base_url = "https://git.example.com"+# Until then the clone hint uses the request's Host header with http://.+# clone_base_url = "https://git.TODO-your-domain.example"++[[repo]]+name = "rust-kernel"+path = "/srv/git/rust-kernel.git"+description = "Rustbelt: a hobby x86_64 kernel written in Rust"++[[repo]]+name = "rust-githost"+path = "/srv/git/rust-githost.git"+description = "forge: the read-only, cgit-style git web host serving this site"
@@ -0,0 +1,29 @@+#!/usr/bin/env bash+# Build forge here and install it on the server (never builds on the server).+#   scripts/deploy.sh                # binary + unit; keeps the server's config+#   scripts/deploy.sh --config       # also overwrite /etc/forge/forge.toml+set -euo pipefail+cd "$(dirname "$0")/.."++SSH_HOST="${FORGE_SSH_HOST:-rust@88.99.212.146}"+SSH_PORT="${FORGE_SSH_PORT:-2222}"+SSH=(ssh -o BatchMode=yes -p "$SSH_PORT" "$SSH_HOST")+SCP=(scp -q -o BatchMode=yes -P "$SSH_PORT")++cargo build --release+"${SCP[@]}" target/release/forge deploy/forge.service deploy/forge.toml.example README.md "$SSH_HOST:/tmp/"+"${SSH[@]}" "set -e+  sudo install -d -m 755 /opt/forge/bin+  sudo install -m 755 /tmp/forge /opt/forge/bin/forge+  sudo install -m 644 /tmp/README.md /opt/forge/README.md+  sudo install -m 644 /tmp/forge.service /etc/systemd/system/forge.service+  sudo install -d -m 755 /etc/forge+  if [ '${1:-}' = '--config' ] || [ ! -f /etc/forge/forge.toml ]; then+    sudo install -m 640 -o root -g forge /tmp/forge.toml.example /etc/forge/forge.toml+  fi+  rm -f /tmp/forge /tmp/forge.service /tmp/forge.toml.example /tmp/README.md+  sudo systemctl daemon-reload+  sudo systemctl enable --quiet forge+  sudo systemctl restart forge+  sleep 1+  systemctl is-active forge"
@@ -0,0 +1,87 @@+#!/usr/bin/env bash+# Mirror local working repos to the forge server, WITHOUT touching them.+#+# The working repos under /workspace have a strict "no remotes, no pushes"+# rule (other agents work in them). So instead of pushing, this script:+#+#   1. keeps a private bare mirror of each repo in $MIRROR_DIR+#      (git clone --mirror the first time, git remote update afterwards;+#      the only remote that ever exists is inside the mirror itself),+#   2. rsyncs that bare mirror over ssh to /srv/git/<name>.git on the server,+#   3. fixes ownership there to the `forge` user.+#+# Safe to run as often as you like (idempotent). Usage:+#   scripts/sync-mirrors.sh            # sync every repo in the list below+#   scripts/sync-mirrors.sh rust-kernel  # sync just one+set -euo pipefail++SSH_HOST="${FORGE_SSH_HOST:-rust@88.99.212.146}"+SSH_PORT="${FORGE_SSH_PORT:-2222}"+MIRROR_DIR="${FORGE_MIRROR_DIR:-/workspace/.forge-mirrors}"+REMOTE_ROOT="${FORGE_REMOTE_ROOT:-/srv/git}"++# name=source-path. Missing sources are skipped with a note.+REPOS=(+  "rust-kernel=/workspace/rust-kernel"+  "rust-githost=/workspace/rust-githost"+  "rustbelt-os=/workspace/rustbelt-os"+)++SSH=(ssh -o BatchMode=yes -p "$SSH_PORT")+log() { printf '[sync] %s\n' "$*"; }++sync_one() {+  local name="$1" src="$2"+  local mirror="$MIRROR_DIR/$name.git"+  local remote="$REMOTE_ROOT/$name.git"++  if ! git -C "$src" rev-parse --git-dir >/dev/null 2>&1; then+    log "$name: $src is not a git repo, skipping"+    return 0+  fi++  # 1. Local bare mirror (reads $src, never writes to it).+  if [[ ! -d "$mirror" ]]; then+    log "$name: creating mirror $mirror"+    git clone --quiet --mirror --no-hardlinks "$src" "$mirror"+  else+    log "$name: refreshing mirror"+    git -C "$mirror" remote update --prune >/dev/null+  fi+  # Don't publish the stash or anything outside branches and tags.+  git -C "$mirror" for-each-ref --format='%(refname)' \+    | { grep -Ev '^refs/(heads|tags)/' || true; } \+    | while read -r ref; do git -C "$mirror" update-ref -d "$ref"; done+  # Follow the working repo's current branch for HEAD (the default branch).+  local head+  head="$(git -C "$src" symbolic-ref -q HEAD || true)"+  if [[ -n "$head" ]] && git -C "$mirror" rev-parse -q --verify "$head" >/dev/null; then+    git -C "$mirror" symbolic-ref HEAD "$head"+  fi++  # 2. Make sure the server-side bare repo exists (its own config and hooks).+  "${SSH[@]}" "$SSH_HOST" "sudo test -d '$remote' || sudo -u forge git init --quiet --bare '$remote'"++  # 3. Copy objects first, then refs/HEAD, so the server never sees a ref+  #    pointing at an object that hasn't arrived yet. The server keeps its own+  #    config/hooks/description (the mirror's config points at /workspace).+  local rsync=(rsync -a --chown=forge:forge --rsync-path="sudo rsync" -e "${SSH[*]}")+  "${rsync[@]}" "$mirror/objects/" "$SSH_HOST:$remote/objects/"+  "${rsync[@]}" --delete \+    --exclude=/config --exclude=/hooks/ --exclude=/description --exclude=/FETCH_HEAD \+    "$mirror/" "$SSH_HOST:$remote/"++  # 4. Ownership (rsync --chown already does this; this is belt and braces).+  "${SSH[@]}" "$SSH_HOST" "sudo chown -R forge:forge '$remote'"++  log "$name: $(git -C "$mirror" rev-parse --abbrev-ref HEAD) @ $(git -C "$mirror" rev-parse --short HEAD) -> $SSH_HOST:$remote"+}++mkdir -p "$MIRROR_DIR"+only="${1:-}"+for entry in "${REPOS[@]}"; do+  name="${entry%%=*}"+  src="${entry#*=}"+  [[ -n "$only" && "$only" != "$name" ]] && continue+  sync_one "$name" "$src"+done