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.
Move writes to SSH, drop web authentication, keep identity in git
a5f19b3c19861e080953096f934df25dc38beaf7 · Keenan Nemetz · 18d ago · parent 653e0cfc
Move writes to SSH, drop web authentication, keep identity in git The authenticated-HTTP design is gone rather than extended. Writes now require an SSH key, the web is read-only, and the user roster lives in git. What replaced what: - internal/sshserver is the only write transport. An embedded SSH server (golang.org/x/crypto/ssh) authenticates by public key and hands the principal to pre-receive through the environment, so the hook's policy code is unchanged. The exec surface is whitelisted to git-upload-pack and git-receive-pack, the repository argument is resolved through the store rather than concatenated, and everything else -- including `sh -c` -- is refused and asserted refused in a test. - internal/identity stores users on refs/heads/main in data/system.git. The directory listing is the table: users/<name>/keys/<sha>.pub. Writes are CAS commits, reads come from an in-memory snapshot so the SSH auth callback is a map lookup. Nothing stored is a secret, so a clone of system.git is a complete backup and git log on it is the audit trail. - internal/transport is now anonymous read-only: upload-pack behind a visibility check, 404 for private repositories, and no push path at all. - internal/web has no login, no sessions and no mutations. Deleted outright: internal/auth, the Basic-auth path, login/logout, web settings/rename/delete, chunked body spooling, the spool janitor, the push semaphore and the hand-rolled byte cap. The cap is now git's receive.maxInputSize per repo, which enforces it on every transport without buffering. Three things worth recording: - The CAS helper read the tree outside the retry loop and compared against a freshly-read old value, so a concurrent writer's commit could be silently discarded while the CAS "succeeded". A test with two stores adding eight keys at once found it losing half of them; read, mutate and compare now all happen inside the loop. - The roster moved from refs/vole/users to refs/heads/main because `git clone` only mirrors refs/heads/*: the documented backup command produced an instance with zero users. Storing on a branch also means git itself refuses to point the ref at a non-commit. - Exec refusals are answered on the channel rather than by rejecting the request. Rejecting made the client print "exec request failed on channel 0", so the reason was unreadable. Verified by hand as well as by test: init -> user -> add-key -> repo -> serve, push over SSH, anonymous HTTP clone, web browse, a private repo answering 404 on both anonymous surfaces while still cloning over SSH, a refused `sh -c` that prints why and executes nothing, and a plain clone of system.git yielding the whole roster with no side database present.
internal/identity/identity.go +476
| @@ -0,0 +1,476 @@ | |||
| 1 | // Package identity keeps the instance's user roster in git, inside a dedicated | ||
| 2 | // system repository. | ||
| 3 | // | ||
| 4 | // The directory listing is the table: users/<name>/keys/<hex>.pub. Everything | ||
| 5 | // stored here is public material, so a copy of the system repo is a complete | ||
| 6 | // backup that leaks nothing. Writes are compare-and-swap commits; reads come | ||
| 7 | // from an in-memory snapshot refreshed when the ref moves, because the SSH auth | ||
| 8 | // callback is on the hot path and must be a map lookup, not a git invocation. | ||
| 9 | // | ||
| 10 | // The roster lives on refs/heads/main on purpose. A custom namespace such as | ||
| 11 | // refs/vole/users is invisible to a plain `git clone`, which only mirrors | ||
| 12 | // refs/heads/*, so the obvious backup command would have silently produced an | ||
| 13 | // instance with no users. The system repo is never pushed to by clients, so it | ||
| 14 | // gains nothing from a reserved namespace. | ||
| 15 | package identity | ||
| 16 | |||
| 17 | import ( | ||
| 18 | "crypto/rand" | ||
| 19 | "crypto/sha256" | ||
| 20 | "encoding/hex" | ||
| 21 | "encoding/json" | ||
| 22 | "fmt" | ||
| 23 | "os" | ||
| 24 | "path/filepath" | ||
| 25 | "sort" | ||
| 26 | "strings" | ||
| 27 | "sync" | ||
| 28 | "sync/atomic" | ||
| 29 | "time" | ||
| 30 | |||
| 31 | "golang.org/x/crypto/ssh" | ||
| 32 | |||
| 33 | "github.com/rodent-software/vole/internal/gitx" | ||
| 34 | ) | ||
| 35 | |||
| 36 | const ( | ||
| 37 | UsersRef = "refs/heads/main" | ||
| 38 | usersDir = "users" | ||
| 39 | metaFile = "meta.json" | ||
| 40 | keysDir = "keys" | ||
| 41 | maxCAS = 64 | ||
| 42 | DirName = "system.git" | ||
| 43 | ) | ||
| 44 | |||
| 45 | type User struct { | ||
| 46 | Name string | ||
| 47 | ID string | ||
| 48 | Created time.Time | ||
| 49 | Disabled bool | ||
| 50 | Keys []Key | ||
| 51 | } | ||
| 52 | |||
| 53 | type Key struct { | ||
| 54 | Fingerprint string // ssh.FingerprintSHA256 of the key | ||
| 55 | Type string | ||
| 56 | Comment string | ||
| 57 | Line string // authorized_keys-format line | ||
| 58 | Added time.Time | ||
| 59 | } | ||
| 60 | |||
| 61 | // Snapshot is an immutable view of the roster, swapped atomically. | ||
| 62 | type Snapshot struct { | ||
| 63 | Users map[string]*User | ||
| 64 | ByFingerprint map[string]string | ||
| 65 | SHA string | ||
| 66 | } | ||
| 67 | |||
| 68 | func (s *Snapshot) Lookup(fingerprint string) (string, bool) { | ||
| 69 | if s == nil { | ||
| 70 | return "", false | ||
| 71 | } | ||
| 72 | name, ok := s.ByFingerprint[fingerprint] | ||
| 73 | return name, ok | ||
| 74 | } | ||
| 75 | |||
| 76 | func (s *Snapshot) Names() []string { | ||
| 77 | if s == nil { | ||
| 78 | return nil | ||
| 79 | } | ||
| 80 | out := make([]string, 0, len(s.Users)) | ||
| 81 | for n := range s.Users { | ||
| 82 | out = append(out, n) | ||
| 83 | } | ||
| 84 | sort.Strings(out) | ||
| 85 | return out | ||
| 86 | } | ||
| 87 | |||
| 88 | type Store struct { | ||
| 89 | Dir string | ||
| 90 | Git *gitx.Repo | ||
| 91 | snap atomic.Pointer[Snapshot] | ||
| 92 | mu sync.Mutex // serialises local writers | ||
| 93 | } | ||
| 94 | |||
| 95 | // Init creates the system repo with an empty roster. | ||
| 96 | func Init(dir string) error { | ||
| 97 | if _, err := os.Stat(dir); err == nil { | ||
| 98 | return nil | ||
| 99 | } | ||
| 100 | if err := gitx.InitBare(dir, "main"); err != nil { | ||
| 101 | return err | ||
| 102 | } | ||
| 103 | s, err := Open(dir) | ||
| 104 | if err != nil { | ||
| 105 | return err | ||
| 106 | } | ||
| 107 | return s.writeTree(map[string][]byte{ | ||
| 108 | usersDir + "/.keep": []byte("the users tree: one directory per user\n"), | ||
| 109 | }, "users: initialise empty roster", "vole") | ||
| 110 | } | ||
| 111 | |||
| 112 | // Open loads an existing system repo. A missing or unreadable roster is a hard | ||
| 113 | // error: booting with an empty roster would silently mean "nobody can | ||
| 114 | // authenticate", and the diagnosis would happen at an inconvenient hour. | ||
| 115 | func Open(dir string) (*Store, error) { | ||
| 116 | g := gitx.Open(dir) | ||
| 117 | if !g.Exists() { | ||
| 118 | return nil, fmt.Errorf("identity: %s is missing; run vole init", dir) | ||
| 119 | } | ||
| 120 | s := &Store{Dir: dir, Git: g} | ||
| 121 | if err := s.Reload(); err != nil { | ||
| 122 | return nil, err | ||
| 123 | } | ||
| 124 | return s, nil | ||
| 125 | } | ||
| 126 | |||
| 127 | // Reload re-reads the roster from the ref. | ||
| 128 | func (s *Store) Reload() error { | ||
| 129 | sha, err := s.Git.RevParseRaw(UsersRef) | ||
| 130 | if err != nil { | ||
| 131 | return err | ||
| 132 | } | ||
| 133 | snap := &Snapshot{Users: map[string]*User{}, ByFingerprint: map[string]string{}, SHA: sha} | ||
| 134 | if sha == "" { | ||
| 135 | s.snap.Store(snap) | ||
| 136 | return nil | ||
| 137 | } | ||
| 138 | // A ref pointing at anything but a commit means the roster is corrupt. Left | ||
| 139 | // unchecked, `ls-tree` on a blob prints nothing and the instance would boot | ||
| 140 | // with an empty roster — meaning nobody can authenticate. | ||
| 141 | typ, err := s.Git.ObjectType(sha) | ||
| 142 | if err != nil { | ||
| 143 | return fmt.Errorf("identity: read %s: %w", UsersRef, err) | ||
| 144 | } | ||
| 145 | if typ != "commit" { | ||
| 146 | return fmt.Errorf("identity: %s points at a %s, want a commit", UsersRef, typ) | ||
| 147 | } | ||
| 148 | entries, err := s.Git.LsTreeR(sha) | ||
| 149 | if err != nil { | ||
| 150 | return fmt.Errorf("identity: read roster: %w", err) | ||
| 151 | } | ||
| 152 | for _, e := range entries { | ||
| 153 | if e.Type != "blob" || !strings.HasSuffix(e.Name, ".pub") { | ||
| 154 | continue | ||
| 155 | } | ||
| 156 | name := userNameFromPath(e.Name) | ||
| 157 | if name == "" { | ||
| 158 | continue | ||
| 159 | } | ||
| 160 | data, err := s.Git.BlobBySHA(e.SHA) | ||
| 161 | if err != nil { | ||
| 162 | return fmt.Errorf("identity: read key %s: %w", e.Name, err) | ||
| 163 | } | ||
| 164 | pub, comment, _, rest, err := ssh.ParseAuthorizedKey(data) | ||
| 165 | if err != nil { | ||
| 166 | return fmt.Errorf("identity: %s: unparsable key: %w", e.Name, err) | ||
| 167 | } | ||
| 168 | _ = rest | ||
| 169 | u := snap.Users[name] | ||
| 170 | if u == nil { | ||
| 171 | u = &User{Name: name} | ||
| 172 | snap.Users[name] = u | ||
| 173 | } | ||
| 174 | fp := ssh.FingerprintSHA256(pub) | ||
| 175 | u.Keys = append(u.Keys, Key{ | ||
| 176 | Fingerprint: fp, | ||
| 177 | Type: pub.Type(), | ||
| 178 | Comment: comment, | ||
| 179 | Line: strings.TrimSpace(string(data)), | ||
| 180 | }) | ||
| 181 | snap.ByFingerprint[fp] = name | ||
| 182 | } | ||
| 183 | // Metadata files. | ||
| 184 | for _, e := range entries { | ||
| 185 | if e.Type != "blob" || !strings.HasSuffix(e.Name, "/"+metaFile) { | ||
| 186 | continue | ||
| 187 | } | ||
| 188 | name := userNameFromPath(e.Name) | ||
| 189 | if name == "" { | ||
| 190 | continue | ||
| 191 | } | ||
| 192 | data, err := s.Git.BlobBySHA(e.SHA) | ||
| 193 | if err != nil { | ||
| 194 | return err | ||
| 195 | } | ||
| 196 | var m struct { | ||
| 197 | ID string `json:"id"` | ||
| 198 | Created time.Time `json:"created"` | ||
| 199 | Disabled bool `json:"disabled"` | ||
| 200 | } | ||
| 201 | if err := json.Unmarshal(data, &m); err != nil { | ||
| 202 | return fmt.Errorf("identity: %s/%s: %w", name, metaFile, err) | ||
| 203 | } | ||
| 204 | u := snap.Users[name] | ||
| 205 | if u == nil { | ||
| 206 | u = &User{Name: name} | ||
| 207 | snap.Users[name] = u | ||
| 208 | } | ||
| 209 | u.ID, u.Created, u.Disabled = m.ID, m.Created, m.Disabled | ||
| 210 | } | ||
| 211 | // Order keys for stable output. | ||
| 212 | for _, u := range snap.Users { | ||
| 213 | sort.Slice(u.Keys, func(i, j int) bool { return u.Keys[i].Fingerprint < u.Keys[j].Fingerprint }) | ||
| 214 | } | ||
| 215 | s.snap.Store(snap) | ||
| 216 | return nil | ||
| 217 | } | ||
| 218 | |||
| 219 | // Snapshot returns the current roster view. Cheap and lock-free. | ||
| 220 | func (s *Store) Snapshot() *Snapshot { return s.snap.Load() } | ||
| 221 | |||
| 222 | // Lookup resolves a key fingerprint to a username. | ||
| 223 | func (s *Store) Lookup(fingerprint string) (string, bool) { | ||
| 224 | name, ok := s.Snapshot().Lookup(fingerprint) | ||
| 225 | if !ok { | ||
| 226 | return "", false | ||
| 227 | } | ||
| 228 | if u, ok := s.Snapshot().Users[name]; ok && u.Disabled { | ||
| 229 | return "", false | ||
| 230 | } | ||
| 231 | return name, true | ||
| 232 | } | ||
| 233 | |||
| 234 | func ValidateName(name string) error { | ||
| 235 | if len(name) < 2 || len(name) > 32 { | ||
| 236 | return fmt.Errorf("username must be 2-32 characters") | ||
| 237 | } | ||
| 238 | for _, r := range name { | ||
| 239 | if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '-' || r == '_' { | ||
| 240 | continue | ||
| 241 | } | ||
| 242 | return fmt.Errorf("username may only contain lowercase letters, digits, dash and underscore") | ||
| 243 | } | ||
| 244 | return nil | ||
| 245 | } | ||
| 246 | |||
| 247 | // Create adds a user with no keys. They cannot authenticate until a key is | ||
| 248 | // added, which is the intended order of operations. | ||
| 249 | func (s *Store) Create(name string) error { | ||
| 250 | if err := ValidateName(name); err != nil { | ||
| 251 | return err | ||
| 252 | } | ||
| 253 | if _, ok := s.Snapshot().Users[name]; ok { | ||
| 254 | return fmt.Errorf("identity: user %q already exists", name) | ||
| 255 | } | ||
| 256 | id, err := randomID() | ||
| 257 | if err != nil { | ||
| 258 | return err | ||
| 259 | } | ||
| 260 | meta, err := json.MarshalIndent(struct { | ||
| 261 | ID string `json:"id"` | ||
| 262 | Created time.Time `json:"created"` | ||
| 263 | Disabled bool `json:"disabled"` | ||
| 264 | }{id, time.Now().UTC(), false}, "", " ") | ||
| 265 | if err != nil { | ||
| 266 | return err | ||
| 267 | } | ||
| 268 | return s.apply(func(files map[string][]byte) error { | ||
| 269 | files[fmt.Sprintf("%s/%s/%s", usersDir, name, metaFile)] = append(meta, '\n') | ||
| 270 | return nil | ||
| 271 | }, fmt.Sprintf("users: create %s", name), "vole") | ||
| 272 | } | ||
| 273 | |||
| 274 | // AddKey registers an SSH public key for a user and returns its fingerprint. | ||
| 275 | func (s *Store) AddKey(name, authorizedKey, addedBy string) (string, error) { | ||
| 276 | pub, _, _, _, err := ssh.ParseAuthorizedKey([]byte(strings.TrimSpace(authorizedKey))) | ||
| 277 | if err != nil { | ||
| 278 | return "", fmt.Errorf("identity: not an SSH public key: %w", err) | ||
| 279 | } | ||
| 280 | fp := ssh.FingerprintSHA256(pub) | ||
| 281 | if owner, ok := s.Lookup(fp); ok { | ||
| 282 | return "", fmt.Errorf("identity: that key is already registered to %s", owner) | ||
| 283 | } | ||
| 284 | if _, ok := s.Snapshot().Users[name]; !ok { | ||
| 285 | return "", fmt.Errorf("identity: no such user %q", name) | ||
| 286 | } | ||
| 287 | line := strings.TrimSpace(authorizedKey) + "\n" | ||
| 288 | path := fmt.Sprintf("%s/%s/%s/%s.pub", usersDir, name, keysDir, keyFileName(pub)) | ||
| 289 | err = s.apply(func(files map[string][]byte) error { | ||
| 290 | files[path] = []byte(line) | ||
| 291 | return nil | ||
| 292 | }, fmt.Sprintf("users: add key for %s (%s)", name, fp), addedBy) | ||
| 293 | if err != nil { | ||
| 294 | return "", err | ||
| 295 | } | ||
| 296 | return fp, nil | ||
| 297 | } | ||
| 298 | |||
| 299 | // RemoveKey revokes a key. Recovery is an operator adding a new key, which is | ||
| 300 | // why there is no reset flow to build. | ||
| 301 | func (s *Store) RemoveKey(name, fingerprint, removedBy string) error { | ||
| 302 | u, ok := s.Snapshot().Users[name] | ||
| 303 | if !ok { | ||
| 304 | return fmt.Errorf("identity: no such user %q", name) | ||
| 305 | } | ||
| 306 | var target *Key | ||
| 307 | for i := range u.Keys { | ||
| 308 | if u.Keys[i].Fingerprint == fingerprint { | ||
| 309 | target = &u.Keys[i] | ||
| 310 | break | ||
| 311 | } | ||
| 312 | } | ||
| 313 | if target == nil { | ||
| 314 | return fmt.Errorf("identity: %s has no key %s", name, fingerprint) | ||
| 315 | } | ||
| 316 | pub, _, _, _, err := ssh.ParseAuthorizedKey([]byte(target.Line)) | ||
| 317 | if err != nil { | ||
| 318 | return err | ||
| 319 | } | ||
| 320 | path := fmt.Sprintf("%s/%s/%s/%s.pub", usersDir, name, keysDir, keyFileName(pub)) | ||
| 321 | return s.apply(func(files map[string][]byte) error { | ||
| 322 | if _, ok := files[path]; !ok { | ||
| 323 | return fmt.Errorf("identity: key file vanished: %s", path) | ||
| 324 | } | ||
| 325 | delete(files, path) | ||
| 326 | return nil | ||
| 327 | }, fmt.Sprintf("users: revoke key for %s (%s)", name, fingerprint), removedBy) | ||
| 328 | } | ||
| 329 | |||
| 330 | // apply is the single write path: read the tree, mutate a flat path map, write | ||
| 331 | // it back with a compare-and-swap ref update, then refresh the snapshot. | ||
| 332 | func (s *Store) apply(mutate func(map[string][]byte) error, message, actor string) error { | ||
| 333 | s.mu.Lock() | ||
| 334 | defer s.mu.Unlock() | ||
| 335 | |||
| 336 | // The read, the mutation and the compare must all see the same ref value. | ||
| 337 | // Reading the tree once outside this loop and then CAS-ing against a fresh | ||
| 338 | // old value silently discards a concurrent writer's commit, because the CAS | ||
| 339 | // succeeds while the tree content is stale. | ||
| 340 | var lastErr error | ||
| 341 | for attempt := 0; attempt < maxCAS; attempt++ { | ||
| 342 | old, err := s.Git.RevParseRaw(UsersRef) | ||
| 343 | if err != nil { | ||
| 344 | return err | ||
| 345 | } | ||
| 346 | files, err := s.readFlat(old) | ||
| 347 | if err != nil { | ||
| 348 | return err | ||
| 349 | } | ||
| 350 | if err := mutate(files); err != nil { | ||
| 351 | return err | ||
| 352 | } | ||
| 353 | tree, err := buildTree(s.Git, files) | ||
| 354 | if err != nil { | ||
| 355 | return err | ||
| 356 | } | ||
| 357 | commit, err := s.Git.CommitTree(tree, old, withActor(message, actor)) | ||
| 358 | if err != nil { | ||
| 359 | return err | ||
| 360 | } | ||
| 361 | ok, err := s.Git.UpdateRefCAS(UsersRef, commit, old) | ||
| 362 | if err != nil { | ||
| 363 | return err | ||
| 364 | } | ||
| 365 | if ok { | ||
| 366 | return s.Reload() | ||
| 367 | } | ||
| 368 | lastErr = fmt.Errorf("identity: lost %d ref race(s)", attempt+1) | ||
| 369 | time.Sleep(time.Duration(attempt+1) * 5 * time.Millisecond) | ||
| 370 | } | ||
| 371 | return lastErr | ||
| 372 | } | ||
| 373 | |||
| 374 | func (s *Store) readFlat(sha string) (map[string][]byte, error) { | ||
| 375 | out := map[string][]byte{} | ||
| 376 | if sha == "" { | ||
| 377 | return out, nil | ||
| 378 | } | ||
| 379 | if typ, err := s.Git.ObjectType(sha); err != nil { | ||
| 380 | return nil, err | ||
| 381 | } else if typ != "commit" { | ||
| 382 | return nil, fmt.Errorf("identity: %s points at a %s, want a commit", UsersRef, typ) | ||
| 383 | } | ||
| 384 | entries, err := s.Git.LsTreeR(sha) | ||
| 385 | if err != nil { | ||
| 386 | return nil, err | ||
| 387 | } | ||
| 388 | for _, e := range entries { | ||
| 389 | if e.Type != "blob" { | ||
| 390 | continue | ||
| 391 | } | ||
| 392 | data, err := s.Git.BlobBySHA(e.SHA) | ||
| 393 | if err != nil { | ||
| 394 | return nil, err | ||
| 395 | } | ||
| 396 | out[e.Name] = data | ||
| 397 | } | ||
| 398 | return out, nil | ||
| 399 | } | ||
| 400 | |||
| 401 | // writeTree is the one-shot path used by Init, where the ref cannot exist yet. | ||
| 402 | func (s *Store) writeTree(files map[string][]byte, message, actor string) error { | ||
| 403 | tree, err := buildTree(s.Git, files) | ||
| 404 | if err != nil { | ||
| 405 | return err | ||
| 406 | } | ||
| 407 | commit, err := s.Git.CommitTree(tree, "", withActor(message, actor)) | ||
| 408 | if err != nil { | ||
| 409 | return err | ||
| 410 | } | ||
| 411 | _, err = s.Git.UpdateRefCAS(UsersRef, commit, "") | ||
| 412 | return err | ||
| 413 | } | ||
| 414 | |||
| 415 | func withActor(message, actor string) string { | ||
| 416 | if strings.TrimSpace(actor) == "" { | ||
| 417 | actor = "unknown" | ||
| 418 | } | ||
| 419 | return message + "\n\nBy: " + actor | ||
| 420 | } | ||
| 421 | |||
| 422 | // buildTree writes a nested tree from flat paths. | ||
| 423 | func buildTree(g *gitx.Repo, files map[string][]byte) (string, error) { | ||
| 424 | var entries []gitx.Entry | ||
| 425 | dirs := map[string]map[string][]byte{} | ||
| 426 | for path, content := range files { | ||
| 427 | parts := strings.SplitN(path, "/", 2) | ||
| 428 | if len(parts) == 1 { | ||
| 429 | blob, err := g.HashObject(content) | ||
| 430 | if err != nil { | ||
| 431 | return "", err | ||
| 432 | } | ||
| 433 | entries = append(entries, gitx.Entry{Mode: "100644", Type: "blob", SHA: blob, Name: parts[0]}) | ||
| 434 | continue | ||
| 435 | } | ||
| 436 | if dirs[parts[0]] == nil { | ||
| 437 | dirs[parts[0]] = map[string][]byte{} | ||
| 438 | } | ||
| 439 | dirs[parts[0]][parts[1]] = content | ||
| 440 | } | ||
| 441 | for name, sub := range dirs { | ||
| 442 | sha, err := buildTree(g, sub) | ||
| 443 | if err != nil { | ||
| 444 | return "", err | ||
| 445 | } | ||
| 446 | entries = append(entries, gitx.Entry{Mode: "040000", Type: "tree", SHA: sha, Name: name}) | ||
| 447 | } | ||
| 448 | return g.MkTree(entries) | ||
| 449 | } | ||
| 450 | |||
| 451 | // keyFileName derives a filesystem-safe name from the key itself. The filename | ||
| 452 | // is only an address; the .pub content stays authoritative. | ||
| 453 | func keyFileName(pub ssh.PublicKey) string { | ||
| 454 | sum := sha256.Sum256(pub.Marshal()) | ||
| 455 | return hex.EncodeToString(sum[:]) | ||
| 456 | } | ||
| 457 | |||
| 458 | // userNameFromPath extracts the user from "users/<name>/...". | ||
| 459 | func userNameFromPath(p string) string { | ||
| 460 | parts := strings.Split(p, "/") | ||
| 461 | if len(parts) < 3 || parts[0] != usersDir { | ||
| 462 | return "" | ||
| 463 | } | ||
| 464 | return parts[1] | ||
| 465 | } | ||
| 466 | |||
| 467 | func randomID() (string, error) { | ||
| 468 | b := make([]byte, 12) | ||
| 469 | if _, err := rand.Read(b); err != nil { | ||
| 470 | return "", err | ||
| 471 | } | ||
| 472 | return hex.EncodeToString(b), nil | ||
| 473 | } | ||
| 474 | |||
| 475 | // KeyDir is where a user's keys live inside the tree. | ||
| 476 | func KeyDir(name string) string { return filepath.Join(usersDir, name, keysDir) } | ||