// Package session provides the core I/O abstraction for a BBS connection. // // In the original TAG-BBS, I/O was managed through global variables // (IO_Flags[], ReadSerReq, WriteConReq, etc.) and errors like carrier // loss or timeout were handled by longjmp() back to the main loop. // // Session replaces all of that. Each connected user gets a Session that // wraps their network connection, manages input/output, tracks timing, // and uses Go's context.Context for cancellation instead of longjmp. // Everything above this layer — menus, commands, editors — talks to // a Session and never touches the raw connection. package session import ( "context" "fmt" "net" "sync" "time" ) // DisconnectReason describes why a session ended. // These map to the original TAG-BBS logoff types in DEFINES.H. type DisconnectReason int const ( DisconnectNormal DisconnectReason = iota // User typed 'goodbye' (STANDARD_LOGOFF) DisconnectTimeout // Idle timeout (SLEEP_LOGOFF) DisconnectKicked // Sysop force-disconnect (ILLEGAL_LOGOFF) DisconnectDropped // Connection lost (CARRIER_LOGOFF) DisconnectOvertime // Time limit exceeded (OVERTIME_LOGOFF) ) func (d DisconnectReason) String() string { switch d { case DisconnectNormal: return "normal logoff" case DisconnectTimeout: return "idle timeout" case DisconnectKicked: return "kicked by sysop" case DisconnectDropped: return "connection lost" case DisconnectOvertime: return "time limit exceeded" default: return "unknown" } } // Session represents a single user's connection to the BBS. // It is the modern equivalent of the original's combination of // serial/console device handles, IO_Flags, and the jmp_buf Environment. type Session struct { conn net.Conn filter *telnetFilter // Context controls the session lifetime. Cancelling it is the // equivalent of longjmp(Environment, ...) in the original — it // unwinds all blocking I/O and returns control to the connection // handler. ctx context.Context cancel context.CancelFunc // Node number for this session (like the original's implicit // single-node, but now we can have many). Node int // User identity — set after authentication. Used by NodeManager // to report who's logged in at each node. UserName string UserID int64 // Terminal dimensions reported by the client via NAWS. // Zero means unknown. Width int Height int // Timing — replaces Time_connect, Time_limit, Time_menu_entry, etc. ConnectedAt time.Time TimeLimit time.Duration timeUsed time.Duration lastCheck time.Time // Disconnect tracking disconnectReason DisconnectReason mu sync.Mutex // Input buffer — raw bytes from the connection are read here, // filtered through the telnet IAC stripper, then individual // bytes are delivered to ReadChar() via the channel. inputCh chan byte inputDone chan struct{} } // New creates a Session wrapping the given network connection. // The session starts its input reader goroutine immediately. func New(conn net.Conn, node int) *Session { ctx, cancel := context.WithCancel(context.Background()) s := &Session{ conn: conn, filter: newTelnetFilter(), ctx: ctx, cancel: cancel, Node: node, ConnectedAt: time.Now(), TimeLimit: 2 * time.Hour, // Default; overridden after auth lastCheck: time.Now(), inputCh: make(chan byte, 256), inputDone: make(chan struct{}), } go s.readLoop() return s } // Context returns the session's context. Command handlers can select // on ctx.Done() to detect disconnection, or pass it to other functions // that accept a context. func (s *Session) Context() context.Context { return s.ctx } // Close ends the session. This cancels the context (which unblocks any // pending ReadChar), closes the network connection (which terminates // the read loop), and waits for the read loop to finish. func (s *Session) Close(reason DisconnectReason) { s.mu.Lock() s.disconnectReason = reason s.mu.Unlock() s.cancel() s.conn.Close() <-s.inputDone // Wait for readLoop to exit } // DisconnectReason returns why the session ended. func (s *Session) Reason() DisconnectReason { s.mu.Lock() defer s.mu.Unlock() return s.disconnectReason } // RemoteAddr returns the client's network address. func (s *Session) RemoteAddr() string { return s.conn.RemoteAddr().String() } // TerminalSize returns the client's terminal dimensions if known. // Returns 80x24 as defaults if the client didn't send NAWS. func (s *Session) TerminalSize() (width, height int) { s.mu.Lock() defer s.mu.Unlock() w, h := s.Width, s.Height if w == 0 { w = 80 } if h == 0 { h = 24 } return w, h } // CheckTime updates the session's time accounting and returns an error // if the time limit has been exceeded. This replaces Check_Online_Status() // from the original, minus the carrier detect check (TCP handles that // via the read loop detecting a closed connection). func (s *Session) CheckTime() error { now := time.Now() elapsed := now.Sub(s.lastCheck) s.lastCheck = now s.timeUsed += elapsed remaining := s.TimeLimit - s.timeUsed if remaining <= 0 { return fmt.Errorf("time limit exceeded") } // One-minute warning, like the original if remaining <= time.Minute && remaining+elapsed > time.Minute { s.WriteString("\r\n*** One minute warning ***\r\n") } return nil } // TimeRemaining returns how much time the user has left. func (s *Session) TimeRemaining() time.Duration { remaining := s.TimeLimit - s.timeUsed if remaining < 0 { return 0 } return remaining } // Negotiate sends the initial telnet option negotiation to the client. // This must be called before any other I/O on the session. func (s *Session) Negotiate() error { _, err := s.conn.Write(telnetNegotiation()) return err } // readLoop runs in its own goroutine for the lifetime of the session. // It reads raw bytes from the connection, strips telnet IAC sequences // via the filter, and feeds clean data bytes into inputCh one at a time. // // This is the modern equivalent of the original's SendIO(ReadSerReq) // that kept an async read pending on the serial port at all times. // When the connection closes (or the context is cancelled), the loop // exits and closes inputDone to signal completion. func (s *Session) readLoop() { defer close(s.inputDone) defer close(s.inputCh) buf := make([]byte, 256) for { n, err := s.conn.Read(buf) if err != nil { // Connection closed or errored — equivalent to carrier loss. s.mu.Lock() if s.disconnectReason == DisconnectNormal { s.disconnectReason = DisconnectDropped } s.mu.Unlock() s.cancel() return } clean := s.filter.Filter(buf[:n]) // Update terminal size if NAWS was received if s.filter.Width > 0 { s.mu.Lock() s.Width = s.filter.Width s.Height = s.filter.Height s.mu.Unlock() } for _, b := range clean { select { case s.inputCh <- b: case <-s.ctx.Done(): return } } } }