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 · 23m ago 4b8c9e1d
history blame raw 15.2 KiB
1package client
2
3import (
4 "errors"
5 "fmt"
6 "io"
7 "net/url"
8 "os"
9 "os/exec"
10 "sort"
11 "strings"
12
13 voleid "rodent.software/vole/core/id"
14 "rodent.software/vole/core/role"
15)
16
17// HostKeys is how this process verifies the SSH host key of the instance it talks
18// to. The zero value is strict: ssh checks the key against the caller's known_hosts
19// and refuses a host it has not been told to trust. A loose known_hosts, or a
20// command line that disables the check, is what lets a man in the middle feed a
21// runner jobs — the runner executes repository code, so the host it trusts is a
22// security boundary, not a convenience.
23type HostKeys struct {
24 // KnownHosts is a known_hosts file to check against. Empty uses ssh's own
25 // defaults (~/.ssh/known_hosts and the system file).
26 KnownHosts string
27 // Insecure turns verification off. It exists for tests and for a disposable
28 // development instance, and a caller that sets it is saying out loud that it
29 // does not care which host answers.
30 Insecure bool
31}
32
33var hostKeys HostKeys
34
35// SetHostKeys replaces the host-key policy for this process. It is expected to be
36// called once, before any command that talks to an instance.
37func SetHostKeys(h HostKeys) { hostKeys = h }
38
39// HostKeysFromEnv reads VOLE_KNOWN_HOSTS and VOLE_INSECURE_HOST_KEY, which are how
40// a command that has no flag of its own opts into a policy.
41func HostKeysFromEnv() HostKeys {
42 h := HostKeys{KnownHosts: strings.TrimSpace(os.Getenv("VOLE_KNOWN_HOSTS"))}
43 switch strings.ToLower(strings.TrimSpace(os.Getenv("VOLE_INSECURE_HOST_KEY"))) {
44 case "1", "true", "yes":
45 h.Insecure = true
46 }
47 return h
48}
49
50// HostKeyArgs are the ssh options that apply the current policy.
51func HostKeyArgs() []string {
52 if hostKeys.Insecure {
53 return []string{"-o", "StrictHostKeyChecking=no", "-o", "UserKnownHostsFile=/dev/null"}
54 }
55 args := []string{"-o", "StrictHostKeyChecking=yes"}
56 if hostKeys.KnownHosts != "" {
57 args = append(args, "-o", "UserKnownHostsFile="+hostKeys.KnownHosts)
58 }
59 return args
60}
61
62// GitSSHCommand is the GIT_SSH_COMMAND that gives git's own ssh calls the same
63// policy, keeping whatever program and key options the operator configured. A
64// command sets it for its children, which is how a checkout, a push and an
65// ssh-keyscan in a workflow all verify the same host.
66func GitSSHCommand() string {
67 base := []string{"ssh"}
68 if v := strings.TrimSpace(os.Getenv("GIT_SSH_COMMAND")); v != "" {
69 base = strings.Fields(v)
70 } else if v := strings.TrimSpace(os.Getenv("VOLE_SSH_COMMAND")); v != "" {
71 base = strings.Fields(v)
72 }
73 parts := append([]string{base[0]}, HostKeyArgs()...)
74 parts = append(parts, base[1:]...)
75 return strings.Join(parts, " ")
76}
77
78// SSHCommand builds an ssh invocation for host, honouring GIT_SSH_COMMAND (which
79// git itself uses, and which a runner therefore already sets) and then
80// VOLE_SSH_COMMAND. The returned command's stderr is left for the caller.
81//
82// The host-key options go in before the configured ones: ssh takes the first value
83// it is given for an option, so an environment variable cannot loosen the check by
84// adding StrictHostKeyChecking=no after it. The explicit HostKeys policy is the only
85// way to do that.
86func SSHCommand(host, port string, args ...string) *exec.Cmd {
87 base := []string{"ssh"}
88 if v := strings.TrimSpace(os.Getenv("GIT_SSH_COMMAND")); v != "" {
89 base = strings.Fields(v)
90 } else if v := strings.TrimSpace(os.Getenv("VOLE_SSH_COMMAND")); v != "" {
91 base = strings.Fields(v)
92 }
93 argv := append([]string{}, HostKeyArgs()...)
94 argv = append(argv, base[1:]...)
95 argv = append(argv, "-T", "-o", "BatchMode=yes")
96 if port != "" {
97 argv = append(argv, "-p", port)
98 }
99 argv = append(argv, host)
100 argv = append(argv, args...)
101 return exec.Command(base[0], argv...)
102}
103
104// ParseSSHRemote turns a git remote URL into the pieces ssh needs. It accepts
105// the URL form (ssh://git@host:port/repo.git) and git's scp-like shorthand
106// (git@host:repo.git), because a clone URL may be either.
107func ParseSSHRemote(remote string) (host, port, path string, err error) {
108 if !strings.Contains(remote, "://") {
109 i := strings.Index(remote, ":")
110 if i < 0 {
111 return "", "", "", fmt.Errorf("%q is not an SSH remote", remote)
112 }
113 user, h := remote[:i], remote[i+1:]
114 if user == "" || h == "" {
115 return "", "", "", fmt.Errorf("%q is not an SSH remote", remote)
116 }
117 return user, "", "/" + strings.TrimPrefix(h, "/"), nil
118 }
119 u, err := url.Parse(remote)
120 if err != nil {
121 return "", "", "", fmt.Errorf("%q is not a URL: %w", remote, err)
122 }
123 if u.Scheme != "ssh" {
124 return "", "", "", fmt.Errorf("%q is not an SSH remote: the instance answers over SSH only, "+
125 "so add an ssh one and pass --remote", remote)
126 }
127 if u.Hostname() == "" {
128 return "", "", "", fmt.Errorf("%q has no host", remote)
129 }
130 user := u.User.Username()
131 if user == "" {
132 user = "git"
133 }
134 p := u.Path
135 if p == "" {
136 return "", "", "", fmt.Errorf("%q names no repository", remote)
137 }
138 return user + "@" + u.Hostname(), u.Port(), p, nil
139}
140
141// SSHTarget turns a remote name or an ssh:// URL into what ssh needs, plus the
142// path the remote names. Both forms have to work: `--remote ssh://…` is how a
143// repository with no configured remote writes at all.
144func SSHTarget(gitDir, remote string) (host, port, path string, err error) {
145 target, err := RemoteURL(gitDir, remote)
146 if err != nil {
147 return "", "", "", err
148 }
149 return ParseSSHRemote(target)
150}
151
152// RunSSH runs one of the instance's read-only verbs over the key the caller
153// already pushes with. No new endpoint, no second credential.
154func RunSSH(host, port, verb string) (string, error) {
155 return runSSH(host, port, verb, nil)
156}
157
158// runSSH is RunSSH with an optional stdin. The secret verbs carry their value
159// this way: an exec line is visible to every process on both hosts, and the
160// instance keeps the command it ran.
161func runSSH(host, port, verb string, stdin io.Reader) (string, error) {
162 cmd := SSHCommand(host, port, verb)
163 cmd.Stdin = stdin
164 var out, stderr strings.Builder
165 cmd.Stdout, cmd.Stderr = &out, &stderr
166 if err := cmd.Run(); err != nil {
167 // A refusal is written to the channel by the server, so the useful reason
168 // is on stdout with a "vole:" prefix; ssh's own complaints are on stderr.
169 if reason, ok := strings.CutPrefix(strings.TrimSpace(out.String()), "vole:"); ok {
170 return "", errors.New(strings.TrimSpace(reason))
171 }
172 return "", fmt.Errorf("%s: %w: %s", host, err, strings.TrimSpace(stderr.String()))
173 }
174 // The output is returned as it arrived. A verb that answers with a value must
175 // not have a byte trimmed off it, and a verb that answers with lines has its
176 // whitespace handled by the caller that knows what the lines are.
177 return out.String(), nil
178}
179
180// Roster is the client's read of the instance's user roster: the active account
181// names. The roster is a server-side SQLite database, so the client asks the
182// instance for the one view it needs over the same key-authenticated SSH channel
183// it already pushes with (`ssh host vole-roster`).
184type Roster struct {
185 names map[string]bool
186}
187
188// LoadRoster asks the instance for its roster and indexes the names.
189func LoadRoster(gitDir, remote string) (*Roster, error) {
190 host, port, _, err := SSHTarget(gitDir, remote)
191 if err != nil {
192 return nil, err
193 }
194 out, err := RunSSH(host, port, "vole-roster")
195 if err != nil {
196 return nil, fmt.Errorf("cannot read the instance roster from %s: %w", host, err)
197 }
198 r := &Roster{names: map[string]bool{}}
199 for _, line := range strings.Split(strings.TrimSpace(out), "\n") {
200 line = strings.TrimSpace(line)
201 if line == "" {
202 continue
203 }
204 // A roster row is one name. Taking the last field also accepts a line
205 // from an instance that still prints two columns.
206 f := strings.Fields(line)
207 if len(f) == 0 {
208 continue
209 }
210 r.names[f[len(f)-1]] = true
211 }
212 return r, nil
213}
214
215// Has reports whether name is an active account.
216func (r *Roster) Has(name string) bool {
217 return r != nil && r.names[name]
218}
219
220// Actor is who a write will be attributed to. The name and URL come from the
221// instance; Repo comes from the remote the command was given.
222type Actor struct {
223 Name, URL, Repo string
224 // Guessed means the instance could not be asked and the name came from git
225 // config. It is never silent: the caller warns, and commands that need an
226 // active account (`vole change new`) refuse instead of writing a random name.
227 Guessed bool
228}
229
230// AskIdentity runs `vole-whoami` and returns the account behind the key that
231// just authenticated, plus where the instance is served (empty when public_url
232// is unset). This is the only correct source of "who am I": git config holds a
233// name somebody typed.
234func AskIdentity(gitDir, remote string) (*Actor, error) {
235 host, port, path, err := SSHTarget(gitDir, remote)
236 if err != nil {
237 return nil, err
238 }
239 out, err := RunSSH(host, port, "vole-whoami")
240 if err != nil {
241 return nil, fmt.Errorf("cannot ask %s who you are: %w", host, err)
242 }
243 f := strings.SplitN(strings.TrimSpace(out), "\t", 2)
244 if len(f) < 1 || f[0] == "" {
245 return nil, fmt.Errorf("whoami: cannot parse %q", strings.TrimSpace(out))
246 }
247 a := &Actor{Name: f[0], Repo: RepoNameFromPath(path)}
248 if len(f) == 2 {
249 a.URL = strings.TrimRight(f[1], "/")
250 }
251 return a, nil
252}
253
254// AskRole asks the instance for the effective role the key holds on the
255// repository named by remote. The default whoami answer stays the account name
256// alone; this is the explicit query for the role that decides where the account
257// may push and whether it may approve.
258func AskRole(gitDir, remote string) (role.Role, error) {
259 host, port, path, err := SSHTarget(gitDir, remote)
260 if err != nil {
261 return role.None, err
262 }
263 if RepoNameFromPath(path) == "" {
264 return role.None, fmt.Errorf("%s does not name a repository", remote)
265 }
266 out, err := RunSSH(host, port, "vole-whoami "+quoteSSHArg(path))
267 if err != nil {
268 return role.None, err
269 }
270 fields := strings.Split(strings.TrimSpace(out), "\t")
271 if len(fields) < 3 {
272 return role.None, fmt.Errorf("whoami: the instance did not answer with a role; it may be older than this client")
273 }
274 r, err := role.Parse(fields[2])
275 if err != nil {
276 return role.None, fmt.Errorf("whoami: %w", err)
277 }
278 return r, nil
279}
280
281// ResolveActor asks the instance who the pushing key is, falling back to the name
282// git config suggests so that `--no-push` on a train still commits something. The
283// fallback is loudly reported: a wrong author is worse than a slow command.
284func ResolveActor(b *Branch) (*Actor, error) {
285 a, err := AskIdentity(b.Repo.Dir, b.Remote)
286 if err == nil {
287 return a, nil
288 }
289 fmt.Fprintf(os.Stderr, "vole: %v\nvole: committing as %q from git config; run `vole whoami` once you are online\n",
290 err, b.Author)
291 return &Actor{Name: b.Author, Repo: b.RepoName(), Guessed: true}, err
292}
293
294// Page is where a browser reads what a command just wrote, or "" when the
295// operator has not told the instance where it is served.
296func (a *Actor) Page(kind, id, anchor string) string {
297 if a == nil || a.URL == "" || a.Repo == "" {
298 return ""
299 }
300 u := fmt.Sprintf("%s/%s/%s/%s", a.URL, url.PathEscape(a.Repo), kind, id)
301 if anchor != "" {
302 u += "#" + anchor
303 }
304 return u
305}
306
307// ChangeStatus asks the instance whether the combined result for a change request
308// has been tested. It answers one line per fact ("combined: ...", "merge: ..."),
309// which the show command prints as it received them.
310func ChangeStatus(gitDir, remote, id string) (string, error) {
311 host, port, path, err := SSHTarget(gitDir, remote)
312 if err != nil {
313 return "", err
314 }
315 if RepoNameFromPath(path) == "" {
316 return "", fmt.Errorf("%s does not name a repository", remote)
317 }
318 out, err := RunSSH(host, port, "vole-change-status "+path+" "+id)
319 if err != nil {
320 return "", err
321 }
322 return strings.TrimSpace(out), nil
323}
324
325// MergeChange asks the instance to land a change request, and returns the server's
326// one-line answer.
327//
328// Landing moves a branch, so it cannot be done from a clone: the rules that decide
329// it — the repository's ACL, the approvals filed against the request, and the run
330// results — live in the instance. The CLI asks over the key it already pushes with,
331// and the server runs the same operation the browser's merge button runs.
332func MergeChange(gitDir, remote, id string) (string, error) {
333 host, port, path, err := SSHTarget(gitDir, remote)
334 if err != nil {
335 return "", err
336 }
337 if RepoNameFromPath(path) == "" {
338 return "", fmt.Errorf("%s does not name a repository", remote)
339 }
340 // The path goes as the remote names it ("/demo.git"), which is the shape the
341 // server's own repository-path check accepts for the git verbs. One check, one
342 // shape, so a name that would be refused on a push is refused here too.
343 out, err := RunSSH(host, port, "vole-change-merge "+path+" "+id)
344 if err != nil {
345 return "", err
346 }
347 return strings.TrimSpace(out), nil
348}
349
350// CancelRun cancels one queued run, or asks a running one to stop. The queue lives
351// on the instance, so the CLI asks over the key it pushes with; the server checks the
352// maintainer capability, records the audit line, and says which of the two happened.
353// The id is the 26-character one the run page shows in its URL.
354func CancelRun(gitDir, remote, runID string) (string, error) {
355 return runVerb(gitDir, remote, runID, "vole-run-cancel")
356}
357
358// RerunRun queues a finished run again with the same pinned job. Attempts keep their
359// history; the run is a rerun of the job it already named, not a new row. The server
360// checks the maintainer capability and records the audit line.
361func RerunRun(gitDir, remote, runID string) (string, error) {
362 return runVerb(gitDir, remote, runID, "vole-run-rerun")
363}
364
365// runVerb resolves the instance and repository for one run-management verb and runs
366// it. The caller has already decided which verb; this is only the wire.
367func runVerb(gitDir, remote, runID, verb string) (string, error) {
368 runID = strings.ToUpper(strings.TrimSpace(runID))
369 if !voleid.Valid(runID) {
370 return "", fmt.Errorf("%q is not a run id; use the 26-character id from the run page", runID)
371 }
372 host, port, path, err := SSHTarget(gitDir, remote)
373 if err != nil {
374 return "", err
375 }
376 if RepoNameFromPath(path) == "" {
377 return "", fmt.Errorf("%s does not name a repository", remote)
378 }
379 out, err := RunSSH(host, port, verb+" "+quoteSSHArg(path)+" "+quoteSSHArg(runID))
380 if err != nil {
381 return "", err
382 }
383 return strings.TrimSpace(out), nil
384}
385
386// DispatchWorkflow queues a manual run of a workflow that declares
387// workflow_dispatch. It is a write to the instance's queue, so the CLI asks over
388// the same key-authenticated channel it pushes with; the server resolves the
389// workflow, validates the inputs and checks the maintainer capability.
390func DispatchWorkflow(gitDir, remote, workflow, ref string, inputs map[string]string) (string, error) {
391 host, port, path, err := SSHTarget(gitDir, remote)
392 if err != nil {
393 return "", err
394 }
395 if RepoNameFromPath(path) == "" {
396 return "", fmt.Errorf("%s does not name a repository", remote)
397 }
398 if ref == "" {
399 ref = "-"
400 }
401 args := []string{"vole-workflow-dispatch", path, workflow, ref}
402 names := make([]string, 0, len(inputs))
403 for name := range inputs {
404 names = append(names, name)
405 }
406 sort.Strings(names)
407 for _, name := range names {
408 args = append(args, name+"="+inputs[name])
409 }
410 quoted := make([]string, len(args))
411 for i, arg := range args {
412 quoted[i] = quoteSSHArg(arg)
413 }
414 out, err := RunSSH(host, port, strings.Join(quoted, " "))
415 if err != nil {
416 return "", err
417 }
418 return strings.TrimSpace(out), nil
419}
420