# forge forge is a self-hosted git forge written in Rust. It is a working name, and the project is not yet released. 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. ## Status forge is at **Milestone 2** ("repo browsing and raw files"). It currently provides: - local accounts (argon2id passwords), with the first user created as admin - sessions, CSRF protection on every POST, and a `__Host-` session cookie - SSH public keys and personal access tokens (`forge_...`) - creating repos, with public or private visibility, collaborators, and an archive flag - clone and push over HTTPS and SSH - basic branch protection: the default branch cannot be deleted or force-pushed, and archived repos reject pushes - 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 - raw files served from a separate user-content origin (see [Raw user content](#raw-user-content)) Not yet: issues, pull requests, webhooks, mirrors, CI. See [Roadmap](#roadmap). ## Requirements - Rust stable, edition 2024 - git 2.38 or newer (boot refuses to start with an older git) ## Quick start 1. Copy the example config. forge reads `config/forge.toml` by default. ```sh cp config/forge.example.toml config/forge.toml ``` 2. Start the server. ```sh cargo run -p forge-web ``` 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: ```sh cargo run -p forge-web -- admin create-user --admin ``` 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`. 4. Add an SSH key at `/settings/keys`. Use an ed25519 key: ```sh ssh-keygen -t ed25519 ``` 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. 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 `. 6. Create an access token at `/settings/tokens`. The token starts with `forge_` and is shown once. Store it somewhere safe. 7. Account security lives at `/settings/security`: - **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. - **Email:** change it with the current password. The address must contain one `@`, have no spaces, and be at most 254 characters. - **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. - **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. 7. Create a repo at `/new`, then push over either transport: ```sh # SSH git remote add origin ssh://git@127.0.0.1:2222//.git git push -u origin main # HTTPS: username and token as the password git remote add origin http://127.0.0.1:7660//.git git push -u origin main ``` ## Configuration The file is TOML. Every key in `config/forge.example.toml` is listed below. Paths are relative to the working directory unless absolute. | Key | Example value | Meaning | |---|---|---| | `database` | `"./data/forge.db"` | SQLite/Turso database file. `admin create-user` creates its parent directory. | | `data_dir` | `"./data"` | Root for repos, instance key, gitconfig, hooks, and sockets. Created with mode 0700. | | `listen` | `"127.0.0.1:7660"` | HTTP listen address for the web UI and smart HTTP. | | `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. | | `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)). | | `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. | | `ssh_listen` | `"0.0.0.0:2222"` | SSH listen address for git over SSH. | | `ssh_public_host` | `"127.0.0.1"` | Host shown in SSH clone URLs. | | `ssh_public_port` | `2222` | Port shown in SSH clone URLs. | | `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`. | | `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. | Production config normally lives at `/etc/forge/forge.toml` (see the systemd unit below). ### Command line and environment ```text forge [--config ] [serve | hook | runner ... | admin create-user [--admin] | admin set-username | admin reset-password | admin trash-list | admin trash-purge --older-than-days | admin audit-purge --older-than-days | admin delete-user [--yes] | admin backup --out [--include-key] [--repos-only | --db-only] | admin backup-verify | admin fsck [--full] | admin lfs-gc | admin restore --from --data-dir ] ``` - `--config ` sets the config file. Precedence: `--config`, then `FORGE_CONFIG`, then `config/forge.toml`. - `serve` (the default when no subcommand is given) starts the server. - `hook ` is called by git hook shims. You do not run it by hand. - `admin create-user [--admin]` creates a user from the CLI. - `admin set-username ` changes only the capitalization of a user's username (`current` matches any case). Example: `forge admin set-username tahinli Tahinli`. - `admin reset-password ` 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. - `admin trash-list` lists deleted repos in `data_dir/trash`, oldest first, as `\t\t`. - `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. - `FORGE_PASSWORD` supplies the password for `admin create-user` without a prompt. - `RUST_LOG` sets the log filter (default `info`). Logs go to stderr. - `GET /healthz` returns `ok `. ## Data layout Everything under `data_dir`: | Path | Purpose | |---|---| | `forge.db`, `forge.db-wal`, `forge.db-shm` | Metadata database (users, sessions, keys, tokens, repos). Sits next to `database` in the example config. | | `forge.key` | Instance key. Encrypts sealed values. Created on first boot. | | `ssh_host_ed25519_key` | SSH host key. Created on first boot. | | `gitconfig` | Managed gitconfig written at boot. Do not edit by hand, it is rewritten at boot. | | `hooks/` | `pre-receive` and `post-receive` shims that run `forge hook `. Rewritten at boot. | | `repos/ab/cd/.git` | Bare repositories. `` is the lowercased ULID; the path does not depend on the repo name. | | `internal.sock` | Unix socket used by git hooks to reach the server. Mode 0600. Removed on shutdown. | | `trash/-.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. | ### Recovering a deleted repo 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: ```sh git clone --mirror /var/lib/forge/trash/-.git recovered.git ``` 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`. ### Backups 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. - **`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. - **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: ```sql VACUUM INTO '/var/backups/forge/forge-2026-10-15.db'; ``` - **Repositories**: do not copy bare repos while pushes are in progress. Use `git bundle create --all` or `git push --mirror` to a copy. - **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`). ## Deployment ### systemd `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`). Typical setup: ```sh sudo useradd --system --home /var/lib/forge --shell /usr/sbin/nologin forge sudo install -d -o forge -g forge /var/lib/forge /etc/forge sudo install -m 0640 -g forge config/forge.example.toml /etc/forge/forge.toml sudo install -m 0755 target/release/forge /usr/local/bin/forge sudo install -m 0644 deploy/forge.service /etc/systemd/system/forge.service sudo systemctl daemon-reload && sudo systemctl enable forge ``` Edit `/etc/forge/forge.toml` before starting: - `base_url`: public `https://` URL. - `user_content_base_url`: a separate registrable domain, not a subdomain of `base_url`. - `ssh_public_host` and `ssh_public_port`: public hostname and port. - `database` and `data_dir`: absolute paths under `/var/lib/forge`. - `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. - `[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`. 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. ```sh sudo -u forge FORGE_CONFIG=/etc/forge/forge.toml /usr/local/bin/forge admin create-user --admin sudo systemctl start forge ``` Open the SSH port (2222 by default) in the firewall. ### Reverse proxy and TLS 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. 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. - 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. - 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. - Body size: Ferron's docs describe no request body size limit, so there is nothing to disable. Do not add one on git paths (`///git-receive-pack`, `git-upload-pack`). - 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. - 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. ### Brute-force throttling Login and git HTTP authentication are throttled in memory, per process, over a 15 minute sliding window: - 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. - 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. - 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. - Throttling events are logged at `warn` with the IP and the folded username. Counters are lost on restart and are not shared between instances. ### Raw user content 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. 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: - No cookies are read or set on it, so app sessions never reach it. - 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`. - SVG, HTML, XML and other non-image binaries are sent with `Content-Disposition: attachment`. 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. 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: ``` forge.example.com { tls { provider acme challenge http-01 contact admin@example.com } buffer_request 0 buffer_response 0 proxy { upstream http://127.0.0.1:7660 { idle_timeout "3600s" } } } usercontent.example.net { tls { provider acme challenge http-01 contact admin@example.com } buffer_request 0 buffer_response 0 proxy { upstream http://127.0.0.1:7661 } } ``` The two hostnames are different registrable domains, so the raw origin is a separate site with no app cookies. With that setup, set `base_url = "https://forge.example.com"` and `user_content_base_url = "https://usercontent.example.net"`. ### Limits - Blob view: the first 1 MiB of a file is shown. Larger files are truncated, with a link to the raw file. - Syntax highlighting: skipped for files over 512 KiB or 20,000 lines. Those are shown without highlighting. - Raw files: up to 8 MiB are read into memory and sent. Larger files are streamed. - 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. 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. ## Security notes From `docs/architecture.md`. These are the properties forge is designed for. Not all are fully implemented in M1. - Authorization goes through a single function, `authorize()`. A private repo that the actor cannot see returns 404, so its existence is not revealed. - 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. - 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. - No shell is used. SSH exec commands are parsed against an allow-list, and git is started with explicit arguments. - Git runs with `receive.fsckObjects=true`, and hooks are fail-closed: if the internal socket is unreachable, the push is rejected. - Users cannot write hooks or repo-local config. Submodules are not recursed. - Run as a dedicated service user, and keep the git binary patched. - Keep `forge.key` and `data_dir` private. Back up `forge.key` separately. ## Roadmap From `docs/architecture.md` (section 10). - **M1: clone and push work.** Accounts, SSH keys, tokens, repo create, smart HTTP and SSH, pre-receive policy, minimal UI. - **M2 (current): repo browsing and raw domain.** Tree, blob, commit and diff views; README rendering; syntax highlighting; raw files on `user_content_base_url`. - **M3: issues.** Issues, comments, labels, milestones, with permissions through `authorize()`. - **M4: pull requests.** PR refs, mergeability via `merge-tree`, reviews, merge strategies. - **M5: webhooks, push mirrors, replication, backups, export.** Including `VACUUM INTO` snapshots, integrity checks, and restore drills. - **M6: custom CI runner and commit-status API.** Required checks enforced on push as well as PR merge. ## License forge is licensed under the GNU Affero General Public License, version 3 or later (AGPL-3.0-or-later). ### Fonts 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`.