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 · 24m ago 4b8c9e1d
history blame raw 7.9 KiB
1package main
2
3import (
4 "bytes"
5 "fmt"
6 "io"
7 "os"
8 "strings"
9
10 "rodent.software/vole/client"
11)
12
13const adminUsage = `usage: vole admin [--remote <name|ssh-url>] [--config <path>] [--json] <command>...
14
15Server management:
16 init [--dir <path>]
17 account create [--key <file|->] [--role <role>] [--bot] <name>
18 create an account, with a key or a passkey invite
19 account invite <name> print a fresh one-time enrollment link
20 account credentials|rm-credential <name> [credential-id]
21 account sessions <name>
22 account revoke-sessions [--all | <name>]
23 account add-key|rm-key|retire|role|grant-admin|revoke-admin|show|list|backup
24 repo create|rename|delete|list
25 acl show|set-role|set-default-role|set-approvals|checks|protect|runners
26 gc [--dry-run] [--grace <duration>]
27 index rebuild
28
29With --remote, the command runs on the instance over SSH and must be run by an
30instance admin. --json prints the server's JSON response instead of text.
31 --remote accepts a Git remote name or an instance URL such as
32 ssh://git@forge.example.com:2222`
33
34// cmdAdmin is the single namespace for server management. Without --remote the
35// command runs against the local vole.toml; with it, the same command is sent to
36// the instance's SSH admin API.
37func cmdAdmin(args []string) error {
38 remote, cfgPath, jsonOut, rest, err := splitAdminGlobals(args)
39 if err != nil {
40 return err
41 }
42 if len(rest) == 0 {
43 fmt.Fprintln(os.Stderr, adminUsage)
44 return usageErr{msg: "no admin command given"}
45 }
46 if rest[0] == "-h" || rest[0] == "--help" || rest[0] == "help" {
47 fmt.Fprintln(os.Stdout, adminUsage)
48 return nil
49 }
50
51 resource, subargs := rest[0], rest[1:]
52 switch resource {
53 case "account", "user", "repo", "acl":
54 case "init", "gc", "index":
55 if remote != "" {
56 return usagef("%s is not available over --remote", resource)
57 }
58 default:
59 fmt.Fprintln(os.Stderr, adminUsage)
60 return usagef("unknown admin command %q", resource)
61 }
62
63 if remote == "" {
64 if jsonOut {
65 return usagef("--json is only supported with --remote")
66 }
67 local := make([]string, 0, len(subargs)+2)
68 if cfgPath != "" {
69 local = append(local, "--config", cfgPath)
70 }
71 local = append(local, subargs...)
72 switch resource {
73 case "init":
74 return cmdInit(local)
75 case "account", "user":
76 return cmdAccount(local)
77 case "repo":
78 return cmdRepo(local)
79 case "acl":
80 return cmdACL(local)
81 case "gc":
82 return cmdGC(local)
83 case "index":
84 return cmdIndex(local)
85 }
86 }
87
88 if cfgPath != "" {
89 return usagef("--config and --remote cannot be used together")
90 }
91 // The SSH admin protocol historically spells this resource "user". Keep
92 // sending that name from the account command so a newer client can still
93 // administer an older server; the server accepts both spellings.
94 wireResource := resource
95 if wireResource == "account" {
96 wireResource = "user"
97 }
98 remoteArgs, stdin, err := prepareRemoteAdmin(wireResource, subargs)
99 if err != nil {
100 return err
101 }
102 remoteArgs = append([]string{wireResource}, remoteArgs...)
103 gitDir, _ := client.CurrentGitDir()
104 host, port, err := client.AdminTarget(gitDir, remote)
105 if err != nil {
106 return err
107 }
108 return client.RunAdmin(host, port, remoteArgs, jsonOut, stdin)
109}
110
111// splitAdminGlobals removes the flags that belong to the admin namespace, so
112// the command's own flags can be handed to the old local parser or sent to the
113// server unchanged.
114func splitAdminGlobals(args []string) (remote, config string, jsonOut bool, rest []string, err error) {
115 for i := 0; i < len(args); i++ {
116 a := args[i]
117 if a == "--" || a == "--end-of-options" {
118 // Before the resource, the terminator belongs to the admin
119 // namespace and can be dropped. After it, it belongs to the
120 // subcommand, which may have a positional that starts with "-".
121 if len(rest) == 0 {
122 rest = append(rest, args[i+1:]...)
123 } else {
124 rest = append(rest, args[i:]...)
125 }
126 return
127 }
128 switch {
129 case strings.HasPrefix(a, "-r="):
130 return "", "", false, nil, usagef("option -r takes its value attached (-rVALUE) or use --remote=VALUE")
131 case strings.HasPrefix(a, "-c="):
132 return "", "", false, nil, usagef("option -c takes its value attached (-cPATH) or use --config=PATH")
133 case a == "--remote" || a == "-r":
134 i++
135 if i >= len(args) {
136 return "", "", false, nil, usagef("%s needs a value", a)
137 }
138 remote = args[i]
139 case strings.HasPrefix(a, "--remote="):
140 remote = strings.TrimPrefix(a, "--remote=")
141 case strings.HasPrefix(a, "-r") && len(a) > 2:
142 remote = a[2:]
143 case a == "--config" || a == "-c":
144 i++
145 if i >= len(args) {
146 return "", "", false, nil, usagef("%s needs a value", a)
147 }
148 config = args[i]
149 case strings.HasPrefix(a, "--config="):
150 config = strings.TrimPrefix(a, "--config=")
151 case strings.HasPrefix(a, "-c") && len(a) > 2:
152 config = a[2:]
153 case a == "--json":
154 jsonOut = true
155 default:
156 rest = append(rest, a)
157 }
158 }
159 return
160}
161
162// peelConfig removes the config global from the front of a resource command's
163// arguments and reports it, so `vole admin --config P account list`,
164// `vole account --config P list` and `vole admin account list --config P` all
165// mean the same thing.
166//
167// It cannot be left to the flag parser: every resource command reads its
168// subcommand from the first argument, so a prepended flag is read as the
169// subcommand. That was the reported bug — the admin namespace peels --config,
170// then hands it back ahead of the subcommand. Only leading options are peeled;
171// once the subcommand appears, the rest is the flag parser's business, and a
172// `--` stops the peel because everything after it is a positional.
173func peelConfig(args []string) (cfgPath string, rest []string, err error) {
174 rest = args
175 for len(rest) > 0 {
176 a := rest[0]
177 switch {
178 case a == "--" || a == "--end-of-options":
179 return cfgPath, rest, nil
180 case a == "--config" || a == "-c":
181 if len(rest) < 2 {
182 return "", nil, usagef("%s needs a value", a)
183 }
184 cfgPath, rest = rest[1], rest[2:]
185 case strings.HasPrefix(a, "--config="):
186 cfgPath, rest = strings.TrimPrefix(a, "--config="), rest[1:]
187 case strings.HasPrefix(a, "-c="):
188 return "", nil, usagef("option -c takes its value attached (-cPATH) or use --config=PATH")
189 case strings.HasPrefix(a, "-c") && len(a) > 2:
190 cfgPath, rest = a[2:], rest[1:]
191 default:
192 return cfgPath, rest, nil
193 }
194 }
195 return cfgPath, rest, nil
196}
197
198// prepareRemoteAdmin handles the commands whose input is a file on the client:
199// account create and account add-key read a public key here and send it on
200// stdin, because the server has no access to the caller's filesystem.
201func prepareRemoteAdmin(resource string, args []string) ([]string, io.Reader, error) {
202 if (resource != "user" && resource != "account") || len(args) == 0 || (args[0] != "add-key" && args[0] != "create") {
203 return args, nil, nil
204 }
205 out := []string{args[0]}
206 keyPath := ""
207 for i := 1; i < len(args); i++ {
208 a := args[i]
209 switch {
210 case a == "--key" || a == "-k":
211 i++
212 if i >= len(args) {
213 return nil, nil, usagef("%s needs a value", a)
214 }
215 keyPath = args[i]
216 case strings.HasPrefix(a, "--key="):
217 keyPath = strings.TrimPrefix(a, "--key=")
218 case strings.HasPrefix(a, "-k") && len(a) > 2:
219 keyPath = a[2:]
220 default:
221 out = append(out, a)
222 }
223 }
224 if keyPath == "" && args[0] == "create" {
225 // Keep `... account create alice < alice.pub` working, but do not hand an
226 // interactive terminal to the server: it would wait for a key that is
227 // never coming. An empty pipe means the passkey-only path.
228 if !stdinIsTerminal() {
229 data, err := io.ReadAll(io.LimitReader(os.Stdin, 1<<20))
230 if err != nil {
231 return nil, nil, err
232 }
233 if strings.TrimSpace(string(data)) != "" {
234 return out, bytes.NewReader(data), nil
235 }
236 }
237 return append(out, "--no-key"), nil, nil
238 }
239 if keyPath == "" || keyPath == "-" {
240 return out, os.Stdin, nil
241 }
242 data, err := os.ReadFile(keyPath)
243 if err != nil {
244 return nil, nil, err
245 }
246 return out, bytes.NewReader(data), nil
247}
248