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/git/vole.git

Write over SSH with a registered key:

git clone ssh://git@0.0.0.0:2222/vole.git

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

Code

392 commit(s) on main

Merge change request 01M70WES5ZZDJ6RP226WB11BBZ: Let people rename and remove their own passkeys keenan · 2h ago 4b8c9e1d
history blame raw 18.8 KiB
1// Package config loads Vole's on-disk TOML configuration. One box, one file.
2package config
3
4import (
5 "fmt"
6 "os"
7 "path/filepath"
8 "strings"
9 "time"
10
11 "github.com/BurntSushi/toml"
12)
13
14type Config struct {
15 DataDir string `toml:"data_dir"`
16 // HTTPAddr serves the web UI and anonymous git clone. When HTTPSAddr is
17 // set it instead answers ACME HTTP-01 challenges and redirects to HTTPS.
18 HTTPAddr string `toml:"http_addr"`
19 // HTTPSAddr enables automatic HTTPS with ACME. Empty leaves TLS to a proxy.
20 HTTPSAddr string `toml:"https_addr"`
21 // ACMEEmail is the contact address given to the certificate authority.
22 ACMEEmail string `toml:"acme_email"`
23 // ACMEDomains are the host names Vole may obtain certificates for. When
24 // empty, the host in PublicURL is used. Each host gets its own certificate.
25 ACMEDomains []string `toml:"acme_domains"`
26 // ACMECacheDir stores the ACME account key and issued certificates. Empty
27 // means <data_dir>/acme.
28 ACMECacheDir string `toml:"acme_cache_dir"`
29 // ACMEDirectoryURL overrides the certificate authority, for a private ACME
30 // server or the Let's Encrypt staging directory while testing.
31 ACMEDirectoryURL string `toml:"acme_directory_url"`
32 // SSHAddr is the only write githttp.
33 SSHAddr string `toml:"ssh_addr"`
34 // SSHMaxConnections bounds simultaneous connections on the SSH listener. Past
35 // it a connection is closed before the handshake rather than queued, because a
36 // queue is the unbounded thing the cap exists to avoid. Zero means 64: enough
37 // for a team, small enough that a flood cannot take every file descriptor.
38 SSHMaxConnections int `toml:"ssh_max_connections"`
39 // SSHMaxGitProcesses bounds git processes across every SSH connection, so a
40 // burst of sessions cannot turn into a burst of processes. Zero means 16, the
41 // same order as git_http_max_concurrent.
42 SSHMaxGitProcesses int `toml:"ssh_max_git_processes"`
43 // SSHHandshakeTimeoutSeconds bounds how long a connection may take to finish the
44 // SSH handshake, so a client that opens a socket and says nothing cannot hold a
45 // connection slot indefinitely. Zero means 20.
46 SSHHandshakeTimeoutSeconds int `toml:"ssh_handshake_timeout_seconds"`
47 // MaxBlobBytes rejects individual objects in pre-receive.
48 MaxBlobBytes int64 `toml:"max_blob_bytes"`
49 // GitHTTP enables the anonymous smart-HTTP read transport: clone, fetch and
50 // the LFS downloads that share its URLs. It is on by default because reading a
51 // public repository without an account is the point of a public repository.
52 // An instance whose repositories are all internal can switch it off and get a
53 // smaller anonymous surface; pushes are on SSH either way.
54 GitHTTP bool `toml:"git_http"`
55 // GitHTTPMaxConcurrent bounds simultaneous reads on that transport. Past it a
56 // clone is refused with 503 and Retry-After rather than queued, because a
57 // queue is another unbounded thing to hold.
58 GitHTTPMaxConcurrent int `toml:"git_http_max_concurrent"`
59 // GitHTTPMaxPerClient bounds simultaneous reads from one address. Zero
60 // disables the per-client rule and leaves only the concurrency bound.
61 GitHTTPMaxPerClient int `toml:"git_http_max_per_client"`
62 // GitHTTPClientIPHeader names the request header a trusted proxy sets with the
63 // client address, e.g. "X-Forwarded-For". Empty means the connection address is
64 // the client, which is right when nothing is in front of Vole. Behind a proxy
65 // without this, every visitor shares one identity and one per-client budget.
66 GitHTTPClientIPHeader string `toml:"git_http_client_ip_header"`
67 // GitHTTPTimeoutSeconds bounds one read, including the git process behind it.
68 // A big clone over a slow link is minutes; a stalled one is hours.
69 GitHTTPTimeoutSeconds int `toml:"git_http_timeout_seconds"`
70 // ReceiveMaxBytes caps a whole push, enforced by git's own
71 // receive.maxInputSize in each repo rather than by buffering in the proxy.
72 ReceiveMaxBytes int64 `toml:"receive_max_bytes"`
73 // RepoMaxRefs bounds the branches and tags in one repository. A contributor
74 // can create refs in their own namespace and a maintainer can create tags, so
75 // without a bound a repository's ref list is a number somebody else chooses —
76 // and every listing, comparison and activity read walks it.
77 RepoMaxRefs int `toml:"repo_max_refs"`
78 // RepoMaxMetadataEntries bounds the files on the metadata branch: issues,
79 // requests, comments and approvals. Every page load parses them, so this is the
80 // bound on how slow one repository can make its own pages.
81 RepoMaxMetadataEntries int `toml:"repo_max_metadata_entries"`
82 // RepoMaxMetadataBytes bounds the total size of those files.
83 RepoMaxMetadataBytes int64 `toml:"repo_max_metadata_bytes"`
84 // RepoMaxBytes bounds a repository's on-disk objects. A push that would leave
85 // the repository over this is refused, so the growth of a repository is bounded
86 // by the cap plus one permitted push.
87 RepoMaxBytes int64 `toml:"repo_max_bytes"`
88 // RepoMaxLFSBytes bounds one repository's LFS objects, CI logs and run
89 // artifacts. It is enforced where the bytes arrive, on the upload.
90 RepoMaxLFSBytes int64 `toml:"repo_max_lfs_bytes"`
91 // ContributorWritesPerHour bounds how many repository writes an untrusted
92 // account may make from the browser in an hour: issues, comments, requests,
93 // labels, state changes. It is deliberately not a per-account budget for
94 // everybody — maintainers and admins are trusted roles that can already change
95 // these rules, push shared branches and run the instance, so budgeting them
96 // would slow their work without bounding anybody who could not do worse.
97 ContributorWritesPerHour int `toml:"contributor_writes_per_hour"`
98 // LFSObjectMaxBytes caps a single LFS object.
99 LFSObjectMaxBytes int64 `toml:"lfs_object_max_bytes"`
100 // LFSWarnBytes makes a push that commits a binary larger than this carry a
101 // suggestion to track it with git-lfs. Zero disables the warning.
102 LFSWarnBytes int64 `toml:"lfs_warn_bytes"`
103 // LeaseSeconds is how long a runner may hold a job without a heartbeat before
104 // the job is handed to somebody else.
105 LeaseSeconds int `toml:"lease_seconds"`
106 // MaxJobsPerRepo bounds how many jobs one repository may have running at once.
107 // Zero means no bound: fairness is then ordering alone (the lease serves the
108 // least busy repository first), which stops a burst taking the whole fleet but
109 // does not limit one repository's footprint. A cap is what an operator wants
110 // when they need the bound itself — one repository's build saturating every
111 // runner on the instance — and it applies per repository, not per account.
112 MaxJobsPerRepo int `toml:"max_jobs_per_repo"`
113 // PagesDomain is the suffix a repository's site is served under:
114 // <repo>.<pages_domain>. Empty disables site hosting entirely.
115 PagesDomain string `toml:"pages_domain"`
116 // PagesRef is the branch a site is read from. It is a normal branch, pushed
117 // and reviewed like any other, so there is no export step to drift.
118 PagesRef string `toml:"pages_ref"`
119 // PagesProcs is how many `git cat-file --batch` processes serve blobs.
120 // Reads parallelise across processes: measured at 12.1k req/s with one and
121 // 24.8k with four.
122 PagesProcs int `toml:"pages_procs"`
123 // PagesCacheBytes caps the in-memory blob cache. The cache is required, not an
124 // optimisation: cold git reads are ~1.2 ms p50, warm ones 0.075 ms.
125 PagesCacheBytes int64 `toml:"pages_cache_bytes"`
126 // OpenRegistration lets anyone create an account by registering a passkey.
127 // Off by default: an instance with no public contribution policy should not
128 // grow accounts just because it is reachable.
129 OpenRegistration bool `toml:"open_registration"`
130 // OpenRegistrationRole is the instance-wide role a self-registered account
131 // gets. Empty or "none" preserves the old behaviour: the account has no
132 // instance grant and only repository defaults apply. The role applies to
133 // public repositories; "admin" is refused for self-registration.
134 OpenRegistrationRole string `toml:"open_registration_role"`
135 // InstanceName is the heading on the landing page. Empty uses "Vole".
136 InstanceName string `toml:"instance_name"`
137 // InstanceRepo names the repository whose tree customizes this instance:
138 // profile/README.md, profile/custom.css, profile/favicon.svg and assets/.
139 // Empty disables the customization entirely.
140 InstanceRepo string `toml:"instance_repo"`
141 // PublicURL is where this instance is reachable from a browser:
142 // "https://forge.example.com" behind a proxy, normally. It is optional for a
143 // read-only instance; browser login is enabled only when it is an https URL,
144 // because WebAuthn is bound to that origin. It is also what the CLI uses to
145 // print a link to something it just created.
146 PublicURL string `toml:"public_url"`
147 // DevLogin is a development-only escape hatch: when set to an account name,
148 // a browser request from loopback is signed in as that account. It requires
149 // dev_only, a loopback http_addr, and no public_url or https_addr, and the
150 // instance logs a warning at startup. Never enable it on a shared instance.
151 DevLogin string `toml:"dev_login"`
152 // DevOnly acknowledges that this instance is a development instance. It is what
153 // makes dev_login an explicit decision rather than a setting somebody copied
154 // from an example: without it, dev_login is refused at startup.
155 DevOnly bool `toml:"dev_only"`
156 // SecretsKey names the file holding the key that seals repository secrets.
157 // Empty means <data_dir>/secrets.key. It is a separate setting because the key
158 // can then be kept somewhere the data directory's backups do not reach, which
159 // is the difference between a backup of secrets and a backup of ciphertext.
160 SecretsKey string `toml:"secrets_key"`
161
162 // Path is the file Load was asked to read. Loaded reports whether it found a
163 // non-empty file there; false means the built-in defaults are in use, which
164 // the status page shows to an instance admin.
165 Path string `toml:"-"`
166 Loaded bool `toml:"-"`
167}
168
169func defaults() *Config {
170 return &Config{
171 DataDir: "./data",
172 HTTPAddr: "127.0.0.1:8080",
173 SSHAddr: "127.0.0.1:2222",
174 SSHMaxConnections: 64,
175 SSHMaxGitProcesses: 16,
176 SSHHandshakeTimeoutSeconds: 20,
177 MaxBlobBytes: 100 << 20, // 100 MiB per blob
178 ReceiveMaxBytes: 1 << 30, // 1 GiB per push
179
180 GitHTTP: true,
181 GitHTTPMaxConcurrent: 16,
182 GitHTTPMaxPerClient: 8,
183 GitHTTPTimeoutSeconds: 600,
184
185 LFSObjectMaxBytes: 2 << 30, // 2 GiB per LFS object
186 LFSWarnBytes: 5 << 20, // flag binaries over 5 MiB that are not in LFS
187
188 PagesRef: "pages",
189 PagesProcs: 4,
190 PagesCacheBytes: 32 << 20, // 32 MiB holds several 10k-file sites
191 OpenRegistrationRole: "none",
192 InstanceRepo: "vole-instance",
193 }
194}
195
196// Load reads path, filling in defaults for anything absent. A missing file is
197// not an error: `vole serve` works out of the box.
198func Load(path string) (*Config, error) {
199 c := defaults()
200 c.Path = path
201 if path != "" {
202 if st, err := os.Stat(path); err == nil && st.Size() > 0 {
203 if _, err := toml.DecodeFile(path, c); err != nil {
204 return nil, fmt.Errorf("config %s: %w", path, err)
205 }
206 c.Loaded = true
207 } else if err != nil && !os.IsNotExist(err) {
208 return nil, err
209 }
210 }
211 return c, nil
212}
213
214// DefaultPath returns the config file to use when the operator did not name
215// one. VOLE_CONFIG wins, then a vole.toml in the current directory, then the
216// per-user instance at ~/.vole/vole.toml. If none exists it returns "vole.toml",
217// which Load treats as the built-in defaults.
218func DefaultPath() string {
219 if p := strings.TrimSpace(os.Getenv("VOLE_CONFIG")); p != "" {
220 return p
221 }
222 if fileExists("vole.toml") {
223 return "vole.toml"
224 }
225 if home, err := os.UserHomeDir(); err == nil {
226 p := filepath.Join(home, ".vole", "vole.toml")
227 if fileExists(p) {
228 return p
229 }
230 }
231 return "vole.toml"
232}
233
234// ResolvePath applies DefaultPath when path is empty, so a command that did not
235// receive --config still finds the instance the operator initialised.
236func ResolvePath(path string) string {
237 if path != "" {
238 return path
239 }
240 return DefaultPath()
241}
242
243func fileExists(path string) bool {
244 st, err := os.Stat(path)
245 return err == nil && !st.IsDir()
246}
247
248func (c *Config) ReposDir() string { return filepath.Join(c.DataDir, "repos") }
249
250// UsersDB is the authoritative user roster: accounts, the names they have ever
251// held, and their SSH keys. It is not derived and not rebuildable.
252func (c *Config) UsersDB() string { return filepath.Join(c.DataDir, "users.db") }
253
254// AuditLog records privileged operations, including every roster change.
255func (c *Config) AuditLog() string { return filepath.Join(c.DataDir, "audit.log") }
256func (c *Config) IndexDB() string { return filepath.Join(c.DataDir, "index.db") }
257
258// SecretsDB holds each repository's sealed secrets. It is a separate file from
259// the roster because it is the one database whose contents are encrypted, and a
260// copy of it without SecretsKeyPath is ciphertext.
261func (c *Config) SecretsDB() string { return filepath.Join(c.DataDir, "secrets.db") }
262
263// SecretsKeyPath is the AES-256 key that seals those values. It is created on
264// first use at 0600; losing it means losing every stored value, and restoring a
265// copy of the database beside a newly generated key is a restore that cannot read
266// what it restored.
267func (c *Config) SecretsKeyPath() string {
268 if path := strings.TrimSpace(c.SecretsKey); path != "" {
269 return path
270 }
271 return filepath.Join(c.DataDir, "secrets.key")
272}
273
274// RunsDB holds the CI queue and finished runs. It is authoritative for work that has
275// not happened yet — a queued or leased job exists only there — while the finished
276// half is derived from the records on each repository's metadata branch, and pending
277// work is reconciled from the refs. Deleting it loses the attempts that were in
278// flight; the current tip of every ref is queued once and `vole admin index rebuild`
279// restores the finished history.
280func (c *Config) RunsDB() string { return filepath.Join(c.DataDir, "runs.db") }
281
282// SpoolDir receives accepted push events from post-receive hooks.
283func (c *Config) SpoolDir() string { return filepath.Join(c.DataDir, "queue", "spool") }
284
285// LFSDir is the instance-wide content-addressed store. Objects are shared by
286// every repository: the same artifact pushed twice costs one copy.
287func (c *Config) LFSDir() string { return filepath.Join(c.DataDir, "lfs") }
288
289// LogsDir holds one file per CI attempt. Logs are not content-addressed and are
290// not shared, so they live outside the LFS pool.
291func (c *Config) LogsDir() string { return filepath.Join(c.DataDir, "logs") }
292
293// Lease returns how long a runner may hold a job without a heartbeat.
294func (c *Config) Lease() time.Duration {
295 if c.LeaseSeconds <= 0 {
296 return 60 * time.Second
297 }
298 return time.Duration(c.LeaseSeconds) * time.Second
299}
300func (c *Config) SSHHostKey() string { return filepath.Join(c.DataDir, "ssh_host_ed25519_key") }
301
302// ACMECachePath is where automatic-HTTPS state lives. The ACME account key is a
303// secret, so it belongs beside the SSH host key rather than in a world-readable
304// system directory.
305func (c *Config) ACMECachePath() string {
306 if dir := strings.TrimSpace(c.ACMECacheDir); dir != "" {
307 return dir
308 }
309 return filepath.Join(c.DataDir, "acme")
310}
311
312// BaseURL is PublicURL without a trailing slash, so a caller can append
313// "/<repo>/issues/<id>" without thinking about it.
314func (c *Config) BaseURL() string { return strings.TrimRight(c.PublicURL, "/") }
315
316// Sample renders a starter config for `vole admin init`.
317func Sample() string {
318 return `# Vole configuration
319data_dir = "./data"
320
321# Read-only: the web UI and anonymous git clone. Leave HTTPS off and terminate
322# TLS in a proxy in front of this address, keeping the Host header intact because
323# site requests are routed by hostname.
324http_addr = "127.0.0.1:8080"
325
326# Or let Vole obtain and renew its own certificates with ACME. Setting https_addr
327# enables it; http_addr then answers ACME challenges and redirects to HTTPS, so
328# it must be reachable on port 80. public_url below (or acme_domains) must name
329# a host that resolves to this server.
330# https_addr = ":443"
331# http_addr = ":80"
332# acme_email = "admin@example.com"
333# acme_domains = ["forge.example.com"]
334# acme_cache_dir = "./data/acme"
335# Use the staging directory while testing to avoid a production rate limit.
336# acme_directory_url = "https://acme-staging-v02.api.letsencrypt.org/directory"
337
338# The only write githttp. Identity is the SSH key presented at handshake.
339ssh_addr = "127.0.0.1:2222"
340
341# Where a browser reads this instance. Set it to the canonical https URL to
342# enable passkey login; WebAuthn is bound to this origin. The CLI also uses it to
343# print the page for something it just created. Trailing slash is ignored.
344# public_url = "https://forge.example.com"
345
346# What the home page calls this forge. Empty uses "Vole".
347# instance_name = "Acme Forge"
348
349# The repository whose tree customizes this instance: profile/README.md,
350# profile/custom.css, profile/favicon.svg, and assets/. Empty disables it.
351# instance_repo = "vole-instance"
352
353# Development only: sign loopback browser requests in as this account. Refused
354# unless http_addr is a loopback address. Never enable this on a shared instance.
355# dev_only = true
356# dev_login = "you"
357
358# Let anyone create an account by registering a passkey. New accounts have no
359# repository-specific access; each repository's default role still applies.
360# Off unless you are running an instance that wants public contributions.
361# open_registration = true
362
363# The instance-wide role a self-registered account gets. It applies to public
364# repositories only; an instance admin still has admin access to every
365# repository. Use "contribute" for an open-source instance. "admin" is refused.
366# open_registration_role = "contribute"
367
368# Reject individual blobs above this size (pre-receive). Point people at
369# git-lfs instead.
370max_blob_bytes = 104857600
371
372# LFS objects live in a content-addressed store shared by every repository.
373# This caps one object; anything larger is refused rather than spooled.
374lfs_object_max_bytes = 2147483648
375
376# A push that commits a binary bigger than this, and is not already an LFS
377# pointer, gets a warning naming the git lfs track command. Zero disables it.
378lfs_warn_bytes = 5242880
379
380# How many jobs one repository may have running at once. Zero disables the bound,
381# which leaves fairness as ordering alone: the lease serves the least busy
382# repository first, so a burst cannot take the whole fleet, but nothing limits how
383# much of it one repository uses. Set it when one repository's build saturates the
384# instance and the ordering is not enough.
385# max_jobs_per_repo = 4
386
387# Static sites: a repository's pages branch is served at
388# <repo>.<pages_domain>. Leave empty to serve no sites at all.
389# pages_domain = "pages.example.com"
390# pages_ref = "pages"
391# pages_procs = 4
392# pages_cache_bytes = 33554432
393
394# Cap a whole push. Enforced by git's receive.maxInputSize in each repo, because
395# receive-pack indexes a pack in memory and a large push costs its own size in
396# RSS.
397receive_max_bytes = 1073741824
398`
399}
400