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 · 16m ago
4b8c9e1d
| 1 | // Package forge opens and owns the state of a Vole instance. |
| 2 | // |
| 3 | // An instance is one directory of durable state: the user roster and access |
| 4 | // rules (data/users.db), the derived index, the CI queue, the object store, and |
| 5 | // the bare repositories. This package is the one place that knows how those fit |
| 6 | // together, so the server, the CLI's administrative commands and an embedding |
| 7 | // application all open the same set in the same order. |
| 8 | package forge |
| 9 | |
| 10 | import ( |
| 11 | "errors" |
| 12 | "fmt" |
| 13 | "os" |
| 14 | "path/filepath" |
| 15 | "strings" |
| 16 | |
| 17 | "rodent.software/vole/ci/logs" |
| 18 | "rodent.software/vole/ci/runs" |
| 19 | "rodent.software/vole/core/lfs" |
| 20 | "rodent.software/vole/forge/access" |
| 21 | "rodent.software/vole/forge/config" |
| 22 | "rodent.software/vole/forge/identity" |
| 23 | "rodent.software/vole/forge/index" |
| 24 | "rodent.software/vole/forge/instancerepo" |
| 25 | "rodent.software/vole/forge/pageview" |
| 26 | "rodent.software/vole/forge/repo" |
| 27 | "rodent.software/vole/forge/secret" |
| 28 | "rodent.software/vole/forge/session" |
| 29 | "rodent.software/vole/internal/audit" |
| 30 | "rodent.software/vole/internal/secure" |
| 31 | ) |
| 32 | |
| 33 | // Instance is the state a running or administered Vole instance owns. |
| 34 | type Instance struct { |
| 35 | Cfg *config.Config |
| 36 | Repos *repo.Store |
| 37 | Identity *identity.Store |
| 38 | Sessions *session.Store |
| 39 | Index *index.Store |
| 40 | Runs *runs.Store |
| 41 | ACLs *access.Store |
| 42 | Locks *lfs.LockStore |
| 43 | LFS *lfs.Store |
| 44 | Logs *logs.Store |
| 45 | PageViews *pageview.Store |
| 46 | // InstanceRepo is the repository whose tree customizes the instance: the |
| 47 | // profile README, custom CSS, favicon, and assets. Nil or disabled means the |
| 48 | // instance keeps its built-in presentation. |
| 49 | InstanceRepo *instancerepo.Store |
| 50 | // Secrets holds each repository's sealed values. It is a separate database |
| 51 | // from the roster because it is the one whose contents are encrypted, and it is |
| 52 | // opened last because nothing else depends on it. |
| 53 | Secrets *secret.Store |
| 54 | |
| 55 | // ImportedACLs is how many legacy refs/vole/acl rule sets were imported by |
| 56 | // Open, so a caller that logs startup can say so. |
| 57 | ImportedACLs int |
| 58 | } |
| 59 | |
| 60 | // Open opens every store an instance needs, in the order their dependencies |
| 61 | // require, and releases what it opened if one of them fails. |
| 62 | func Open(cfg *config.Config, binPath string) (*Instance, error) { |
| 63 | // Directories that hold secrets are created at 0700. The repositories tree is |
| 64 | // content, not secrets, but it lives inside the data directory and there is no |
| 65 | // reason for it to be wider than the directory that contains it. |
| 66 | for _, dir := range []string{cfg.DataDir, cfg.ReposDir(), cfg.SpoolDir(), cfg.LFSDir(), cfg.LogsDir()} { |
| 67 | if err := secure.Mkdir(dir); err != nil { |
| 68 | return nil, fmt.Errorf("data directory %s: %w", dir, err) |
| 69 | } |
| 70 | } |
| 71 | // An instance that cannot record privileged actions must not start. Checking |
| 72 | // here, before a single store or listener is open, turns "the audit log has |
| 73 | // been silently broken since the last upgrade" into a startup failure with a |
| 74 | // path in it. The write path itself still refuses individual changes; this is |
| 75 | // the difference between one failed command and an instance that cannot serve |
| 76 | // its promise at all. |
| 77 | if err := audit.Check(cfg.AuditLog()); err != nil { |
| 78 | return nil, err |
| 79 | } |
| 80 | ident, err := identity.Open(cfg.UsersDB()) |
| 81 | if err != nil { |
| 82 | return nil, fmt.Errorf("%w\nrun `vole admin init` first", err) |
| 83 | } |
| 84 | ident.AuditPath = cfg.AuditLog() |
| 85 | |
| 86 | sessions, err := session.Open(cfg.UsersDB()) |
| 87 | if err != nil { |
| 88 | ident.Close() |
| 89 | return nil, fmt.Errorf("open sessions: %w", err) |
| 90 | } |
| 91 | sessions.AuditPath = cfg.AuditLog() |
| 92 | |
| 93 | ix, err := index.Open(cfg.IndexDB()) |
| 94 | if err != nil { |
| 95 | sessions.Close() |
| 96 | ident.Close() |
| 97 | return nil, fmt.Errorf("open index: %w", err) |
| 98 | } |
| 99 | rn, err := runs.Open(cfg.RunsDB()) |
| 100 | if err != nil { |
| 101 | ix.Close() |
| 102 | sessions.Close() |
| 103 | ident.Close() |
| 104 | return nil, fmt.Errorf("open run queue: %w", err) |
| 105 | } |
| 106 | acls, err := access.Open(cfg.UsersDB()) |
| 107 | if err != nil { |
| 108 | rn.Close() |
| 109 | ix.Close() |
| 110 | sessions.Close() |
| 111 | ident.Close() |
| 112 | return nil, fmt.Errorf("open access rules: %w", err) |
| 113 | } |
| 114 | acls.AuditPath = cfg.AuditLog() |
| 115 | // Instance roles are account standing, owned by the roster. Wiring the |
| 116 | // lookup here means every ACL read from this instance applies them. |
| 117 | acls.InstanceRole = ident.InstanceRole |
| 118 | // Retirement has to reach the rules too: an entry or a runners-list membership |
| 119 | // outlives the account that holds it, and a retired approver or runner must not |
| 120 | // keep satisfying a gate. |
| 121 | acls.Active = ident.Active |
| 122 | |
| 123 | lk, err := lfs.OpenLocks(cfg.UsersDB()) |
| 124 | if err != nil { |
| 125 | acls.Close() |
| 126 | rn.Close() |
| 127 | ix.Close() |
| 128 | sessions.Close() |
| 129 | ident.Close() |
| 130 | return nil, fmt.Errorf("open locks: %w", err) |
| 131 | } |
| 132 | lk.AuditPath = cfg.AuditLog() |
| 133 | |
| 134 | rs := repo.New(cfg.ReposDir(), binPath, acls) |
| 135 | rs.MaxInputBytes = cfg.ReceiveMaxBytes |
| 136 | imported, err := rs.ImportACLs() |
| 137 | if err != nil { |
| 138 | lk.Close() |
| 139 | acls.Close() |
| 140 | rn.Close() |
| 141 | ix.Close() |
| 142 | sessions.Close() |
| 143 | ident.Close() |
| 144 | return nil, err |
| 145 | } |
| 146 | views, err := pageview.Open(cfg.UsersDB()) |
| 147 | if err != nil { |
| 148 | lk.Close() |
| 149 | acls.Close() |
| 150 | rn.Close() |
| 151 | ix.Close() |
| 152 | sessions.Close() |
| 153 | ident.Close() |
| 154 | return nil, fmt.Errorf("open page views: %w", err) |
| 155 | } |
| 156 | |
| 157 | // Secrets are opened last because nothing else depends on them, and they are |
| 158 | // the one store whose failure an operator can act on: a key file that does not |
| 159 | // match the database is a restore that cannot read what it restored. |
| 160 | secrets, err := secret.Open(cfg.SecretsDB(), cfg.SecretsKeyPath()) |
| 161 | if err != nil { |
| 162 | views.Close() |
| 163 | lk.Close() |
| 164 | acls.Close() |
| 165 | rn.Close() |
| 166 | ix.Close() |
| 167 | sessions.Close() |
| 168 | ident.Close() |
| 169 | return nil, fmt.Errorf("open secrets: %w", err) |
| 170 | } |
| 171 | secrets.AuditPath = cfg.AuditLog() |
| 172 | // The repository store deletes a repository's secrets with the repository, so |
| 173 | // it needs the store it deletes from. Wiring it here is what keeps a deleted |
| 174 | // repository from leaving values behind for a name that is created again. |
| 175 | rs.Secrets = secrets |
| 176 | |
| 177 | return &Instance{ |
| 178 | Cfg: cfg, |
| 179 | Repos: rs, |
| 180 | Identity: ident, |
| 181 | Sessions: sessions, |
| 182 | Index: ix, |
| 183 | Runs: rn, |
| 184 | ACLs: acls, |
| 185 | Locks: lk, |
| 186 | LFS: lfs.New(cfg.LFSDir(), cfg.LFSObjectMaxBytes), |
| 187 | Logs: logs.New(cfg.LogsDir()), |
| 188 | PageViews: views, |
| 189 | InstanceRepo: instancerepo.New(rs, cfg.InstanceRepo), |
| 190 | Secrets: secrets, |
| 191 | ImportedACLs: imported, |
| 192 | }, nil |
| 193 | } |
| 194 | |
| 195 | // TightenSecrets brings the modes of everything that holds a credential to what |
| 196 | // they must be: the account database and its write-ahead files, the sealed secret |
| 197 | // store and its key, the SSH host key, the ACME account directory and the audit |
| 198 | // log. It runs after the stores are open |
| 199 | // because opening a store may create its file, and it reports what it changed so |
| 200 | // the caller can say so — an upgraded instance silently changing modes is better |
| 201 | // than one that leaves a private key readable, but it should not be silent. |
| 202 | // |
| 203 | // A path that cannot be tightened is an error: the instance is about to serve |
| 204 | // credentials it cannot protect. |
| 205 | func TightenSecrets(cfg *config.Config) ([]string, error) { |
| 206 | paths := []string{ |
| 207 | cfg.DataDir, |
| 208 | cfg.ReposDir(), |
| 209 | cfg.SpoolDir(), |
| 210 | cfg.LFSDir(), |
| 211 | cfg.LogsDir(), |
| 212 | cfg.UsersDB(), cfg.UsersDB() + "-wal", cfg.UsersDB() + "-shm", |
| 213 | cfg.IndexDB(), cfg.IndexDB() + "-wal", cfg.IndexDB() + "-shm", |
| 214 | cfg.RunsDB(), cfg.RunsDB() + "-wal", cfg.RunsDB() + "-shm", |
| 215 | cfg.SecretsDB(), cfg.SecretsDB() + "-wal", cfg.SecretsDB() + "-shm", |
| 216 | cfg.SecretsKeyPath(), |
| 217 | cfg.AuditLog(), |
| 218 | cfg.SSHHostKey(), |
| 219 | cfg.ACMECachePath(), |
| 220 | } |
| 221 | results, err := secure.Harden(paths...) |
| 222 | var changed []string |
| 223 | for _, r := range results { |
| 224 | changed = append(changed, r.Path) |
| 225 | } |
| 226 | if err != nil { |
| 227 | return changed, err |
| 228 | } |
| 229 | return changed, nil |
| 230 | } |
| 231 | |
| 232 | // OpenPath loads the configuration at cfgPath and opens the instance it names. |
| 233 | func OpenPath(cfgPath string) (*Instance, error) { |
| 234 | cfg, err := config.Load(config.ResolvePath(cfgPath)) |
| 235 | if err != nil { |
| 236 | return nil, err |
| 237 | } |
| 238 | bin, err := os.Executable() |
| 239 | if err != nil { |
| 240 | return nil, err |
| 241 | } |
| 242 | return Open(cfg, bin) |
| 243 | } |
| 244 | |
| 245 | // Close releases every store, reporting the first error but closing the rest. |
| 246 | func (i *Instance) Close() error { |
| 247 | if i == nil { |
| 248 | return nil |
| 249 | } |
| 250 | var err error |
| 251 | for _, closeFn := range []func() error{ |
| 252 | i.Secrets.Close, |
| 253 | i.PageViews.Close, |
| 254 | i.Locks.Close, |
| 255 | i.ACLs.Close, |
| 256 | i.Runs.Close, |
| 257 | i.Index.Close, |
| 258 | i.Sessions.Close, |
| 259 | i.Identity.Close, |
| 260 | } { |
| 261 | if closeFn == nil { |
| 262 | continue |
| 263 | } |
| 264 | if cerr := closeFn(); cerr != nil && err == nil { |
| 265 | err = cerr |
| 266 | } |
| 267 | } |
| 268 | return err |
| 269 | } |
| 270 | |
| 271 | // Init creates the three things an instance lives in — the config file, the |
| 272 | // repository directory and the roster database — and leaves existing state |
| 273 | // alone. It returns the absolute instance directory. |
| 274 | func Init(dir string) (string, error) { |
| 275 | if dir == "" { |
| 276 | dir = "." |
| 277 | } |
| 278 | abs, err := filepath.Abs(dir) |
| 279 | if err != nil { |
| 280 | return "", err |
| 281 | } |
| 282 | if err := os.MkdirAll(abs, 0o755); err != nil { |
| 283 | return "", err |
| 284 | } |
| 285 | path, err := InitAt(filepath.Join(abs, "vole.toml")) |
| 286 | if err != nil { |
| 287 | return "", err |
| 288 | } |
| 289 | return filepath.Dir(path), nil |
| 290 | } |
| 291 | |
| 292 | // InitAt prepares the instance a config file describes and returns that file's |
| 293 | // absolute path. A missing config is created beside its own data directory, so |
| 294 | // `init --dir` keeps its one-directory shape. An existing config is used as |
| 295 | // written, which is how a package ships /etc/vole/vole.toml and still has init |
| 296 | // create the repositories and roster database it names. |
| 297 | func InitAt(cfgPath string) (string, error) { |
| 298 | abs, err := filepath.Abs(cfgPath) |
| 299 | if err != nil { |
| 300 | return "", err |
| 301 | } |
| 302 | // An absolute data_dir lets `serve` run from any working directory. |
| 303 | if err := os.MkdirAll(filepath.Dir(abs), 0o755); err != nil { |
| 304 | return "", err |
| 305 | } |
| 306 | if _, err := os.Stat(abs); errors.Is(err, os.ErrNotExist) { |
| 307 | body := strings.Replace(config.Sample(), |
| 308 | `data_dir = "./data"`, fmt.Sprintf("data_dir = %q", filepath.Join(filepath.Dir(abs), "data")), 1) |
| 309 | if err := os.WriteFile(abs, []byte(body), 0o644); err != nil { |
| 310 | return "", err |
| 311 | } |
| 312 | } |
| 313 | cfg, err := config.Load(abs) |
| 314 | if err != nil { |
| 315 | return "", err |
| 316 | } |
| 317 | if err := secure.Mkdir(cfg.DataDir); err != nil { |
| 318 | return "", err |
| 319 | } |
| 320 | if err := secure.Mkdir(cfg.ReposDir()); err != nil { |
| 321 | return "", err |
| 322 | } |
| 323 | if err := identity.Init(cfg.UsersDB()); err != nil { |
| 324 | return "", err |
| 325 | } |
| 326 | // A roster created by an older install keeps whatever mode it was created |
| 327 | // with; init is also an upgrade path, so tighten it here too. |
| 328 | if _, err := secure.HardenFileIfExists(cfg.UsersDB()); err != nil { |
| 329 | return "", err |
| 330 | } |
| 331 | if err := seedInstanceRepo(cfg); err != nil { |
| 332 | return "", err |
| 333 | } |
| 334 | return abs, nil |
| 335 | } |
| 336 | |
| 337 | // seedInstanceRepo creates the configured instance repository during init and |
| 338 | // seeds its starter tree. It is idempotent: a repository that already has a |
| 339 | // commit is left exactly as it is, so `vole admin init` stays an upgrade path. |
| 340 | func seedInstanceRepo(cfg *config.Config) error { |
| 341 | name := strings.TrimSpace(cfg.InstanceRepo) |
| 342 | if name == "" { |
| 343 | return nil |
| 344 | } |
| 345 | acls, err := access.Open(cfg.UsersDB()) |
| 346 | if err != nil { |
| 347 | return fmt.Errorf("open access rules: %w", err) |
| 348 | } |
| 349 | defer acls.Close() |
| 350 | acls.AuditPath = cfg.AuditLog() |
| 351 | bin, err := os.Executable() |
| 352 | if err != nil { |
| 353 | bin = "vole" |
| 354 | } |
| 355 | repos := repo.New(cfg.ReposDir(), bin, acls) |
| 356 | m, created, err := instancerepo.Ensure(repos, name, "", "vole") |
| 357 | if err != nil { |
| 358 | return fmt.Errorf("instance repository %s: %w", name, err) |
| 359 | } |
| 360 | if !created { |
| 361 | return nil |
| 362 | } |
| 363 | ix, err := index.Open(cfg.IndexDB()) |
| 364 | if err != nil { |
| 365 | return fmt.Errorf("open index: %w", err) |
| 366 | } |
| 367 | defer ix.Close() |
| 368 | if err := ix.Upsert(m); err != nil { |
| 369 | return fmt.Errorf("index the instance repository: %w", err) |
| 370 | } |
| 371 | return nil |
| 372 | } |
| 373 | |
| 374 | // RenameRepo moves a repository and everything keyed by its old name: the |
| 375 | // derived index rows, the CI queue's rows and cursors, and the object store's |
| 376 | // per-repository namespace. |
| 377 | func (i *Instance) RenameRepo(old, next string) error { |
| 378 | m, err := i.Repos.Open(old) |
| 379 | if err != nil { |
| 380 | return err |
| 381 | } |
| 382 | if err := i.Repos.Rename(old, next); err != nil { |
| 383 | return err |
| 384 | } |
| 385 | _ = i.Index.Remove(m.ID) |
| 386 | if renamed, err := i.Repos.Open(next); err == nil { |
| 387 | _ = i.Index.Upsert(renamed) |
| 388 | } |
| 389 | if err := i.Runs.RenameRepo(old, next); err != nil { |
| 390 | return err |
| 391 | } |
| 392 | if err := i.LFS.RenameRepo(old, next); err != nil { |
| 393 | return fmt.Errorf("renamed in the database, but the object namespace did not move: %w", err) |
| 394 | } |
| 395 | return nil |
| 396 | } |
| 397 | |
| 398 | // DeleteRepo removes a repository and the state that would otherwise point at |
| 399 | // something nobody can clone: index rows, queue rows and cursors, and locks. |
| 400 | // The actor is recorded with the deletion. |
| 401 | func (i *Instance) DeleteRepo(name, actor string) error { |
| 402 | e, _ := i.Index.Get(name) |
| 403 | if err := i.Repos.Delete(name, actor); err != nil { |
| 404 | return err |
| 405 | } |
| 406 | if e != nil { |
| 407 | _ = i.Index.Remove(e.ID) |
| 408 | } |
| 409 | _ = i.Runs.ForgetRepo(name) |
| 410 | if e != nil { |
| 411 | _, _ = i.Locks.ForgetRepo(e.ID) |
| 412 | } |
| 413 | return nil |
| 414 | } |
| 415 | |
| 416 | // RebuildIndex reconstructs the derived repository and issue rows. |
| 417 | func (i *Instance) RebuildIndex() (repos int, err error) { |
| 418 | return i.Index.Rebuild(i.Repos) |
| 419 | } |
| 420 |