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 | } | ||