urit/internal/session/session.go
2026-05-02 21:11:50 -04:00

250 lines
6.9 KiB
Go

// 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
}
}
}
}