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 | } | ||