Sign in

Search issue titles and bodies, change requests and comments. Private repositories you cannot read are not included.

vole
Clone

Read only, no credentials:

https://rodent.software/vole.git

Write over SSH with a registered key:

git clone ssh://git@rodent.software:2222/vole.git

A free and open-source forge that lives in your Git repo.

Code

395 commit(s) on main

update readme Keenan Nemetz · 29m ago 32290114
source preview history blame raw 24.2 KiB
1# Configuration
2
3Vole is designed to work out of the box. The defaults are enough for a local
4instance. Change them when you want Vole to listen on another address, limit what
5people can push, or serve websites.
6
7There is no general control panel. One TOML file is the whole configuration. An
8instance admin can see the effective values and the file they came from on the
9Admin page's Configuration tab, and that view is read-only.
10
11The one exception is the customizable repository: `instance_repo` names a
12private repository whose tree supplies the landing page's introduction, a
13stylesheet, a favicon, and static assets. It is not written to `vole.toml`,
14because the packaged service runs with `ProtectSystem=strict` and cannot write
15its config file. See [Customizing the instance](#customizing-the-instance).
16
17## A minimal config
18
19```toml
20data_dir = "./data"
21http_addr = "127.0.0.1:8080"
22ssh_addr = "127.0.0.1:2222"
23# public_url = "https://forge.example.com"
24```
25
26## Which config Vole reads
27
28Local commands look for a config in this order:
29
301. `--config <path>`
312. the `VOLE_CONFIG` environment variable
323. `./vole.toml`
334. `~/.vole/vole.toml`
34
35If none exists, Vole uses the built-in defaults with `data_dir = "./data"`.
36This is why `vole admin init --dir ~/.vole` works from any directory: later
37commands find `~/.vole/vole.toml` without repeating the path. For an instance
38somewhere else, run commands from that directory, pass `--config`, or set
39`VOLE_CONFIG`:
40
41```sh
42vole admin --config /srv/vole/vole.toml account list
43export VOLE_CONFIG=/srv/vole/vole.toml
44vole admin account list
45```
46
47## Settings
48
49| Setting | Default | What it does |
50|---|---:|---|
51| `data_dir` | `"./data"` | Where Vole keeps repositories, databases, stored files, and the audit log |
52| `secrets_key` | `<data_dir>/secrets.key` | The file holding the key that seals each repository's secrets. Point it somewhere a backup of `data_dir` does not reach, so a copy of the database is a copy of ciphertext |
53| `http_addr` | `"127.0.0.1:8080"` | Address for the web interface and anonymous Git clone; with `https_addr`, the ACME challenge and redirect address |
54| `https_addr` | empty | Address for automatic HTTPS; setting it enables ACME |
55| `ssh_addr` | `"127.0.0.1:2222"` | Address for SSH, the authenticated Git transport; its port is also the one the clone link names, while the clone host comes from `public_url` |
56| `ssh_max_connections` | `64` | Simultaneous connections on that listener; past it a connection is closed before the handshake rather than queued |
57| `ssh_max_git_processes` | `16` | Git processes across every SSH connection; past it a command waits a short while, then is told to retry |
58| `ssh_handshake_timeout_seconds` | `20` | How long a connection may take to finish the SSH handshake before it is dropped |
59| `instance_name` | `"Vole"` | What the instance calls itself: the home page heading, every page's document title, and the brand link's accessible name |
60| `instance_repo` | `"vole-instance"` | Repository whose tree customizes the instance; empty disables it |
61| `public_url` | empty | Canonical browser address; must be HTTPS to enable passkey sign-in |
62| `acme_email` | empty | Contact address given to the certificate authority |
63| `acme_domains` | empty | Host names to obtain certificates for; defaults to the host in `public_url` |
64| `acme_cache_dir` | `<data_dir>/acme` | Where the ACME account key and certificates are stored |
65| `acme_directory_url` | Let's Encrypt production | ACME directory; use the staging URL or a private CA while testing |
66| `dev_login` | empty | Development only: sign loopback browser requests in as this account; requires `dev_only` |
67| `dev_only` | `false` | Acknowledge that this instance is a development instance; without it `dev_login` is refused |
68| `open_registration` | `false` | Let anyone create an account by registering a passkey; requires `public_url` to be HTTPS |
69| `open_registration_role` | `"none"` | Instance-wide role a self-registered account gets; applies to public repositories, `admin` is refused |
70| `max_blob_bytes` | `104857600` (100 MiB) | Largest single file accepted in a push |
71| `receive_max_bytes` | `1073741824` (1 GiB) | Largest whole push Vole will accept |
72| `lfs_object_max_bytes` | `2147483648` (2 GiB) | Largest single LFS object |
73| `lfs_warn_bytes` | `5242880` (5 MiB) | Warn when a large binary is committed outside LFS; `0` turns the warning off |
74| `repo_max_refs` | `500` | Branches and tags in one repository; a push that would exceed it is refused |
75| `repo_max_metadata_entries` | `2000` | Documents on the metadata branch in one repository: issues, requests, comments, approvals, and release records |
76| `repo_max_metadata_bytes` | `33554432` (32 MiB) | Total size of those files |
77| `repo_max_bytes` | `5368709120` (5 GiB) | Git objects in one repository; a repository already over it refuses further pushes |
78| `repo_max_lfs_bytes` | `10737418240` (10 GiB) | LFS objects and run artifacts in one repository, checked on upload |
79| `contributor_writes_per_hour` | `300` | Browser writes an untrusted (`contribute`) account may make in an hour: issues, comments, requests, labels, state changes. Maintainers and admins are trusted and are not budgeted |
80| `git_http` | `true` | Serve anonymous clone, fetch and LFS downloads over smart HTTP; off leaves SSH as the only transport |
81| `git_http_max_concurrent` | `16` | Simultaneous anonymous reads on that transport; past it a clone is refused with `503` and `Retry-After` |
82| `git_http_max_per_client` | `8` | Simultaneous reads from one address; `0` leaves only the concurrency bound |
83| `git_http_client_ip_header` | empty | Header a trusted proxy sets with the client address, e.g. `"X-Forwarded-For"`. The same header is what the sign-in rate limits key on |
84| `git_http_timeout_seconds` | `600` | How long one clone may run, Git process included |
85| `lease_seconds` | `60` | How long a runner may hold a job without checking in before it is given to someone else |
86| `max_jobs_per_repo` | `0` | How many jobs one repository may run at once; `0` means no limit |
87| `pages_domain` | empty | Domain suffix for websites, as in `<repo>.<pages_domain>`; empty disables sites. Use a different registrable domain from the forge when sites are untrusted — see [Sites](sites.md#same-site-risk) |
88| `pages_ref` | `"pages"` | Branch served as a website |
89| `pages_procs` | `4` | Number of Git reader processes per active site |
90| `pages_cache_bytes` | `33554432` (32 MiB) | Memory used to cache site files |
91
92The two push limits work at different levels. `max_blob_bytes` checks each file in
93a push. `receive_max_bytes` caps the size of the whole push and is enforced by Git
94while it receives the data, before any branch is updated.
95
96## Anonymous read limits
97
98Smart HTTP is the transport an unauthenticated visitor uses: `git clone`,
99fetch, and the LFS downloads that share the same URLs. Pushes never arrive there;
100they are SSH only. Because the reader has no account, the transport is bounded in
101every dimension that costs the instance something.
102
103```toml
104git_http = true
105git_http_max_concurrent = 16
106git_http_max_per_client = 8
107git_http_client_ip_header = "X-Forwarded-For"
108git_http_timeout_seconds = 600
109```
110
111`git_http_max_concurrent` bounds simultaneous reads, and a clone past it is
112refused with `503` and `Retry-After` rather than queued: a waiting queue is
113another thing that grows with load. `git_http_max_per_client` keeps one address
114from taking every slot, and `0` turns that rule off.
115
116Behind a proxy every connection appears to come from the proxy, so the
117per-client rule needs the address the proxy saw. Name the header it sets:
118
119```toml
120git_http_client_ip_header = "X-Forwarded-For"
121```
122
123Vole takes the last entry of that header, which is the one the nearest proxy
124appended. Leave it empty when nothing sits in front of Vole: the connection's own
125address cannot be forged, and a header no proxy sets can.
126
127`git_http = false` removes the endpoints entirely, which is the setting for an
128instance whose repositories are all internal and none of which are cloned
129anonymously. SSH pushes are unaffected. LFS downloads share the HTTP URLs, so they
130go with it; `git lfs` will report a missing object rather than fetch from SSH.
131
132A release archive is served under the same limit: `git_http_timeout_seconds`
133does not apply to the release page's tarball, which has its own concurrency bound.
134
135## Customizing the instance
136
137`instance_repo` names a repository whose tree customizes the instance. The idea is
138GitHub's `.github` repository: fixed paths read as data, not templates.
139
140```toml
141instance_repo = "vole-instance" # the default; empty disables customization
142```
143
144An instance admin can scaffold it from **Admin → Configuration** (the Instance
145repository panel), or create a repository with that name normally and push the
146files. `vole admin init` creates it with the starter files when it is missing,
147so a new instance starts with it. It is an ordinary repository: it appears in the
148repository list, has the usual pages, history, issues, and change requests, and
149its visibility is set in Settings. It is private by default, so only instance
150admins see it until one of them publishes it. The files below are served to
151visitors either way.
152
153| Path | What it does |
154|---|---|
155| `profile/README.md` | Landing page introduction; markdown, sanitized like an issue body |
156| `profile/private/README.md` | Replaces it for signed-in viewers when present |
157| `profile/custom.css` | Instance-wide styles, loaded last |
158| `profile/themes/<id>.css` | One stylesheet per selectable theme |
159| `profile/favicon.svg` | Replaces the favicon links |
160| `assets/` | Served at `/-/static/custom/assets/`; CSS, images and fonts only |
161
162Other files are ignored, and assets are limited to CSS, images and fonts: a
163same-origin script is not a theme. `profile/README.md` and every other file are
164edited by pushing; the home page has no separate editor. A push is live within a
165couple of seconds, and a load failure keeps the previous theme.
166
167The introduction is rendered and sanitized the same way an issue body is, and it
168appears above the repository list. An empty `profile/README.md` removes the
169card's body. The heading is `instance_name`, which is what the forge calls itself:
170
171```toml
172instance_name = "Acme Forge"
173```
174
175The name is also the document title on every page (`Issues · Acme Forge`) and
176the accessible name of the brand link ("Acme Forge home"), so a renamed instance
177is renamed in the tab as well as in the heading. The brand mark in the top bar
178stays Vole's emblem, and prose about the software itself, such as the status
179page's "restart Vole", keeps the product name: the instance name describes the
180organisation's forge, not the program serving it.
181
182### Theming
183
184`profile/custom.css` is loaded after the built-in `vole.css`, `everforest.css`,
185and `palettes.css`, and after any theme pack, so it can override every theme.
186Override the design tokens rather than copying rules; the layout, contrast, and
187responsive behaviour then stay intact.
188
189Vole ships the Everforest variants, Catppuccin Mocha and Latte, Nord, Solarized
190Light, Rosé Pine Moon and Dawn, Kanagawa Wave, Dracula, Tokyo Night, and Ayu
191Light. The Appearance picker lists them before the instance's own theme packs.
192
193#### Theme packs
194
195Account → Appearance lists the built-in Everforest variants plus every CSS file
196in `profile/themes/`. A theme is self-describing: the file name is the id and
197the label, and the rules are scoped to that id. `acme-ocean.css` is the
198“Acme Ocean” theme; `acme-paper.css` is “Acme Paper”.
199
200```css
201/* profile/themes/acme-ocean.css */
202:root[data-theme="acme-ocean"] {
203 color-scheme: dark;
204 --bg: #0b1720;
205 --bg-panel: #102433;
206 --bg-raised: #163041;
207 --fg: #d7e8f0;
208 --fg-muted: #8fb0c0;
209 --fg-faint: #6f8f9f;
210 --accent: #38bdf8;
211 --accent-fg: #04121a;
212 --accent-soft: color-mix(in srgb, var(--accent) 14%, transparent);
213}
214```
215
216```css
217/* profile/themes/acme-paper.css */
218:root[data-theme="acme-paper"] {
219 color-scheme: light;
220 --bg: #fffdf7;
221 --bg-panel: #f8f3e8;
222 --bg-raised: #f1eadc;
223 --fg: #2b2a26;
224 --fg-muted: #6b675d;
225 --fg-faint: #8d887c;
226 --accent: #9a5b13;
227 --accent-fg: #fffdf7;
228 --accent-soft: color-mix(in srgb, var(--accent) 14%, transparent);
229}
230```
231
232The file name must use lowercase letters, digits, and dashes (at most 41
233characters), and cannot collide with a built-in value: `system`, `nord`,
234`dracula`, `tokyo-night`, or a name starting with `everforest-`, `catppuccin-`,
235`solarized-`, `rose-pine-`, `kanagawa-`, or `ayu-`. A file whose name breaks that
236rule fails the snapshot load with a message on the Admin page; the previous theme
237stays in use. A light theme must set `color-scheme: light`
238and light surface and text tokens, or form controls and inherited colours stay
239dark.
240
241Every theme file is linked on every page, so keep them small. The selection is a
242browser preference, not an account setting: it lives in `localStorage`, and a
243theme whose file is deleted falls back to System.
244
245#### Tokens
246
247Use `:root[data-theme]`, not plain `:root`. Everforest sets its tokens on
248`:root[data-theme^="everforest-"]`, which is more specific than `:root`, so a
249plain `:root` override can lose even though the custom file loads later.
250
251```css
252:root[data-theme] {
253 --accent: #c678dd;
254 --accent-soft: color-mix(in srgb, var(--accent) 14%, transparent);
255 --radius: 4px;
256 --radius-lg: 6px;
257 --page-max: 1200px;
258}
259```
260
261A value that has to differ between dark and light modes belongs in one of these
262blocks. They match every Everforest variant:
263
264```css
265:root[data-theme^="everforest-dark"] { --bg: #101418; }
266:root[data-theme^="everforest-light"] { --bg: #fffdf7; }
267```
268
269The tokens, grouped by what they change:
270
271| Group | Tokens |
272|---|---|
273| surfaces | `--bg`, `--bg-panel`, `--bg-raised`, `--bg-hover`, `--bg-active`, `--border`, `--border-strong` |
274| text | `--fg`, `--fg-muted`, `--fg-faint` |
275| identity | `--accent`, `--accent-fg`, `--accent-soft` |
276| status | `--good`, `--good-soft`, `--bad`, `--bad-soft`, `--warn`, `--warn-soft`, `--running`, `--merged`, `--merged-soft`, `--status-fg` |
277| shape | `--radius`, `--radius-lg`, `--page-max`, `--page-gutter`, `--search-w` |
278| type | `--font`, `--mono`, `--fs`, `--lh`. Leave `--font-scale` alone: it is the reader's text-size preset |
279| syntax | `--syn-comment`, `--syn-punctuation`, `--syn-operator`, `--syn-number`, `--syn-string`, `--syn-keyword`, `--syn-function`, `--syn-type`, `--syn-variable`, `--syn-added`, `--syn-deleted` |
280
281If a token cannot express the change, these page-level classes cover most
282re-skins: `.panel`, `.readme`, `.md`, `.repo-card`, `.repo-grid`, `.home-grid`,
283`.home-rail`, `.repohead`, `.tabs`, `.chip`, `.label`, `.status`,
284`.config-table`, `.diff`, `.blob`. They are not a public API: an upgrade may move
285markup, so prefer a token when one exists.
286
287## Public URL
288
289Set `public_url` when people reach Vole through a different address than
290`http_addr`, for example through a proxy:
291
292```toml
293public_url = "https://forge.example.com"
294```
295
296The CLI uses this to print a link to an issue, comment, or release it just
297created. It is also the WebAuthn relying-party origin: browser sign-in is enabled
298only when `public_url` is an `https://` URL. Vole does not change how it routes
299requests based on this setting.
300
301The host name in `public_url` is also the import prefix a Go module path uses:
302`public_url = "https://forge.example.com"` publishes a public repository as
303`forge.example.com/<repository>`. See [Go modules](go-modules.md).
304
305It is also where generated clone and LFS download links point. That is a security
306property, not a convenience: without it those links follow the request's `Host`
307header, which is a value the browser's request carries and an attacker can choose.
308Copying a clone URL out of a page and into a terminal is normal use, so the URL a
309page shows should be the instance's, not the caller's. Set `public_url` on any
310instance other than a loopback preview.
311
312The same host is what the SSH clone link shows, with the port from `ssh_addr`:
313an instance listening on `0.0.0.0:2222` with `public_url =
314"https://forge.example.com"` offers `ssh://git@forge.example.com:2222/demo.git`.
315A bind address is not something a person can connect to, so it is never used as
316the clone host when a name is configured.
317
318Forwarded headers (`X-Forwarded-Proto`, `X-Forwarded-For`) are believed only when
319`git_http_client_ip_header` is set, because that is the instance's statement that
320a proxy it trusts is rewriting them. Without it, a client that sets a forwarding
321header is describing itself, and Vole ignores it.
322
323## Automatic HTTPS with ACME
324
325Vole can obtain and renew its own TLS certificates, so a small public instance
326does not need a proxy in front of it. Set `https_addr`, point the host name in
327`public_url` at the server, and make both ports reachable:
328
329```toml
330http_addr = ":80"
331https_addr = ":443"
332public_url = "https://forge.example.com"
333acme_email = "admin@example.com"
334```
335
336`https_addr` turns it on, and `public_url` must then be an `https://` URL. Vole
337serves the forge over TLS on that address. `http_addr` stops serving pages: it
338answers ACME HTTP-01 challenges and redirects everything else to HTTPS. The
339certificate host comes from `public_url`; to serve more than one name, list each
340one in `acme_domains` (each host gets its own certificate):
341
342```toml
343acme_domains = ["forge.example.com", "git.example.com"]
344```
345
346Certificates are obtained on first use and renewed in the background. Vole tries
347tls-alpn-01 on port 443 and http-01 on port 80, so a network that blocks one can
348still validate through the other. DNS must already point at the instance; ACME
349cannot issue a name that does not resolve here. There is no wildcard support:
350list concrete names, or keep a proxy for a wildcard certificate.
351
352State lives in `data/acme/` by default. It contains the ACME account key, so the
353directory is created private; back it up with the other instance data, though
354losing it only means registering again and asking for new certificates.
355
356While setting this up, use the staging directory so a mistake does not consume a
357production rate limit:
358
359```toml
360acme_directory_url = "https://acme-staging-v02.api.letsencrypt.org/directory"
361```
362
363Ports below 1024 need `CAP_NET_BIND_SERVICE`. The packaged systemd unit can grant
364it; see [Packages](packaging.md). An instance with `https_addr` empty behaves as
365before and leaves TLS to a proxy.
366
367When `pages_domain` is also set, Vole obtains a certificate for a site host the
368first time that host is requested, provided the repository exists and is
369publicly readable. Site certificates therefore work with a wildcard DNS record
370pointing at the instance, but they count against the certificate authority's
371per-domain rate limit.
372
373## Open registration
374
375By default only an instance admin can create an account. Set
376`open_registration = true` to let anyone create one by registering a passkey:
377
378```toml
379public_url = "https://forge.example.com"
380open_registration = true
381open_registration_role = "contribute"
382```
383
384The setting has no effect unless `public_url` is HTTPS. New accounts have no
385repository-specific access, but `open_registration_role` gives them an
386instance-wide role that applies to every public repository. `contribute` lets
387them read, discuss, and push to their own `refs/heads/contrib/<name>/` namespace
388without being added to each repository. It does not open private repositories;
389`admin` is refused for self-registration. A registered account can add its own
390SSH key from **Account**, so it can push. There is no email verification;
391registration is a deliberate policy choice, not a safe default.
392
393A repository can still be published for anonymous readers with:
394
395```sh
396vole admin acl set-default-role demo read
397```
398
399## Development sign-in
400
401Browsers require HTTPS for passkeys, which makes a local `http://127.0.0.1:8080`
402preview awkward to use for write testing. `dev_login` is an explicit escape hatch,
403and `dev_only` is what makes it explicit:
404
405```toml
406http_addr = "127.0.0.1:8080"
407dev_only = true
408dev_login = "you"
409```
410
411When set, a request from loopback is signed in as that account using a normal
412server-side session. Vole refuses to start unless all of these hold: `dev_only`
413is set, `http_addr` is a loopback address, there is no `https_addr`, and
414`public_url` is unset or itself loopback. It logs a warning when the setting is
415active. The session cookie is deliberately not named `__Host-` so a browser will
416store it over plain HTTP. Never enable this on an instance other people can reach.
417
418The request check does not trust the `Host` header alone. A loopback listener
419behind a proxy sees loopback as the peer address, so a development request must
420also name the configured `http_addr` (same port, and either the same host or
421another loopback name), and any request carrying `Forwarded`,
422`X-Forwarded-For`, `X-Forwarded-Host`, `X-Forwarded-Proto` or `X-Real-IP` is
423refused. A proxy that rewrites `Host` to `127.0.0.1` therefore gets no session.
424
425Signing out opts the browser out of automatic dev sign-in; visit `/-/dev/login` to
426sign back in. Remove `dev_login` and restart to turn it off entirely.
427
428## Refused configurations
429
430`vole serve` validates the configuration before it opens anything and refuses to
431start on a combination that cannot be safe. It reports every problem at once, so
432one restart is enough to fix the file.
433
434| Refused when | Why |
435|---|---|
436| `dev_login` without `dev_only` | A credential-free session must be a decision, not a copied line |
437| `dev_login` with `https_addr` | That listener is reachable from the network |
438| `dev_login` with a non-loopback `public_url` | A published instance is not a development instance |
439| `dev_login` with a non-loopback `http_addr` | The session is only for the machine it runs on |
440| `open_registration` without an HTTPS `public_url` | WebAuthn credentials are bound to an HTTPS origin |
441| `open_registration_role` = `admin` | Self-registration cannot grant instance administration |
442| `public_url` with a user, path, query or fragment | It is an origin that links are appended to |
443| `pages_domain` that is not a bare DNS suffix | Site routing matches a visitor's `Host` against it |
444| `receive_max_bytes` below `max_blob_bytes` | No push could ever contain a permitted file |
445| `https_addr` with no `acme_domains` or `public_url` | There is no name to obtain a certificate for |
446| `acme_email` that is not one address | The certificate authority has nobody to contact |
447| `acme_directory_url` that is not HTTPS | Renewal would fail after the typo was forgotten |
448| a negative size, lease or limit | It is never what the operator meant |
449
450## Running behind a proxy
451
452If a proxy already terminates TLS, leave `https_addr` empty. Vole serves plain
453HTTP on `http_addr`; keep the `Host` header intact, because Vole uses it to
454decide which site a request belongs to.
455
456```nginx
457server {
458 listen 443 ssl;
459 server_name *.forge.example.com;
460 location / {
461 proxy_set_header Host $host;
462 proxy_set_header X-Forwarded-Proto $scheme;
463 proxy_pass http://127.0.0.1:8080;
464 }
465}
466```
467
468SSH is separate. Expose `ssh_addr` directly, or through a TCP proxy that keeps the
469connection intact.
470
471## What Vole stores
472
473| Path | What it is |
474|---|---|
475| `data/users.db` | Accounts, names, instance roles, SSH keys, passkeys, enrollment links, browser sessions, access rules, locks, and daily page-view counters. This is the main account database. Session and enrollment-link tokens are stored only as hashes. |
476| `data/repos/` | Your Git repositories. |
477| `data/index.db` | A rebuildable search index. |
478| `data/runs.db` | The CI queue, finished-run records and steps, artifact pointers, and contributor-branch CI approvals. It is a source of truth: back it up. Deleting it loses CI history and pending work. |
479| `data/lfs/` | Stored large files and build artifacts. |
480| `data/logs/` | One file per CI attempt, streamed from the runner while the job runs. |
481| `data/audit.log` | A record of privileged actions: one tab-separated line per action, `timestamp`, `action`, `actor`, `subject`, `detail`. Created and kept at mode `0600`. See [Audit log](operations.md#audit-log). |
482| `data/secrets.db`, `data/secrets.key` | Each repository's secrets, sealed with AES-256-GCM under the key. Losing the key loses every stored value, and restoring the database beside a newly generated key reads nothing. |
483| `data/ssh_host_ed25519_key` | The SSH host key. It is the instance's long-term secret; browser credentials are public keys and session tokens are hashed. |
484| `data/acme/` | The ACME account key and issued TLS certificates, when automatic HTTPS is enabled. |
485
486See [Operations](operations.md) for backup and recovery.
487