Tahinli/forge
public0 watching
★ 0
| 1 | # forge |
| 2 | |
| 3 | forge is a self-hosted git forge written in Rust. It is a working name, and the project is not yet released. |
| 4 | |
| 5 | It runs as a single process (`forge`) that serves the web UI, git over HTTPS (smart HTTP), and git over SSH. Repositories are plain bare git repositories on disk. Account and repo metadata live in a SQLite/Turso database. |
| 6 | |
| 7 | ## Status |
| 8 | |
| 9 | forge is at **Milestone 2** ("repo browsing and raw files"). It currently provides: |
| 10 | |
| 11 | - local accounts (argon2id passwords), with the first user created as admin |
| 12 | - sessions, CSRF protection on every POST, and a `__Host-` session cookie |
| 13 | - SSH public keys and personal access tokens (`forge_...`) |
| 14 | - creating repos, with public or private visibility, collaborators, and an archive flag |
| 15 | - clone and push over HTTPS and SSH |
| 16 | - basic branch protection: the default branch cannot be deleted or force-pushed, and archived repos reject pushes |
| 17 | - repo browsing: the repo home page with the rendered README, directory listings with the last commit per entry, file views with syntax highlighting and `#L12` line anchors, Markdown rendering with a source toggle, commit history (paginated), commit views with diffs, branch and tag lists, and `.tar.gz` / `.zip` archives |
| 18 | - raw files served from a separate user-content origin (see [Raw user content](#raw-user-content)) |
| 19 | |
| 20 | Not yet: issues, pull requests, webhooks, mirrors, CI. See [Roadmap](#roadmap). |
| 21 | |
| 22 | ## Requirements |
| 23 | |
| 24 | - Rust stable, edition 2024 |
| 25 | - git 2.38 or newer (boot refuses to start with an older git) |
| 26 | |
| 27 | ## Quick start |
| 28 | |
| 29 | 1. Copy the example config. forge reads `config/forge.toml` by default. |
| 30 | |
| 31 | ```sh |
| 32 | cp config/forge.example.toml config/forge.toml |
| 33 | ``` |
| 34 | |
| 35 | 2. Start the server. |
| 36 | |
| 37 | ```sh |
| 38 | cargo run -p forge-web |
| 39 | ``` |
| 40 | |
| 41 | 3. Create the admin account. Either open http://127.0.0.1:7660/setup (it works only while there are zero users), or use the CLI: |
| 42 | |
| 43 | ```sh |
| 44 | cargo run -p forge-web -- admin create-user <username> --admin |
| 45 | ``` |
| 46 | |
| 47 | The CLI reads the password from `FORGE_PASSWORD` if set, or prompts for it twice. Passwords must be at least 12 characters. The first user is always an admin, even without `--admin`. |
| 48 | |
| 49 | 4. Add an SSH key at `/settings/keys`. Use an ed25519 key: |
| 50 | |
| 51 | ```sh |
| 52 | ssh-keygen -t ed25519 |
| 53 | ``` |
| 54 | |
| 55 | Paste the contents of the `.pub` file. RSA keys are rejected with a message asking for ed25519. The reason is that `ssh-rsa` keys sign with SHA-1, and forge disables SHA-1 signatures. ECDSA and security-key (`sk-`) variants are also accepted by the form. |
| 56 | |
| 57 | 5. To change only the capitalization of your username (for example `tahinli` to `Tahinli`), open `/settings/profile`. Any other change is refused. The instance operator can do the same from a shell with `cargo run -p forge-web -- admin set-username <current> <NewCase>`. |
| 58 | |
| 59 | 6. Create an access token at `/settings/tokens`. The token starts with `forge_` and is shown once. Store it somewhere safe. |
| 60 | |
| 61 | 7. Account security lives at `/settings/security`: |
| 62 | - **Password:** change it with the current password. The new one needs 10 to 1024 characters. Your other sessions are signed out; the one you are using stays signed in. Wrong current passwords count toward the same per-user login limit as failed logins. |
| 63 | - **Email:** change it with the current password. The address must contain one `@`, have no spaces, and be at most 254 characters. |
| 64 | - **Two-factor authentication (TOTP):** click "Enable 2FA", add the secret or `otpauth://` link to an authenticator app, and confirm with a 6-digit code. Ten single-use recovery codes are then shown once, so save them. Turning 2FA off needs the password and a code. Regenerating recovery codes needs a code. Codes are TOTP (RFC 6238, SHA-1, 6 digits, 30-second steps), and a code from one step either side of the current time is accepted. Each code works only once. With 2FA on, a correct password leads to `/login/2fa`, which asks for a code or a recovery code. The pending login lasts 5 minutes. Each user gets 10 second-factor attempts per 15 minutes. |
| 65 | - **Git over HTTPS is unaffected by 2FA.** It authenticates with access tokens (step 6), not with passwords or TOTP codes. Revoke a token at `/settings/tokens` if it leaks. |
| 66 | |
| 67 | 7. Create a repo at `/new`, then push over either transport: |
| 68 | |
| 69 | ```sh |
| 70 | # SSH |
| 71 | git remote add origin ssh://git@127.0.0.1:2222/<owner>/<repo>.git |
| 72 | git push -u origin main |
| 73 | |
| 74 | # HTTPS: username and token as the password |
| 75 | git remote add origin http://127.0.0.1:7660/<owner>/<repo>.git |
| 76 | git push -u origin main |
| 77 | ``` |
| 78 | |
| 79 | ## Configuration |
| 80 | |
| 81 | The file is TOML. Every key in `config/forge.example.toml` is listed below. Paths are relative to the working directory unless absolute. |
| 82 | |
| 83 | | Key | Example value | Meaning | |
| 84 | |---|---|---| |
| 85 | | `database` | `"./data/forge.db"` | SQLite/Turso database file. `admin create-user` creates its parent directory. | |
| 86 | | `data_dir` | `"./data"` | Root for repos, instance key, gitconfig, hooks, and sockets. Created with mode 0700. | |
| 87 | | `listen` | `"127.0.0.1:7660"` | HTTP listen address for the web UI and smart HTTP. | |
| 88 | | `base_url` | `"http://127.0.0.1:7660"` | Public base URL used in links and clone URLs. Set this to your public `https://` URL in production. | |
| 89 | | `user_content_base_url` | `"http://127.0.0.1:7661"` | Separate origin for raw user content. Set it to a different registrable domain in production (see [Raw user content](#raw-user-content)). | |
| 90 | | `user_content_listen` | `"127.0.0.1:7661"` | Address of the listener that serves raw user content. Your reverse proxy forwards the user-content hostname to it. | |
| 91 | | `ssh_listen` | `"0.0.0.0:2222"` | SSH listen address for git over SSH. | |
| 92 | | `ssh_public_host` | `"127.0.0.1"` | Host shown in SSH clone URLs. | |
| 93 | | `ssh_public_port` | `2222` | Port shown in SSH clone URLs. | |
| 94 | | `registration` | `"closed"` | Self-registration policy: `closed`, `invite`, or `open`. Default is `closed`. M1 has no sign-up page, so accounts come from `/setup`, the CLI, or `/admin/users`. | |
| 95 | | `trusted_proxies` | `[]` | Addresses or CIDR networks (for example `["127.0.0.1"]`) whose `X-Forwarded-For` header is trusted. Set it to the address your reverse proxy connects from. Empty means the TCP peer is the client, so a spoofed header cannot change the client IP. Used for login and git throttling. | |
| 96 | |
| 97 | Production config normally lives at `/etc/forge/forge.toml` (see the systemd unit below). |
| 98 | |
| 99 | ### Command line and environment |
| 100 | |
| 101 | ```text |
| 102 | forge [--config <path>] [serve | hook <pre-receive|post-receive> | runner <register|run|check> ... | admin create-user <username> [--admin] | admin set-username <current> <NewCase> | admin reset-password <username> | admin trash-list | admin trash-purge --older-than-days <N> | admin audit-purge --older-than-days <N> | admin delete-user <username> [--yes] | admin backup --out <DIR> [--include-key] [--repos-only | --db-only] | admin backup-verify <PATH> | admin fsck [--full] | admin lfs-gc | admin restore --from <SET> --data-dir <NEW_DIR>] |
| 103 | ``` |
| 104 | |
| 105 | - `--config <path>` sets the config file. Precedence: `--config`, then `FORGE_CONFIG`, then `config/forge.toml`. |
| 106 | - `serve` (the default when no subcommand is given) starts the server. |
| 107 | - `hook <stage>` is called by git hook shims. You do not run it by hand. |
| 108 | - `admin create-user <username> [--admin]` creates a user from the CLI. |
| 109 | - `admin set-username <current> <NewCase>` changes only the capitalization of a user's username (`current` matches any case). Example: `forge admin set-username tahinli Tahinli`. |
| 110 | - `admin reset-password <username>` sets a random 20-character temporary password, prints it once, and signs the user out of every session. Give the password to the user, who should change it at `/settings/security`. Nothing else changes. Use it when a user is locked out of their account. Two-factor settings are not changed, so the user still needs a code. |
| 111 | - `admin trash-list` lists deleted repos in `data_dir/trash`, oldest first, as `<deleted_at>\t<repo id>\t<path>`. |
| 112 | - `admin trash-purge --older-than-days N` permanently removes trash entries deleted at least N days ago. Nothing else is removed, and this cannot be undone. |
| 113 | - `FORGE_PASSWORD` supplies the password for `admin create-user` without a prompt. |
| 114 | - `RUST_LOG` sets the log filter (default `info`). Logs go to stderr. |
| 115 | - `GET /healthz` returns `ok <build sha>`. |
| 116 | |
| 117 | ## Data layout |
| 118 | |
| 119 | Everything under `data_dir`: |
| 120 | |
| 121 | | Path | Purpose | |
| 122 | |---|---| |
| 123 | | `forge.db`, `forge.db-wal`, `forge.db-shm` | Metadata database (users, sessions, keys, tokens, repos). Sits next to `database` in the example config. | |
| 124 | | `forge.key` | Instance key. Encrypts sealed values. Created on first boot. | |
| 125 | | `ssh_host_ed25519_key` | SSH host key. Created on first boot. | |
| 126 | | `gitconfig` | Managed gitconfig written at boot. Do not edit by hand, it is rewritten at boot. | |
| 127 | | `hooks/` | `pre-receive` and `post-receive` shims that run `forge hook <name>`. Rewritten at boot. | |
| 128 | | `repos/ab/cd/<id>.git` | Bare repositories. `<id>` is the lowercased ULID; the path does not depend on the repo name. | |
| 129 | | `internal.sock` | Unix socket used by git hooks to reach the server. Mode 0600. Removed on shutdown. | |
| 130 | | `trash/<id>-<unix_ts>.git` | Repos deleted from the settings page (`/{owner}/{repo}/settings`). Each is a plain bare repository, moved here by a rename. The database rows are gone, so the repo no longer appears in forge. | |
| 131 | |
| 132 | ### Recovering a deleted repo |
| 133 | |
| 134 | A deleted repo is not erased at once. Its bare directory is moved to `data_dir/trash/`, and only `admin trash-purge` removes it. To recover it, list the trash with `forge admin trash-list` and clone from the directory, which works without the server: |
| 135 | |
| 136 | ```sh |
| 137 | git clone --mirror /var/lib/forge/trash/<id>-<unix_ts>.git recovered.git |
| 138 | ``` |
| 139 | |
| 140 | The clone restores all branches and tags. The owner, visibility, description and collaborators are not restored, so re-create the repo under the same name and push the mirror to it with `git push --mirror`. |
| 141 | |
| 142 | ### Backups |
| 143 | |
| 144 | See [`docs/backup.md`](docs/backup.md) for what a backup set contains, how to ship it off-site, how to restore, and the quarterly restore drill. |
| 145 | |
| 146 | - **`forge.key` must be backed up separately and stored safely, offline.** Without it, sealed values cannot be decrypted, and they are lost. Keep it out of the same backup set as the database if you can. |
| 147 | - **Never copy a live `forge.db`** (or its `-wal`/`-shm` files) with `cp` or `rsync`. Take a consistent snapshot with `VACUUM INTO`, for example from the `sqlite3` shell while forge is running: |
| 148 | |
| 149 | ```sql |
| 150 | VACUUM INTO '/var/backups/forge/forge-2026-10-15.db'; |
| 151 | ``` |
| 152 | |
| 153 | - **Repositories**: do not copy bare repos while pushes are in progress. Use `git bundle create --all` or `git push --mirror` to a copy. |
| 154 | - **Unix socket path length**: `data_dir/internal.sock` must be shorter than 108 bytes, the unix socket path limit. Boot fails with an error otherwise. Choose a short `data_dir` (for example `/var/lib/forge`). |
| 155 | |
| 156 | ## Deployment |
| 157 | |
| 158 | ### systemd |
| 159 | |
| 160 | `deploy/forge.service` runs `/usr/local/bin/forge serve` as a dedicated `forge` user, with `FORGE_CONFIG=/etc/forge/forge.toml`, working directory `/var/lib/forge`, and a hardened sandbox (`ProtectSystem=strict`, `/var/lib/forge` and, if present, `/var/backups/forge` writable, no capabilities, `UMask=0077`). |
| 161 | |
| 162 | Typical setup: |
| 163 | |
| 164 | ```sh |
| 165 | sudo useradd --system --home /var/lib/forge --shell /usr/sbin/nologin forge |
| 166 | sudo install -d -o forge -g forge /var/lib/forge /etc/forge |
| 167 | sudo install -m 0640 -g forge config/forge.example.toml /etc/forge/forge.toml |
| 168 | sudo install -m 0755 target/release/forge /usr/local/bin/forge |
| 169 | sudo install -m 0644 deploy/forge.service /etc/systemd/system/forge.service |
| 170 | sudo systemctl daemon-reload && sudo systemctl enable forge |
| 171 | ``` |
| 172 | |
| 173 | Edit `/etc/forge/forge.toml` before starting: |
| 174 | |
| 175 | - `base_url`: public `https://` URL. |
| 176 | - `user_content_base_url`: a separate registrable domain, not a subdomain of `base_url`. |
| 177 | - `ssh_public_host` and `ssh_public_port`: public hostname and port. |
| 178 | - `database` and `data_dir`: absolute paths under `/var/lib/forge`. |
| 179 | - `trusted_proxies = ["127.0.0.1"]` when the reverse proxy runs on this host. Otherwise every client shares the proxy's IP for login and git throttling. |
| 180 | - `[backup] dir`: under `/var/lib/forge` or `/var/backups/forge`. The unit makes only those writable. Create the latter with `sudo install -d -o forge -g forge -m 0700 /var/backups/forge`. |
| 181 | |
| 182 | Create the admin before starting. `/setup` lets the first visitor claim the admin account while there are no users, so create the admin before the reverse proxy exposes the site. |
| 183 | |
| 184 | ```sh |
| 185 | sudo -u forge FORGE_CONFIG=/etc/forge/forge.toml /usr/local/bin/forge admin create-user <name> --admin |
| 186 | sudo systemctl start forge |
| 187 | ``` |
| 188 | |
| 189 | Open the SSH port (2222 by default) in the firewall. |
| 190 | |
| 191 | ### Reverse proxy and TLS |
| 192 | |
| 193 | Run forge behind a TLS reverse proxy for any non-localhost use. The session cookie is named `__Host-forge_session` and is marked `Secure`, so browsers send it only over HTTPS. Logging in over plain HTTP outside localhost will not work. Set `base_url` to the public HTTPS URL. |
| 194 | |
| 195 | Git routes need a proxy that does not buffer or time out early. The example below targets [Ferron](https://ferron.sh) 3.0.0-rc.7, the newest release found (a pre-release; the latest stable, 2.8.1, was not checked against this config). Ferron uses its own `ferron.conf` format, not KDL. |
| 196 | |
| 197 | - Buffering: `buffer_request` and `buffer_response` are off by default. The example sets both to `0` so they stay off, because git smart HTTP streams packs in both directions. |
| 198 | - Timeouts: `connection_timeout` (default 2s) and `idle_timeout` (default 60s) are per upstream. The example raises `idle_timeout` to `3600s`, because `git upload-pack` can stay silent while it builds a pack. Ferron's proxy docs list no total-request timeout, so there is no separate read/send timeout to raise. |
| 199 | - Body size: Ferron's docs describe no request body size limit, so there is nothing to disable. Do not add one on git paths (`/<owner>/<repo>/git-receive-pack`, `git-upload-pack`). |
| 200 | - Forwarded headers: Ferron sets `X-Forwarded-For`, `X-Forwarded-Proto`, `X-Real-IP` and `Forwarded` itself and keeps the incoming `Host`, so the example adds no header lines. |
| 201 | - SSH: port 2222 is not proxied. Ferron handles HTTP(S) only, so forge serves SSH directly on `ssh_listen`. Open that port on the host. |
| 202 | |
| 203 | ### Brute-force throttling |
| 204 | |
| 205 | Login and git HTTP authentication are throttled in memory, per process, over a 15 minute sliding window: |
| 206 | |
| 207 | - Login: 10 failed attempts per username, and 30 per client IP. A throttled attempt gets HTTP 429 with the message "Too many attempts. Try again later.", and the password is not checked. A successful login clears that username's counter. |
| 208 | - Git HTTP (Basic auth with a token): 30 failed attempts per client IP. The next request gets HTTP 429 with `Retry-After`. Anonymous requests and successful logins are not counted. |
| 209 | - The client IP is the TCP peer, unless the peer is in `trusted_proxies`. Then it is the right-most `X-Forwarded-For` address that is not a trusted proxy. IPv6 addresses are counted per /64. |
| 210 | - Throttling events are logged at `warn` with the IP and the folded username. Counters are lost on restart and are not shared between instances. |
| 211 | |
| 212 | ### Raw user content |
| 213 | |
| 214 | forge runs a third listener, `user_content_listen` (default `127.0.0.1:7661`). It serves only the raw files of repos, on the origin `user_content_base_url`. It has its own explicit routes and does not share the app's router. |
| 215 | |
| 216 | Put it on its own registrable domain in production, for example `usercontent.example.net` next to `forge.example.com`. Browsers treat that as a separate site, so a file a user uploads cannot run script against the app origin. The raw origin is also built to be inert: |
| 217 | |
| 218 | - No cookies are read or set on it, so app sessions never reach it. |
| 219 | - Every response has `Content-Security-Policy: default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; sandbox`, `X-Content-Type-Options: nosniff`, and `Referrer-Policy: no-referrer`. |
| 220 | - SVG, HTML, XML and other non-image binaries are sent with `Content-Disposition: attachment`. |
| 221 | |
| 222 | Private repos need a signed link. The app puts a `?token=` on raw links for private repos, and that token is valid for 10 minutes. Public repos need no token. A missing, hidden, or bad token gets 404. |
| 223 | |
| 224 | Example Ferron 3.0.0-rc.7 config (`ferron.conf`) with two hostnames. A bare host block already gets automatic ACME TLS; the `tls` block only sets the account contact: |
| 225 | |
| 226 | ``` |
| 227 | forge.example.com { |
| 228 | tls { |
| 229 | provider acme |
| 230 | challenge http-01 |
| 231 | contact admin@example.com |
| 232 | } |
| 233 | buffer_request 0 |
| 234 | buffer_response 0 |
| 235 | proxy { |
| 236 | upstream http://127.0.0.1:7660 { |
| 237 | idle_timeout "3600s" |
| 238 | } |
| 239 | } |
| 240 | } |
| 241 | |
| 242 | usercontent.example.net { |
| 243 | tls { |
| 244 | provider acme |
| 245 | challenge http-01 |
| 246 | contact admin@example.com |
| 247 | } |
| 248 | buffer_request 0 |
| 249 | buffer_response 0 |
| 250 | proxy { |
| 251 | upstream http://127.0.0.1:7661 |
| 252 | } |
| 253 | } |
| 254 | ``` |
| 255 | |
| 256 | The two hostnames are different registrable domains, so the raw origin is a separate site with no app cookies. |
| 257 | |
| 258 | With that setup, set `base_url = "https://forge.example.com"` and `user_content_base_url = "https://usercontent.example.net"`. |
| 259 | |
| 260 | ### Limits |
| 261 | |
| 262 | - Blob view: the first 1 MiB of a file is shown. Larger files are truncated, with a link to the raw file. |
| 263 | - Syntax highlighting: skipped for files over 512 KiB or 20,000 lines. Those are shown without highlighting. |
| 264 | - Raw files: up to 8 MiB are read into memory and sent. Larger files are streamed. |
| 265 | - Commit diffs: at most 300 files, 2,000 lines per file, and 20,000 lines in total. Anything past that is cut off with a notice. |
| 266 | |
| 267 | Compression is currently **off for every route**. Git smart-HTTP responses must not be compressed, and the server cannot yet switch compression off per route, so it is disabled globally (`TODO(M1)` in `boot.rs`). Let the proxy do no compression on git paths either. |
| 268 | |
| 269 | ## Security notes |
| 270 | |
| 271 | From `docs/architecture.md`. These are the properties forge is designed for. Not all are fully implemented in M1. |
| 272 | |
| 273 | - Authorization goes through a single function, `authorize()`. A private repo that the actor cannot see returns 404, so its existence is not revealed. |
| 274 | - Passwords use argon2id. Tokens and session tokens are stored as SHA-256 hashes and compared in constant time. Tokens are shown once and kept out of URLs and logs. |
| 275 | - SSH keys must match exactly. `ssh-rsa` with SHA-1 is disabled, and the SSH server accepts only `GIT_PROTOCOL` from env requests. Handshake and inactivity timeouts apply. |
| 276 | - No shell is used. SSH exec commands are parsed against an allow-list, and git is started with explicit arguments. |
| 277 | - Git runs with `receive.fsckObjects=true`, and hooks are fail-closed: if the internal socket is unreachable, the push is rejected. |
| 278 | - Users cannot write hooks or repo-local config. Submodules are not recursed. |
| 279 | - Run as a dedicated service user, and keep the git binary patched. |
| 280 | - Keep `forge.key` and `data_dir` private. Back up `forge.key` separately. |
| 281 | |
| 282 | ## Roadmap |
| 283 | |
| 284 | From `docs/architecture.md` (section 10). |
| 285 | |
| 286 | - **M1: clone and push work.** Accounts, SSH keys, tokens, repo create, smart HTTP and SSH, pre-receive policy, minimal UI. |
| 287 | - **M2 (current): repo browsing and raw domain.** Tree, blob, commit and diff views; README rendering; syntax highlighting; raw files on `user_content_base_url`. |
| 288 | - **M3: issues.** Issues, comments, labels, milestones, with permissions through `authorize()`. |
| 289 | - **M4: pull requests.** PR refs, mergeability via `merge-tree`, reviews, merge strategies. |
| 290 | - **M5: webhooks, push mirrors, replication, backups, export.** Including `VACUUM INTO` snapshots, integrity checks, and restore drills. |
| 291 | - **M6: custom CI runner and commit-status API.** Required checks enforced on push as well as PR merge. |
| 292 | |
| 293 | ## License |
| 294 | |
| 295 | forge is licensed under the GNU Affero General Public License, version 3 or later (AGPL-3.0-or-later). |
| 296 | |
| 297 | ### Fonts |
| 298 | |
| 299 | The web UI bundles two fonts, served from the forge origin under `/assets/fonts/`: Figtree (UI) and IBM Plex Mono (code). Both are licensed under the SIL Open Font License 1.1. The license texts are in `style/fonts/` and are also served at `/assets/fonts/Figtree-OFL.txt` and `/assets/fonts/IBMPlexMono-OFL.txt`. Sources, pinned versions and checksums are listed in `style/fonts/README.md`. |