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.
Bound what an unauthenticated SSH client can spend
78cd3437a597b0155682d2e8d5129bf7cdeac85d · Keenan Nemetz · 12d ago · parent b1499c72
Bound what an unauthenticated SSH client can spend
The SSH listener accepted every connection, gave each one a goroutine, and
started a process per session channel. A client that opened a socket and
never finished the handshake held all three for as long as it liked, and a
client that opened channels held a goroutine each. The host key was
hardened by `vole serve` on the way in, but the SSH package on its own
would happily load a key the whole machine could read.
Four bounds, all enforced before the expensive thing happens:
- A handshake deadline, cleared once the handshake completes so a clone
or a push is not cut. `ssh_handshake_timeout_seconds`, default 20.
- `ssh_max_connections` (default 64) tokens, one per open connection. Past
the cap the connection is closed before the handshake, the goroutine or
the process, and the accept loop keeps running so the listener cannot be
wedged. The "listener is full" warning is throttled to once per 10s so a
flood cannot become a disk problem instead.
- Eight session channels per connection, rejected at the channel layer
rather than by spawning a goroutine per request.
- `ssh_max_git_processes` (default 16) across every connection, covering
upload-pack, receive-pack and vole-change-merge. A command waits up to
30s for a slot and is then told to retry, rather than parked forever.
Payloads are bounded too: an exec line over 4 KiB is refused before it is
parsed, and a vole-runner request line over 1 MiB ends the session with an
error. The runner bound is a per-line reader, so json.Decoder's buffering
cannot hide an oversized request and a long-lived runner is not limited in
total.
Loading an existing host key now tightens it to 0600 and warns, and a mode
that cannot be tightened fails startup, so the package is safe on its own
rather than only behind `vole serve`.
Tests hold a connection without handshaking (it is dropped and its slot
returns), flood one connection with channels (the ninth is rejected and
closing one releases a slot), send an oversized exec line and an oversized
runner request (both refused), saturate the process pool (a bounded wait,
then a refusal), and load a 0644 host key (tightened).
server/ssh/server.go +184−5
| @@ -37,6 +37,27 @@ import ( | |||
| 37 | 37 | ||
| 38 | 38 | const serverVersion = "SSH-2.0-vole" | |
| 39 | 39 | ||
| 40 | const ( | ||
| 41 | // defaultHandshakeTimeout bounds one connection's handshake. A client that opens | ||
| 42 | // a socket and says nothing would otherwise hold a connection slot, a goroutine | ||
| 43 | // and a file descriptor for as long as it liked. The handshake is a few round | ||
| 44 | // trips, so this is generous for a real client and fatal for a slowloris. | ||
| 45 | defaultHandshakeTimeout = 20 * time.Second | ||
| 46 | // maxChannelsPerConnection bounds the session channels one connection may have | ||
| 47 | // open at once. Each accepted channel starts a goroutine and may start a | ||
| 48 | // process, so the count is the client's leverage on the instance. Git opens one | ||
| 49 | // or two; a session pool opens one per runner. | ||
| 50 | maxChannelsPerConnection = 8 | ||
| 51 | // maxCommandBytes bounds the exec request line. The longest legitimate command | ||
| 52 | // is a git verb with a repository path, so anything near this is a client | ||
| 53 | // sending a payload it expects the server to parse. | ||
| 54 | maxCommandBytes = 4 << 10 | ||
| 55 | // gitProcessWait is how long a git command waits for a process slot when the | ||
| 56 | // instance is at its cap. A short wait absorbs a burst; past it the client is | ||
| 57 | // told to come back rather than parked forever. | ||
| 58 | gitProcessWait = 30 * time.Second | ||
| 59 | ) | ||
| 60 | |||
| 40 | 61 | type Config struct { | |
| 41 | 62 | Identity *identity.Store | |
| 42 | 63 | Repos *repo.Store | |
| @@ -70,6 +91,15 @@ type Config struct { | |||
| 70 | 91 | // PublicURL is where a browser reads this instance, handed to `vole-whoami` so | |
| 71 | 92 | // the CLI can print a link to what it just created. Empty is fine. | |
| 72 | 93 | PublicURL string | |
| 94 | // MaxConnections bounds simultaneous connections on the listener. Zero uses a | ||
| 95 | // default; see server/ssh's maxConnections. | ||
| 96 | MaxConnections int | ||
| 97 | // MaxGitProcesses bounds git processes across all connections. Zero uses a | ||
| 98 | // default; see server/ssh's maxGitProcesses. | ||
| 99 | MaxGitProcesses int | ||
| 100 | // HandshakeTimeout bounds how long a connection may take to complete the SSH | ||
| 101 | // handshake. Zero uses the default. | ||
| 102 | HandshakeTimeout time.Duration | ||
| 73 | 103 | } | |
| 74 | 104 | ||
| 75 | 105 | type Server struct { | |
| @@ -77,6 +107,41 @@ type Server struct { | |||
| 77 | 107 | sshCfg *xssh.ServerConfig | |
| 78 | 108 | mu sync.Mutex | |
| 79 | 109 | ln net.Listener | |
| 110 | |||
| 111 | // conns holds one token per open connection, and gitSlots one per running git | ||
| 112 | // process. Both are channels rather than counters because taking a token is | ||
| 113 | // also the wait. | ||
| 114 | conns chan struct{} | ||
| 115 | gitSlots chan struct{} | ||
| 116 | |||
| 117 | // refused throttles the "listener is full" warning. A flood is exactly when the | ||
| 118 | // log must not become the next unbounded resource. | ||
| 119 | refusedMu sync.Mutex | ||
| 120 | refusedAt time.Time | ||
| 121 | } | ||
| 122 | |||
| 123 | // maxConnections is the configured listener cap, or the default when unset. | ||
| 124 | func (c Config) maxConnections() int { | ||
| 125 | if c.MaxConnections > 0 { | ||
| 126 | return c.MaxConnections | ||
| 127 | } | ||
| 128 | return 64 | ||
| 129 | } | ||
| 130 | |||
| 131 | // handshakeTimeout is the configured handshake bound, or the default when unset. | ||
| 132 | func (c Config) handshakeTimeout() time.Duration { | ||
| 133 | if c.HandshakeTimeout > 0 { | ||
| 134 | return c.HandshakeTimeout | ||
| 135 | } | ||
| 136 | return defaultHandshakeTimeout | ||
| 137 | } | ||
| 138 | |||
| 139 | // maxGitProcesses is the configured process cap, or the default when unset. | ||
| 140 | func (c Config) maxGitProcesses() int { | ||
| 141 | if c.MaxGitProcesses > 0 { | ||
| 142 | return c.MaxGitProcesses | ||
| 143 | } | ||
| 144 | return 16 | ||
| 80 | 145 | } | |
| 81 | 146 | ||
| 82 | 147 | // New loads or creates the host key and prepares the SSH config. | |
| @@ -84,11 +149,15 @@ func New(cfg Config) (*Server, error) { | |||
| 84 | 149 | if cfg.Log == nil { | |
| 85 | 150 | cfg.Log = slog.Default() | |
| 86 | 151 | } | |
| 87 | signer, err := loadOrCreateHostKey(cfg.HostKeyPath) | ||
| 152 | signer, err := loadOrCreateHostKey(cfg.HostKeyPath, cfg.Log) | ||
| 88 | 153 | if err != nil { | |
| 89 | 154 | return nil, err | |
| 90 | 155 | } | |
| 91 | s := &Server{cfg: cfg} | ||
| 156 | s := &Server{ | ||
| 157 | cfg: cfg, | ||
| 158 | conns: make(chan struct{}, cfg.maxConnections()), | ||
| 159 | gitSlots: make(chan struct{}, cfg.maxGitProcesses()), | ||
| 160 | } | ||
| 92 | 161 | s.sshCfg = &xssh.ServerConfig{ | |
| 93 | 162 | ServerVersion: serverVersion, | |
| 94 | 163 | MaxAuthTries: 6, | |
| @@ -130,7 +199,57 @@ func (s *Server) Serve(ln net.Listener) error { | |||
| 130 | 199 | } | |
| 131 | 200 | return err | |
| 132 | 201 | } | |
| 133 | go s.handleConn(conn) | ||
| 202 | // Past the cap the connection is refused here, before a handshake, a | ||
| 203 | // goroutine or a git process can be spent on it. The accept loop keeps | ||
| 204 | // running, so the listener is never wedged behind a flood either. | ||
| 205 | select { | ||
| 206 | case s.conns <- struct{}{}: | ||
| 207 | default: | ||
| 208 | s.logRefused(conn) | ||
| 209 | conn.Close() | ||
| 210 | continue | ||
| 211 | } | ||
| 212 | go func(c net.Conn) { | ||
| 213 | defer func() { <-s.conns }() | ||
| 214 | s.handleConn(c) | ||
| 215 | }(conn) | ||
| 216 | } | ||
| 217 | } | ||
| 218 | |||
| 219 | // logRefused reports a connection the listener had no room for, at most once every | ||
| 220 | // ten seconds so a flood cannot fill the disk with the news. | ||
| 221 | func (s *Server) logRefused(conn net.Conn) { | ||
| 222 | s.refusedMu.Lock() | ||
| 223 | now := time.Now() | ||
| 224 | silent := now.Sub(s.refusedAt) < 10*time.Second | ||
| 225 | if !silent { | ||
| 226 | s.refusedAt = now | ||
| 227 | } | ||
| 228 | s.refusedMu.Unlock() | ||
| 229 | if silent { | ||
| 230 | return | ||
| 231 | } | ||
| 232 | s.cfg.Log.Warn("ssh: connection refused, listener is at its cap", | ||
| 233 | "remote", conn.RemoteAddr().String(), "max", cap(s.conns)) | ||
| 234 | } | ||
| 235 | |||
| 236 | // acquireGitProcess takes one of the instance's git process slots. It waits a | ||
| 237 | // bounded time so a burst queues briefly instead of failing instantly, and reports | ||
| 238 | // whether it got one; the returned function releases it. | ||
| 239 | func (s *Server) acquireGitProcess(wait time.Duration) (func(), bool) { | ||
| 240 | release := func() { <-s.gitSlots } | ||
| 241 | select { | ||
| 242 | case s.gitSlots <- struct{}{}: | ||
| 243 | return release, true | ||
| 244 | default: | ||
| 245 | } | ||
| 246 | t := time.NewTimer(wait) | ||
| 247 | defer t.Stop() | ||
| 248 | select { | ||
| 249 | case s.gitSlots <- struct{}{}: | ||
| 250 | return release, true | ||
| 251 | case <-t.C: | ||
| 252 | return nil, false | ||
| 134 | 253 | } | |
| 135 | 254 | } | |
| 136 | 255 | ||
| @@ -144,11 +263,21 @@ func (s *Server) Close() error { | |||
| 144 | 263 | } | |
| 145 | 264 | ||
| 146 | 265 | func (s *Server) handleConn(c net.Conn) { | |
| 266 | // The handshake is the one part of a connection with a deadline: a clone copies | ||
| 267 | // a repository and a push receives a pack, and both are legitimately slow. | ||
| 268 | if err := c.SetDeadline(time.Now().Add(s.cfg.handshakeTimeout())); err != nil { | ||
| 269 | c.Close() | ||
| 270 | return | ||
| 271 | } | ||
| 147 | 272 | sconn, chans, reqs, err := xssh.NewServerConn(c, s.sshCfg) | |
| 148 | 273 | if err != nil { | |
| 149 | 274 | c.Close() | |
| 150 | 275 | return | |
| 151 | 276 | } | |
| 277 | if err := c.SetDeadline(time.Time{}); err != nil { | ||
| 278 | sconn.Close() | ||
| 279 | return | ||
| 280 | } | ||
| 152 | 281 | defer sconn.Close() | |
| 153 | 282 | go xssh.DiscardRequests(reqs) | |
| 154 | 283 | ||
| @@ -158,16 +287,30 @@ func (s *Server) handleConn(c net.Conn) { | |||
| 158 | 287 | fp := sconn.Permissions.Extensions["fingerprint"] | |
| 159 | 288 | s.cfg.Log.Info("ssh connect", "user", user, "remote", sconn.RemoteAddr().String()) | |
| 160 | 289 | ||
| 290 | // One token per open session channel. A flood is rejected at the channel layer | ||
| 291 | // rather than spawning a goroutine per request. | ||
| 292 | slots := make(chan struct{}, maxChannelsPerConnection) | ||
| 161 | 293 | for newCh := range chans { | |
| 162 | 294 | if newCh.ChannelType() != "session" { | |
| 163 | 295 | newCh.Reject(xssh.UnknownChannelType, "only session channels are supported") | |
| 164 | 296 | continue | |
| 165 | 297 | } | |
| 298 | select { | ||
| 299 | case slots <- struct{}{}: | ||
| 300 | default: | ||
| 301 | newCh.Reject(xssh.Prohibited, fmt.Sprintf( | ||
| 302 | "at most %d open channels per connection", maxChannelsPerConnection)) | ||
| 303 | continue | ||
| 304 | } | ||
| 166 | 305 | ch, chReqs, err := newCh.Accept() | |
| 167 | 306 | if err != nil { | |
| 307 | <-slots | ||
| 168 | 308 | continue | |
| 169 | 309 | } | |
| 170 | go s.handleSession(ch, chReqs, user, fp, sconn.RemoteAddr().String()) | ||
| 310 | go func(ch xssh.Channel, reqs <-chan *xssh.Request) { | ||
| 311 | defer func() { <-slots }() | ||
| 312 | s.handleSession(ch, reqs, user, fp, sconn.RemoteAddr().String()) | ||
| 313 | }(ch, chReqs) | ||
| 171 | 314 | } | |
| 172 | 315 | } | |
| 173 | 316 | ||
| @@ -205,6 +348,15 @@ func (s *Server) exec(ch xssh.Channel, req *xssh.Request, command, user, fp, rem | |||
| 205 | 348 | exit(ch, 1) | |
| 206 | 349 | } | |
| 207 | 350 | ||
| 351 | // The exec line is the only client-supplied payload on this path. Refuse an | ||
| 352 | // oversized one before parsing it: the longest legitimate command is a git verb | ||
| 353 | // and a repository path. | ||
| 354 | if len(command) > maxCommandBytes { | ||
| 355 | s.cfg.Log.Warn("ssh: refused oversized command", "user", user, "bytes", len(command)) | ||
| 356 | refuse("command line is longer than %d bytes", maxCommandBytes) | ||
| 357 | return | ||
| 358 | } | ||
| 359 | |||
| 208 | 360 | argv, err := splitCommand(command) | |
| 209 | 361 | if err != nil { | |
| 210 | 362 | refuse("malformed command") | |
| @@ -223,6 +375,13 @@ func (s *Server) exec(ch xssh.Channel, req *xssh.Request, command, user, fp, rem | |||
| 223 | 375 | // and the run results it needs live here, not in the caller's clone; the verb | |
| 224 | 376 | // takes a repository name and a request id and answers one line. | |
| 225 | 377 | if len(argv) >= 1 && argv[0] == "vole-change-merge" { | |
| 378 | release, ok := s.acquireGitProcess(gitProcessWait) | ||
| 379 | if !ok { | ||
| 380 | s.cfg.Log.Warn("ssh: no git process slot for a merge", "user", user) | ||
| 381 | refuse("the server is busy running other git commands; try again shortly") | ||
| 382 | return | ||
| 383 | } | ||
| 384 | defer release() | ||
| 226 | 385 | req.Reply(true, nil) | |
| 227 | 386 | exit(ch, s.serveChangeMerge(ch, argv, user, remote)) | |
| 228 | 387 | return | |
| @@ -308,6 +467,17 @@ func (s *Server) exec(ch xssh.Channel, req *xssh.Request, command, user, fp, rem | |||
| 308 | 467 | return | |
| 309 | 468 | } | |
| 310 | 469 | } | |
| 470 | // One process slot per git command, instance-wide. The connection and channel | ||
| 471 | // caps bound how many sessions can ask; this bounds what they cost together. | ||
| 472 | release, ok := s.acquireGitProcess(gitProcessWait) | ||
| 473 | if !ok { | ||
| 474 | s.cfg.Log.Warn("ssh: no git process slot", "user", user, "repo", name, | ||
| 475 | "max", cap(s.gitSlots), "wait", gitProcessWait.String()) | ||
| 476 | refuse("the server is busy running other git commands; try again shortly") | ||
| 477 | return | ||
| 478 | } | ||
| 479 | defer release() | ||
| 480 | |||
| 311 | 481 | if err := req.Reply(true, nil); err != nil { | |
| 312 | 482 | return | |
| 313 | 483 | } | |
| @@ -420,7 +590,16 @@ func exit(ch xssh.Channel, code int) { | |||
| 420 | 590 | ch.SendRequest("exit-status", false, xssh.Marshal(struct{ Status uint32 }{uint32(code)})) | |
| 421 | 591 | } | |
| 422 | 592 | ||
| 423 | func loadOrCreateHostKey(path string) (xssh.Signer, error) { | ||
| 593 | func loadOrCreateHostKey(path string, log *slog.Logger) (xssh.Signer, error) { | ||
| 594 | // The host key is the instance's long-term secret. A copy another account can | ||
| 595 | // read is already disclosed, so a loose mode is corrected before the key is | ||
| 596 | // used, and a mode that cannot be corrected fails startup: an instance that | ||
| 597 | // cannot protect its key must not serve it. | ||
| 598 | if changed, err := secure.HardenFileIfExists(path); err != nil { | ||
| 599 | return nil, err | ||
| 600 | } else if changed { | ||
| 601 | log.Warn("tightened the SSH host key permissions", "path", path) | ||
| 602 | } | ||
| 424 | 603 | if data, err := os.ReadFile(path); err == nil { | |
| 425 | 604 | blk, _ := pem.Decode(data) | |
| 426 | 605 | if blk == nil { | |