package server import ( "fmt" "log" "net" "sort" "sync" "time" "github.com/urit/urit/internal/auth" "github.com/urit/urit/internal/config" "github.com/urit/urit/internal/menu" "github.com/urit/urit/internal/session" "github.com/urit/urit/internal/store" ) // maxNodes is the fixed pool size for node numbers. // Node numbers are recycled: when a user disconnects, their number // goes back into the pool for the next connection. This provides a // natural connection limit and keeps node numbers small and readable. // // The original TAG-BBS was single-node (serial port). Our pool of 256 // is generous for a retro BBS — even large boards rarely had more than // a few dozen simultaneous callers. const maxNodes = 256 // Server manages listener(s) and active connections. type Server struct { cfg *config.Config store store.Store listener net.Listener // Session management — protected by mu. mu sync.Mutex sessions map[int]*session.Session // Web access tokens — shared between telnet and HTTP. // Telnet users generate tokens; HTTP uses them for auth. Tokens *TokenStore // Chat — inter-node real-time messaging. Chat *ChatManager // StartedAt records when the server was created, used for uptime. StartedAt time.Time // Node number recycling pool. nodeBitmap tracks which node numbers // (1 through maxNodes) are currently assigned. AllocNode picks the // lowest free number; FreeNode returns it to the pool. // // This replaces the monotonically-incrementing connCount from earlier // versions. A bitmap is efficient and gives predictable, small node // numbers that are easy for sysops to reference. nodeBitmap [(maxNodes + 7) / 8]byte } // New creates a Server from the given configuration. func New(cfg *config.Config, db store.Store) *Server { return &Server{ cfg: cfg, store: db, sessions: make(map[int]*session.Session), Tokens: NewTokenStore(), Chat: NewChatManager(), StartedAt: time.Now(), } } // ListenAndServe starts the telnet listener and blocks, accepting // connections until the listener is closed. Each connection is handled // in its own goroutine — this is the multi-user equivalent of the // original TAG-BBS's single-caller Await_Logon() loop. func (s *Server) ListenAndServe() error { if !s.cfg.Telnet.Enabled { return fmt.Errorf("telnet is not enabled in configuration") } ln, err := net.Listen("tcp", s.cfg.Telnet.Address) if err != nil { return fmt.Errorf("telnet listen %s: %w", s.cfg.Telnet.Address, err) } s.listener = ln log.Printf("Telnet listening on %s", s.cfg.Telnet.Address) for { conn, err := ln.Accept() if err != nil { // Listener was closed (shutdown) if opErr, ok := err.(*net.OpError); ok && !opErr.Temporary() { return nil } log.Printf("Accept error: %v", err) continue } go s.handleConnection(conn) } } // Close shuts down the listener and disconnects all active sessions. func (s *Server) Close() error { s.mu.Lock() for _, sess := range s.sessions { sess.Close(session.DisconnectKicked) } s.mu.Unlock() if s.listener != nil { return s.listener.Close() } return nil } // --- WebTokenizer implementation --- // These methods let the menu layer generate web access tokens for // HTTP file downloads without coupling to the token store directly. func (s *Server) GenerateWebToken(userID int64, userName string, secLibrary int) (string, error) { return s.Tokens.Generate(userID, userName, secLibrary) } func (s *Server) HTTPAddress() string { if s.cfg.HTTP.Enabled { return s.cfg.HTTP.Address } return "" } // --- ChatAgent implementation --- // These methods let the menu layer manage chat sessions without // coupling to the ChatManager directly. func (s *Server) EnterChat(node int) <-chan string { return s.Chat.EnterChat(node) } func (s *Server) LinkChat(a, b int) bool { return s.Chat.LinkChat(a, b) } func (s *Server) SendChat(from int, msg string) bool { return s.Chat.SendChat(from, msg) } func (s *Server) EndChat(node int) { s.Chat.EndChat(node) } func (s *Server) ChatPartner(node int) int { return s.Chat.Partner(node) } func (s *Server) IsInChat(node int) bool { return s.Chat.IsInChat(node) } // --- NodeManager interface implementation --- // These methods allow the menu layer to list, message, and disconnect // nodes without coupling to the server's internal session management. // ActiveNodes returns info about all currently connected sessions. // Results are sorted by node number for stable display. func (s *Server) ActiveNodes() []menu.NodeInfo { s.mu.Lock() defer s.mu.Unlock() nodes := make([]menu.NodeInfo, 0, len(s.sessions)) for _, sess := range s.sessions { nodes = append(nodes, menu.NodeInfo{ Node: sess.Node, RemoteAddr: sess.RemoteAddr(), UserName: sess.UserName, UserID: sess.UserID, ConnectedAt: sess.ConnectedAt, }) } sort.Slice(nodes, func(i, j int) bool { return nodes[i].Node < nodes[j].Node }) return nodes } // DisconnectNode forcibly disconnects the given node number. func (s *Server) DisconnectNode(node int) error { s.mu.Lock() sess, ok := s.sessions[node] s.mu.Unlock() if !ok { return fmt.Errorf("node %d not found", node) } // Send a courtesy message before disconnecting sess.Color(session.AnsiFgRed, session.AnsiBold) sess.WriteString("\r\n*** Disconnected by sysop ***\r\n") sess.Color(session.AnsiReset) sess.Close(session.DisconnectKicked) return nil } // SendToNode sends a message string to the given node's terminal. func (s *Server) SendToNode(node int, msg string) error { s.mu.Lock() sess, ok := s.sessions[node] s.mu.Unlock() if !ok { return fmt.Errorf("node %d not found", node) } return sess.WriteString(msg) } // --- Node number recycling --- // allocNode assigns the lowest available node number (1-maxNodes). // Returns 0 if all nodes are in use. // Must be called with s.mu held. func (s *Server) allocNode() int { for i := 1; i <= maxNodes; i++ { byteIdx := (i - 1) / 8 bitIdx := uint((i - 1) % 8) if s.nodeBitmap[byteIdx]&(1< maxNodes { return } byteIdx := (node - 1) / 8 bitIdx := uint((node - 1) % 8) s.nodeBitmap[byteIdx] &^= 1 << bitIdx } // --- Connection handling --- // handleConnection manages a single telnet session from connect to // disconnect. In the original TAG-BBS, this entire flow was the main // loop body — Reset_System, Await_Logon, Logon_Sequence, Menu, logoff. // Here each connection gets its own goroutine and Session. func (s *Server) handleConnection(conn net.Conn) { s.mu.Lock() nodeNum := s.allocNode() if nodeNum == 0 { s.mu.Unlock() // All nodes in use — reject the connection. conn.Write([]byte("All nodes are busy. Please try again later.\r\n")) conn.Close() log.Printf("Connection rejected (all %d nodes in use): %s", maxNodes, conn.RemoteAddr()) return } s.mu.Unlock() // Create the session — this replaces the global User struct, // IO_Flags array, and jmp_buf Environment from the original. sess := session.New(conn, nodeNum) s.mu.Lock() s.sessions[nodeNum] = sess s.mu.Unlock() log.Printf("[Node %d] Connected: %s", nodeNum, sess.RemoteAddr()) defer func() { reason := sess.Reason() sess.Close(reason) // Clean up any active chat session for this node s.Chat.EndChat(nodeNum) s.mu.Lock() delete(s.sessions, nodeNum) s.freeNode(nodeNum) s.mu.Unlock() log.Printf("[Node %d] Disconnected: %s (%s)", nodeNum, sess.RemoteAddr(), reason) }() // Send telnet negotiation — puts client into character mode with // server-side echo. This replaces the modem handshake/baud detection // that Await_Logon() did in the original. if err := sess.Negotiate(); err != nil { log.Printf("[Node %d] Negotiation error: %v", nodeNum, err) return } // Run the BBS session: banner → login → menu → logoff. // This is the modernized equivalent of the original's // MenuSend(Logon.Text) → Logon_Sequence() → Menu() → logoff flow. s.runSession(sess) } // runSession is the main BBS session handler. It runs the full // lifecycle: welcome banner → authentication → main menu → logoff. // // In the original TAG-BBS, this was the body of the FOREVER loop in // TAG.C: Reset_System → Await_Logon → Logon_Sequence → Menu → logoff. func (s *Server) runSession(sess *session.Session) { sess.ClearScreen() // Welcome banner sess.Color(session.AnsiFgCyan, session.AnsiBold) sess.WriteString("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\r\n") sess.Color(session.AnsiFgBrightWhite) sess.Printf(" %s\r\n", s.cfg.System.Name) sess.Color(session.AnsiFgCyan) sess.WriteString(" Running URIT BBS v0.2.0\r\n") sess.Printf(" Operated by %s\r\n", s.cfg.System.Sysop) sess.Color(session.AnsiFgCyan, session.AnsiBold) sess.WriteString("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\r\n") sess.Color(session.AnsiReset) sess.NewLine() // Welcome screen file (optional) sess.SendFile(s.cfg.System.Screens + "welcome.ans") // Authentication result, err := auth.Login(sess, s.store, s.cfg) if err != nil { log.Printf("[Node %d] Auth error: %v", sess.Node, err) return } user := result.User log.Printf("[Node %d] Logged in: %s (ID=%d, Status=%s)", sess.Node, user.Name, user.ID, user.StatusLabel()) // Update session identity — makes this info available via NodeManager // so cmdWho and the sysop console can show who's logged in. sess.UserName = user.Name sess.UserID = user.ID // --- Login event logging and stats --- // This replaces Append_Stat(STAT_LOGON) and the per-type call // counters from the original TAG-BBS. s.store.LogEvent("login", user.ID, user.Name, sess.Node, sess.RemoteAddr(), user.StatusLabel()) s.store.IncrementStat("total_calls", 1) switch { case user.IsGuest(): s.store.IncrementStat("guest_calls", 1) case user.IsNew(): s.store.IncrementStat("new_calls", 1) default: s.store.IncrementStat("valid_calls", 1) } if result.IsNew { s.store.IncrementStat("new_accounts", 1) } // Deferred logoff tracking — runs when the session ends for any // reason (normal logoff, timeout, kick, disconnect). This replaces // Append_Stat(STAT_LOGOFF) and the time accounting from TAG.C. defer func() { reason := sess.Reason() elapsed := time.Since(sess.ConnectedAt) s.store.LogEvent("logoff", user.ID, user.Name, sess.Node, sess.RemoteAddr(), reason.String()) s.store.IncrementStat("total_time_secs", int64(elapsed.Seconds())) // Update user's time tracking in the database. // TimeUsed is per-session; TimeTotal is cumulative. if !result.IsGuest && user.ID > 0 { secs := int64(elapsed.Seconds()) user.TimeUsed = secs user.TimeTotal += secs now := time.Now() user.LastOn = &now if err := s.store.UpdateUser(user); err != nil { log.Printf("[Node %d] Error updating user time: %v", sess.Node, err) } } }() // --- Logon sequence (replaces TAG.C post-login and MENU.C preamble) --- // 1. Time reset logic (matches original MENU.C) // Must happen first — if the user has no time, we disconnect them // immediately rather than showing informational screens. // Original: if >12 hours since last login or sysop, reset TimeUsed; // otherwise carry forward remaining time from previous session. if !result.IsGuest && user.ID > 0 { resetTime := user.SecStatus == 255 // Sysop always gets full time if user.LastOn != nil { elapsed := time.Since(*user.LastOn) if elapsed >= 12*time.Hour { resetTime = true } } else { resetTime = true // First login ever } if resetTime { user.TimeUsed = 0 } // Apply user's time limit to the session, accounting for time // already used (carried forward from a recent session). limit := time.Duration(user.TimeLimit) * time.Second used := time.Duration(user.TimeUsed) * time.Second remaining := limit - used if remaining <= 0 { // No time left — show the "no time" screen and disconnect. // Matches original: MenuSend("Logon24hrs.Text") then longjmp. sess.SendFile(s.cfg.System.Screens + "notime.ans") sess.Color(session.AnsiFgRed) sess.WriteString(" You have no time remaining. Try again later.\r\n") sess.Color(session.AnsiReset) sess.NewLine() log.Printf("[Node %d] No time remaining for %s, disconnecting", sess.Node, user.Name) return } sess.TimeLimit = remaining } // 2. Post-login screen files // The original had separate files: Logon.Text, GuestLogon.Text. // We add newuser.ans for first-time registrations. if result.IsGuest { sess.SendFile(s.cfg.System.Screens + "guest.ans") } else if result.IsNew { sess.SendFile(s.cfg.System.Screens + "newuser.ans") } else { sess.SendFile(s.cfg.System.Screens + "logon.ans") } // 3. Last caller display // Not in the original (single-node Amiga), but a classic BBS feature. // Shows who was the last person to log in before the current user. if lastCaller, err := s.store.GetLastCaller(user.ID); err == nil && lastCaller != nil { sess.Color(session.AnsiFgBrightBlack) sess.Printf(" Last caller: %s on %s\r\n", lastCaller.UserName, lastCaller.CreatedAt.Format("Jan 02 at 3:04 PM")) sess.Color(session.AnsiReset) } // 4. Unread mail check if !result.IsGuest && user.ID > 0 { unread, _ := s.store.CountUnreadMail(user.ID) if unread > 0 { sess.Color(session.AnsiFgBrightYellow) sess.Printf("*** You have %d unread mail message(s) ***\r\n", unread) sess.Color(session.AnsiReset) sess.NewLine() } } // 5. New user validation notice // The original had security levels: 0=guest, 1=new/unvalidated, // 2+=validated. New users could use the BBS but with restricted // board/library access until the sysop validated them (which // bumped their security levels up). if !result.IsGuest && user.IsNew() { sess.Color(session.AnsiFgYellow) sess.WriteString(" Your account is awaiting validation by the sysop.\r\n") sess.WriteString(" Some areas may have restricted access.\r\n") sess.Color(session.AnsiReset) sess.NewLine() } // 6. System call count and session info totalCalls, _ := s.store.GetStat("total_calls") if totalCalls > 0 { sess.Color(session.AnsiFgBrightBlack) sess.Printf(" Call #%d", totalCalls) if !result.IsGuest && user.LastOn != nil { sess.Printf(" | Last on: %s", user.LastOn.Format("Jan 02 at 3:04 PM")) } sess.WriteString("\r\n") sess.Color(session.AnsiReset) } // Post-login info sess.Color(session.AnsiFgBrightBlack) width, height := sess.TerminalSize() sess.Printf(" Node %d | %s | Terminal %dx%d | Time limit %s\r\n", sess.Node, user.StatusLabel(), width, height, fmtDuration(sess.TimeRemaining())) sess.Color(session.AnsiReset) sess.NewLine() // Hand off to the main menu loop — replaces the original's Menu() call. ctx := &menu.Context{ Sess: sess, User: user, Store: s.store, Cfg: s.cfg, Auth: result, Nodes: s, // Server implements NodeManager Tokens: s, // Server implements WebTokenizer Chat: s, // Server implements ChatAgent } menu.Run(ctx) } // fmtDuration formats a time.Duration as "Xh Ym" for display. func fmtDuration(d time.Duration) string { mins := int(d.Minutes()) hours := mins / 60 mins = mins % 60 if hours > 0 { return fmt.Sprintf("%dh %dm", hours, mins) } return fmt.Sprintf("%dm", mins) }