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

client/remote.go +210

@@ -0,0 +1,210 @@
1 package client
2
3 import (
4 "errors"
5 "fmt"
6 "net/url"
7 "os"
8 "os/exec"
9 "strings"
10 )
11
12 // SSHCommand builds an ssh invocation for host, honouring GIT_SSH_COMMAND (which
13 // git itself uses, and which a runner therefore already sets) and then
14 // VOLE_SSH_COMMAND. The returned command's stderr is left for the caller.
15 func SSHCommand(host, port string, args ...string) *exec.Cmd {
16 base := []string{"ssh"}
17 if v := strings.TrimSpace(os.Getenv("GIT_SSH_COMMAND")); v != "" {
18 base = strings.Fields(v)
19 } else if v := strings.TrimSpace(os.Getenv("VOLE_SSH_COMMAND")); v != "" {
20 base = strings.Fields(v)
21 }
22 argv := append([]string{}, base[1:]...)
23 argv = append(argv, "-T", "-o", "BatchMode=yes")
24 if port != "" {
25 argv = append(argv, "-p", port)
26 }
27 argv = append(argv, host)
28 argv = append(argv, args...)
29 return exec.Command(base[0], argv...)
30 }
31
32 // ParseSSHRemote turns a git remote URL into the pieces ssh needs. It accepts
33 // the URL form (ssh://git@host:port/repo.git) and git's scp-like shorthand
34 // (git@host:repo.git), because a clone URL may be either.
35 func ParseSSHRemote(remote string) (host, port, path string, err error) {
36 if !strings.Contains(remote, "://") {
37 i := strings.Index(remote, ":")
38 if i < 0 {
39 return "", "", "", fmt.Errorf("%q is not an SSH remote", remote)
40 }
41 user, h := remote[:i], remote[i+1:]
42 if user == "" || h == "" {
43 return "", "", "", fmt.Errorf("%q is not an SSH remote", remote)
44 }
45 return user, "", "/" + strings.TrimPrefix(h, "/"), nil
46 }
47 u, err := url.Parse(remote)
48 if err != nil {
49 return "", "", "", fmt.Errorf("%q is not a URL: %w", remote, err)
50 }
51 if u.Scheme != "ssh" {
52 return "", "", "", fmt.Errorf("%q is not an SSH remote: the instance answers over SSH only, "+
53 "so add an ssh one and pass --remote", remote)
54 }
55 if u.Hostname() == "" {
56 return "", "", "", fmt.Errorf("%q has no host", remote)
57 }
58 user := u.User.Username()
59 if user == "" {
60 user = "git"
61 }
62 p := u.Path
63 if p == "" {
64 return "", "", "", fmt.Errorf("%q names no repository", remote)
65 }
66 return user + "@" + u.Hostname(), u.Port(), p, nil
67 }
68
69 // SSHTarget turns a remote name or an ssh:// URL into what ssh needs, plus the
70 // path the remote names. Both forms have to work: `--remote ssh://…` is how a
71 // repository with no configured remote writes at all.
72 func SSHTarget(gitDir, remote string) (host, port, path string, err error) {
73 target, err := RemoteURL(gitDir, remote)
74 if err != nil {
75 return "", "", "", err
76 }
77 return ParseSSHRemote(target)
78 }
79
80 // RunSSH runs one of the instance's read-only verbs over the key the caller
81 // already pushes with. No new endpoint, no second credential.
82 func RunSSH(host, port, verb string) (string, error) {
83 cmd := SSHCommand(host, port, verb)
84 var out, stderr strings.Builder
85 cmd.Stdout, cmd.Stderr = &out, &stderr
86 if err := cmd.Run(); err != nil {
87 // A refusal is written to the channel by the server, so the useful reason
88 // is on stdout with a "vole:" prefix; ssh's own complaints are on stderr.
89 if reason, ok := strings.CutPrefix(strings.TrimSpace(out.String()), "vole:"); ok {
90 return "", errors.New(strings.TrimSpace(reason))
91 }
92 return "", fmt.Errorf("%s: %w: %s", host, err, strings.TrimSpace(stderr.String()))
93 }
94 return out.String(), nil
95 }
96
97 // Roster is the client's read of the instance's user roster.
98 //
99 // The roster is a server-side SQLite database, so the client asks the instance
100 // for the one view it needs — active account id and name — over the same
101 // key-authenticated SSH channel it already pushes with (`ssh host vole-roster`).
102 // No new endpoint, no second credential, and nothing the client can write.
103 type Roster struct {
104 byName map[string]string // active name -> id
105 byID map[string]string // id -> current name
106 }
107
108 // LoadRoster asks the instance for its roster and indexes it both ways.
109 func LoadRoster(gitDir, remote string) (*Roster, error) {
110 host, port, _, err := SSHTarget(gitDir, remote)
111 if err != nil {
112 return nil, err
113 }
114 out, err := RunSSH(host, port, "vole-roster")
115 if err != nil {
116 return nil, fmt.Errorf("cannot read the instance roster from %s: %w", host, err)
117 }
118 r := &Roster{byName: map[string]string{}, byID: map[string]string{}}
119 for _, line := range strings.Split(strings.TrimSpace(out), "\n") {
120 if line == "" {
121 continue
122 }
123 f := strings.SplitN(line, "\t", 2)
124 if len(f) != 2 || f[0] == "" || f[1] == "" {
125 return nil, fmt.Errorf("roster: cannot parse %q", line)
126 }
127 r.byName[f[1]] = f[0]
128 r.byID[f[0]] = f[1]
129 }
130 return r, nil
131 }
132
133 // IDFor resolves a name or a full id to an account id.
134 func (r *Roster) IDFor(nameOrID string) (string, error) {
135 if id, ok := r.byName[nameOrID]; ok {
136 return id, nil
137 }
138 if _, ok := r.byID[nameOrID]; ok {
139 return nameOrID, nil
140 }
141 return "", fmt.Errorf("no active account %q in the instance roster (see `vole user list` on the server)", nameOrID)
142 }
143
144 // NameFor renders an account id as a name when the roster knows it.
145 func (r *Roster) NameFor(accountID string) string {
146 if n, ok := r.byID[accountID]; ok {
147 return n
148 }
149 return accountID
150 }
151
152 // Actor is who a write will be attributed to. The id and URL come from the
153 // instance; Repo comes from the remote the command was given.
154 type Actor struct {
155 ID, Name, URL, Repo string
156 // Guessed means the instance could not be asked and the name came from git
157 // config. It is never silent: the caller warns, and commands that need an
158 // account id (`vole request new`) refuse instead of writing a random name.
159 Guessed bool
160 }
161
162 // AskIdentity runs `vole-whoami` and returns the account behind the key that
163 // just authenticated, plus where the instance is served (empty when public_url
164 // is unset). This is the only correct source of "who am I": git config holds a
165 // name somebody typed.
166 func AskIdentity(gitDir, remote string) (*Actor, error) {
167 host, port, path, err := SSHTarget(gitDir, remote)
168 if err != nil {
169 return nil, err
170 }
171 out, err := RunSSH(host, port, "vole-whoami")
172 if err != nil {
173 return nil, fmt.Errorf("cannot ask %s who you are: %w", host, err)
174 }
175 f := strings.SplitN(strings.TrimSpace(out), "\t", 3)
176 if len(f) < 2 || f[0] == "" || f[1] == "" {
177 return nil, fmt.Errorf("whoami: cannot parse %q", strings.TrimSpace(out))
178 }
179 a := &Actor{ID: f[0], Name: f[1], Repo: RepoNameFromPath(path)}
180 if len(f) == 3 {
181 a.URL = strings.TrimRight(f[2], "/")
182 }
183 return a, nil
184 }
185
186 // ResolveActor asks the instance who the pushing key is, falling back to the name
187 // git config suggests so that `--no-push` on a train still commits something. The
188 // fallback is loudly reported: a wrong author is worse than a slow command.
189 func ResolveActor(b *Branch) (*Actor, error) {
190 a, err := AskIdentity(b.Repo.Dir, b.Remote)
191 if err == nil {
192 return a, nil
193 }
194 fmt.Fprintf(os.Stderr, "vole: %v\nvole: committing as %q from git config; run `vole whoami` once you are online\n",
195 err, b.Author)
196 return &Actor{Name: b.Author, Repo: b.RepoName(), Guessed: true}, err
197 }
198
199 // Page is where a browser reads what a command just wrote, or "" when the
200 // operator has not told the instance where it is served.
201 func (a *Actor) Page(kind, id, anchor string) string {
202 if a == nil || a.URL == "" || a.Repo == "" {
203 return ""
204 }
205 u := fmt.Sprintf("%s/r/%s/%s/%s", a.URL, url.PathEscape(a.Repo), kind, id)
206 if anchor != "" {
207 u += "#" + anchor
208 }
209 return u
210 }