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.

Blame · server/ssh/secret.go

Line attribution on main · 197 line(s)

0c40a1d3
Keenan Nemetz
7d ago
1 package ssh
2
3 // A repository's secrets over SSH.
4 //
5 // vole-secret list <repository>
6 // vole-secret get <repository> <name>
7 // vole-secret set <repository> <name> (the value is the whole of stdin)
8 // vole-secret rm <repository> <name>
9 //
10 // The value travels on stdin and never in the command line: an exec line is
11 // visible to every process on both hosts, and the instance records the command it
12 // ran. What comes back is the value and nothing else, so `vole secret get` can be
13 // redirected without stripping a banner first.
14 //
15 // The capability is the maintainer one: the accounts that can approve work,
16 // manage releases and allow CI are the accounts that own the credentials the
17 // repository's jobs use.
18
19 import (
20 "errors"
21 "fmt"
22 "io"
23 "strings"
24 "time"
25
26 xssh "golang.org/x/crypto/ssh"
27
d37d301f
Keenan Nemetz
15h ago
28 "rodent.software/vole/ci/runs"
29 "rodent.software/vole/forge/secret"
0c40a1d3
Keenan Nemetz
7d ago
30 )
31
32 // maxSecretInput bounds what `set` will read from the client. It is one
33 // NAME=value entry plus room for a name, which is the most a value can be; a
34 // larger body is refused before it reaches the store.
35 const maxSecretInput = secret.MaxEntryBytes + 64
36
37 func (s *Server) serveSecret(ch xssh.Channel, argv []string, user string) int {
38 if len(argv) < 3 {
39 fmt.Fprint(ch, "vole: usage: vole-secret list <repository>\n"+
40 "vole: vole-secret get|set|rm <repository> <name>\n")
41 return 129
42 }
43 op := argv[1]
44 name, ok := repoName(argv[2])
45 if !ok {
46 fmt.Fprint(ch, "vole: invalid repository name\n")
47 return 129
48 }
49 if s.cfg.Instance == nil || s.cfg.Instance.Secrets == nil {
50 fmt.Fprint(ch, "vole: this instance has no secret store\n")
51 return 1
52 }
53 m, err := s.cfg.Repos.Open(name)
54 if err != nil {
55 fmt.Fprintf(ch, "vole: %v\n", err)
56 return 1
57 }
58 acl, err := m.ACL()
59 if err != nil || acl == nil {
60 fmt.Fprintf(ch, "vole: cannot read access rules for %s\n", name)
61 return 1
62 }
63 if !acl.CanTriage(user) {
64 fmt.Fprintf(ch, "vole: %s may not manage secrets in %s\n", user, name)
65 return 1
66 }
67 store := s.cfg.Instance.Secrets
68
69 if op == "list" {
70 infos, err := store.List(m.ID)
71 if err != nil {
72 fmt.Fprintf(ch, "vole: %v\n", err)
73 return 1
74 }
75 if len(infos) == 0 {
76 fmt.Fprintf(ch, "no secrets are stored for %s\n", name)
77 return 0
78 }
79 for _, info := range infos {
80 fmt.Fprintf(ch, "%-32s v%-3d %s\n", info.Name, info.Version, secretDetail(info))
81 }
82 return 0
83 }
84
85 if len(argv) < 4 {
86 fmt.Fprintf(ch, "vole: usage: vole-secret %s <repository> <name>\n", op)
87 return 129
88 }
89 secretName := strings.TrimSpace(argv[3])
90 if err := secret.ValidateName(secretName); err != nil {
91 fmt.Fprintf(ch, "vole: %v\n", err)
92 return 1
93 }
94
95 switch op {
96 case "get":
97 value, _, err := store.Read(m.ID, secretName, user, "cli")
98 if err != nil {
99 fmt.Fprintf(ch, "vole: %v\n", secretProblem(err, secretName, name))
100 return 1
101 }
102 // The value is the whole answer, with nothing appended: a caller that
103 // redirects it to a file must get the bytes that were stored.
104 fmt.Fprint(ch, value)
105 return 0
106
107 case "set":
108 body, err := io.ReadAll(io.LimitReader(ch, maxSecretInput+1))
109 if err != nil {
110 fmt.Fprintf(ch, "vole: %v\n", err)
111 return 1
112 }
113 if len(body) > maxSecretInput {
114 fmt.Fprintf(ch, "vole: %s is larger than one environment variable can hold (%d bytes)\n",
115 secretName, secret.MaxEntryBytes)
116 return 1
117 }
118 version, err := store.Set(m.ID, secretName, string(body), user)
119 if err != nil {
120 fmt.Fprintf(ch, "vole: %v\n", err)
121 return 1
122 }
123 fmt.Fprintf(ch, "saved %s v%d for %s\n", secretName, version, name)
124 return 0
125
126 case "rm":
127 if err := store.Delete(m.ID, secretName, user); err != nil {
128 fmt.Fprintf(ch, "vole: %v\n", secretProblem(err, secretName, name))
129 return 1
130 }
131 fmt.Fprintf(ch, "deleted %s from %s\n", secretName, name)
132 return 0
133 }
134 fmt.Fprintf(ch, "vole: %s is not a secret command\n", op)
135 return 129
136 }
137
138 // secretDetail says what a list line is about: which version this is, who wrote
139 // it, and whether anybody has read it.
140 func secretDetail(info secret.Info) string {
141 read := "never read"
142 if !info.LastRead.IsZero() {
143 read = "last read " + info.LastRead.Format(time.RFC3339)
144 }
145 return fmt.Sprintf("updated %s by %s, %s", info.Updated.Format("2006-01-02 15:04"), info.UpdatedBy, read)
146 }
147
148 // secretProblem turns the store's two refusals into the sentence a person needed:
149 // a missing name is about this repository, and everything else is passed through.
150 func secretProblem(err error, secretName, repoName string) string {
151 if errors.Is(err, secret.ErrNotFound) {
152 return fmt.Sprintf("%s has no secret %s", repoName, secretName)
153 }
154 return err.Error()
155 }
4ac42680
Keenan Nemetz
7d ago
156
157 // leaseSecrets resolves what a leased job declared. The queue row holds names, so
158 // this is the one place a value leaves the store on its way to a job, and the store
159 // records the read against the account holding the lease.
160 //
161 // A name the repository has no value for is not a failure. The job runs without
162 // that variable and the runner says which one it went without, because a workflow
163 // is allowed to have an optional secret and a required one is the step's
164 // ${NAME:?}. A value that cannot be read for another reason — a database restored
165 // beside a new key — is reported the same way, with the reason, so the job is not
166 // stopped by an instance problem it cannot fix.
167 func (s *Server) leaseSecrets(run *runs.Run, actor string) (map[string]runnerSecret, []runnerSecretIssue) {
168 if len(run.Secrets) == 0 {
169 return nil, nil
170 }
171 var store *secret.Store
172 if s.cfg.Instance != nil {
173 store = s.cfg.Instance.Secrets
174 }
175 meta, metaErr := s.cfg.Repos.Meta(run.Repo)
176 delivered := make(map[string]runnerSecret, len(run.Secrets))
177 var missing []runnerSecretIssue
178 for _, name := range run.Secrets {
179 switch {
180 case metaErr != nil:
181 missing = append(missing, runnerSecretIssue{Name: name, Reason: metaErr.Error()})
182 case store == nil:
183 missing = append(missing, runnerSecretIssue{Name: name, Reason: "this instance has no secret store"})
184 default:
185 value, version, err := store.Read(meta.ID, name, actor, "run="+run.ID)
186 switch {
187 case errors.Is(err, secret.ErrNotFound):
188 missing = append(missing, runnerSecretIssue{Name: name})
189 case err != nil:
190 missing = append(missing, runnerSecretIssue{Name: name, Reason: err.Error()})
191 default:
192 delivered[name] = runnerSecret{Version: version, Value: value}
193 }
194 }
195 }
196 return delivered, missing
197 }