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.
Merge change request 01M70WES5ZZDJ6RP226WB11BBZ: Let people rename and remove their own passkeys
keenan · 2h ago
4b8c9e1d
| 1 | // Package config loads Vole's on-disk TOML configuration. One box, one file. |
| 2 | package config |
| 3 | |
| 4 | import ( |
| 5 | "fmt" |
| 6 | "os" |
| 7 | "path/filepath" |
| 8 | "strings" |
| 9 | "time" |
| 10 | |
| 11 | "github.com/BurntSushi/toml" |
| 12 | ) |
| 13 | |
| 14 | type 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 | |
| 169 | func 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. |
| 198 | func 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. |
| 218 | func 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. |
| 236 | func ResolvePath(path string) string { |
| 237 | if path != "" { |
| 238 | return path |
| 239 | } |
| 240 | return DefaultPath() |
| 241 | } |
| 242 | |
| 243 | func fileExists(path string) bool { |
| 244 | st, err := os.Stat(path) |
| 245 | return err == nil && !st.IsDir() |
| 246 | } |
| 247 | |
| 248 | func (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. |
| 252 | func (c *Config) UsersDB() string { return filepath.Join(c.DataDir, "users.db") } |
| 253 | |
| 254 | // AuditLog records privileged operations, including every roster change. |
| 255 | func (c *Config) AuditLog() string { return filepath.Join(c.DataDir, "audit.log") } |
| 256 | func (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. |
| 261 | func (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. |
| 267 | func (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. |
| 280 | func (c *Config) RunsDB() string { return filepath.Join(c.DataDir, "runs.db") } |
| 281 | |
| 282 | // SpoolDir receives accepted push events from post-receive hooks. |
| 283 | func (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. |
| 287 | func (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. |
| 291 | func (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. |
| 294 | func (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 | } |
| 300 | func (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. |
| 305 | func (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. |
| 314 | func (c *Config) BaseURL() string { return strings.TrimRight(c.PublicURL, "/") } |
| 315 | |
| 316 | // Sample renders a starter config for `vole admin init`. |
| 317 | func Sample() string { |
| 318 | return `# Vole configuration |
| 319 | data_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. |
| 324 | http_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. |
| 339 | ssh_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. |
| 370 | max_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. |
| 374 | lfs_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. |
| 378 | lfs_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. |
| 397 | receive_max_bytes = 1073741824 |
| 398 | ` |
| 399 | } |
| 400 |