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 · 16m ago 4b8c9e1d
history blame raw 12.6 KiB
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.
8package forge
9
10import (
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.
34type 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.
62func 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.
205func 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.
233func 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.
246func (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.
274func 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.
297func 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.
340func 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.
377func (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.
401func (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.
417func (i *Instance) RebuildIndex() (repos int, err error) {
418 return i.Index.Rebuild(i.Repos)
419}
420