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.
Merge change request 01M70WES5ZZDJ6RP226WB11BBZ: Let people rename and remove their own passkeys
keenan · 16m ago
4b8c9e1d
| 1 | // Package ssh is Vole's write transport. Identity comes from the SSH key |
| 2 | // presented at handshake; the only thing a session may do is run git's own |
| 3 | // upload-pack or receive-pack against a repository that exists in the store. |
| 4 | // |
| 5 | // The exec surface is a remote shell until it is made not one, so it is |
| 6 | // whitelisted by exact command name and validated path, and everything else is |
| 7 | // refused. A test asserts that `sh -c` cannot get through. |
| 8 | package ssh |
| 9 | |
| 10 | import ( |
| 11 | "crypto/ed25519" |
| 12 | "crypto/rand" |
| 13 | "crypto/x509" |
| 14 | "encoding/pem" |
| 15 | "fmt" |
| 16 | "io" |
| 17 | "log/slog" |
| 18 | "net" |
| 19 | "os" |
| 20 | "os/exec" |
| 21 | "path/filepath" |
| 22 | "rodent.software/vole/internal/secure" |
| 23 | "strconv" |
| 24 | "strings" |
| 25 | "sync" |
| 26 | "time" |
| 27 | |
| 28 | xssh "golang.org/x/crypto/ssh" |
| 29 | |
| 30 | "rodent.software/vole/ci/logs" |
| 31 | "rodent.software/vole/ci/runs" |
| 32 | "rodent.software/vole/core/lfs" |
| 33 | "rodent.software/vole/forge" |
| 34 | "rodent.software/vole/forge/access" |
| 35 | "rodent.software/vole/forge/identity" |
| 36 | "rodent.software/vole/forge/repo" |
| 37 | ) |
| 38 | |
| 39 | const serverVersion = "SSH-2.0-vole" |
| 40 | |
| 41 | const ( |
| 42 | // defaultHandshakeTimeout bounds one connection's handshake. A client that opens |
| 43 | // a socket and says nothing would otherwise hold a connection slot, a goroutine |
| 44 | // and a file descriptor for as long as it liked. The handshake is a few round |
| 45 | // trips, so this is generous for a real client and fatal for a slowloris. |
| 46 | defaultHandshakeTimeout = 20 * time.Second |
| 47 | // maxChannelsPerConnection bounds the session channels one connection may have |
| 48 | // open at once. Each accepted channel starts a goroutine and may start a |
| 49 | // process, so the count is the client's leverage on the instance. Git opens one |
| 50 | // or two; a session pool opens one per runner. |
| 51 | maxChannelsPerConnection = 8 |
| 52 | // maxCommandBytes bounds the exec request line. The longest legitimate command |
| 53 | // is a git verb with a repository path, so anything near this is a client |
| 54 | // sending a payload it expects the server to parse. |
| 55 | maxCommandBytes = 4 << 10 |
| 56 | // gitProcessWait is how long a git command waits for a process slot when the |
| 57 | // instance is at its cap. A short wait absorbs a burst; past it the client is |
| 58 | // told to come back rather than parked forever. |
| 59 | gitProcessWait = 30 * time.Second |
| 60 | ) |
| 61 | |
| 62 | type Config struct { |
| 63 | Identity *identity.Store |
| 64 | Repos *repo.Store |
| 65 | Instance *forge.Instance |
| 66 | Log *slog.Logger |
| 67 | HostKeyPath string |
| 68 | MaxBlobBytes int64 |
| 69 | // Quotas for one repository, handed to pre-receive. Zero disables a check. |
| 70 | MaxRefs int |
| 71 | MaxMetadataEntries int |
| 72 | MaxMetadataBytes int64 |
| 73 | MaxRepoBytes int64 |
| 74 | MaxLFSBytes int64 |
| 75 | // AuditPath is handed to hooks so privileged operations can be recorded. |
| 76 | AuditPath string |
| 77 | // SpoolDir is where post-receive drops accepted push events. |
| 78 | SpoolDir string |
| 79 | // RunsDB is handed to pre-receive so required checks are enforced there. |
| 80 | RunsDB string |
| 81 | // IndexDB is handed to post-receive so a push refreshes the read model. |
| 82 | IndexDB string |
| 83 | // Runs is the CI queue, served to runners over the vole-runner verb. |
| 84 | Runs *runs.Store |
| 85 | // LFS is the object store behind git-lfs-transfer. |
| 86 | LFS *lfs.Store |
| 87 | // Logs holds one file per CI attempt, streamed from the runner. |
| 88 | Logs *logs.Store |
| 89 | // LFSWarnBytes is handed to pre-receive as the threshold for suggesting LFS. |
| 90 | LFSWarnBytes int64 |
| 91 | // Locks is the Git LFS lock table, keyed by account name. Nil means this server |
| 92 | // offers no locking, and `git-lfs-transfer` does not advertise the capability. |
| 93 | Locks *lfs.LockStore |
| 94 | // Lease is how long a runner may hold a job without a heartbeat. |
| 95 | Lease time.Duration |
| 96 | // MaxJobsPerRepo bounds how many jobs one repository may have running at once. |
| 97 | // Zero means no bound, and fairness is ordering alone. |
| 98 | MaxJobsPerRepo int |
| 99 | // PagesRef is the branch a site is served from. The runner needs it because the |
| 100 | // workflow cannot know instance configuration. |
| 101 | PagesRef string |
| 102 | // PublicURL is where a browser reads this instance, handed to `vole-whoami` so |
| 103 | // the CLI can print a link to what it just created. Empty is fine. |
| 104 | PublicURL string |
| 105 | // MaxConnections bounds simultaneous connections on the listener. Zero uses a |
| 106 | // default; see server/ssh's maxConnections. |
| 107 | MaxConnections int |
| 108 | // MaxGitProcesses bounds git processes across all connections. Zero uses a |
| 109 | // default; see server/ssh's maxGitProcesses. |
| 110 | MaxGitProcesses int |
| 111 | // DispatchWorkflow queues a manual run. The server wires it after construction |
| 112 | // because the dispatcher lives on the instance and the SSH server is one of its |
| 113 | // callers, not the other way round. |
| 114 | DispatchWorkflow func(repo, workflow, ref string, inputs map[string]string, actor string) (int, error) |
| 115 | // HandshakeTimeout bounds how long a connection may take to complete the SSH |
| 116 | // handshake. Zero uses the default. |
| 117 | HandshakeTimeout time.Duration |
| 118 | } |
| 119 | |
| 120 | type Server struct { |
| 121 | cfg Config |
| 122 | sshCfg *xssh.ServerConfig |
| 123 | mu sync.Mutex |
| 124 | ln net.Listener |
| 125 | |
| 126 | // conns holds one token per open connection, and gitSlots one per running git |
| 127 | // process. Both are channels rather than counters because taking a token is |
| 128 | // also the wait. |
| 129 | conns chan struct{} |
| 130 | gitSlots chan struct{} |
| 131 | // done closes when the server is closing, so a lease parked on the queue answers |
| 132 | // instead of sitting out its wait. |
| 133 | done chan struct{} |
| 134 | // live is the connections being served, and handlers waits for their goroutines |
| 135 | // and session goroutines. Close ends them and returns only when nothing this |
| 136 | // server started is still running: a closed server must not be writing into a |
| 137 | // directory somebody is about to remove. |
| 138 | live map[net.Conn]struct{} |
| 139 | handlers sync.WaitGroup |
| 140 | closing bool |
| 141 | // hostKey is this instance's public host key as one authorized_keys line. It |
| 142 | // travels with a lease so a job that acts as a bot can write the known_hosts |
| 143 | // entry it needs without trusting whatever answers a scan. |
| 144 | hostKey string |
| 145 | |
| 146 | // refused throttles the "listener is full" warning. A flood is exactly when the |
| 147 | // log must not become the next unbounded resource. |
| 148 | refusedMu sync.Mutex |
| 149 | refusedAt time.Time |
| 150 | } |
| 151 | |
| 152 | // SetDispatchWorkflow wires the instance's manual-run operation after the SSH |
| 153 | // server exists. It is a setter rather than a Config field because the two objects |
| 154 | // reference each other through the instance. |
| 155 | func (s *Server) SetDispatchWorkflow(fn func(repo, workflow, ref string, inputs map[string]string, actor string) (int, error)) { |
| 156 | s.cfg.DispatchWorkflow = fn |
| 157 | } |
| 158 | |
| 159 | // maxConnections is the configured listener cap, or the default when unset. |
| 160 | func (c Config) maxConnections() int { |
| 161 | if c.MaxConnections > 0 { |
| 162 | return c.MaxConnections |
| 163 | } |
| 164 | return 64 |
| 165 | } |
| 166 | |
| 167 | // handshakeTimeout is the configured handshake bound, or the default when unset. |
| 168 | func (c Config) handshakeTimeout() time.Duration { |
| 169 | if c.HandshakeTimeout > 0 { |
| 170 | return c.HandshakeTimeout |
| 171 | } |
| 172 | return defaultHandshakeTimeout |
| 173 | } |
| 174 | |
| 175 | // maxGitProcesses is the configured process cap, or the default when unset. |
| 176 | func (c Config) maxGitProcesses() int { |
| 177 | if c.MaxGitProcesses > 0 { |
| 178 | return c.MaxGitProcesses |
| 179 | } |
| 180 | return 16 |
| 181 | } |
| 182 | |
| 183 | // New loads or creates the host key and prepares the SSH config. |
| 184 | func New(cfg Config) (*Server, error) { |
| 185 | if cfg.Log == nil { |
| 186 | cfg.Log = slog.Default() |
| 187 | } |
| 188 | signer, err := loadOrCreateHostKey(cfg.HostKeyPath, cfg.Log) |
| 189 | if err != nil { |
| 190 | return nil, err |
| 191 | } |
| 192 | s := &Server{ |
| 193 | cfg: cfg, |
| 194 | conns: make(chan struct{}, cfg.maxConnections()), |
| 195 | gitSlots: make(chan struct{}, cfg.maxGitProcesses()), |
| 196 | done: make(chan struct{}), |
| 197 | live: make(map[net.Conn]struct{}), |
| 198 | hostKey: strings.TrimSpace(string(xssh.MarshalAuthorizedKey(signer.PublicKey()))), |
| 199 | } |
| 200 | s.sshCfg = &xssh.ServerConfig{ |
| 201 | ServerVersion: serverVersion, |
| 202 | MaxAuthTries: 6, |
| 203 | PublicKeyCallback: func(c xssh.ConnMetadata, key xssh.PublicKey) (*xssh.Permissions, error) { |
| 204 | fp := xssh.FingerprintSHA256(key) |
| 205 | name, ok := cfg.Identity.Lookup(fp) |
| 206 | scoped := false |
| 207 | if !ok && cfg.Runs != nil { |
| 208 | // A run credential is a key for one job. It resolves to the account the |
| 209 | // job acts as, but the session it opens is marked, because a job may do |
| 210 | // less than its account: it may write as that account, not administer it. |
| 211 | cred, err := cfg.Runs.RunCredential(fp, time.Now().UTC()) |
| 212 | if err == nil && cred != nil { |
| 213 | if acct := cfg.Identity.Snapshot().ByName[cred.Account]; acct != nil && acct.Active() { |
| 214 | name, ok, scoped = cred.Account, true, true |
| 215 | } |
| 216 | } |
| 217 | } |
| 218 | if !ok { |
| 219 | cfg.Log.Warn("ssh: unknown key", "fingerprint", fp, "remote", c.RemoteAddr().String()) |
| 220 | return nil, fmt.Errorf("key not registered") |
| 221 | } |
| 222 | ext := map[string]string{"user": name, "fingerprint": fp} |
| 223 | if scoped { |
| 224 | ext["credential"] = fp |
| 225 | } |
| 226 | return &xssh.Permissions{Extensions: ext}, nil |
| 227 | }, |
| 228 | } |
| 229 | s.sshCfg.AddHostKey(signer) |
| 230 | return s, nil |
| 231 | } |
| 232 | |
| 233 | func (s *Server) ListenAndServe(addr string) error { |
| 234 | ln, err := net.Listen("tcp", addr) |
| 235 | if err != nil { |
| 236 | return err |
| 237 | } |
| 238 | return s.Serve(ln) |
| 239 | } |
| 240 | |
| 241 | // Serve accepts connections until the listener is closed. |
| 242 | func (s *Server) Serve(ln net.Listener) error { |
| 243 | s.mu.Lock() |
| 244 | s.ln = ln |
| 245 | s.mu.Unlock() |
| 246 | for { |
| 247 | conn, err := ln.Accept() |
| 248 | if err != nil { |
| 249 | if strings.Contains(err.Error(), "use of closed") { |
| 250 | return nil |
| 251 | } |
| 252 | return err |
| 253 | } |
| 254 | // Past the cap the connection is refused here, before a handshake, a |
| 255 | // goroutine or a git process can be spent on it. The accept loop keeps |
| 256 | // running, so the listener is never wedged behind a flood either. |
| 257 | select { |
| 258 | case s.conns <- struct{}{}: |
| 259 | default: |
| 260 | s.logRefused(conn) |
| 261 | conn.Close() |
| 262 | continue |
| 263 | } |
| 264 | if !s.track(conn) { |
| 265 | // Close won the race for this connection: it is not being served. |
| 266 | <-s.conns |
| 267 | conn.Close() |
| 268 | continue |
| 269 | } |
| 270 | go func(c net.Conn) { |
| 271 | defer func() { <-s.conns }() |
| 272 | defer s.handlers.Done() |
| 273 | defer s.forget(c) |
| 274 | s.handleConn(c) |
| 275 | }(conn) |
| 276 | } |
| 277 | } |
| 278 | |
| 279 | // logRefused reports a connection the listener had no room for, at most once every |
| 280 | // ten seconds so a flood cannot fill the disk with the news. |
| 281 | func (s *Server) logRefused(conn net.Conn) { |
| 282 | s.refusedMu.Lock() |
| 283 | now := time.Now() |
| 284 | silent := now.Sub(s.refusedAt) < 10*time.Second |
| 285 | if !silent { |
| 286 | s.refusedAt = now |
| 287 | } |
| 288 | s.refusedMu.Unlock() |
| 289 | if silent { |
| 290 | return |
| 291 | } |
| 292 | s.cfg.Log.Warn("ssh: connection refused, listener is at its cap", |
| 293 | "remote", conn.RemoteAddr().String(), "max", cap(s.conns)) |
| 294 | } |
| 295 | |
| 296 | // acquireGitProcess takes one of the instance's git process slots. It waits a |
| 297 | // bounded time so a burst queues briefly instead of failing instantly, and reports |
| 298 | // whether it got one; the returned function releases it. |
| 299 | func (s *Server) acquireGitProcess(wait time.Duration) (func(), bool) { |
| 300 | release := func() { <-s.gitSlots } |
| 301 | select { |
| 302 | case s.gitSlots <- struct{}{}: |
| 303 | return release, true |
| 304 | default: |
| 305 | } |
| 306 | t := time.NewTimer(wait) |
| 307 | defer t.Stop() |
| 308 | select { |
| 309 | case s.gitSlots <- struct{}{}: |
| 310 | return release, true |
| 311 | case <-t.C: |
| 312 | return nil, false |
| 313 | } |
| 314 | } |
| 315 | |
| 316 | // closeGrace bounds how long Close waits for connection handlers. A handler that is |
| 317 | // still running after this is reported rather than waited for. |
| 318 | const closeGrace = 10 * time.Second |
| 319 | |
| 320 | // track records a connection to serve, or reports false when the server is already |
| 321 | // closing. Adding to the WaitGroup under the same lock that sets closing is what |
| 322 | // makes Close's wait well defined: every handler is either registered before the |
| 323 | // wait starts or never started. |
| 324 | func (s *Server) track(conn net.Conn) bool { |
| 325 | s.mu.Lock() |
| 326 | defer s.mu.Unlock() |
| 327 | if s.closing { |
| 328 | return false |
| 329 | } |
| 330 | s.live[conn] = struct{}{} |
| 331 | s.handlers.Add(1) |
| 332 | return true |
| 333 | } |
| 334 | |
| 335 | func (s *Server) forget(conn net.Conn) { |
| 336 | s.mu.Lock() |
| 337 | delete(s.live, conn) |
| 338 | s.mu.Unlock() |
| 339 | } |
| 340 | |
| 341 | // Close stops the listener, ends the connections being served, and waits for their |
| 342 | // goroutines. Waiting is the point: a server that has been closed has no handler |
| 343 | // left to write into its data directory, which is what a caller shutting down — or a |
| 344 | // test removing the temporary directory it handed the server — has to be able to |
| 345 | // assume. |
| 346 | func (s *Server) Close() error { |
| 347 | s.mu.Lock() |
| 348 | if s.closing { |
| 349 | // Closing a closed server is a no-op rather than an error: every caller is a |
| 350 | // shutdown path, and none of them can do anything about it. |
| 351 | s.mu.Unlock() |
| 352 | return nil |
| 353 | } |
| 354 | s.closing = true |
| 355 | ln := s.ln |
| 356 | live := make([]net.Conn, 0, len(s.live)) |
| 357 | for c := range s.live { |
| 358 | live = append(live, c) |
| 359 | } |
| 360 | s.mu.Unlock() |
| 361 | |
| 362 | close(s.done) |
| 363 | var err error |
| 364 | if ln != nil { |
| 365 | err = ln.Close() |
| 366 | } |
| 367 | for _, c := range live { |
| 368 | _ = c.Close() |
| 369 | } |
| 370 | // The wait is bounded because a git process stuck on a dead filesystem must not |
| 371 | // hold up a shutdown forever. Everything a healthy handler does when its |
| 372 | // connection ends — noticing the close, writing one last row — takes far less. |
| 373 | finished := make(chan struct{}) |
| 374 | go func() { |
| 375 | s.handlers.Wait() |
| 376 | close(finished) |
| 377 | }() |
| 378 | select { |
| 379 | case <-finished: |
| 380 | case <-time.After(closeGrace): |
| 381 | s.cfg.Log.Warn("ssh: connections did not finish closing", "grace", closeGrace) |
| 382 | } |
| 383 | return err |
| 384 | } |
| 385 | |
| 386 | func (s *Server) handleConn(c net.Conn) { |
| 387 | // The handshake is the one part of a connection with a deadline: a clone copies |
| 388 | // a repository and a push receives a pack, and both are legitimately slow. |
| 389 | if err := c.SetDeadline(time.Now().Add(s.cfg.handshakeTimeout())); err != nil { |
| 390 | c.Close() |
| 391 | return |
| 392 | } |
| 393 | sconn, chans, reqs, err := xssh.NewServerConn(c, s.sshCfg) |
| 394 | if err != nil { |
| 395 | c.Close() |
| 396 | return |
| 397 | } |
| 398 | if err := c.SetDeadline(time.Time{}); err != nil { |
| 399 | sconn.Close() |
| 400 | return |
| 401 | } |
| 402 | defer sconn.Close() |
| 403 | go xssh.DiscardRequests(reqs) |
| 404 | |
| 405 | // The account name is the authorization identity, resolved from the key at |
| 406 | // the handshake and used unchanged everywhere downstream. |
| 407 | user := sconn.Permissions.Extensions["user"] |
| 408 | fp := sconn.Permissions.Extensions["fingerprint"] |
| 409 | s.cfg.Log.Info("ssh connect", "user", user, "remote", sconn.RemoteAddr().String()) |
| 410 | |
| 411 | // One token per open session channel. A flood is rejected at the channel layer |
| 412 | // rather than spawning a goroutine per request. |
| 413 | slots := make(chan struct{}, maxChannelsPerConnection) |
| 414 | // Closed waits for the session goroutines, so a connection handler is not done |
| 415 | // until the work it started is: Close relies on that. |
| 416 | var sessions sync.WaitGroup |
| 417 | for newCh := range chans { |
| 418 | if newCh.ChannelType() != "session" { |
| 419 | newCh.Reject(xssh.UnknownChannelType, "only session channels are supported") |
| 420 | continue |
| 421 | } |
| 422 | select { |
| 423 | case slots <- struct{}{}: |
| 424 | default: |
| 425 | newCh.Reject(xssh.Prohibited, fmt.Sprintf( |
| 426 | "at most %d open channels per connection", maxChannelsPerConnection)) |
| 427 | continue |
| 428 | } |
| 429 | ch, chReqs, err := newCh.Accept() |
| 430 | if err != nil { |
| 431 | <-slots |
| 432 | continue |
| 433 | } |
| 434 | sessions.Add(1) |
| 435 | go func(ch xssh.Channel, reqs <-chan *xssh.Request) { |
| 436 | defer sessions.Done() |
| 437 | defer func() { <-slots }() |
| 438 | s.handleSession(ch, reqs, user, fp, sconn.RemoteAddr().String()) |
| 439 | }(ch, chReqs) |
| 440 | } |
| 441 | sessions.Wait() |
| 442 | } |
| 443 | |
| 444 | func (s *Server) handleSession(ch xssh.Channel, reqs <-chan *xssh.Request, user, fp, remote string) { |
| 445 | defer ch.Close() |
| 446 | for req := range reqs { |
| 447 | switch req.Type { |
| 448 | case "exec": |
| 449 | var payload struct{ Command string } |
| 450 | xssh.Unmarshal(req.Payload, &payload) |
| 451 | s.exec(ch, req, payload.Command, user, fp, remote) |
| 452 | return |
| 453 | case "shell", "pty-req", "subsystem": |
| 454 | req.Reply(false, nil) |
| 455 | fmt.Fprintf(ch, "vole: interactive shells are not available; push and fetch with git\n") |
| 456 | exit(ch, 1) |
| 457 | return |
| 458 | case "env": |
| 459 | // Accepting client env would let a session rewrite VOLE_ACTOR. |
| 460 | req.Reply(false, nil) |
| 461 | default: |
| 462 | req.Reply(false, nil) |
| 463 | } |
| 464 | } |
| 465 | } |
| 466 | |
| 467 | func (s *Server) exec(ch xssh.Channel, req *xssh.Request, command, user, fp, remote string) { |
| 468 | // A refusal is still answered on the channel rather than at the request |
| 469 | // layer: replying false makes the client print "exec request failed on |
| 470 | // channel 0" and the reason is lost. Nothing is ever executed on these |
| 471 | // paths — the message is the whole response. |
| 472 | refuse := func(format string, args ...any) { |
| 473 | req.Reply(true, nil) |
| 474 | fmt.Fprintf(ch, "vole: "+format+"\n", args...) |
| 475 | exit(ch, 1) |
| 476 | } |
| 477 | |
| 478 | // The exec line is the only client-supplied payload on this path. Refuse an |
| 479 | // oversized one before parsing it: the longest legitimate command is a git verb |
| 480 | // and a repository path. |
| 481 | if len(command) > maxCommandBytes { |
| 482 | s.cfg.Log.Warn("ssh: refused oversized command", "user", user, "bytes", len(command)) |
| 483 | refuse("command line is longer than %d bytes", maxCommandBytes) |
| 484 | return |
| 485 | } |
| 486 | |
| 487 | argv, err := splitCommand(command) |
| 488 | if err != nil { |
| 489 | refuse("malformed command") |
| 490 | return |
| 491 | } |
| 492 | // A run credential is a key for one job, and the job may do less than its account: |
| 493 | // push and fetch for its own repository, and ask who it is. Every other verb — |
| 494 | // administering the instance, reading secrets, leasing work, landing a change — is |
| 495 | // an account capability, and a credential that could reach them would be a |
| 496 | // long-lived key with extra steps. |
| 497 | cred, credErr := s.cfg.Runs.RunCredential(fp, time.Now().UTC()) |
| 498 | if credErr != nil { |
| 499 | s.cfg.Log.Warn("ssh: cannot read a run credential", "fingerprint", fp, "err", credErr) |
| 500 | } |
| 501 | if cred != nil && !(len(argv) >= 1 && argv[0] == "vole-whoami" && len(argv) <= 2) && |
| 502 | !(len(argv) == 2 && (argv[0] == "git-upload-pack" || argv[0] == "git-receive-pack")) { |
| 503 | refuse("a job credential may only push, fetch, and ask who it is") |
| 504 | return |
| 505 | } |
| 506 | // The CI runner is the one non-git verb. It reuses the same key identity rather |
| 507 | // than introducing a token, and it is a stream: the runner leases jobs and |
| 508 | // reports results over this channel. |
| 509 | if len(argv) == 1 && argv[0] == "vole-runner" { |
| 510 | req.Reply(true, nil) |
| 511 | s.serveRunner(ch, user) |
| 512 | exit(ch, 0) |
| 513 | return |
| 514 | } |
| 515 | // `vole change merge` lands a change request. It moves a branch, so the rules |
| 516 | // and the run results it needs live here, not in the caller's clone; the verb |
| 517 | // takes a repository name and a request id and answers one line. |
| 518 | if len(argv) >= 1 && argv[0] == "vole-change-merge" { |
| 519 | release, ok := s.acquireGitProcess(gitProcessWait) |
| 520 | if !ok { |
| 521 | s.cfg.Log.Warn("ssh: no git process slot for a merge", "user", user) |
| 522 | refuse("the server is busy running other git commands; try again shortly") |
| 523 | return |
| 524 | } |
| 525 | defer release() |
| 526 | req.Reply(true, nil) |
| 527 | exit(ch, s.serveChangeMerge(ch, argv, user, remote)) |
| 528 | return |
| 529 | } |
| 530 | // `vole change show` asks the instance about the combined result; the clone has |
| 531 | // the commits but not the approvals or run results that answer it. |
| 532 | if len(argv) >= 1 && argv[0] == "vole-change-status" { |
| 533 | release, ok := s.acquireGitProcess(gitProcessWait) |
| 534 | if !ok { |
| 535 | s.cfg.Log.Warn("ssh: no git process slot for a status", "user", user) |
| 536 | refuse("the server is busy running other git commands; try again shortly") |
| 537 | return |
| 538 | } |
| 539 | defer release() |
| 540 | req.Reply(true, nil) |
| 541 | exit(ch, s.serveChangeStatus(ch, argv, user, remote)) |
| 542 | return |
| 543 | } |
| 544 | // Canceling and rerunning a run are writes to the repository's CI queue, not to a |
| 545 | // ref. The verbs are the CLI counterparts of the buttons on the run page, and the |
| 546 | // handlers ask the same maintainer capability before they touch the queue. |
| 547 | if len(argv) >= 1 && argv[0] == "vole-run-cancel" { |
| 548 | req.Reply(true, nil) |
| 549 | exit(ch, s.serveRunCancel(ch, argv, user)) |
| 550 | return |
| 551 | } |
| 552 | if len(argv) >= 1 && argv[0] == "vole-run-rerun" { |
| 553 | req.Reply(true, nil) |
| 554 | exit(ch, s.serveRunRerun(ch, argv, user)) |
| 555 | return |
| 556 | } |
| 557 | // A manual workflow run is a write to the repository's CI queue, not to a ref. |
| 558 | // The verb is the CLI counterpart of the browser's Run workflow form; the |
| 559 | // dispatcher re-checks the maintainer capability before it queues anything. |
| 560 | if len(argv) >= 1 && argv[0] == "vole-workflow-dispatch" { |
| 561 | req.Reply(true, nil) |
| 562 | exit(ch, s.serveWorkflowDispatch(ch, argv, user)) |
| 563 | return |
| 564 | } |
| 565 | // Repository secrets are a server-side store, so the CLI asks the instance for |
| 566 | // them over the same key it pushes with: reading, writing and deleting are all |
| 567 | // maintainer capabilities, re-checked here rather than trusted from the client. |
| 568 | if len(argv) >= 1 && argv[0] == "vole-secret" { |
| 569 | req.Reply(true, nil) |
| 570 | exit(ch, s.serveSecret(ch, argv, user)) |
| 571 | return |
| 572 | } |
| 573 | // The admin API is a fixed command set, not a shell. It is available only to |
| 574 | // accounts marked as instance admins. |
| 575 | if len(argv) >= 1 && argv[0] == "vole-admin" { |
| 576 | req.Reply(true, nil) |
| 577 | exit(ch, s.serveAdmin(ch, argv[1:], user, remote)) |
| 578 | return |
| 579 | } |
| 580 | // git-lfs speaks its own protocol over an SSH remote: the client runs |
| 581 | // `git-lfs-transfer <repo> upload|download` on the same connection family as |
| 582 | // the push. No token changes hands; the key is the identity. |
| 583 | if len(argv) == 3 && argv[0] == "git-lfs-transfer" { |
| 584 | s.serveLFS(ch, req, argv[1], argv[2], user) |
| 585 | return |
| 586 | } |
| 587 | // The client cannot see the server's database, so the roster is served |
| 588 | // read-only over the same key-authenticated channel it already uses for git: |
| 589 | // one account name per line. `vole change` uses it to check a typed name |
| 590 | // against the active roster, and it is the whole surface — nothing here is |
| 591 | // writable. |
| 592 | if len(argv) == 1 && argv[0] == "vole-roster" { |
| 593 | req.Reply(true, nil) |
| 594 | for _, name := range s.cfg.Identity.Roster() { |
| 595 | fmt.Fprintln(ch, name) |
| 596 | } |
| 597 | s.cfg.Log.Info("ssh roster", "user", user, "accounts", s.cfg.Identity.Snapshot().Count) |
| 598 | exit(ch, 0) |
| 599 | return |
| 600 | } |
| 601 | // `vole whoami` asks the instance which account the presented key is. That |
| 602 | // mapping exists only here: a client's git config holds a name somebody typed, |
| 603 | // and guessing from it is how an issue ends up filed by an account that does |
| 604 | // not exist. The second field is where a browser reads this instance, empty |
| 605 | // when the operator has not set public_url — the caller that asks "who am I" |
| 606 | // is usually the same one that wants to print a link to what it just wrote. |
| 607 | // With a repository it also answers the effective role there, which is the |
| 608 | // question `vole whoami --role` asks. |
| 609 | if len(argv) >= 1 && argv[0] == "vole-whoami" && len(argv) <= 2 { |
| 610 | repoArg := "" |
| 611 | if len(argv) == 2 { |
| 612 | name, ok := repoName(argv[1]) |
| 613 | if !ok { |
| 614 | refuse("invalid repository path") |
| 615 | return |
| 616 | } |
| 617 | if cred != nil && cred.Repo != name { |
| 618 | refuse("this job's credential is for %q, not %q", cred.Repo, name) |
| 619 | return |
| 620 | } |
| 621 | repoArg = argv[1] |
| 622 | } |
| 623 | req.Reply(true, nil) |
| 624 | exit(ch, s.serveWhoAmI(ch, user, repoArg)) |
| 625 | return |
| 626 | } |
| 627 | if len(argv) != 2 { |
| 628 | refuse("only git-upload-pack, git-receive-pack, vole-admin, vole-roster, vole-whoami, vole-runner, vole-change-merge, vole-change-status, vole-run-cancel, vole-run-rerun and vole-workflow-dispatch are available") |
| 629 | return |
| 630 | } |
| 631 | verb := argv[0] |
| 632 | if verb != "git-upload-pack" && verb != "git-receive-pack" { |
| 633 | s.cfg.Log.Warn("ssh: refused command", "user", user, "command", verb) |
| 634 | refuse("%q is not permitted; this server only runs git-upload-pack and git-receive-pack", verb) |
| 635 | return |
| 636 | } |
| 637 | |
| 638 | name, ok := repoName(argv[1]) |
| 639 | if !ok { |
| 640 | refuse("invalid repository path") |
| 641 | return |
| 642 | } |
| 643 | |
| 644 | // A credential names one repository. Anything else is a job reaching outside the |
| 645 | // work it was given, even if its account could reach further. |
| 646 | if cred != nil && cred.Repo != name { |
| 647 | refuse("this job's credential is for %q, not %q", cred.Repo, name) |
| 648 | return |
| 649 | } |
| 650 | m, err := s.cfg.Repos.Open(name) |
| 651 | if err != nil { |
| 652 | refuse("repository %q not found", name) |
| 653 | return |
| 654 | } |
| 655 | // Fetching runs the same read rule as the web and HTTP transports. Without |
| 656 | // this check any authenticated key could clone a private repository just by |
| 657 | // naming it; with it, an instance administrator still may, because they can |
| 658 | // already rewrite its rules. |
| 659 | if verb == "git-upload-pack" { |
| 660 | a, err := m.ACL() |
| 661 | if err != nil { |
| 662 | refuse("cannot read access rules for %s", name) |
| 663 | return |
| 664 | } |
| 665 | role := a.EffectiveRole(user, s.cfg.Identity.IsAdmin(user)) |
| 666 | if !role.AtLeast(access.Read) { |
| 667 | s.cfg.Log.Warn("ssh: refused read", "user", user, "repo", name, "role", role) |
| 668 | // The same shape as a missing repository: do not confirm that a |
| 669 | // private repository exists. |
| 670 | refuse("repository %q not found", name) |
| 671 | return |
| 672 | } |
| 673 | } |
| 674 | // One process slot per git command, instance-wide. The connection and channel |
| 675 | // caps bound how many sessions can ask; this bounds what they cost together. |
| 676 | release, ok := s.acquireGitProcess(gitProcessWait) |
| 677 | if !ok { |
| 678 | s.cfg.Log.Warn("ssh: no git process slot", "user", user, "repo", name, |
| 679 | "max", cap(s.gitSlots), "wait", gitProcessWait.String()) |
| 680 | refuse("the server is busy running other git commands; try again shortly") |
| 681 | return |
| 682 | } |
| 683 | defer release() |
| 684 | |
| 685 | if err := req.Reply(true, nil); err != nil { |
| 686 | return |
| 687 | } |
| 688 | |
| 689 | cmd := exec.Command("git", strings.TrimPrefix(verb, "git-"), m.Dir) |
| 690 | cmd.Env = append(os.Environ(), |
| 691 | "GIT_DIR="+m.Dir, |
| 692 | "VOLE_ACTOR="+user, |
| 693 | "VOLE_KEY_ID="+fp, |
| 694 | "VOLE_REPO="+name, |
| 695 | "VOLE_MAX_BLOB="+strconv.FormatInt(s.cfg.MaxBlobBytes, 10), |
| 696 | "VOLE_REPO_ID="+m.ID, |
| 697 | "VOLE_MAX_REFS="+strconv.Itoa(s.cfg.MaxRefs), |
| 698 | "VOLE_MAX_METADATA_ENTRIES="+strconv.Itoa(s.cfg.MaxMetadataEntries), |
| 699 | "VOLE_MAX_METADATA_BYTES="+strconv.FormatInt(s.cfg.MaxMetadataBytes, 10), |
| 700 | "VOLE_MAX_REPO_BYTES="+strconv.FormatInt(s.cfg.MaxRepoBytes, 10), |
| 701 | "VOLE_MAX_LFS_BYTES="+strconv.FormatInt(s.cfg.MaxLFSBytes, 10), |
| 702 | "VOLE_LFS_DIR="+lfsRoot(s.cfg.LFS), |
| 703 | "VOLE_LFS_MAX="+strconv.FormatInt(lfsMax(s.cfg.LFS), 10), |
| 704 | "VOLE_LFS_WARN="+strconv.FormatInt(s.cfg.LFSWarnBytes, 10), |
| 705 | "VOLE_AUDIT="+s.cfg.AuditPath, |
| 706 | "VOLE_USERS_DB="+s.cfg.Identity.Path(), |
| 707 | "VOLE_SPOOL="+s.cfg.SpoolDir, |
| 708 | "VOLE_RUNS_DB="+s.cfg.RunsDB, |
| 709 | "VOLE_INDEX_DB="+s.cfg.IndexDB, |
| 710 | ) |
| 711 | cmd.Stdin = struct{ io.Reader }{ch} |
| 712 | cmd.Stdout = struct{ io.Writer }{ch} |
| 713 | cmd.Stderr = struct{ io.Writer }{ch.Stderr()} |
| 714 | |
| 715 | start := time.Now() |
| 716 | runErr := cmd.Run() |
| 717 | code := 0 |
| 718 | if runErr != nil { |
| 719 | code = 1 |
| 720 | if ee, ok := runErr.(*exec.ExitError); ok { |
| 721 | code = ee.ExitCode() |
| 722 | } |
| 723 | } |
| 724 | s.cfg.Log.Info("ssh git", "user", user, "verb", strings.TrimPrefix(verb, "git-"), |
| 725 | "repo", name, "exit", code, "duration", time.Since(start).String()) |
| 726 | exit(ch, code) |
| 727 | } |
| 728 | |
| 729 | // repoName turns a git SSH path argument into a repo name. git sends either |
| 730 | // "/demo.git" (ssh:// URL) or "demo.git" (scp-like), and never anything we |
| 731 | // should trust as a path — so the result is a validated name, not a path. |
| 732 | func repoName(arg string) (string, bool) { |
| 733 | arg = strings.TrimPrefix(arg, "/") |
| 734 | arg = strings.TrimSuffix(arg, "/") |
| 735 | if !strings.HasSuffix(arg, ".git") { |
| 736 | return "", false |
| 737 | } |
| 738 | name := strings.TrimSuffix(arg, ".git") |
| 739 | if strings.ContainsAny(name, "/\\") || strings.Contains(name, "..") { |
| 740 | return "", false |
| 741 | } |
| 742 | if err := repo.ValidateName(name); err != nil { |
| 743 | return "", false |
| 744 | } |
| 745 | return name, true |
| 746 | } |
| 747 | |
| 748 | // splitCommand understands the quoting git uses: verb 'path'. |
| 749 | func splitCommand(s string) ([]string, error) { |
| 750 | var out []string |
| 751 | var cur strings.Builder |
| 752 | inSingle, inDouble := false, false |
| 753 | started := false |
| 754 | for i := 0; i < len(s); i++ { |
| 755 | c := s[i] |
| 756 | switch { |
| 757 | case inSingle: |
| 758 | if c == '\'' { |
| 759 | inSingle = false |
| 760 | } else { |
| 761 | cur.WriteByte(c) |
| 762 | } |
| 763 | case inDouble: |
| 764 | if c == '"' { |
| 765 | inDouble = false |
| 766 | } else if c == '\\' && i+1 < len(s) { |
| 767 | i++ |
| 768 | cur.WriteByte(s[i]) |
| 769 | } else { |
| 770 | cur.WriteByte(c) |
| 771 | } |
| 772 | case c == '\'': |
| 773 | inSingle, started = true, true |
| 774 | case c == '"': |
| 775 | inDouble, started = true, true |
| 776 | case c == '\\' && i+1 < len(s): |
| 777 | i++ |
| 778 | cur.WriteByte(s[i]) |
| 779 | started = true |
| 780 | case c == ' ' || c == '\t': |
| 781 | if started { |
| 782 | out = append(out, cur.String()) |
| 783 | cur.Reset() |
| 784 | started = false |
| 785 | } |
| 786 | default: |
| 787 | cur.WriteByte(c) |
| 788 | started = true |
| 789 | } |
| 790 | } |
| 791 | if inSingle || inDouble { |
| 792 | return nil, fmt.Errorf("unterminated quote") |
| 793 | } |
| 794 | if started { |
| 795 | out = append(out, cur.String()) |
| 796 | } |
| 797 | return out, nil |
| 798 | } |
| 799 | |
| 800 | func exit(ch xssh.Channel, code int) { |
| 801 | ch.SendRequest("exit-status", false, xssh.Marshal(struct{ Status uint32 }{uint32(code)})) |
| 802 | } |
| 803 | |
| 804 | func loadOrCreateHostKey(path string, log *slog.Logger) (xssh.Signer, error) { |
| 805 | // The host key is the instance's long-term secret. A copy another account can |
| 806 | // read is already disclosed, so a loose mode is corrected before the key is |
| 807 | // used, and a mode that cannot be corrected fails startup: an instance that |
| 808 | // cannot protect its key must not serve it. |
| 809 | if changed, err := secure.HardenFileIfExists(path); err != nil { |
| 810 | return nil, err |
| 811 | } else if changed { |
| 812 | log.Warn("tightened the SSH host key permissions", "path", path) |
| 813 | } |
| 814 | if data, err := os.ReadFile(path); err == nil { |
| 815 | blk, _ := pem.Decode(data) |
| 816 | if blk == nil { |
| 817 | return nil, fmt.Errorf("%s: not a PEM file", path) |
| 818 | } |
| 819 | key, err := x509.ParsePKCS8PrivateKey(blk.Bytes) |
| 820 | if err != nil { |
| 821 | return nil, fmt.Errorf("%s: %w", path, err) |
| 822 | } |
| 823 | return xssh.NewSignerFromKey(key) |
| 824 | } else if !os.IsNotExist(err) { |
| 825 | return nil, err |
| 826 | } |
| 827 | |
| 828 | _, priv, err := ed25519.GenerateKey(rand.Reader) |
| 829 | if err != nil { |
| 830 | return nil, err |
| 831 | } |
| 832 | der, err := x509.MarshalPKCS8PrivateKey(priv) |
| 833 | if err != nil { |
| 834 | return nil, err |
| 835 | } |
| 836 | if err := secure.Mkdir(filepath.Dir(path)); err != nil { |
| 837 | return nil, err |
| 838 | } |
| 839 | blk := pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: der}) |
| 840 | if err := secure.WriteFile(path, blk); err != nil { |
| 841 | return nil, err |
| 842 | } |
| 843 | return xssh.NewSignerFromKey(priv) |
| 844 | } |
| 845 |