package net import ( "crypto/rand" "crypto/rsa" "crypto/tls" "crypto/x509" "crypto/x509/pkix" "fmt" "log" "math/big" "net" "net/http" "sort" "sync" "sync/atomic" "time" "thehouseoficarus/internal/color" "thehouseoficarus/internal/config" "thehouseoficarus/internal/player" ) type SessionState int const ( StateAccountName SessionState = iota StatePassword StateNewAccountPass StateNewAccount StateMenu StateNewCharName StateNewCharConfirm StateRenameAccount StateRenameChar StateRenameCharName StateDeleteChar StatePurgeAccount StateGame StateChangeDesc StateTalk StateTalkSequence StateDropAllConfirm StateRecipeChoice StateHowMany StateSmithProduct StateFletchProduct StateCraftProduct StateProductChoice StateColorChoice StateBank StateHacking StateDangerConfirm StateUndigConfirm ) type Session struct { Conn Conn State SessionState Account *player.Account Player *player.Player PendingChar string PendingPass string PendingMenu []map[string]string PendingItemID string PendingSkill string PendingLastFlag string PendingBackground bool Disconnecting bool DisconnectTicks int PendingUndigDir string PendingUndigRoom int promptVisible bool writeMu sync.Mutex // seq is a monotonically increasing sequence number assigned when the // session is added to a Hub. Hub.AllSessions and Hub.PlayersInRoom sort // sessions by seq so per-tick consumers iterate players in a stable, // registration order instead of Go's randomized map iteration order. // Sessions constructed without being added (e.g. in tests) keep seq=0 // and tie-break by map-append order, which is also deterministic. seq uint64 } // sessionSeq is the source of Session.seq values. It is incremented under the // adding Hub's mutex, so no extra atomicity is needed for correctness; atomic // access keeps the package-level default counter safe if Hub.Add is ever // called concurrently across hubs (it isn't today, but this is defensive). var sessionSeq atomic.Uint64 type Server struct { config *config.Config telnetLn net.Listener telnetTLSLn net.Listener httpServer *http.Server httpsServer *http.Server httpsTLSConfig *tls.Config hub *Hub handler func(*Session, string) } type Hub struct { mu sync.Mutex sessions map[*Session]bool rooms map[int]map[*Session]bool onRemove func(*Session) } func NewHub() *Hub { return &Hub{ sessions: make(map[*Session]bool), rooms: make(map[int]map[*Session]bool), } } func (h *Hub) OnRemove(cb func(*Session)) { h.mu.Lock() defer h.mu.Unlock() h.onRemove = cb } func (h *Hub) Add(s *Session) { h.mu.Lock() defer h.mu.Unlock() s.seq = sessionSeq.Add(1) h.sessions[s] = true } func (h *Hub) Remove(s *Session) { h.mu.Lock() defer h.mu.Unlock() if s.Player != nil && s.State == StateGame && !s.Disconnecting { s.Disconnecting = true s.DisconnectTicks = 10 return } h.hardRemoveLocked(s) } func (h *Hub) HardRemove(s *Session) { h.mu.Lock() defer h.mu.Unlock() h.hardRemoveLocked(s) } func (h *Hub) hardRemoveLocked(s *Session) { delete(h.sessions, s) for _, room := range h.rooms { delete(room, s) } if h.onRemove != nil { h.onRemove(s) } } func (h *Hub) EnterRoom(s *Session, roomID int) { h.mu.Lock() defer h.mu.Unlock() h.leaveRoomLocked(s) if h.rooms[roomID] == nil { h.rooms[roomID] = make(map[*Session]bool) } h.rooms[roomID][s] = true } func (h *Hub) LeaveRoom(s *Session) { h.mu.Lock() defer h.mu.Unlock() h.leaveRoomLocked(s) } func (h *Hub) leaveRoomLocked(s *Session) { for _, room := range h.rooms { delete(room, s) } } func (h *Hub) AllSessions() []*Session { h.mu.Lock() defer h.mu.Unlock() var out []*Session for s := range h.sessions { out = append(out, s) } sort.SliceStable(out, func(i, j int) bool { return out[i].seq < out[j].seq }) return out } func (h *Hub) PlayersInRoom(roomID int) []*Session { h.mu.Lock() defer h.mu.Unlock() var out []*Session if room, ok := h.rooms[roomID]; ok { for s := range room { out = append(out, s) } } sort.SliceStable(out, func(i, j int) bool { return out[i].seq < out[j].seq }) return out } func NewServer(cfg *config.Config) (*Server, error) { s := &Server{ config: cfg, hub: NewHub(), } if cfg.Telnet.Enabled { addr := fmt.Sprintf(":%d", cfg.Telnet.Port) ln, err := net.Listen("tcp", addr) if err != nil { return nil, fmt.Errorf("telnet listen: %w", err) } s.telnetLn = ln } if cfg.TelnetTLS.Enabled { cert, err := loadOrGenerateCert(cfg, "telnet_tls") if err != nil { return nil, fmt.Errorf("telnet_tls cert: %w", err) } tlsCfg := &tls.Config{ Certificates: []tls.Certificate{cert}, MinVersion: tls.VersionTLS12, } addr := fmt.Sprintf(":%d", cfg.TelnetTLS.Port) ln, err := net.Listen("tcp", addr) if err != nil { return nil, fmt.Errorf("telnet_tls listen: %w", err) } s.telnetTLSLn = tls.NewListener(ln, tlsCfg) } if cfg.HTTP.Enabled { s.httpServer = &http.Server{ Addr: fmt.Sprintf(":%d", cfg.HTTP.Port), Handler: newHTTPMux(s), } } if cfg.HTTPS.Enabled { cert, err := loadOrGenerateCert(cfg, "https") if err != nil { return nil, fmt.Errorf("https cert: %w", err) } s.httpsTLSConfig = &tls.Config{ Certificates: []tls.Certificate{cert}, MinVersion: tls.VersionTLS12, } s.httpsServer = &http.Server{ Addr: fmt.Sprintf(":%d", cfg.HTTPS.Port), Handler: newHTTPMux(s), } } return s, nil } func loadOrGenerateCert(cfg *config.Config, service string) (tls.Certificate, error) { if cfg.TLS.CertFile != "" && cfg.TLS.KeyFile != "" { return tls.LoadX509KeyPair(cfg.TLS.CertFile, cfg.TLS.KeyFile) } log.Printf("%s: using self-signed certificate (no shared tls cert configured)", service) return generateSelfSignedCert() } func generateSelfSignedCert() (tls.Certificate, error) { key, err := rsa.GenerateKey(rand.Reader, 2048) if err != nil { return tls.Certificate{}, fmt.Errorf("rsa key generation: %w", err) } serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128)) if err != nil { return tls.Certificate{}, fmt.Errorf("serial number: %w", err) } now := time.Now() template := &x509.Certificate{ SerialNumber: serial, Subject: pkix.Name{ CommonName: "THOI Self-Signed Certificate", }, NotBefore: now.Add(-1 * time.Hour), NotAfter: now.Add(365 * 24 * time.Hour), KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature, ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, BasicConstraintsValid: true, IsCA: true, DNSNames: []string{"localhost"}, IPAddresses: []net.IP{net.ParseIP("127.0.0.1"), net.ParseIP("::1")}, } certDER, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key) if err != nil { return tls.Certificate{}, fmt.Errorf("x509 creation: %w", err) } return tls.Certificate{ Certificate: [][]byte{certDER}, PrivateKey: key, }, nil } func (s *Server) Hub() *Hub { return s.hub } func (s *Server) ListenAndServe(handler func(*Session, string)) error { s.handler = handler if s.telnetLn != nil { go s.serveTelnet() } if s.telnetTLSLn != nil { go s.serveTelnetTLS() } if s.httpServer != nil { go s.serveHTTP() } if s.httpsServer != nil { go s.serveHTTPS() } if s.telnetLn == nil && s.telnetTLSLn == nil && s.httpServer == nil && s.httpsServer == nil { return fmt.Errorf("no listeners configured") } select {} } func (s *Server) serveTelnet() { for { conn, err := s.telnetLn.Accept() if err != nil { return } sess := &Session{ Conn: newTCPConn(conn), State: StateAccountName, } s.hub.Add(sess) go s.handleSession(sess, s.handler) } } func (s *Server) serveTelnetTLS() { for { conn, err := s.telnetTLSLn.Accept() if err != nil { return } sess := &Session{ Conn: newTCPConn(conn), State: StateAccountName, } s.hub.Add(sess) go s.handleSession(sess, s.handler) } } func (s *Server) serveHTTP() { err := s.httpServer.ListenAndServe() if err != nil && err != http.ErrServerClosed { log.Printf("http server error: %v", err) } } func (s *Server) serveHTTPS() { addr := fmt.Sprintf(":%d", s.config.HTTPS.Port) ln, err := net.Listen("tcp", addr) if err != nil { log.Printf("https server error: %v", err) return } tlsLn := tls.NewListener(ln, s.httpsTLSConfig) if err := s.httpsServer.Serve(tlsLn); err != nil && err != http.ErrServerClosed { log.Printf("https server error: %v", err) } } func (s *Server) handleSession(sess *Session, handler func(*Session, string)) { defer func() { if r := recover(); r != nil { log.Printf("session panic: %v", r) } s.hub.Remove(sess) sess.Close() }() sess.Conn.Write([]byte("\033[2J\033[H")) sess.Write(WelcomeBanner()) sess.Write("What's your account name? ") for { line, err := sess.Conn.ReadMessage() if err != nil { log.Printf("session read error: %v", err) return } if len(line) > 1024 { line = line[:1024] } handler(sess, line) } } // wrap applies the player's wrap_width option to outgoing text. It is a no-op // before login (no Player) so the welcome banner is never reflowed. The width // is clamped to a minimum of 80 columns. func (sess *Session) wrap(msg string) string { if sess.Player == nil { return msg } width := sess.Player.OptionInt("wrap_width") if width < 80 { width = 80 } return color.WrapANSI(msg, width) } // ClearPrompt marks the prompt as consumed so the next Write/WriteLine does not // emit a leading \r\n. Useful for clients that locally advance the line on Enter. func (sess *Session) ClearPrompt() { sess.writeMu.Lock() defer sess.writeMu.Unlock() sess.promptVisible = false } // Write sends raw text with no wrapping. Used for prompts and partial lines. func (sess *Session) Write(msg string) { sess.writeMu.Lock() defer sess.writeMu.Unlock() if sess.promptVisible { sess.Conn.Write([]byte("\r\n")) sess.promptVisible = false } sess.Conn.Write([]byte(msg)) } func (sess *Session) WriteLine(msg string) { msg = sess.wrap(msg) sess.writeMu.Lock() defer sess.writeMu.Unlock() if sess.promptVisible { sess.Conn.Write([]byte("\r\n")) sess.promptVisible = false } sess.Conn.Write([]byte(msg + "\r\n")) } func (sess *Session) WriteLines(lines ...string) { sess.writeMu.Lock() defer sess.writeMu.Unlock() for i, l := range lines { if i == 0 && sess.promptVisible { sess.Conn.Write([]byte("\r\n")) sess.promptVisible = false } sess.Conn.Write([]byte(sess.wrap(l) + "\r\n")) } } func (sess *Session) Writef(format string, args ...interface{}) { msg := sess.wrap(fmt.Sprintf(format, args...)) sess.writeMu.Lock() defer sess.writeMu.Unlock() if sess.promptVisible { sess.Conn.Write([]byte("\r\n")) sess.promptVisible = false } sess.Conn.Write([]byte(msg)) } func (sess *Session) WritePrompt(prompt string) { sess.writeMu.Lock() defer sess.writeMu.Unlock() if sess.promptVisible { return } sess.Conn.Write([]byte(prompt)) sess.promptVisible = true } func (sess *Session) Reprompt(prompt string) { sess.writeMu.Lock() defer sess.writeMu.Unlock() sess.Conn.Write([]byte(prompt)) sess.promptVisible = true } func (sess *Session) Close() error { sess.writeMu.Lock() defer sess.writeMu.Unlock() return sess.Conn.Close() }