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.

Blame · server/ssh/credential.go

Line attribution on main · 91 line(s)

6e1fca51
Keenan Nemetz
6d ago
1 package ssh
2
3 // The key a job acts with.
4 //
ac541b26
Keenan Nemetz
6d ago
5 // A workflow that names `acts-as: <account>` gets a credential for that account
6 // when the job is leased: an ed25519 pair generated here, the public half stored in
7 // the queue with the run, repository and expiry, and the private half sent on the
8 // lease channel. The runner writes it into the job's run directory, so a step's
9 // `git push` or `vole issue comment` authenticates as the account without the
10 // repository holding a long-lived key.
6e1fca51
Keenan Nemetz
6d ago
11 //
12 // Two checks decide whether the job can have it: the account must exist, be active,
13 // and be a bot account. The role it may act with is not decided here — the hook
14 // applies the same role rules to a credential session that it applies to the
15 // account's own key, so a job cannot act as more than its account may.
16
17 import (
18 "crypto/ed25519"
19 "crypto/rand"
20 "encoding/pem"
21 "fmt"
22 "time"
23
24 xssh "golang.org/x/crypto/ssh"
d37d301f
Keenan Nemetz
19h ago
25 "rodent.software/vole/ci/runs"
6e1fca51
Keenan Nemetz
6d ago
26 )
27
28 const (
29 // credentialGrace is how long a run's key remains valid after the job's own
30 // budget ends. A step may need to push its last commit after the last step, and
31 // the runner's finish is what normally ends the key; this is the backstop for a
32 // runner that dies without one.
33 credentialGrace = 30 * time.Minute
34 // credentialMin and credentialMax bound the backstop. A run with no budget gets
35 // the minimum; a long one is still capped, so a key cannot outlive its job by a
36 // working day.
37 credentialMin = time.Hour
38 credentialMax = 6 * time.Hour
39 )
40
41 // mintCredential issues a key for one run and returns the private half and the
42 // instance's host key line. A non-empty reason means the run asked for an account it
43 // cannot have, and the job fails with that line instead of running without a key.
44 func (s *Server) mintCredential(run *runs.Run, now time.Time) (*runnerCredential, string, string) {
45 acct, err := s.cfg.Identity.Find(run.Bot)
46 if err != nil || acct == nil {
47 return nil, "", fmt.Sprintf("this job acts as %s, which is not an account on this instance", run.Bot)
48 }
49 if !acct.Active() {
50 return nil, "", fmt.Sprintf("this job acts as %s, which is retired", run.Bot)
51 }
52 if !acct.Bot() {
53 return nil, "", fmt.Sprintf("this job acts as %s, which is not a bot account", run.Bot)
54 }
55 pub, priv, err := ed25519.GenerateKey(rand.Reader)
56 if err != nil {
57 return nil, "", "cannot generate a credential: " + err.Error()
58 }
59 publicKey, err := xssh.NewPublicKey(pub)
60 if err != nil {
61 return nil, "", "cannot prepare a credential: " + err.Error()
62 }
63 block, err := xssh.MarshalPrivateKey(priv, "vole run "+run.ID)
64 if err != nil {
65 return nil, "", "cannot encode a credential: " + err.Error()
66 }
67 fingerprint := xssh.FingerprintSHA256(publicKey)
68 if err := s.cfg.Runs.SetRunCredential(runs.RunCredential{
69 Fingerprint: fingerprint,
70 Account: run.Bot,
71 Repo: run.Repo,
72 RunID: run.ID,
73 Expires: now.Add(credentialTTL(run.Timeout)),
74 }, now); err != nil {
75 return nil, "", "cannot record a credential: " + err.Error()
76 }
77 return &runnerCredential{
78 PrivateKey: string(pem.EncodeToMemory(block)),
79 Fingerprint: fingerprint,
80 }, s.hostKey, ""
81 }
82
83 // credentialTTL is how long a run's key may live: its own budget plus a grace, so a
84 // step that pushes at the end of the budget is not cut off, bounded at both ends.
85 func credentialTTL(budget string) time.Duration {
86 ttl := credentialMin
87 if d, err := time.ParseDuration(budget); err == nil && d > 0 {
88 ttl = d
89 }
90 return min(ttl+credentialGrace, credentialMax)
91 }