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.

Code

395 commit(s) on main

update readme Keenan Nemetz · 26m ago 32290114
history blame raw 20.5 KiB
1package client
2
3import (
4 "errors"
5 "fmt"
6 "io"
7 "net/url"
8 "os"
9 "os/exec"
10 "sort"
11 "strconv"
12 "strings"
13 "time"
14
15 voleid "rodent.software/vole/core/id"
16 "rodent.software/vole/core/role"
17)
18
19// HostKeys is how this process verifies the SSH host key of the instance it talks
20// to. The zero value is strict: ssh checks the key against the caller's known_hosts
21// and refuses a host it has not been told to trust. A loose known_hosts, or a
22// command line that disables the check, is what lets a man in the middle feed a
23// runner jobs — the runner executes repository code, so the host it trusts is a
24// security boundary, not a convenience.
25type HostKeys struct {
26 // KnownHosts is a known_hosts file to check against. Empty uses ssh's own
27 // defaults (~/.ssh/known_hosts and the system file).
28 KnownHosts string
29 // Insecure turns verification off. It exists for tests and for a disposable
30 // development instance, and a caller that sets it is saying out loud that it
31 // does not care which host answers.
32 Insecure bool
33}
34
35var hostKeys HostKeys
36
37// SetHostKeys replaces the host-key policy for this process. It is expected to be
38// called once, before any command that talks to an instance.
39func SetHostKeys(h HostKeys) { hostKeys = h }
40
41// HostKeysFromEnv reads VOLE_KNOWN_HOSTS and VOLE_INSECURE_HOST_KEY, which are how
42// a command that has no flag of its own opts into a policy.
43func HostKeysFromEnv() HostKeys {
44 h := HostKeys{KnownHosts: strings.TrimSpace(os.Getenv("VOLE_KNOWN_HOSTS"))}
45 switch strings.ToLower(strings.TrimSpace(os.Getenv("VOLE_INSECURE_HOST_KEY"))) {
46 case "1", "true", "yes":
47 h.Insecure = true
48 }
49 return h
50}
51
52// HostKeyArgs are the ssh options that apply the current policy.
53func HostKeyArgs() []string {
54 if hostKeys.Insecure {
55 return []string{"-o", "StrictHostKeyChecking=no", "-o", "UserKnownHostsFile=/dev/null"}
56 }
57 args := []string{"-o", "StrictHostKeyChecking=yes"}
58 if hostKeys.KnownHosts != "" {
59 args = append(args, "-o", "UserKnownHostsFile="+hostKeys.KnownHosts)
60 }
61 return args
62}
63
64// GitSSHCommand is the GIT_SSH_COMMAND that gives git's own ssh calls the same
65// policy, keeping whatever program and key options the operator configured. A
66// command sets it for its children, which is how a checkout, a push and an
67// ssh-keyscan in a workflow all verify the same host.
68func GitSSHCommand() string {
69 base := []string{"ssh"}
70 if v := strings.TrimSpace(os.Getenv("GIT_SSH_COMMAND")); v != "" {
71 base = strings.Fields(v)
72 } else if v := strings.TrimSpace(os.Getenv("VOLE_SSH_COMMAND")); v != "" {
73 base = strings.Fields(v)
74 }
75 parts := append([]string{base[0]}, HostKeyArgs()...)
76 parts = append(parts, base[1:]...)
77 return strings.Join(parts, " ")
78}
79
80// SSHCommand builds an ssh invocation for host, honouring GIT_SSH_COMMAND (which
81// git itself uses, and which a runner therefore already sets) and then
82// VOLE_SSH_COMMAND. The returned command's stderr is left for the caller.
83//
84// The host-key options go in before the configured ones: ssh takes the first value
85// it is given for an option, so an environment variable cannot loosen the check by
86// adding StrictHostKeyChecking=no after it. The explicit HostKeys policy is the only
87// way to do that.
88func SSHCommand(host, port string, args ...string) *exec.Cmd {
89 base := []string{"ssh"}
90 if v := strings.TrimSpace(os.Getenv("GIT_SSH_COMMAND")); v != "" {
91 base = strings.Fields(v)
92 } else if v := strings.TrimSpace(os.Getenv("VOLE_SSH_COMMAND")); v != "" {
93 base = strings.Fields(v)
94 }
95 argv := append([]string{}, HostKeyArgs()...)
96 argv = append(argv, base[1:]...)
97 argv = append(argv, "-T", "-o", "BatchMode=yes")
98 if port != "" {
99 argv = append(argv, "-p", port)
100 }
101 argv = append(argv, host)
102 argv = append(argv, args...)
103 return exec.Command(base[0], argv...)
104}
105
106// ParseSSHRemote turns a git remote URL into the pieces ssh needs. It accepts
107// the URL form (ssh://git@host:port/repo.git) and git's scp-like shorthand
108// (git@host:repo.git), because a clone URL may be either.
109func ParseSSHRemote(remote string) (host, port, path string, err error) {
110 if !strings.Contains(remote, "://") {
111 i := strings.Index(remote, ":")
112 if i < 0 {
113 return "", "", "", fmt.Errorf("%q is not an SSH remote", remote)
114 }
115 user, h := remote[:i], remote[i+1:]
116 if user == "" || h == "" {
117 return "", "", "", fmt.Errorf("%q is not an SSH remote", remote)
118 }
119 return user, "", "/" + strings.TrimPrefix(h, "/"), nil
120 }
121 u, err := url.Parse(remote)
122 if err != nil {
123 return "", "", "", fmt.Errorf("%q is not a URL: %w", remote, err)
124 }
125 if u.Scheme != "ssh" {
126 return "", "", "", fmt.Errorf("%q is not an SSH remote: the instance answers over SSH only, "+
127 "so add an ssh one and pass --remote", remote)
128 }
129 if u.Hostname() == "" {
130 return "", "", "", fmt.Errorf("%q has no host", remote)
131 }
132 user := u.User.Username()
133 if user == "" {
134 user = "git"
135 }
136 p := u.Path
137 if p == "" {
138 return "", "", "", fmt.Errorf("%q names no repository", remote)
139 }
140 return user + "@" + u.Hostname(), u.Port(), p, nil
141}
142
143// SSHTarget turns a remote name or an ssh:// URL into what ssh needs, plus the
144// path the remote names. Both forms have to work: `--remote ssh://…` is how a
145// repository with no configured remote writes at all.
146func SSHTarget(gitDir, remote string) (host, port, path string, err error) {
147 target, err := RemoteURL(gitDir, remote)
148 if err != nil {
149 return "", "", "", err
150 }
151 return ParseSSHRemote(target)
152}
153
154// RunSSH runs one of the instance's read-only verbs over the key the caller
155// already pushes with. No new endpoint, no second credential.
156func RunSSH(host, port, verb string) (string, error) {
157 return runSSH(host, port, verb, nil)
158}
159
160// runSSH is RunSSH with an optional stdin. The secret verbs carry their value
161// this way: an exec line is visible to every process on both hosts, and the
162// instance keeps the command it ran.
163func runSSH(host, port, verb string, stdin io.Reader) (string, error) {
164 cmd := SSHCommand(host, port, verb)
165 cmd.Stdin = stdin
166 var out, stderr strings.Builder
167 cmd.Stdout, cmd.Stderr = &out, &stderr
168 if err := cmd.Run(); err != nil {
169 // A refusal is written to the channel by the server, so the useful reason
170 // is on stdout with a "vole:" prefix; ssh's own complaints are on stderr.
171 if reason, ok := strings.CutPrefix(strings.TrimSpace(out.String()), "vole:"); ok {
172 return "", errors.New(strings.TrimSpace(reason))
173 }
174 return "", fmt.Errorf("%s: %w: %s", host, err, strings.TrimSpace(stderr.String()))
175 }
176 // The output is returned as it arrived. A verb that answers with a value must
177 // not have a byte trimmed off it, and a verb that answers with lines has its
178 // whitespace handled by the caller that knows what the lines are.
179 return out.String(), nil
180}
181
182// Roster is the client's read of the instance's user roster: the active account
183// names. The roster is a server-side SQLite database, so the client asks the
184// instance for the one view it needs over the same key-authenticated SSH channel
185// it already pushes with (`ssh host vole-roster`).
186type Roster struct {
187 names map[string]bool
188}
189
190// LoadRoster asks the instance for its roster and indexes the names.
191func LoadRoster(gitDir, remote string) (*Roster, error) {
192 host, port, _, err := SSHTarget(gitDir, remote)
193 if err != nil {
194 return nil, err
195 }
196 out, err := RunSSH(host, port, "vole-roster")
197 if err != nil {
198 return nil, fmt.Errorf("cannot read the instance roster from %s: %w", host, err)
199 }
200 r := &Roster{names: map[string]bool{}}
201 for _, line := range strings.Split(strings.TrimSpace(out), "\n") {
202 line = strings.TrimSpace(line)
203 if line == "" {
204 continue
205 }
206 // A roster row is one name. Taking the last field also accepts a line
207 // from an instance that still prints two columns.
208 f := strings.Fields(line)
209 if len(f) == 0 {
210 continue
211 }
212 r.names[f[len(f)-1]] = true
213 }
214 return r, nil
215}
216
217// Has reports whether name is an active account.
218func (r *Roster) Has(name string) bool {
219 return r != nil && r.names[name]
220}
221
222// Actor is who a write will be attributed to. The name and URL come from the
223// instance; Repo comes from the remote the command was given.
224type Actor struct {
225 Name, URL, Repo string
226 // Guessed means the instance could not be asked and the name came from git
227 // config. It is never silent: the caller warns, and commands that need an
228 // active account (`vole change new`) refuse instead of writing a random name.
229 Guessed bool
230}
231
232// AskIdentity runs `vole-whoami` and returns the account behind the key that
233// just authenticated, plus where the instance is served (empty when public_url
234// is unset). This is the only correct source of "who am I": git config holds a
235// name somebody typed.
236func AskIdentity(gitDir, remote string) (*Actor, error) {
237 host, port, path, err := SSHTarget(gitDir, remote)
238 if err != nil {
239 return nil, err
240 }
241 out, err := RunSSH(host, port, "vole-whoami")
242 if err != nil {
243 return nil, fmt.Errorf("cannot ask %s who you are: %w", host, err)
244 }
245 f := strings.SplitN(strings.TrimSpace(out), "\t", 2)
246 if len(f) < 1 || f[0] == "" {
247 return nil, fmt.Errorf("whoami: cannot parse %q", strings.TrimSpace(out))
248 }
249 a := &Actor{Name: f[0], Repo: RepoNameFromPath(path)}
250 if len(f) == 2 {
251 a.URL = strings.TrimRight(f[1], "/")
252 }
253 return a, nil
254}
255
256// AskRole asks the instance for the effective role the key holds on the
257// repository named by remote. The default whoami answer stays the account name
258// alone; this is the explicit query for the role that decides where the account
259// may push and whether it may approve.
260func AskRole(gitDir, remote string) (role.Role, error) {
261 host, port, path, err := SSHTarget(gitDir, remote)
262 if err != nil {
263 return role.None, err
264 }
265 if RepoNameFromPath(path) == "" {
266 return role.None, fmt.Errorf("%s does not name a repository", remote)
267 }
268 out, err := RunSSH(host, port, "vole-whoami "+quoteSSHArg(path))
269 if err != nil {
270 return role.None, err
271 }
272 fields := strings.Split(strings.TrimSpace(out), "\t")
273 if len(fields) < 3 {
274 return role.None, fmt.Errorf("whoami: the instance did not answer with a role; it may be older than this client")
275 }
276 r, err := role.Parse(fields[2])
277 if err != nil {
278 return role.None, fmt.Errorf("whoami: %w", err)
279 }
280 return r, nil
281}
282
283// ResolveActor asks the instance who the pushing key is, falling back to the name
284// git config suggests so that `--no-push` on a train still commits something. The
285// fallback is loudly reported: a wrong author is worse than a slow command.
286func ResolveActor(b *Branch) (*Actor, error) {
287 a, err := AskIdentity(b.Repo.Dir, b.Remote)
288 if err == nil {
289 return a, nil
290 }
291 fmt.Fprintf(os.Stderr, "vole: %v\nvole: committing as %q from git config; run `vole whoami` once you are online\n",
292 err, b.Author)
293 return &Actor{Name: b.Author, Repo: b.RepoName(), Guessed: true}, err
294}
295
296// Page is where a browser reads what a command just wrote, or "" when the
297// operator has not told the instance where it is served.
298func (a *Actor) Page(kind, id, anchor string) string {
299 if a == nil || a.URL == "" || a.Repo == "" {
300 return ""
301 }
302 u := fmt.Sprintf("%s/%s/%s/%s", a.URL, url.PathEscape(a.Repo), kind, id)
303 if anchor != "" {
304 u += "#" + anchor
305 }
306 return u
307}
308
309// ChangeStatus asks the instance whether the combined result for a change request
310// has been tested. It answers one line per fact ("combined: ...", "merge: ..."),
311// which the show command prints as it received them.
312func ChangeStatus(gitDir, remote, id string) (string, error) {
313 host, port, path, err := SSHTarget(gitDir, remote)
314 if err != nil {
315 return "", err
316 }
317 if RepoNameFromPath(path) == "" {
318 return "", fmt.Errorf("%s does not name a repository", remote)
319 }
320 out, err := RunSSH(host, port, "vole-change-status "+path+" "+id)
321 if err != nil {
322 return "", err
323 }
324 return strings.TrimSpace(out), nil
325}
326
327// MergeChange asks the instance to land a change request, and returns the server's
328// one-line answer.
329//
330// Landing moves a branch, so it cannot be done from a clone: the rules that decide
331// it — the repository's ACL, the approvals filed against the request, and the run
332// results — live in the instance. The CLI asks over the key it already pushes with,
333// and the server runs the same operation the browser's merge button runs.
334func MergeChange(gitDir, remote, id string) (string, error) {
335 host, port, path, err := SSHTarget(gitDir, remote)
336 if err != nil {
337 return "", err
338 }
339 if RepoNameFromPath(path) == "" {
340 return "", fmt.Errorf("%s does not name a repository", remote)
341 }
342 // The path goes as the remote names it ("/demo.git"), which is the shape the
343 // server's own repository-path check accepts for the git verbs. One check, one
344 // shape, so a name that would be refused on a push is refused here too.
345 out, err := RunSSH(host, port, "vole-change-merge "+path+" "+id)
346 if err != nil {
347 return "", err
348 }
349 return strings.TrimSpace(out), nil
350}
351
352// CancelRun cancels one queued run, or asks a running one to stop. The queue lives
353// on the instance, so the CLI asks over the key it pushes with; the server checks the
354// maintainer capability, records the audit line, and says which of the two happened.
355// The id is the 26-character one the run page shows in its URL.
356func CancelRun(gitDir, remote, runID string) (string, error) {
357 return runVerb(gitDir, remote, runID, "vole-run-cancel")
358}
359
360// RerunRun queues a finished run again with the same pinned job. Attempts keep their
361// history; the run is a rerun of the job it already named, not a new row. The server
362// checks the maintainer capability and records the audit line.
363func RerunRun(gitDir, remote, runID string) (string, error) {
364 return runVerb(gitDir, remote, runID, "vole-run-rerun")
365}
366
367// RunSummary is one row of `vole run list`. The server answers with the fields in
368// this order, tab-separated; the CLI formats them. Keeping the wire this small means
369// the listing does not grow a second serialization of the queue's row.
370type RunSummary struct {
371 ID string
372 Status string
373 // Workflow and Job are the stable keys. The titles are pinned with the run, but a
374 // line is a place for the thing a command takes, not a paragraph.
375 Workflow string
376 Job string
377 Branch string
378 SHA string
379 Trigger string
380 // TriggerTitle is Trigger in the words the web uses ("pushed to main").
381 TriggerTitle string
382 ExitCode int
383 Attempts int
384 HasLog bool
385 Created time.Time
386}
387
388// listRunFields is the number of tab-separated fields serveRunList writes. A change
389// to one side without the other is a parse error rather than a silently wrong column.
390const listRunFields = 12
391
392// ListRuns asks the instance for the newest runs in this repository. status, branch
393// and sha are filters; an empty string means no filter. The server bounds limit.
394func ListRuns(gitDir, remote, status, branch, sha string, limit int) ([]RunSummary, error) {
395 host, port, path, err := SSHTarget(gitDir, remote)
396 if err != nil {
397 return nil, err
398 }
399 if RepoNameFromPath(path) == "" {
400 return nil, fmt.Errorf("%s does not name a repository", remote)
401 }
402 args := []string{"vole-run-list", path,
403 valueOrDash(status), valueOrDash(branch), valueOrDash(sha), strconv.Itoa(limit)}
404 quoted := make([]string, len(args))
405 for i, arg := range args {
406 quoted[i] = quoteSSHArg(arg)
407 }
408 out, err := RunSSH(host, port, strings.Join(quoted, " "))
409 if err != nil {
410 return nil, err
411 }
412 return parseRunSummaries(out)
413}
414
415// ShowRun asks the instance for one run the way its page would answer. The result is
416// display text, like `vole change show`'s status block: the server owns the words so
417// the CLI and the browser cannot disagree about what a run did.
418func ShowRun(gitDir, remote, runID string) (string, error) {
419 host, port, command, err := runCommand(gitDir, remote, runID, "vole-run-show")
420 if err != nil {
421 return "", err
422 }
423 out, err := RunSSH(host, port, command)
424 if err != nil {
425 return "", err
426 }
427 return strings.TrimRight(out, "\n"), nil
428}
429
430// StreamRunLog copies one attempt's log to w. attempt 0 means the attempt the run is
431// on (or its newest logged one), and step 0 means the whole attempt. The bytes are
432// written as they arrive, so a large log is not held in memory just to be printed;
433// a refusal arrives on stderr and never at the top of the log.
434func StreamRunLog(gitDir, remote, runID string, attempt, step int, w io.Writer) error {
435 host, port, command, err := runCommand(gitDir, remote, runID, "vole-run-log",
436 strconv.Itoa(attempt), strconv.Itoa(step))
437 if err != nil {
438 return err
439 }
440 cmd := SSHCommand(host, port, command)
441 cmd.Stdout = w
442 var stderr strings.Builder
443 cmd.Stderr = &stderr
444 if err := cmd.Run(); err != nil {
445 reason := strings.TrimSpace(stderr.String())
446 if msg, ok := strings.CutPrefix(reason, "vole:"); ok {
447 return errors.New(strings.TrimSpace(msg))
448 }
449 if reason != "" {
450 return fmt.Errorf("%s: %w: %s", host, err, reason)
451 }
452 return fmt.Errorf("%s: %w", host, err)
453 }
454 return nil
455}
456
457// parseRunSummaries reads the tab-separated rows serveRunList writes. An answer with
458// the wrong width is a protocol mismatch, not an empty listing, because dropping it
459// would make an upgraded server look like a repository with no runs.
460func parseRunSummaries(out string) ([]RunSummary, error) {
461 var list []RunSummary
462 for _, line := range strings.Split(out, "\n") {
463 if strings.TrimSpace(line) == "" {
464 continue
465 }
466 f := strings.Split(line, "\t")
467 if len(f) != listRunFields {
468 return nil, fmt.Errorf("the instance answered a run list with %d fields, want %d; upgrade the client and server together", len(f), listRunFields)
469 }
470 exitCode, err := strconv.Atoi(f[8])
471 if err != nil {
472 return nil, fmt.Errorf("run %s: exit %q is not a number", f[0], f[8])
473 }
474 attempts, err := strconv.Atoi(f[9])
475 if err != nil {
476 return nil, fmt.Errorf("run %s: attempts %q is not a number", f[0], f[9])
477 }
478 created, err := time.Parse(time.RFC3339, f[11])
479 if err != nil {
480 return nil, fmt.Errorf("run %s: created %q is not a time", f[0], f[11])
481 }
482 list = append(list, RunSummary{
483 ID: f[0], Status: f[1], Workflow: f[2], Job: f[3], Branch: f[4],
484 SHA: f[5], Trigger: f[6], TriggerTitle: f[7], ExitCode: exitCode,
485 Attempts: attempts, HasLog: f[10] == "yes", Created: created,
486 })
487 }
488 return list, nil
489}
490
491// valueOrDash keeps an empty filter in its argument position, so the server can tell
492// "unset" from a value that starts with a dash.
493func valueOrDash(v string) string {
494 if strings.TrimSpace(v) == "" {
495 return "-"
496 }
497 return v
498}
499
500// runCommand resolves the instance and repository for a run verb and builds the one
501// command line the server parses. The caller has already decided which verb; this is
502// only the wire.
503func runCommand(gitDir, remote, runID, verb string, extra ...string) (host, port, command string, err error) {
504 runID = strings.ToUpper(strings.TrimSpace(runID))
505 if !voleid.Valid(runID) {
506 return "", "", "", fmt.Errorf("%q is not a run id; use the 26-character id from the run page", runID)
507 }
508 host, port, path, err := SSHTarget(gitDir, remote)
509 if err != nil {
510 return "", "", "", err
511 }
512 if RepoNameFromPath(path) == "" {
513 return "", "", "", fmt.Errorf("%s does not name a repository", remote)
514 }
515 args := append([]string{verb, path, runID}, extra...)
516 quoted := make([]string, len(args))
517 for i, arg := range args {
518 quoted[i] = quoteSSHArg(arg)
519 }
520 return host, port, strings.Join(quoted, " "), nil
521}
522
523// runVerb resolves the instance and repository for one run-management verb and runs
524// it. The caller has already decided which verb; this is only the wire.
525func runVerb(gitDir, remote, runID, verb string) (string, error) {
526 host, port, command, err := runCommand(gitDir, remote, runID, verb)
527 if err != nil {
528 return "", err
529 }
530 out, err := RunSSH(host, port, command)
531 if err != nil {
532 return "", err
533 }
534 return strings.TrimSpace(out), nil
535}
536
537// DispatchWorkflow queues a manual run of a workflow that declares
538// workflow_dispatch. It is a write to the instance's queue, so the CLI asks over
539// the same key-authenticated channel it pushes with; the server resolves the
540// workflow, validates the inputs and checks the maintainer capability.
541func DispatchWorkflow(gitDir, remote, workflow, ref string, inputs map[string]string) (string, error) {
542 host, port, path, err := SSHTarget(gitDir, remote)
543 if err != nil {
544 return "", err
545 }
546 if RepoNameFromPath(path) == "" {
547 return "", fmt.Errorf("%s does not name a repository", remote)
548 }
549 if ref == "" {
550 ref = "-"
551 }
552 args := []string{"vole-workflow-dispatch", path, workflow, ref}
553 names := make([]string, 0, len(inputs))
554 for name := range inputs {
555 names = append(names, name)
556 }
557 sort.Strings(names)
558 for _, name := range names {
559 args = append(args, name+"="+inputs[name])
560 }
561 quoted := make([]string, len(args))
562 for i, arg := range args {
563 quoted[i] = quoteSSHArg(arg)
564 }
565 out, err := RunSSH(host, port, strings.Join(quoted, " "))
566 if err != nil {
567 return "", err
568 }
569 return strings.TrimSpace(out), nil
570}
571