diff options
Diffstat (limited to 'internal/net/server.go')
| -rw-r--r-- | internal/net/server.go | 78 |
1 files changed, 78 insertions, 0 deletions
diff --git a/internal/net/server.go b/internal/net/server.go index 9d99acc..05a98d1 100644 --- a/internal/net/server.go +++ b/internal/net/server.go @@ -168,6 +168,14 @@ type Server struct { // stopping flips when Shutdown begins so listener goroutines treat // their subsequent Accept/serve errors as expected, not fatal. stopping atomic.Bool + // conns counts live sessions across every transport. handleSession + // admits against maxConnections so a pre-auth connection flood cannot + // hold an unbounded number of reader goroutines, buffers, and outboxes. + conns atomic.Int64 + // idleTimeout returns the read timeout armed before each message. It is a + // field so tests can drive the idle-drop path without waiting out the + // production windows; nil means sessionIdleTimeout. + idleTimeout func(*Session) time.Duration } type Hub struct { @@ -187,6 +195,64 @@ const ( closeFlushTimeout = 2 * time.Second ) +// Connection admission and idle-read limits. The listener admits at most +// maxConnections live sessions; each read arms an idle deadline (short before +// authentication, generous once the session has an account) that every +// message resets, so a silent client cannot pin a reader goroutine forever +// while legitimate long sessions keep working. +const ( + maxConnections = 1000 + loginReadTimeout = 2 * time.Minute + idleReadTimeout = 30 * time.Minute +) + +// admitConnection reserves a connection slot, returning false when the server +// is at capacity. Every admitted connection must call releaseConnection. +func (s *Server) admitConnection() bool { + if s.conns.Add(1) > maxConnections { + s.conns.Add(-1) + return false + } + return true +} + +func (s *Server) releaseConnection() { + s.conns.Add(-1) +} + +// sessionIdleTimeout returns the read timeout to arm for this session. +func (s *Server) sessionIdleTimeout(sess *Session) time.Duration { + if s.idleTimeout != nil { + return s.idleTimeout(sess) + } + return sessionIdleTimeout(sess) +} + +// sessionIdleTimeout returns the read timeout for a session: the account-name +// and password prompts get a short window (pre-auth sockets are the ones an +// anonymous flood targets), everything else gets a generous idle timeout. +func sessionIdleTimeout(sess *Session) time.Duration { + switch sess.State() { + case StateAccountName, StatePassword: + return loginReadTimeout + default: + return idleReadTimeout + } +} + +// armReadDeadline sets an idle read deadline on the session's transport when +// it supports one. tcpConn and wsConn (both in this package) expose their +// underlying connection, so telnet, telnet-TLS, and websocket are all covered. +func armReadDeadline(sess *Session, d time.Duration) { + deadline := time.Now().Add(d) + switch c := sess.Conn.(type) { + case *tcpConn: + _ = c.conn.SetReadDeadline(deadline) + case *wsConn: + _ = c.conn.SetReadDeadline(deadline) + } +} + // StartWriter puts the session into queued mode: writes are enqueued and a // dedicated goroutine drains the queue to the network with per-message write // deadlines. The goroutine exits after Close() — draining whatever was @@ -705,12 +771,24 @@ func (s *Server) handleSession(sess *Session, handler func(*Session, string)) { sess.Close() }() + // Admit before reading anything: an over-capacity connection (including + // one arriving through the websocket transport, whose handler shares this + // path) is told so and dropped without holding a read loop. + if !s.admitConnection() { + sess.Write("Server is at full capacity. Please try again later.\r\n") + return + } + defer s.releaseConnection() + sess.Write("\033[2J\033[H") sess.Write(WelcomeBanner()) sess.Write("What's your account name? ") for { + // Re-armed every iteration so any message from the client resets the + // idle countdown. + armReadDeadline(sess, s.sessionIdleTimeout(sess)) line, err := sess.Conn.ReadMessage() if err != nil { log.Printf("session read error: %v", err) |
