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.

refactor

c5902451905acdaa478f8476844ccd4a510d9ca9 · Keenan Nemetz · 17d ago · parent ec2aba4b

cmd/vole/roster.go −231

@@ -1,231 +0,0 @@
1 package main
2
3 import (
4 "errors"
5 "fmt"
6 "net/url"
7 "os"
8 "os/exec"
9 "strings"
10 )
11
12 // roster is the client's read of the instance's user roster.
13 //
14 // The roster is a server-side SQLite database, so the CLI asks the instance for
15 // the one view it needs — active account id and name — over the same
16 // key-authenticated SSH channel it already pushes with (`ssh host vole-roster`).
17 // No new endpoint, no second credential, and nothing the client can write.
18 //
19 // The CLI uses it to turn a name typed on the command line into the account id
20 // that ACL entries and approvals actually store.
21 type roster struct {
22 byName map[string]string // active name -> id
23 byID map[string]string // id -> current name
24 }
25
26 // loadRoster asks the instance for its roster and indexes it both ways.
27 func loadRoster(gitDir, remote string) (*roster, error) {
28 host, port, _, err := sshTarget(gitDir, remote)
29 if err != nil {
30 return nil, err
31 }
32 out, err := runRoster(host, port)
33 if err != nil {
34 return nil, err
35 }
36 r := &roster{byName: map[string]string{}, byID: map[string]string{}}
37 for _, line := range strings.Split(strings.TrimSpace(out), "\n") {
38 if line == "" {
39 continue
40 }
41 f := strings.SplitN(line, "\t", 2)
42 if len(f) != 2 || f[0] == "" || f[1] == "" {
43 return nil, fmt.Errorf("roster: cannot parse %q", line)
44 }
45 r.byName[f[1]] = f[0]
46 r.byID[f[0]] = f[1]
47 }
48 return r, nil
49 }
50
51 // sshTarget turns a remote name or an ssh:// URL into what `ssh` needs, plus the
52 // path the remote names. Both forms have to work: `--remote ssh://…` is how a
53 // repository with no configured remote writes at all.
54 func sshTarget(gitDir, remote string) (host, port, path string, err error) {
55 target := remote
56 if !isRemoteURL(target) {
57 raw, err := gitOut(gitDir, "remote", "get-url", target)
58 if err != nil {
59 return "", "", "", fmt.Errorf("no remote named %q; pass --remote or add one", target)
60 }
61 target = strings.TrimSpace(raw)
62 }
63 if strings.Contains(target, "://") && !strings.HasPrefix(target, "ssh://") {
64 return "", "", "", fmt.Errorf("%s is not an ssh remote: the instance answers over SSH only, "+
65 "so add an ssh one and pass --remote", target)
66 }
67 host, port, path, err = parseSSHRemote(target)
68 if err != nil {
69 return "", "", "", err
70 }
71 return host, port, path, nil
72 }
73
74 // currentGitDir is the clone the CLI is working in. Every metadata write happens here,
75 // on a branch nobody has checked out.
76 func currentGitDir() (string, error) {
77 out, err := exec.Command("git", "rev-parse", "--absolute-git-dir").Output()
78 if err != nil {
79 return "", fmt.Errorf("not inside a git repository")
80 }
81 return strings.TrimSpace(string(out)), nil
82 }
83
84 // idFor resolves a name or a full id to an account id.
85 func (r *roster) idFor(nameOrID string) (string, error) {
86 if id, ok := r.byName[nameOrID]; ok {
87 return id, nil
88 }
89 if _, ok := r.byID[nameOrID]; ok {
90 return nameOrID, nil
91 }
92 return "", fmt.Errorf("no active account %q in the instance roster (see `vole user list` on the server)", nameOrID)
93 }
94
95 // nameFor renders an account id as a name when the roster knows it.
96 func (r *roster) nameFor(accountID string) string {
97 if n, ok := r.byID[accountID]; ok {
98 return n
99 }
100 return accountID
101 }
102
103 // askIdentity runs `vole-whoami` and returns the account behind the key that just
104 // authenticated, plus where the instance is served (empty when public_url is
105 // unset). This is the only correct source of "who am I": git config holds a name
106 // somebody typed.
107 func askIdentity(gitDir, remote string) (*actor, error) {
108 host, port, path, err := sshTarget(gitDir, remote)
109 if err != nil {
110 return nil, err
111 }
112 out, err := runSSH(host, port, "vole-whoami")
113 if err != nil {
114 return nil, fmt.Errorf("cannot ask %s who you are: %w", host, err)
115 }
116 f := strings.SplitN(strings.TrimSpace(out), "\t", 3)
117 if len(f) < 2 || f[0] == "" || f[1] == "" {
118 return nil, fmt.Errorf("whoami: cannot parse %q", strings.TrimSpace(out))
119 }
120 a := &actor{ID: f[0], Name: f[1], Repo: repoNameFromPath(path)}
121 if len(f) == 3 {
122 a.URL = strings.TrimRight(f[2], "/")
123 }
124 return a, nil
125 }
126
127 // actor is who a write will be attributed to. The id comes from the instance, the
128 // URL from its configuration, and Repo from the remote the command was given.
129 type actor struct {
130 ID, Name, URL, Repo string
131 // Guessed means the instance could not be asked and the name came from git
132 // config. It is never silent: the caller warns, and commands that need an
133 // account id (`vole request new`) refuse instead of writing a random name.
134 Guessed bool
135 }
136
137 // resolveActor asks the instance who the pushing key is, falling back to the name
138 // git config suggests so that `--no-push` on a train still commits something. The
139 // fallback is loudly reported: a wrong author is worse than a slow command.
140 func resolveActor(c *branchCLI) (*actor, error) {
141 a, err := askIdentity(c.git.Dir, c.remote)
142 if err == nil {
143 return a, nil
144 }
145 fmt.Fprintf(os.Stderr, "vole: %v\nvole: committing as %q from git config; run `vole whoami` once you are online\n",
146 err, c.author)
147 return &actor{Name: c.author, Repo: c.repoName(), Guessed: true}, err
148 }
149
150 // page is where a browser reads what a command just wrote, or "" when the
151 // operator has not told the instance where it is served.
152 func (a *actor) page(kind, id string, anchor string) string {
153 if a == nil || a.URL == "" || a.Repo == "" {
154 return ""
155 }
156 u := fmt.Sprintf("%s/r/%s/%s/%s", a.URL, url.PathEscape(a.Repo), kind, id)
157 if anchor != "" {
158 u += "#" + anchor
159 }
160 return u
161 }
162
163 // repoNameFromPath is the repository a remote path names: /demo.git -> demo.
164 func repoNameFromPath(path string) string {
165 name := strings.TrimSuffix(strings.TrimPrefix(path, "/"), ".git")
166 if i := strings.IndexByte(name, '/'); i >= 0 {
167 name = name[i+1:]
168 }
169 return name
170 }
171
172 // repoName is the repository this clone's remote names, for building page URLs.
173 func (c *branchCLI) repoName() string {
174 if _, _, path, err := sshTarget(c.git.Dir, c.remote); err == nil {
175 return repoNameFromPath(path)
176 }
177 return ""
178 }
179
180 // runRoster runs `vole-roster` on the instance. The ssh command mirrors the one
181 // the push uses — GIT_SSH_COMMAND, then VOLE_SSH_COMMAND — so a key that makes
182 // the push work also reads the roster.
183 func runRoster(host, port string) (string, error) {
184 out, err := runSSH(host, port, "vole-roster")
185 if err != nil {
186 return "", fmt.Errorf("cannot read the instance roster from %s: %w", host, err)
187 }
188 return out, nil
189 }
190
191 // runSSH runs one of the instance's read-only verbs over the key the caller
192 // already pushes with. No new endpoint, no second credential.
193 func runSSH(host, port, verb string) (string, error) {
194 base := []string{"ssh"}
195 if v := strings.TrimSpace(os.Getenv("GIT_SSH_COMMAND")); v != "" {
196 base = strings.Fields(v)
197 } else if v := strings.TrimSpace(os.Getenv("VOLE_SSH_COMMAND")); v != "" {
198 base = strings.Fields(v)
199 }
200 args := append([]string{}, base[1:]...)
201 args = append(args, "-T", "-o", "BatchMode=yes")
202 if port != "" {
203 args = append(args, "-p", port)
204 }
205 args = append(args, host, verb)
206
207 cmd := exec.Command(base[0], args...)
208 var out, stderr strings.Builder
209 cmd.Stdout, cmd.Stderr = &out, &stderr
210 if err := cmd.Run(); err != nil {
211 // A refusal is written to the channel by the server, so the useful reason
212 // is on stdout with a "vole:" prefix; ssh's own complaints are on stderr.
213 if reason, ok := strings.CutPrefix(strings.TrimSpace(out.String()), "vole:"); ok {
214 return "", errors.New(strings.TrimSpace(reason))
215 }
216 return "", fmt.Errorf("%s: %w: %s", host, err, strings.TrimSpace(stderr.String()))
217 }
218 return out.String(), nil
219 }
220
221 func gitOut(dir string, args ...string) (string, error) {
222 cmd := exec.Command("git", args...)
223 cmd.Dir = dir
224 cmd.Env = append(os.Environ(), "GIT_TERMINAL_PROMPT=0")
225 var out, stderr strings.Builder
226 cmd.Stdout, cmd.Stderr = &out, &stderr
227 if err := cmd.Run(); err != nil {
228 return "", fmt.Errorf("%w: %s", err, strings.TrimSpace(stderr.String()))
229 }
230 return out.String(), nil
231 }