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.

Phase 1: a working git remote with access control

653e0cfc227c992e5b26ea10057aa0b424b6ce4f · Keenan Nemetz · 18d ago · parent c5626f1b

Phase 1: a working git remote with access control

Vole is now a functioning forge foundation: one CGO-free binary that serves
real git over smart HTTP and enforces per-repo policy in pre-receive.

What is in:

- internal/transport drives `git http-backend` as a CGI subprocess. Chunked
  uploads are spooled to learn CONTENT_LENGTH (git's default client config
  produces them), spool files are cleaned on error and abort, and a janitor
  sweeps anything a crash left behind. Pushes are capped by size and
  serialised behind a semaphore, because Phase 0 measured 1.13 GB of git RSS
  for a single 1 GiB push.
- internal/acl stores roles on refs/vole/acl inside each repository, so
  access rules travel with a clone. One evaluation function is shared by the
  hook and the web layer.
- internal/hook enforces policy in pre-receive: authentication, write role,
  protected refs, per-blob size limits, and a refusal to accept pushes to
  refs/vole/* - without that last check a writer could push their own
  acl.json and grant themselves admin.
- internal/auth holds users, personal access tokens and web sessions in
  SQLite. Passwords use PBKDF2-SHA256; tokens are 256-bit random values and
  are stored as SHA-256 hashes, since there is no low-entropy secret to grind.
- internal/index is the derived read model, rebuildable from disk and refs.
- internal/repo owns the bare-repo store and the metadata ref CAS helper.
- internal/web is the browse surface: repo list, tree, blob, raw, history,
  login, and per-repo settings for access rules, rename and delete.
- internal/signing creates the instance ed25519 key at first boot for the
  Phase 7 work.

Every Phase 1 exit criterion has a test that would fail if it regressed:
clone/push against a real listener, read-only and anonymous rejection with the
ref proven unmoved, rebuild-from-refs compared against the actual acl ref, a
spool file proven absent after an aborted chunked upload, and two pushes
completing through a single-slot semaphore. scripts/ci.sh runs gofmt, vet,
build, a markdown link check and the race-enabled suite.

Verified by hand as well: a clean `vole init` -> user -> token -> push ->
clone cycle, web login and browse, and a 256 MiB chunked push killed
mid-flight leaving no ref, no spool file and no orphaned git processes.

internal/config/config.go +102

@@ -0,0 +1,102 @@
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 "time"
9
10 "github.com/BurntSushi/toml"
11 )
12
13 type Config struct {
14 DataDir string `toml:"data_dir"`
15 Addr string `toml:"addr"`
16 AdminUsers []string `toml:"admin_users"`
17 MaxPushBytes int64 `toml:"max_push_bytes"`
18 PushConcurrency int `toml:"push_concurrency"`
19 QueueTimeout string `toml:"queue_timeout"`
20 MaxBlobBytes int64 `toml:"max_blob_bytes"`
21 SessionTTL string `toml:"session_ttl"`
22
23 // Derived, not serialised.
24 QueueTimeoutDur time.Duration `toml:"-"`
25 SessionTTLDur time.Duration `toml:"-"`
26 Path string `toml:"-"`
27 }
28
29 func defaults() *Config {
30 return &Config{
31 DataDir: "./data",
32 Addr: "127.0.0.1:8080",
33 MaxPushBytes: 1 << 30, // 1 GiB: a 1 GiB push peaks at ~1.13 GB of git RSS
34 PushConcurrency: 1,
35 QueueTimeout: "5m",
36 MaxBlobBytes: 100 << 20, // 100 MiB per blob
37 SessionTTL: "720h",
38 }
39 }
40
41 // Load reads path, filling in defaults for anything absent. A missing file is
42 // not an error: a bare `vole serve` works out of the box.
43 func Load(path string) (*Config, error) {
44 c := defaults()
45 c.Path = path
46 if path != "" {
47 st, err := os.Stat(path)
48 switch {
49 case err == nil && st.Size() > 0:
50 if _, err := toml.DecodeFile(path, c); err != nil {
51 return nil, fmt.Errorf("config %s: %w", path, err)
52 }
53 case err != nil && !os.IsNotExist(err):
54 return nil, err
55 }
56 }
57 var err error
58 if c.QueueTimeoutDur, err = time.ParseDuration(c.QueueTimeout); err != nil {
59 return nil, fmt.Errorf("queue_timeout: %w", err)
60 }
61 if c.SessionTTLDur, err = time.ParseDuration(c.SessionTTL); err != nil {
62 return nil, fmt.Errorf("session_ttl: %w", err)
63 }
64 return c, nil
65 }
66
67 func (c *Config) ReposDir() string { return filepath.Join(c.DataDir, "repos") }
68 func (c *Config) AuthDB() string { return filepath.Join(c.DataDir, "auth.db") }
69 func (c *Config) IndexDB() string { return filepath.Join(c.DataDir, "index.db") }
70 func (c *Config) SpoolDir() string { return filepath.Join(c.DataDir, "spool") }
71 func (c *Config) InstanceKey() string { return filepath.Join(c.DataDir, "instance.key") }
72
73 func (c *Config) IsAdmin(user string) bool {
74 for _, a := range c.AdminUsers {
75 if a == user {
76 return true
77 }
78 }
79 return false
80 }
81
82 // Sample renders a starter config for `vole init`.
83 func Sample() string {
84 return `# Vole configuration
85 data_dir = "./data"
86 addr = "127.0.0.1:8080"
87
88 # Users listed here may create and delete repositories.
89 admin_users = []
90
91 # A 1 GiB push peaks at about 1.13 GB of git RSS, so the cap and the concurrency
92 # limit are memory guards, not politeness.
93 max_push_bytes = 1073741824
94 push_concurrency = 1
95 queue_timeout = "5m"
96
97 # Reject individual blobs above this size. Point people at git-lfs instead.
98 max_blob_bytes = 104857600
99
100 session_ttl = "720h"
101 `
102 }