commit 466290d316c26a83d364d581be82be485ca1f0d7 Author: Rudi Date: Sat Oct 17 21:12:15 2026 +0000 initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a750b45 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +*.pem +*.toml +sessman3270 \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..efb9808 --- /dev/null +++ b/LICENSE @@ -0,0 +1,24 @@ +This is free and unencumbered software released into the public domain. + +Anyone is free to copy, modify, publish, use, compile, sell, or +distribute this software, either in source code form or as a compiled +binary, for any purpose, commercial or non-commercial, and by any +means. + +In jurisdictions that recognize copyright laws, the author or authors +of this software dedicate any and all copyright interest in the +software to the public domain. We make this dedication for the benefit +of the public at large and to the detriment of our heirs and +successors. We intend this dedication to be an overt act of +relinquishment in perpetuity of all present and future rights to this +software under copyright law. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR +OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, +ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +OTHER DEALINGS IN THE SOFTWARE. + +For more information, please refer to diff --git a/README.md b/README.md new file mode 100644 index 0000000..543c80e --- /dev/null +++ b/README.md @@ -0,0 +1,82 @@ + +# Sessman 3270 + +A simple session manager for 3270 systems extending go3270 + +Written by a human with AI assistance as an attempt to see what hybrid development could consist of and produce. There is a default user in the example config with the same username as password. + + +## Features + +- Persistent 3270 sessions originating from the sessman host +- Ability to reconnect to persistent session from another 3270 +- PA3 hotkey to background the current session and return to the menu +- Multi user by design, public or password based authentication +- Line Commands to (H)old, (R)esume, (K)ill or start a (B)ackgrounded session +- Admin commands to kill sessions or shut down server remotely +- Cross platform, runs anywhere Go is supported + + +## Deployment + +This project uses native Go tools, to generate a build run + +```bash + go build +``` +Installing sessman3270 consists of copying the config.toml.dist file and changing it to fit your needs. +- If you wish to use SSL/TLS this is set by command line flags + + +## Run + +```bash +Usage of ./sessman3270: + -P Allow public access + -c string + Path to toml config file (default "config.toml") + -k string + Path to TLS private key file (PEM) + -l string + Listen address (default "0.0.0.0:2323") + -p string + Generate bcrypt hash for password and exit + -public + Allow public access + -t string + Path to TLS certificate file (PEM) + +``` + +The most basic way to run sessman3270 is to start the executable, it will look for a config.toml or exit. + +```bash + ./sessman3270 +``` + +Override the listen host with `-l` +```bash + ./sessman3270 -l 192.168.1.20:623 +``` + +Generate a password hash with `-p` + +```bash + ./sessman3270 -p $mySecretP4ssw0rd +``` + +Enable TLS by providing a key and certificate + +```bash + ./sessman3270 -c config.toml -k privkey.pem -t fullchain.pem +``` + + +## Acknowledgements + + - [Go3270](https://https://github.com/racingmars/go3270) + - [Proxy3270](https://github.com/racingmars/proxy3270) + - [TUBES for z/OS](https://www.macro4.com/products/tubes-zos/) + - [Qwen3.6-35B-A3B-MTP-GGUF](https://huggingface.co/byteshape/Qwen3.6-35B-A3B-MTP-GGUF) + - [Gemini 3.8 Flash](https://blog.google/innovation-and-ai/models-and-research/gemini-models/3-8-flash-and-3-8-flash-cyber/) + diff --git a/config.toml.dist b/config.toml.dist new file mode 100644 index 0000000..d8a916a --- /dev/null +++ b/config.toml.dist @@ -0,0 +1,48 @@ +[server] +bind_address = "0.0.0.0:2323" +codepage = "037" +public = true + +[login] +title = "SESSION BROKER" +subtitle = "Enter your Username and Password to begin or resume a session." +prompt_username = "Username ===>" +prompt_password = "Password ===>" +instructions = "[Enter] Log on [Clear] Reset" + +[menu] +title = "SESSION MANAGER" +prompt_select = "Selection ===>" +footer = "[1-N] Connect [PA3] Return to Menu [PF3] Disconnect" + +[[systems]] +name = "PUBVM" +description = "Public VM/SP 6 Service" +host_port = "pubvm.org:623" +tls = true +insecure_skip_verify = true + +[[systems]] +name = "HFVMVS" +description = "HugFreeVikings MVS" +host_port = "MVS.HUGFREEVIKINGS.WTF:1023" +tls = false + +[[systems]] +name = "Forum3270" +description = "3270bbs hosted by Moshix" +host_port = "www.moshix.tech:2023" +tls = true +insecure_skip_verify = true + +[[systems]] +name = "z/Xplore" +description = "IBM z/Xplore System" +host_port = "204.90.115.200:623" +tls = false + +[[users]] +username = "ibmuser" +hash = "$2a$10$/ajS78q3gMgbfiVWkvS5kO/T8tx2yIXuyslg.TeRPgSwyDQgELp9C" +admin = true + diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..0dadfed --- /dev/null +++ b/go.mod @@ -0,0 +1,9 @@ +module sessman3270 + +go 1.27.2 + +require ( + github.com/BurntSushi/toml v1.6.0 + github.com/racingmars/go3270 v0.9.13 + golang.org/x/crypto v0.58.0 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..388ebfc --- /dev/null +++ b/go.sum @@ -0,0 +1,6 @@ +github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= +github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= +github.com/racingmars/go3270 v0.9.13 h1:qFjzx6zUMR0u1dFN3Wv17D5sXdX/PUqBTt77/4angZk= +github.com/racingmars/go3270 v0.9.13/go.mod h1:JCzKbsCGdevsd+2iLMRw3Cd+Wk7vmBeGlnfHmeJEcsU= +golang.org/x/crypto v0.58.0 h1:COkYLr4k7nDI4r6QtMML2AfzeIswWHAJthLS6K/M/54= +golang.org/x/crypto v0.58.0/go.mod h1:Xh+kl5A+M0gMiWDytH2Hhr5Z7WbNCpv/eB/EFgfSk+w= diff --git a/main.go b/main.go new file mode 100644 index 0000000..34e6889 --- /dev/null +++ b/main.go @@ -0,0 +1,1049 @@ +package main + +import ( + "bytes" + "crypto/sha256" + "crypto/subtle" + "crypto/tls" + "encoding/hex" + "errors" + "flag" + "fmt" + "log" + "net" + "os" + "os/signal" + "strings" + "sync" + "syscall" + "time" + + "github.com/BurntSushi/toml" + "github.com/racingmars/go3270" + "golang.org/x/crypto/bcrypt" +) + +func ensureSlotCapacity(s *UserMasterSession, slotIdx int) { + for len(s.Slots) <= slotIdx { + s.Slots = append(s.Slots, nil) + } +} + +var ( + GlobalConfig *Config + ConfiguredSystem []TargetSystem + + configFile string + listen string + certFile string + keyFile string + genPassword string + publicFlag bool + + serverListener net.Listener + shutdownFunc = func() { os.Exit(0) } + adminNoticeSleep = 1500 * time.Millisecond + + registryMu sync.Mutex + userSessions = make(map[string]*UserMasterSession) + + telnetEOR = []byte{IAC, EOR} + telnetDoTimingMark = []byte{IAC, DO, OPT_TIME_MARK} + telnetWillTimingMark = []byte{IAC, WILL, OPT_TIME_MARK} +) + +func init() { + flag.StringVar(&configFile, "c", "config.toml", "Path to toml config file") + flag.StringVar(&listen, "l", "0.0.0.0:2323", "Listen address") + flag.StringVar(&certFile, "t", "", "Path to TLS certificate file (PEM)") + flag.StringVar(&keyFile, "k", "", "Path to TLS private key file (PEM)") + flag.StringVar(&genPassword, "p", "", "Generate bcrypt hash for password and exit") + flag.BoolVar(&publicFlag, "public", false, "Allow public access") + flag.BoolVar(&publicFlag, "P", false, "Allow public access") +} + +func main() { + flag.Parse() + + var hasGenPassword, hasListen bool + flag.CommandLine.Visit(func(f *flag.Flag) { + switch f.Name { + case "p": + hasGenPassword = true + case "l": + hasListen = true + } + }) + + if hasGenPassword { + if len(strings.TrimSpace(genPassword)) < 3 { + fmt.Fprintln(os.Stderr, "Error: Password cannot be less than 3 characters") + os.Exit(1) + } + hash, err := bcrypt.GenerateFromPassword([]byte(genPassword), bcrypt.DefaultCost) + if err != nil { + fmt.Fprintf(os.Stderr, "Error generating bcrypt hash: %v\n", err) + os.Exit(1) + } + fmt.Println(string(hash)) + return + } + + cfg, err := loadHosts(configFile) + if err != nil { + log.Fatalf("Configuration error: %v", err) + } + + if hasListen { + cfg.Server.BindAddress = listen + } + + enabledTLS, certPath, keyPath, err := resolveTLSConfig(certFile, keyFile, &cfg.Server) + if err != nil { + log.Fatalf("TLS configuration error: %v", err) + } + + if publicFlag { + cfg.Public = true + cfg.Server.Public = true + } + + GlobalConfig = cfg + ConfiguredSystem = cfg.Systems + + applyCodepage(cfg.Server.Codepage) + + log.Printf("Loaded configuration from %s (%d target systems, Codepage: %s)", + configFile, len(ConfiguredSystem), cfg.Server.Codepage) + + if len(cfg.Users) > 0 { + if isPublic() { + log.Printf("Public access enabled (%d users configured)", len(cfg.Users)) + } else { + log.Printf("User whitelist enabled (%d users configured)", len(cfg.Users)) + } + } else { + log.Printf("User whitelist not configured (public access mode)") + } + + for _, sys := range ConfiguredSystem { + tlsMode := "Plaintext" + if sys.TLS { + tlsMode = "TLS" + } + log.Printf(" + %s -> %s (%s)", sys.Name, sys.HostPort, tlsMode) + } + + bindAddr := cfg.Server.BindAddress + listener, err := startServerListener(bindAddr, enabledTLS, certPath, keyPath) + if err != nil { + log.Fatalf("Failed to bind on %s: %v", bindAddr, err) + } + serverListener = listener + defer listener.Close() + + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM) + go func() { + sig, ok := <-sigCh + if !ok { + return + } + log.Printf("Received signal %v: draining sessions and shutting down...", sig) + for _, sess := range drainAllSessions() { + terminateSession(sess, "Server shutting down.") + } + _ = listener.Close() + }() + defer signal.Stop(sigCh) + + if enabledTLS { + log.Printf("Session Broker online and listening on %s (TLS)", bindAddr) + } else { + log.Printf("Session Broker online and listening on %s", bindAddr) + } + + var tempDelay time.Duration + for { + conn, err := listener.Accept() + if err != nil { + if errors.Is(err, net.ErrClosed) { + log.Printf("Listener closed, stopping main loop: %v", err) + return + } + if tempDelay == 0 { + tempDelay = 5 * time.Millisecond + } else { + tempDelay *= 2 + } + if maxDelay := 1 * time.Second; tempDelay > maxDelay { + tempDelay = maxDelay + } + log.Printf("Accept error: %v; retrying in %v", err, tempDelay) + time.Sleep(tempDelay) + continue + } + tempDelay = 0 + log.Printf("[%s] Accepted new inbound connection", conn.RemoteAddr()) + go handleNewConnection(conn) + } +} + +func resolveTLSConfig(certFlag, keyFlag string, cfgServer *ServerConfig) (bool, string, string, error) { + cert := strings.TrimSpace(certFlag) + key := strings.TrimSpace(keyFlag) + + tlsEnabled := key != "" || cert != "" + + if tlsEnabled && (key == "" || cert == "") { + return false, "", "", fmt.Errorf("-t and -k require both of each other") + } + + if !tlsEnabled && cfgServer != nil && cfgServer.TLS { + tlsEnabled = true + cert = strings.TrimSpace(cfgServer.CertFile) + key = strings.TrimSpace(cfgServer.KeyFile) + if cert == "" || key == "" { + return false, "", "", fmt.Errorf("both certificate and key are required when TLS is enabled") + } + } + + return tlsEnabled, cert, key, nil +} + +func startServerListener(bindAddr string, enabledTLS bool, certPath, keyPath string) (net.Listener, error) { + if enabledTLS { + cert, err := tls.LoadX509KeyPair(certPath, keyPath) + if err != nil { + return nil, fmt.Errorf("failed to load TLS certificate/key (%s, %s): %w", certPath, keyPath, err) + } + tlsConfig := &tls.Config{ + Certificates: []tls.Certificate{cert}, + } + return tls.Listen("tcp4", bindAddr, tlsConfig) + } + return net.Listen("tcp4", bindAddr) +} + +func computeSessionKey(username, password string) string { + h := sha256.New() + h.Write([]byte(strings.TrimSpace(username))) + h.Write([]byte(":")) + h.Write([]byte(password)) + return hex.EncodeToString(h.Sum(nil)) +} + +func isPublic() bool { + return publicFlag || (GlobalConfig != nil && (GlobalConfig.Public || GlobalConfig.Server.Public)) +} + +func authenticateAndAuthorize(username, password string) AuthResult { + if GlobalConfig == nil || len(GlobalConfig.Users) == 0 { + return AuthResult{OK: true, Reason: "", IsAdmin: false} + } + + trimmedUser := strings.TrimSpace(username) + var matchedUser *UserConfig + for i := range GlobalConfig.Users { + if strings.TrimSpace(GlobalConfig.Users[i].Username) == trimmedUser { + matchedUser = &GlobalConfig.Users[i] + break + } + } + + if matchedUser != nil { + if hash := matchedUser.GetHash(); hash != "" { + if strings.HasPrefix(hash, "$2") { + if err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)); err == nil { + return AuthResult{OK: true, Reason: "", IsAdmin: matchedUser.Admin} + } + } else if subtle.ConstantTimeCompare([]byte(hash), []byte(password)) == 1 { + log.Printf("[SECURITY WARNING] User '%s' authenticated with plaintext password in config. Use -p to hash it.", username) + return AuthResult{OK: true, Reason: "", IsAdmin: matchedUser.Admin} + } + } + } + + if isPublic() { + return AuthResult{OK: true, Reason: "", IsAdmin: false} + } + return AuthResult{OK: false, Reason: fmt.Sprintf("user '%s' not authorized", username)} +} + +func applyCodepage(cp string) { + switch strings.ToLower(strings.TrimSpace(cp)) { + case "1047", "cp1047", "ibm1047": + go3270.SetCodepage(go3270.Codepage1047()) + case "bracket", "brackets", "cpbracket": + go3270.SetCodepage(go3270.CodepageBracket()) + case "500", "cp500": + go3270.SetCodepage(go3270.Codepage500()) + case "273", "cp273": + go3270.SetCodepage(go3270.Codepage273()) + case "1140", "cp1140": + go3270.SetCodepage(go3270.Codepage1140()) + default: + go3270.SetCodepage(go3270.Codepage037()) + } +} + +func loadHosts(configPath string) (*Config, error) { + data, err := os.ReadFile(configPath) + if err != nil { + return nil, fmt.Errorf("could not read %s: %w", configPath, err) + } + + cfg := Config{ + Server: ServerConfig{ + BindAddress: listen, + Codepage: "037", + }, + Login: LoginConfig{ + Title: "SESSMAN3270 SESSION BROKER", + Subtitle: "Enter your Username and Password to begin or resume a session.", + PromptUsername: "Username ===>", + PromptPassword: "Password ===>", + Instructions: "[Enter] Submit [Clear] Reset ", + }, + Menu: MenuConfig{ + Title: "SESSMAN3270 CENTRAL SESSION MANAGER", + PromptSelect: "Selection ===>", + Footer: "[PA3] Return to Menu [PF3] Disconnect", + }, + } + + if _, err := toml.Decode(string(data), &cfg); err != nil { + return nil, fmt.Errorf("failed to parse TOML from %s: %w", configPath, err) + } + + if len(cfg.Systems) == 0 { + return nil, fmt.Errorf("no systems defined in %s", configPath) + } + + return &cfg, nil +} + +func cleanupSessionIfEmpty(s *UserMasterSession) { + if s == nil || s.SessionKey == "" { + return + } + registryMu.Lock() + defer registryMu.Unlock() + + s.Mu.Lock() + shouldDelete := s.IsDisconnected && s.countActiveSlotsLocked() == 0 + key := s.SessionKey + s.Mu.Unlock() + + if shouldDelete && userSessions[key] == s { + delete(userSessions, key) + } +} + +func handleNewConnection(conn net.Conn) { + if tlsConn, ok := conn.(*tls.Conn); ok { + _ = tlsConn.SetDeadline(time.Now().Add(5 * time.Second)) + if err := tlsConn.Handshake(); err != nil { + log.Printf("[%s] TLS handshake failed: %v", conn.RemoteAddr(), err) + _ = conn.Close() + return + } + _ = tlsConn.SetDeadline(time.Time{}) + } + + devInfo, err := go3270.NegotiateTelnet(conn) + if err != nil { + log.Printf("[%s] Telnet negotiation failed: %v", conn.RemoteAddr(), err) + _ = conn.Close() + return + } + termType := ResolveTerminalType(devInfo.TerminalType(), devInfo) + log.Printf("[%s] Telnet negotiation completed (Terminal: %s)", conn.RemoteAddr(), termType) + + creds, err := promptLogin(conn, devInfo) + if err != nil { + log.Printf("[%s] Client disconnected during login: %v", conn.RemoteAddr(), err) + _ = conn.Close() + return + } + + sessionKey := creds.SessionKey + shortTag := sessionKey[:8] + + authRes := authenticateAndAuthorize(creds.Username, creds.Password) + if !authRes.OK { + log.Printf("[%s] Authentication failed for user '%s': %s", conn.RemoteAddr(), creds.Username, authRes.Reason) + send3270Message(conn, fmt.Sprintf("Authentication failed: %s.", authRes.Reason), devInfo.Codepage()) + time.Sleep(adminNoticeSleep) + _ = conn.Close() + return + } + + isAdmin := authRes.IsAdmin + + registryMu.Lock() + userSession, exists := userSessions[sessionKey] + if !exists { + userSession = &UserMasterSession{ + Username: creds.Username, + SessionKey: sessionKey, + DisplayTag: shortTag, + Slots: make([]*SessionSlot, 0), + } + userSessions[sessionKey] = userSession + } + registryMu.Unlock() + + userSession.Mu.Lock() + oldConn := userSession.ClientConn + userSession.ClientConn = conn + userSession.Username = creds.Username + userSession.TerminalType = termType + userSession.DevInfo = devInfo + userSession.IsDisconnected = false + userSession.ActiveSlotIdx = -1 + userSession.IsAdmin = isAdmin + activeSlots := userSession.countActiveSlotsLocked() + userSession.Mu.Unlock() + + if !exists { + log.Printf("[%s] '%s' authenticated -> allocated new session %s (Admin: %v)", conn.RemoteAddr(), creds.Username, shortTag, isAdmin) + } else { + log.Printf("[%s] '%s' authenticated -> resumed existing session %s (%d active slots, Admin: %v)", + conn.RemoteAddr(), creds.Username, shortTag, activeSlots, isAdmin) + } + + if oldConn != nil { + log.Printf("[%s] '%s' Closing previous client connection socket", shortTag, creds.Username) + _ = oldConn.Close() + } + + if activeSlots > 0 { + handleResumeSession(userSession, conn) + return + } + + runMenuLoop(userSession, conn) +} + +func dialUpstream(target TargetSystem) (net.Conn, error) { + dialer := &net.Dialer{Timeout: 10 * time.Second} + + if target.TLS { + host, _, err := net.SplitHostPort(target.HostPort) + if err != nil { + host = target.HostPort + } + tlsConfig := &tls.Config{ + InsecureSkipVerify: target.InsecureSkipVerify, + ServerName: host, + } + return tls.DialWithDialer(dialer, "tcp", target.HostPort, tlsConfig) + } + + return dialer.Dial("tcp", target.HostPort) +} + +func sendQueryReply(s *UserMasterSession, upConn net.Conn, isTN3270E, isBound bool) { + s.Mu.Lock() + termType := s.TerminalType + if termType == "" { + termType = ResolveTerminalType(termType, s.DevInfo) + } + rows, cols, _ := GetTerminalMatrix(termType, s.DevInfo) + s.Mu.Unlock() + isColor := strings.Contains(termType, "3279") + reply := buildQueryReply(rows, cols, isColor, isTN3270E, isBound) + _, _ = upConn.Write(reply) +} + +func ensureSlotConnected(s *UserMasterSession, slotIdx int, background bool, clientConn net.Conn) *SessionSlot { + s.Mu.Lock() + if s.ClientConn != clientConn { + s.Mu.Unlock() + return nil + } + + username, displayTag := s.Username, s.DisplayTag + + if slotIdx < len(s.Slots) && s.Slots[slotIdx] != nil { + slot := s.Slots[slotIdx] + if background { + log.Printf("[%s] '%s' Slot [%d] %s already active in background", displayTag, username, slotIdx+1, slot.System.Name) + } else { + log.Printf("[%s] '%s' Resuming existing slot [%d] %s", displayTag, username, slotIdx+1, slot.System.Name) + } + s.Mu.Unlock() + return slot + } + + if slotIdx < 0 || slotIdx >= len(ConfiguredSystem) { + s.Mu.Unlock() + return nil + } + + termType := ResolveTerminalType(s.TerminalType, s.DevInfo) + _, _, maxBuf := GetTerminalMatrix(termType, s.DevInfo) + cp := s.codepageLocked() + s.Mu.Unlock() + + target := ConfiguredSystem[slotIdx] + mode := "TCP" + if target.TLS { + mode = "TLS" + } + if background { + log.Printf("[%s] '%s' Background dialing %s (%s, %s, Terminal: %s)...", displayTag, username, target.Name, target.HostPort, mode, termType) + } else { + log.Printf("[%s] '%s' Dialing %s (%s, %s, Terminal: %s)...", displayTag, username, target.Name, target.HostPort, mode, termType) + } + + upConn, err := dialUpstream(target) + if err != nil { + log.Printf("[%s] '%s' Failed to connect to %s (%s): %v", displayTag, username, target.Name, target.HostPort, err) + send3270Message(clientConn, fmt.Sprintf("Error dialing %s over %s: %v", target.Name, mode, err), cp) + time.Sleep(adminNoticeSleep) + return nil + } + + initData, isTN3270E, isBound, err := negotiateUpstreamTelnet(upConn, termType) + if err != nil { + _ = upConn.Close() + log.Printf("[%s] '%s' Upstream telnet negotiation failed for %s: %v", displayTag, username, target.Name, err) + send3270Message(clientConn, fmt.Sprintf("Error negotiating with %s: %v", target.Name, err), cp) + time.Sleep(adminNoticeSleep) + return nil + } + + if background { + log.Printf("[%s] '%s' Backgrounded connection established to %s (%s, TN3270E: %v, Terminal: %s)", displayTag, username, target.Name, target.HostPort, isTN3270E, termType) + if is3270Query(initData) { + sendQueryReply(s, upConn, isTN3270E, isBound) + } + } else { + log.Printf("[%s] '%s' Upstream connection to %s (%s)", displayTag, username, target.Name, target.HostPort) + } + + s.Mu.Lock() + defer s.Mu.Unlock() + if s.ClientConn != clientConn { + _ = upConn.Close() + return nil + } + + ensureSlotCapacity(s, slotIdx) + + if s.Slots[slotIdx] != nil { + _ = upConn.Close() + return s.Slots[slotIdx] + } + + slot := &SessionSlot{ + System: target, + Upstream: upConn, + LastScreen: initData, + IsTN3270E: isTN3270E, + IsBound: isBound, + MaxBufferSize: maxBuf, + } + s.Slots[slotIdx] = slot + go upstreamReader(s, slot, slotIdx) + return slot +} + +func connectOrResumeSlot(s *UserMasterSession, slotIdx int, clientConn net.Conn) { + slot := ensureSlotConnected(s, slotIdx, false, clientConn) + if slot == nil { + return + } + + s.Mu.Lock() + if s.ClientConn != clientConn { + s.Mu.Unlock() + return + } + s.ActiveSlotIdx = slotIdx + s.IsDisconnected = false + s.Mu.Unlock() + + defer func() { + s.Mu.Lock() + if s.ClientConn == clientConn { + s.ActiveSlotIdx = -1 + } + s.Mu.Unlock() + }() + + slot.Mu.Lock() + slot.ScreenChanged = false + slot.IsHeld = false + if len(slot.LastScreen) > 0 { + _, _ = clientConn.Write(slot.LastScreen) + } + slot.Mu.Unlock() + + bridgeClientToUpstream(s, slot, clientConn) +} + +func cleanCloseUpstream(up net.Conn, isTN3270E bool) { + if up == nil { + return + } + defer up.Close() + + _ = up.SetWriteDeadline(time.Now().Add(250 * time.Millisecond)) + + // 1. If TN3270E, send RFC 2355 UNBIND data message (reason 0x01: normal end of session) + if isTN3270E { + unbindMsg := []byte{TN3270E_DATA_UNBIND, 0x00, 0x00, 0x00, 0x00, TN3270E_UNBIND_NORMAL, IAC, EOR} + _, _ = up.Write(unbindMsg) + } + + // 2. Send RFC 854 Telnet option unwinding + unwind := make([]byte, 0, 18) + unwind = append(unwind, + IAC, WONT, OPT_BINARY, + IAC, DONT, OPT_BINARY, + IAC, WONT, OPT_EOR, + IAC, DONT, OPT_EOR, + ) + if isTN3270E { + unwind = append(unwind, IAC, WONT, OPT_TN3270E, IAC, DONT, OPT_TN3270E) + } + _, _ = up.Write(unwind) +} + +func killSlot(s *UserMasterSession, slotIdx int) { + s.Mu.Lock() + if slotIdx < 0 || slotIdx >= len(s.Slots) || s.Slots[slotIdx] == nil { + s.Mu.Unlock() + return + } + slot := s.Slots[slotIdx] + s.Slots[slotIdx] = nil + if s.ActiveSlotIdx == slotIdx { + s.ActiveSlotIdx = -1 + } + sysName := slot.System.Name + username, displayTag := s.Username, s.DisplayTag + s.Mu.Unlock() + + slot.Mu.Lock() + up := slot.Upstream + isTN3270E := slot.IsTN3270E + slot.Upstream = nil + slot.Mu.Unlock() + + cleanCloseUpstream(up, isTN3270E) + cleanupSessionIfEmpty(s) + + log.Printf("[%s] '%s' Killed system connection [%d] %s (TN3270E: %v)", displayTag, username, slotIdx+1, sysName, isTN3270E) +} + +func bridgeClientToUpstream(s *UserMasterSession, slot *SessionSlot, clientConn net.Conn) { + if clientConn == nil { + return + } + buf := make([]byte, 4096) + + for { + n, err := clientConn.Read(buf) + if err != nil { + s.Mu.Lock() + username, displayTag := s.Username, s.DisplayTag + if s.ClientConn == clientConn { + log.Printf("[%s] '%s' disconnected during active session [%s].", displayTag, username, slot.System.Name) + s.IsDisconnected = true + s.ActiveSlotIdx = -1 + s.Mu.Unlock() + cleanupSessionIfEmpty(s) + } else { + log.Printf("[%s] '%s' Transferring previous session [%s] to new terminal.", displayTag, username, slot.System.Name) + s.Mu.Unlock() + } + return + } + + packet := buf[:n] + aid := extractAID(packet) + if aid == go3270.AIDPA3 { + username, displayTag := s.Identity() + log.Printf("[%s] '%s' sent PA3. Backgrounding [%s] and returning to menu.", displayTag, username, slot.System.Name) + return + } + + slot.Mu.Lock() + upConn := slot.Upstream + isTN3270E := slot.IsTN3270E + isBound := slot.IsBound + seq := slot.SeqNumber + if isTN3270E && isBound { + slot.SeqNumber = (seq + 1) & TN3270E_MAX_SEQ + } + slot.Mu.Unlock() + + if upConn == nil { + username, displayTag := s.Identity() + log.Printf("[%s] '%s' Upstream host [%s] has disconnected.", displayTag, username, slot.System.Name) + send3270Message(clientConn, fmt.Sprintf("Upstream host %s sent disconnect.", slot.System.Name), s.Codepage()) + time.Sleep(adminNoticeSleep) + return + } + + toSend := packet + if isTN3270E { + if isBound { + seqHi := byte(seq >> 8) + seqLo := byte(seq & 0xFF) + if seqLo == IAC { + toSend = make([]byte, 0, 6+len(packet)) + toSend = append(toSend, TN3270E_DATA_3270, 0x00, 0x00, seqHi, IAC, IAC) + } else { + toSend = make([]byte, 0, 5+len(packet)) + toSend = append(toSend, TN3270E_DATA_3270, 0x00, 0x00, seqHi, seqLo) + } + } else { + toSend = make([]byte, 0, 5+len(packet)) + toSend = append(toSend, TN3270E_DATA_SSCP_LU, 0x00, 0x00, 0x00, 0x00) + } + toSend = append(toSend, packet...) + } + + _, err = upConn.Write(toSend) + if err != nil { + username, displayTag := s.Identity() + log.Printf("[%s] '%s' Upstream write error on [%s]: %v", displayTag, username, slot.System.Name, err) + return + } + } +} + +func upstreamReader(s *UserMasterSession, slot *SessionSlot, slotIdx int) { + buf := make([]byte, 8192) + var recBuf []byte + + for { + slot.Mu.Lock() + upConn := slot.Upstream + slot.Mu.Unlock() + if upConn == nil { + return + } + + n, err := upConn.Read(buf) + if err != nil { + username, displayTag := s.Identity() + log.Printf("[%s] '%s' Upstream host %s severed connection. Killing [%d].", displayTag, username, slot.System.Name, slotIdx+1) + slot.Mu.Lock() + if slot.Upstream != nil { + _ = slot.Upstream.Close() + slot.Upstream = nil + } + slot.Mu.Unlock() + + s.Mu.Lock() + if slotIdx < len(s.Slots) && s.Slots[slotIdx] == slot { + s.Slots[slotIdx] = nil + if s.ActiveSlotIdx == slotIdx { + s.ActiveSlotIdx = -1 + } + } + s.Mu.Unlock() + cleanupSessionIfEmpty(s) + return + } + + data := buf[:n] + + // Handle upstream IAC DO TIMING-MARK (FF FD 06) keep-alive locally + if bytes.Contains(data, telnetDoTimingMark) { + _, _ = upConn.Write(telnetWillTimingMark) + data = bytes.ReplaceAll(data, telnetDoTimingMark, nil) + if len(data) == 0 { + continue + } + } + + s.Mu.Lock() + isActive := !s.IsDisconnected && (s.ActiveSlotIdx >= 0 && s.ActiveSlotIdx < len(s.Slots) && s.Slots[s.ActiveSlotIdx] == slot) + client := s.ClientConn + username, displayTag := s.Username, s.DisplayTag + _, _, defaultMaxBuf := GetTerminalMatrix(s.TerminalType, s.DevInfo) + slot.Mu.Lock() + isTN3270E := slot.IsTN3270E + slot.Mu.Unlock() + s.Mu.Unlock() + + if !isTN3270E && isActive && client != nil { + _, _ = client.Write(data) + } + + // Cap buffer at 64KB to avoid unbounded memory use from constant ticks + if len(recBuf) > 65536 { + recBuf = recBuf[len(recBuf)-16384:] + } + recBuf = append(recBuf, data...) + for { + idx := indexTelnetEOR(recBuf) + if idx == -1 { + break + } + record := recBuf[:idx+2] + recBuf = recBuf[idx+2:] + + screenData := record + if isTN3270E && len(record) >= 7 { // 5-byte header + data + IAC EOR (2 bytes) + dataType := record[0] + respFlag := record[2] + seqHi := record[3] + seqLo := record[4] + + if dataType == TN3270E_DATA_UNBIND { + reason := byte(0) + if len(record) > 7 { + reason = record[5] + } + log.Printf("[%s] '%s' Upstream host %s sent UNBIND (reason: 0x%02x). Session returned to unbound/SSCP-LU state.", displayTag, username, slot.System.Name, reason) + slot.Mu.Lock() + slot.IsBound = false + slot.Mu.Unlock() + continue + } + + if respFlag == TN3270E_RESP_ALWAYS { + ack := []byte{TN3270E_DATA_RESP, 0x00, 0x00, seqHi, seqLo, 0x00, IAC, EOR} + _, _ = upConn.Write(ack) + } + + switch dataType { + case TN3270E_DATA_BIND: + slot.Mu.Lock() + slot.IsBound = true + slot.Mu.Unlock() + continue + case TN3270E_DATA_3270: + slot.Mu.Lock() + slot.IsBound = true + slot.Mu.Unlock() + screenData = record[5:] + case TN3270E_DATA_SSCP_LU: + slot.Mu.Lock() + slot.IsBound = false + slot.Mu.Unlock() + screenData = record[5:] + default: + continue + } + } + + if isTN3270E && isActive && client != nil { + _, _ = client.Write(screenData) + } + + // If background session's host sends a 3270 Query command, respond + // with the attached terminal's characteristics locally + if !isActive && is3270Query(screenData) { + slot.Mu.Lock() + isBound := slot.IsBound + slot.Mu.Unlock() + sendQueryReply(s, upConn, isTN3270E, isBound) + } + + if len(screenData) > 2 { + firstByte := screenData[0] + isEraseWrite := firstByte == CMD_ERASE_WRITE_SNA || firstByte == CMD_ERASE_WRITE || + firstByte == CMD_ERASE_WRITE_ALT_SNA || firstByte == CMD_ERASE_WRITE_ALT || + firstByte == CMD_ERASE_ALL_UNPROTECTED + + slot.Mu.Lock() + if slot.MaxBufferSize <= 0 { + slot.MaxBufferSize = defaultMaxBuf + } + maxBuf := slot.MaxBufferSize + if slot.IsHeld { + // Allow screen erase/resets so base state is accurate, but + // drop/suppress incremental write ticks and do not set ScreenChanged + if isEraseWrite { + slot.LastScreen = bytes.Clone(screenData) + } + slot.Mu.Unlock() + continue + } + + if isEraseWrite || len(slot.LastScreen) == 0 { + slot.LastScreen = bytes.Clone(screenData) + } else if len(slot.LastScreen)+len(screenData) <= maxBuf { + slot.LastScreen = append(slot.LastScreen, screenData...) + } else { + // Exceeds maxBuf: retain initial screen baseline and append newest write update, + // trimming older intermediate writes so total buffer remains strictly <= maxBuf + baseLen := min(maxBuf/2, len(slot.LastScreen)) + eorIdx := lastIndexTelnetEOR(slot.LastScreen[:baseLen]) + if eorIdx != -1 { + baseLen = eorIdx + 2 + } + newBuf := make([]byte, 0, baseLen+len(screenData)) + newBuf = append(newBuf, slot.LastScreen[:baseLen]...) + newBuf = append(newBuf, screenData...) + slot.LastScreen = newBuf + } + if !isActive { + slot.ScreenChanged = true + } + slot.Mu.Unlock() + } + } + } +} + +func findSession(targetID string) *UserMasterSession { + cleanID := strings.TrimSpace(targetID) + cleanTag := strings.TrimPrefix(cleanID, "#") + + registryMu.Lock() + defer registryMu.Unlock() + + // 1. Exact match on DisplayTag (hex, case-insensitive) + for _, sess := range userSessions { + _, displayTag := sess.Identity() + if strings.EqualFold(displayTag, cleanTag) { + return sess + } + } + + // 2. Direct map lookup on SessionKey (hex, case-insensitive) + if sess, ok := userSessions[strings.ToLower(cleanID)]; ok { + return sess + } + + // 3. Exact match on Username (case-sensitive) + for _, sess := range userSessions { + username, _ := sess.Identity() + if username == cleanID { + return sess + } + } + + // 4. Prefix match on SessionKey (at least 4 chars) + if len(cleanTag) >= 4 { + cleanTagLower := strings.ToLower(cleanTag) + for _, sess := range userSessions { + if strings.HasPrefix(strings.ToLower(sess.SessionKey), cleanTagLower) { + return sess + } + } + } + + return nil +} + +func terminateSession(sess *UserMasterSession, msg string) { + sess.Mu.Lock() + type upInfo struct { + conn net.Conn + isTN3270E bool + } + var upstreams []upInfo + for _, slot := range sess.Slots { + if slot != nil { + slot.Mu.Lock() + if slot.Upstream != nil { + upstreams = append(upstreams, upInfo{conn: slot.Upstream, isTN3270E: slot.IsTN3270E}) + slot.Upstream = nil + } + slot.Mu.Unlock() + } + } + sess.Slots = nil + sess.ActiveSlotIdx = -1 + sess.IsDisconnected = true + c := sess.ClientConn + sess.ClientConn = nil + cp := sess.codepageLocked() + sess.Mu.Unlock() + + if c != nil { + if msg != "" { + send3270Message(c, msg, cp) + time.Sleep(adminNoticeSleep / 5) + } + _ = c.Close() + } + + for _, up := range upstreams { + cleanCloseUpstream(up.conn, up.isTN3270E) + } +} + +func handleAdminKill(adminSession *UserMasterSession, targetID string) bool { + adminSession.Mu.Lock() + adminUser := adminSession.Username + adminConn := adminSession.ClientConn + adminCP := adminSession.codepageLocked() + adminSession.Mu.Unlock() + + target := findSession(targetID) + if target == nil { + log.Printf("[ADMIN] %s attempted to kill session '%s' (not found)", adminUser, targetID) + send3270Message(adminConn, fmt.Sprintf("Session '%s' not found.", targetID), adminCP) + time.Sleep(adminNoticeSleep) + return false + } + + targetUser, targetTag := target.Identity() + log.Printf("[ADMIN] %s killing session '%s' (User: %s)", adminUser, targetTag, targetUser) + + registryMu.Lock() + delete(userSessions, target.SessionKey) + registryMu.Unlock() + + if target == adminSession { + terminateSession(target, "Session terminated.") + return true + } + + terminateSession(target, "Session terminated by administrator.") + send3270Message(adminConn, fmt.Sprintf("Session '%s' (%s) terminated.", targetTag, targetUser), adminCP) + time.Sleep(adminNoticeSleep) + return false +} + +func drainAllSessions() []*UserMasterSession { + registryMu.Lock() + defer registryMu.Unlock() + + allSessions := make([]*UserMasterSession, 0, len(userSessions)) + for _, sess := range userSessions { + allSessions = append(allSessions, sess) + } + userSessions = make(map[string]*UserMasterSession) + return allSessions +} + +func handleAdminKillAll(adminSession *UserMasterSession) { + adminUser, _ := adminSession.Identity() + log.Printf("[ADMIN] %s executed 'killall'", adminUser) + + allSessions := drainAllSessions() + for _, sess := range allSessions { + terminateSession(sess, "Session terminated by administrator.") + } +} + +func handleAdminBye(adminSession *UserMasterSession) { + adminUser, _ := adminSession.Identity() + log.Printf("[ADMIN] %s executed 'bye': shutting down server", adminUser) + + allSessions := drainAllSessions() + for _, sess := range allSessions { + terminateSession(sess, "Server shutting down.") + } + + if serverListener != nil { + _ = serverListener.Close() + } + + if shutdownFunc != nil { + shutdownFunc() + } +} diff --git a/main_test.go b/main_test.go new file mode 100644 index 0000000..f23e132 --- /dev/null +++ b/main_test.go @@ -0,0 +1,3637 @@ +package main + +//This file was entirely AI managed + +import ( + "bytes" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/tls" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "fmt" + "io" + "math/big" + "net" + "os" + "path/filepath" + "strconv" + "strings" + "testing" + "time" + + "github.com/BurntSushi/toml" + "github.com/racingmars/go3270" + "golang.org/x/crypto/bcrypt" +) + +// TestBackgroundSessionBuffering verifies that: +// 1. When a session is in the background (ActiveSlotIdx == -1), updates from upstream +// do NOT force themselves onto the client connection. +// 2. Updates are properly buffered in slot.LastScreen. +// 3. When the session is reconnected, the buffered screen updates are sent to the client. +func TestBackgroundSessionBuffering(t *testing.T) { + clientServer, clientUser := net.Pipe() + defer clientServer.Close() + defer clientUser.Close() + + upServer, upClient := net.Pipe() + defer upServer.Close() + defer upClient.Close() + + initialScreen := []byte{CMD_ERASE_WRITE, 0xC3, 0x11, 0x40, 0x40, 'H', 'E', 'L', 'L', 'O', IAC, EOR} // EW command + data + IAC EOR + + slot := &SessionSlot{ + System: TargetSystem{Name: "Test System"}, + Upstream: upClient, + LastScreen: initialScreen, + } + + userSession := &UserMasterSession{ + Username: "TESTUSER", + SessionKey: "dummykey", + DisplayTag: "12345678", + TerminalType: "IBM-3278-2", + ClientConn: clientServer, + ActiveSlotIdx: -1, // Session is currently in background / menu + Slots: []*SessionSlot{slot}, + IsDisconnected: false, + } + + go upstreamReader(userSession, slot, 0) + + // Send an update while session is in background + updatePacket := []byte{CMD_WRITE, 0xC3, 0x11, 0x40, 0x50, 'W', 'O', 'R', 'L', 'D', IAC, EOR} // W command + data + IAC EOR + + doneWrite := make(chan struct{}) + go func() { + _, _ = upServer.Write(updatePacket) + close(doneWrite) + }() + + select { + case <-doneWrite: + case <-time.After(2 * time.Second): + t.Fatal("Timeout waiting for upstream write") + } + + // Give upstreamReader a moment to process + time.Sleep(100 * time.Millisecond) + + // Verify nothing was written to the client connection + buf := make([]byte, 256) + _ = clientUser.SetReadDeadline(time.Now().Add(200 * time.Millisecond)) + n, err := clientUser.Read(buf) + if err == nil && n > 0 { + t.Fatalf("Expected NO data on client connection while session is in background, but got %d bytes: %x", n, buf[:n]) + } + + // Verify that the update WAS buffered in slot.LastScreen + slot.Mu.Lock() + lastScreen := make([]byte, len(slot.LastScreen)) + copy(lastScreen, slot.LastScreen) + slot.Mu.Unlock() + + expectedCombined := append(append([]byte{}, initialScreen...), updatePacket...) + if !bytes.Equal(lastScreen, expectedCombined) { + t.Fatalf("Buffered LastScreen does not match expected combined records.\nGot: %x\nExpected: %x", lastScreen, expectedCombined) + } + + // Now simulate reconnecting: user enters session + userSession.Mu.Lock() + userSession.ActiveSlotIdx = 0 + userSession.Mu.Unlock() + + // Writing slot.LastScreen to clientConn (as connectOrResumeSlot does) + go func() { + slot.Mu.Lock() + _, _ = userSession.ClientConn.Write(slot.LastScreen) + slot.Mu.Unlock() + }() + + _ = clientUser.SetReadDeadline(time.Now().Add(2 * time.Second)) + reconnectedData := make([]byte, len(expectedCombined)) + _, err = io.ReadFull(clientUser, reconnectedData) + if err != nil { + t.Fatalf("Failed to read buffered screen upon reconnect: %v", err) + } + if !bytes.Equal(reconnectedData, expectedCombined) { + t.Fatalf("Reconnected client received incorrect screen data.\nGot: %x\nExpected: %x", reconnectedData, expectedCombined) + } + + // Now send a live update while session IS active + liveUpdate := []byte{CMD_WRITE, 0xC3, 0x11, 0x40, 0x60, 'L', 'I', 'V', 'E', IAC, EOR} + go func() { + _, _ = upServer.Write(liveUpdate) + }() + + liveBuf := make([]byte, len(liveUpdate)) + _, err = io.ReadFull(clientUser, liveBuf) + if err != nil { + t.Fatalf("Active session failed to forward live update: %v", err) + } + if !bytes.Equal(liveBuf, liveUpdate) { + t.Fatalf("Active session live update mismatch.\nGot: %x\nExpected: %x", liveBuf, liveUpdate) + } +} + +// TestEraseWriteReplacesBuffer verifies that an Erase/Write command clears +// previously accumulated Write records instead of growing indefinitely. +func TestEraseWriteReplacesBuffer(t *testing.T) { + clientServer, clientUser := net.Pipe() + defer clientServer.Close() + defer clientUser.Close() + + upServer, upClient := net.Pipe() + defer upServer.Close() + defer upClient.Close() + + slot := &SessionSlot{ + System: TargetSystem{Name: "TESTSYS"}, + Upstream: upClient, + LastScreen: []byte{CMD_ERASE_WRITE, 0xC1, 'A', IAC, EOR}, + } + + userSession := &UserMasterSession{ + Username: "USER1", + ClientConn: clientServer, + ActiveSlotIdx: -1, + Slots: []*SessionSlot{slot}, + } + + go upstreamReader(userSession, slot, 0) + + // Send Write update + write1 := []byte{CMD_WRITE, 0xC1, 'B', IAC, EOR} + _, _ = upServer.Write(write1) + time.Sleep(50 * time.Millisecond) + + // Now send a new full Erase/Write screen + newEW := []byte{CMD_ERASE_WRITE, 0xC2, 'C', IAC, EOR} + _, _ = upServer.Write(newEW) + time.Sleep(50 * time.Millisecond) + + slot.Mu.Lock() + lastScreen := make([]byte, len(slot.LastScreen)) + copy(lastScreen, slot.LastScreen) + slot.Mu.Unlock() + + if !bytes.Equal(lastScreen, newEW) { + t.Fatalf("Expected slot.LastScreen to be replaced by new Erase/Write.\nGot: %x\nExpected: %x", lastScreen, newEW) + } +} + +// TestMultiRecordFraming verifies that multiple records ending in IAC EOR within +// a single TCP packet are properly parsed and handled without dropping records. +func TestMultiRecordFraming(t *testing.T) { + clientServer, clientUser := net.Pipe() + defer clientServer.Close() + defer clientUser.Close() + + upServer, upClient := net.Pipe() + defer upServer.Close() + defer upClient.Close() + + slot := &SessionSlot{ + System: TargetSystem{Name: "TESTSYS"}, + Upstream: upClient, + LastScreen: nil, + } + + userSession := &UserMasterSession{ + Username: "USER1", + ClientConn: clientServer, + ActiveSlotIdx: -1, + Slots: []*SessionSlot{slot}, + } + + go upstreamReader(userSession, slot, 0) + + rec1 := []byte{CMD_ERASE_WRITE, 0xC1, 'X', IAC, EOR} + rec2 := []byte{CMD_WRITE, 0xC2, 'Y', IAC, EOR} + combined := append(append([]byte{}, rec1...), rec2...) + + _, _ = upServer.Write(combined) + time.Sleep(100 * time.Millisecond) + + slot.Mu.Lock() + lastScreen := make([]byte, len(slot.LastScreen)) + copy(lastScreen, slot.LastScreen) + slot.Mu.Unlock() + + if !bytes.Equal(lastScreen, combined) { + t.Fatalf("Expected both records preserved in LastScreen.\nGot: %x\nExpected: %x", lastScreen, combined) + } +} + +// TestMultipleSessionsBackgroundIsolation verifies that when Session 1 is active, +// background updates to Session 0 do NOT bleed into the client connection. +func TestMultipleSessionsBackgroundIsolation(t *testing.T) { + clientServer, clientUser := net.Pipe() + defer clientServer.Close() + defer clientUser.Close() + + upServer0, upClient0 := net.Pipe() + defer upServer0.Close() + defer upClient0.Close() + + upServer1, upClient1 := net.Pipe() + defer upServer1.Close() + defer upClient1.Close() + + slot0 := &SessionSlot{ + System: TargetSystem{Name: "SYS0"}, + Upstream: upClient0, + LastScreen: []byte{CMD_ERASE_WRITE, 0xC0, '0', IAC, EOR}, + } + slot1 := &SessionSlot{ + System: TargetSystem{Name: "SYS1"}, + Upstream: upClient1, + LastScreen: []byte{CMD_ERASE_WRITE, 0xC1, '1', IAC, EOR}, + } + + userSession := &UserMasterSession{ + Username: "USER1", + ClientConn: clientServer, + ActiveSlotIdx: 1, // Slot 1 is currently active! Slot 0 is in background! + Slots: []*SessionSlot{slot0, slot1}, + } + + go upstreamReader(userSession, slot0, 0) + go upstreamReader(userSession, slot1, 1) + + // Send an update on Slot 0 (background) + up0Update := []byte{CMD_WRITE, 0xC0, 'U', 'P', 'D', '0', IAC, EOR} + _, _ = upServer0.Write(up0Update) + time.Sleep(50 * time.Millisecond) + + // Send an update on Slot 1 (active) + up1Update := []byte{CMD_WRITE, 0xC1, 'A', 'C', 'T', '1', IAC, EOR} + go func() { + _, _ = upServer1.Write(up1Update) + }() + + // The client should receive ONLY up1Update, NOT up0Update! + buf := make([]byte, len(up1Update)) + _ = clientUser.SetReadDeadline(time.Now().Add(1 * time.Second)) + _, err := io.ReadFull(clientUser, buf) + if err != nil { + t.Fatalf("Failed to read from active slot: %v", err) + } + if !bytes.Equal(buf, up1Update) { + t.Fatalf("Expected active slot data %x, but got %x", up1Update, buf) + } + + // Verify nothing else was written + extraBuf := make([]byte, 100) + _ = clientUser.SetReadDeadline(time.Now().Add(100 * time.Millisecond)) + n, _ := clientUser.Read(extraBuf) + if n > 0 { + t.Fatalf("Unexpected extra data on client (Slot 0 leaked!): %x", extraBuf[:n]) + } + + // Verify Slot 0 DID buffer its update + slot0.Mu.Lock() + slot0Last := make([]byte, len(slot0.LastScreen)) + copy(slot0Last, slot0.LastScreen) + slot0.Mu.Unlock() + + expectedSlot0 := append([]byte{CMD_ERASE_WRITE, 0xC0, '0', IAC, EOR}, up0Update...) + if !bytes.Equal(slot0Last, expectedSlot0) { + t.Fatalf("Slot 0 failed to buffer update.\nGot: %x\nExpected: %x", slot0Last, expectedSlot0) + } +} + +// TestPA3SwitchToBackground verifies that PA3 exits bridgeClientToUpstream +// and resets ActiveSlotIdx to -1. +func TestPA3SwitchToBackground(t *testing.T) { + clientServer, clientUser := net.Pipe() + defer clientServer.Close() + defer clientUser.Close() + + upServer, upClient := net.Pipe() + defer upServer.Close() + defer upClient.Close() + + slot := &SessionSlot{ + System: TargetSystem{Name: "SYS"}, + Upstream: upClient, + } + + userSession := &UserMasterSession{ + Username: "USER1", + ClientConn: clientServer, + ActiveSlotIdx: 0, + Slots: []*SessionSlot{slot}, + } + + bridgeDone := make(chan struct{}) + go func() { + bridgeClientToUpstream(userSession, slot, clientServer) + close(bridgeDone) + }() + + // Send PA3 from client + pa3Packet := []byte{byte(go3270.AIDPA3)} + _, _ = clientUser.Write(pa3Packet) + + select { + case <-bridgeDone: + // Bridge exited properly on PA3 + case <-time.After(2 * time.Second): + t.Fatal("bridgeClientToUpstream failed to return on PA3") + } +} + +// TestBackgroundScreenNotice verifies that when a background session receives +// a screen update, its ScreenChanged flag is set. +func TestBackgroundScreenNotice(t *testing.T) { + clientServer, clientUser := net.Pipe() + defer clientServer.Close() + defer clientUser.Close() + + upServer, upClient := net.Pipe() + defer upServer.Close() + defer upClient.Close() + + initialScreen := []byte{CMD_ERASE_WRITE, 0xC3, 'I', 'N', 'I', 'T', IAC, EOR} + slot := &SessionSlot{ + System: TargetSystem{Name: "System 1"}, + Upstream: upClient, + LastScreen: initialScreen, + } + + slot.Mu.Lock() + initialChanged := slot.ScreenChanged + slot.Mu.Unlock() + if initialChanged { + t.Fatalf("Expected initial ScreenChanged to be false") + } + + userSession := &UserMasterSession{ + Username: "USER1", + ClientConn: clientServer, + ActiveSlotIdx: -1, // in background + Slots: []*SessionSlot{slot}, + } + + go upstreamReader(userSession, slot, 0) + + // Send an update while in background + updatePacket := []byte{CMD_WRITE, 0xC3, 'U', 'P', 'D', IAC, EOR} + _, _ = upServer.Write(updatePacket) + time.Sleep(100 * time.Millisecond) + + slot.Mu.Lock() + changedAfterUpdate := slot.ScreenChanged + slot.Mu.Unlock() + if !changedAfterUpdate { + t.Fatalf("Expected ScreenChanged to be true after background update") + } +} + +func TestSend3270Message(t *testing.T) { + clientServer, clientUser := net.Pipe() + defer clientServer.Close() + defer clientUser.Close() + + done := make(chan struct{}) + go func() { + buf := make([]byte, 1024) + n, err := clientUser.Read(buf) + if err != nil { + t.Errorf("Read error: %v", err) + } + if n == 0 { + t.Errorf("Expected data from send3270Message, got 0 bytes") + } + close(done) + }() + + send3270Message(clientServer, "Test Notification", nil) + + select { + case <-done: + case <-time.After(2 * time.Second): + t.Fatal("Timeout waiting for send3270Message output") + } +} + +func TestUserMasterSessionCodepage(t *testing.T) { + s := &UserMasterSession{} + if s.Codepage() != nil { + t.Fatalf("Expected nil codepage when DevInfo is nil") + } +} + +func generateTestSystems(n int) []TargetSystem { + systems := make([]TargetSystem, n) + for i := 0; i < n; i++ { + systems[i] = TargetSystem{ + Name: fmt.Sprintf("SYS%02d", i+1), + Description: fmt.Sprintf("Test System #%d", i+1), + HostPort: "127.0.0.1:2323", + } + } + return systems +} + +func TestMenuPaginationScreenLayout(t *testing.T) { + origConfig := GlobalConfig + origSystems := ConfiguredSystem + defer func() { + GlobalConfig = origConfig + ConfiguredSystem = origSystems + }() + + GlobalConfig = &Config{ + Menu: MenuConfig{ + Title: "TEST SESSION MANAGER", + PromptSelect: "Selection ===>", + Footer: "[1-N] Connect [PF7/PF8] Page [PF3] Disconnect", + }, + } + ConfiguredSystem = generateTestSystems(99) + + s := &UserMasterSession{ + Username: "TESTUSER", + DisplayTag: "12345678", + } + + // 99 systems / 13 per page = 8 pages (pages 0 through 7) + for page := 0; page < 8; page++ { + s.Mu.Lock() + s.MenuPage = page + s.Mu.Unlock() + + screen := buildMainMenuScreen(s) + + // 1. Verify 24-line screen compatibility (no fields on Row < 0 or Row > 23) + // and verify Row 20 is blank (no fields on Row 20) + hasRow20 := false + var row21Fields []go3270.Field + var row22Fields []go3270.Field + var row23Fields []go3270.Field + systemRows := make(map[int]string) + + for _, f := range screen { + if f.Row < 0 || f.Row > 23 { + t.Fatalf("Page %d: Field exceeds 24-line limit (Row %d): %+v", page+1, f.Row, f) + } + if f.Col < 0 || f.Col > 79 { + t.Fatalf("Page %d: Field exceeds 80-column limit (Col %d): %+v", page+1, f.Col, f) + } + if f.Row == 20 { + hasRow20 = true + } + if f.Row == 21 { + row21Fields = append(row21Fields, f) + } + if f.Row == 22 { + row22Fields = append(row22Fields, f) + } + if f.Row == 23 { + row23Fields = append(row23Fields, f) + } + if f.Row >= 7 && f.Row <= 19 { + systemRows[f.Row] = f.Content + } + } + + if hasRow20 { + t.Fatalf("Page %d: Row 20 should be blank before Selection, but found fields on Row 20", page+1) + } + + // 2. Verify Selection prompt on Row 21 + if len(row21Fields) == 0 { + t.Fatalf("Page %d: Row 21 must contain Selection prompt", page+1) + } + if row21Fields[0].Content != "Selection ===>" { + t.Fatalf("Page %d: Row 21 prompt mismatch: got '%s'", page+1, row21Fields[0].Content) + } + + // 3. Verify Page indicator on Row 22 on the far right + if len(row22Fields) != 1 { + t.Fatalf("Page %d: Expected exactly 1 field on Row 22 (page indicator), found %d", page+1, len(row22Fields)) + } + expectedIndicator := fmt.Sprintf("Page %d of 8", page+1) + if row22Fields[0].Content != expectedIndicator { + t.Fatalf("Page %d: Indicator mismatch: got '%s', want '%s'", page+1, row22Fields[0].Content, expectedIndicator) + } + expectedCol := 79 - len(expectedIndicator) + if row22Fields[0].Col != expectedCol { + t.Fatalf("Page %d: Indicator Col mismatch: got %d, want %d (far right)", page+1, row22Fields[0].Col, expectedCol) + } + + // 4. Verify Footer on Row 23 + if len(row23Fields) == 0 { + t.Fatalf("Page %d: Row 23 must contain footer", page+1) + } + + // 5. Verify system count and [%2d] formatting on this page + var expectedSystems int + if page < 7 { + expectedSystems = 13 // 13 systems per page + } else { + expectedSystems = 8 // remaining 99 - (7 * 13) = 8 systems on last page + } + + if len(systemRows) != expectedSystems { + t.Fatalf("Page %d: Expected %d systems, found %d", page+1, expectedSystems, len(systemRows)) + } + + startIdx := page * 13 + for i := 0; i < expectedSystems; i++ { + rowNum := 7 + i + content := systemRows[rowNum] + expectedSysNum := startIdx + i + 1 + expectedPrefix := fmt.Sprintf("[%2d]", expectedSysNum) + if !strings.HasPrefix(content, expectedPrefix) { + t.Fatalf("Page %d, Row %d: Expected prefix '%s', got '%s'", page+1, rowNum, expectedPrefix, content) + } + } + } +} + +func TestMenuPaginationBoundsAndClamping(t *testing.T) { + origConfig := GlobalConfig + origSystems := ConfiguredSystem + defer func() { + GlobalConfig = origConfig + ConfiguredSystem = origSystems + }() + + GlobalConfig = &Config{ + Menu: MenuConfig{ + Title: "TEST TITLE", + PromptSelect: "Selection ===>", + Footer: "Footer", + }, + } + + s := &UserMasterSession{ + Username: "USER", + DisplayTag: "12345678", + } + + findRow22 := func(screen go3270.Screen) (string, bool) { + for _, f := range screen { + if f.Row == 22 { + return f.Content, true + } + } + return "", false + } + + // Case 1: 0 systems -> 1 page, no Row 22 indicator rendered + ConfiguredSystem = nil + s.MenuPage = 0 + if content, ok := findRow22(buildMainMenuScreen(s)); ok { + t.Fatalf("Expected no Row 22 page indicator for 0 systems, got '%s'", content) + } + + // Case 2: 13 systems -> 1 page, no Row 22 indicator rendered + ConfiguredSystem = generateTestSystems(13) + s.MenuPage = 0 + if content, ok := findRow22(buildMainMenuScreen(s)); ok { + t.Fatalf("Expected no Row 22 page indicator for 13 systems, got '%s'", content) + } + + // Case 3: 14 systems -> 2 pages, "Page 1 of 2" + ConfiguredSystem = generateTestSystems(14) + s.MenuPage = 0 + if content, ok := findRow22(buildMainMenuScreen(s)); !ok || content != "Page 1 of 2" { + t.Fatalf("Expected 'Page 1 of 2' for 14 systems, got '%s' (found=%v)", content, ok) + } + + // Case 4: Negative MenuPage clamped to 0 + s.MenuPage = -99 + if content, ok := findRow22(buildMainMenuScreen(s)); !ok || content != "Page 1 of 2" { + t.Fatalf("Negative MenuPage should clamp to Page 1 of 2, got '%s' (found=%v)", content, ok) + } + + // Case 5: Out of bounds MenuPage clamped to last page + s.MenuPage = 99 + if content, ok := findRow22(buildMainMenuScreen(s)); !ok || content != "Page 2 of 2" { + t.Fatalf("Out-of-bounds MenuPage should clamp to Page 2 of 2, got '%s' (found=%v)", content, ok) + } +} + +func TestRunMenuLoopPF7PF8Pagination(t *testing.T) { + origConfig := GlobalConfig + origSystems := ConfiguredSystem + defer func() { + GlobalConfig = origConfig + ConfiguredSystem = origSystems + }() + + GlobalConfig = &Config{ + Menu: MenuConfig{ + Title: "MENU", + PromptSelect: "Selection ===>", + Footer: "Footer", + }, + } + ConfiguredSystem = generateTestSystems(99) // 8 pages + + clientServer, clientUser := net.Pipe() + defer clientServer.Close() + defer clientUser.Close() + + s := &UserMasterSession{ + Username: "TESTUSER", + DisplayTag: "12345678", + ClientConn: clientServer, + ActiveSlotIdx: -1, + IsDisconnected: false, + MenuPage: 0, + } + + done := make(chan struct{}) + go func() { + runMenuLoop(s, clientServer) + close(done) + }() + + // Helper to read a screen sent by server and respond with an AID key + sendAID := func(aid go3270.AID) { + buf := make([]byte, 4096) + _ = clientUser.SetReadDeadline(time.Now().Add(2 * time.Second)) + _, err := clientUser.Read(buf) + if err != nil { + t.Fatalf("Failed to read screen from server: %v", err) + } + // Send 3270 response packet: [AID, CursorRowByte, CursorColByte, IAC, EOR] + _, err = clientUser.Write([]byte{byte(aid), 0x40, 0x40, IAC, EOR}) + if err != nil { + t.Fatalf("Failed to write AID response: %v", err) + } + time.Sleep(50 * time.Millisecond) + } + + // 1. Initial page is 0 + if s.MenuPage != 0 { + t.Fatalf("Expected initial MenuPage 0, got %d", s.MenuPage) + } + + // 2. Press PF7 on page 0: should stay at page 0 + sendAID(go3270.AIDPF7) + s.Mu.Lock() + p := s.MenuPage + s.Mu.Unlock() + if p != 0 { + t.Fatalf("Expected MenuPage to stay 0 after PF7 on page 0, got %d", p) + } + + // 3. Press PF8: should advance to page 1 + sendAID(go3270.AIDPF8) + s.Mu.Lock() + p = s.MenuPage + s.Mu.Unlock() + if p != 1 { + t.Fatalf("Expected MenuPage 1 after PF8, got %d", p) + } + + // 4. Press PF20 (alternate PF8): should advance to page 2 + sendAID(go3270.AIDPF20) + s.Mu.Lock() + p = s.MenuPage + s.Mu.Unlock() + if p != 2 { + t.Fatalf("Expected MenuPage 2 after PF20, got %d", p) + } + + // 5. Advance to last page (page 7) + for i := 2; i < 7; i++ { + sendAID(go3270.AIDPF8) + } + s.Mu.Lock() + p = s.MenuPage + s.Mu.Unlock() + if p != 7 { + t.Fatalf("Expected MenuPage 7 (last page), got %d", p) + } + + // 6. Press PF8 on last page: should stay on page 7 + sendAID(go3270.AIDPF8) + s.Mu.Lock() + p = s.MenuPage + s.Mu.Unlock() + if p != 7 { + t.Fatalf("Expected MenuPage to stay 7 after PF8 on last page, got %d", p) + } + + // 7. Press PF7: should go back to page 6 + sendAID(go3270.AIDPF7) + s.Mu.Lock() + p = s.MenuPage + s.Mu.Unlock() + if p != 6 { + t.Fatalf("Expected MenuPage 6 after PF7, got %d", p) + } + + // 8. Press PF19 (alternate PF7): should go back to page 5 + sendAID(go3270.AIDPF19) + s.Mu.Lock() + p = s.MenuPage + s.Mu.Unlock() + if p != 5 { + t.Fatalf("Expected MenuPage 5 after PF19, got %d", p) + } + + // 9. Send PF3 to disconnect cleanly + sendAID(go3270.AIDPF3) + // Read the disconnect notification sent by send3270Message so the write to net.Pipe unblocks + discBuf := make([]byte, 2048) + _, _ = clientUser.Read(discBuf) + + select { + case <-done: + // Clean exit + case <-time.After(2 * time.Second): + t.Fatal("Timeout waiting for runMenuLoop to exit on PF3") + } +} + +func generateTestCertificate(t *testing.T) (certFile, keyFile string) { + priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatalf("Failed to generate private key: %v", err) + } + + template := x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{ + Organization: []string{"Test sessman3270"}, + }, + NotBefore: time.Now().Add(-1 * time.Hour), + NotAfter: time.Now().Add(24 * time.Hour), + KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + BasicConstraintsValid: true, + IPAddresses: []net.IP{net.ParseIP("127.0.0.1")}, + } + + derBytes, err := x509.CreateCertificate(rand.Reader, &template, &template, &priv.PublicKey, priv) + if err != nil { + t.Fatalf("Failed to create certificate: %v", err) + } + + tmpDir := t.TempDir() + certPath := filepath.Join(tmpDir, "cert.pem") + keyPath := filepath.Join(tmpDir, "key.pem") + + certOut, err := os.Create(certPath) + if err != nil { + t.Fatalf("Failed to create cert file: %v", err) + } + defer certOut.Close() + if err := pem.Encode(certOut, &pem.Block{Type: "CERTIFICATE", Bytes: derBytes}); err != nil { + t.Fatalf("Failed to write cert: %v", err) + } + + keyOut, err := os.Create(keyPath) + if err != nil { + t.Fatalf("Failed to create key file: %v", err) + } + defer keyOut.Close() + privBytes, err := x509.MarshalECPrivateKey(priv) + if err != nil { + t.Fatalf("Failed to marshal key: %v", err) + } + if err := pem.Encode(keyOut, &pem.Block{Type: "EC PRIVATE KEY", Bytes: privBytes}); err != nil { + t.Fatalf("Failed to write key: %v", err) + } + + return certPath, keyPath +} + +func TestResolveTLSConfig(t *testing.T) { + tests := []struct { + name string + certFlag string + keyFlag string + cfgServer *ServerConfig + wantEnabled bool + wantCert string + wantKey string + wantErrSub string + }{ + { + name: "Defaults - Plaintext", + certFlag: "", + keyFlag: "", + cfgServer: nil, + wantEnabled: false, + wantCert: "", + wantKey: "", + wantErrSub: "", + }, + { + name: "-k given without -t triggers error and infers TLS requirement", + certFlag: "", + keyFlag: "/path/to/key.pem", + cfgServer: nil, + wantEnabled: false, + wantErrSub: "-t and -k require both of each other", + }, + { + name: "-t given without -k triggers error", + certFlag: "/path/to/cert.pem", + keyFlag: "", + cfgServer: nil, + wantEnabled: false, + wantErrSub: "-t and -k require both of each other", + }, + { + name: "-k and -t given infers TLS enabled", + certFlag: "/path/to/cert.pem", + keyFlag: "/path/to/key.pem", + cfgServer: nil, + wantEnabled: true, + wantCert: "/path/to/cert.pem", + wantKey: "/path/to/key.pem", + wantErrSub: "", + }, + { + name: "ServerConfig fallback enables TLS when configured", + certFlag: "", + keyFlag: "", + cfgServer: &ServerConfig{TLS: true, CertFile: "/cfg/cert.pem", KeyFile: "/cfg/key.pem"}, + wantEnabled: true, + wantCert: "/cfg/cert.pem", + wantKey: "/cfg/key.pem", + wantErrSub: "", + }, + { + name: "CLI -k overrides config key and requires -t when config has no cert", + certFlag: "", + keyFlag: "/cli/key.pem", + cfgServer: &ServerConfig{TLS: true, KeyFile: "/cfg/key.pem"}, + wantEnabled: false, + wantErrSub: "-t and -k require both of each other", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + enabled, cert, key, err := resolveTLSConfig(tt.certFlag, tt.keyFlag, tt.cfgServer) + if tt.wantErrSub != "" { + if err == nil { + t.Fatalf("Expected error containing '%s', got nil", tt.wantErrSub) + } + if !strings.Contains(err.Error(), tt.wantErrSub) { + t.Fatalf("Expected error containing '%s', got '%v'", tt.wantErrSub, err) + } + } else { + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + if enabled != tt.wantEnabled { + t.Fatalf("enabled mismatch: got %v, want %v", enabled, tt.wantEnabled) + } + if cert != tt.wantCert { + t.Fatalf("cert mismatch: got '%s', want '%s'", cert, tt.wantCert) + } + if key != tt.wantKey { + t.Fatalf("key mismatch: got '%s', want '%s'", key, tt.wantKey) + } + } + }) + } +} + +func TestTLSServerListenerAndClientConnection(t *testing.T) { + certFile, keyFile := generateTestCertificate(t) + + ln, err := startServerListener("127.0.0.1:0", true, certFile, keyFile) + if err != nil { + t.Fatalf("Failed to start TLS listener: %v", err) + } + defer ln.Close() + + addr := ln.Addr().String() + + acceptedChan := make(chan net.Conn, 1) + go func() { + conn, err := ln.Accept() + if err == nil { + acceptedChan <- conn + if tc, ok := conn.(*tls.Conn); ok { + _ = tc.Handshake() + } + } + }() + + tlsConf := &tls.Config{ + InsecureSkipVerify: true, + } + clientConn, err := tls.Dial("tcp", addr, tlsConf) + if err != nil { + t.Fatalf("TLS client connection failed: %v", err) + } + defer clientConn.Close() + + select { + case srvConn := <-acceptedChan: + defer srvConn.Close() + msg := []byte("PING") + if _, err := srvConn.Write(msg); err != nil { + t.Fatalf("Failed to write to client: %v", err) + } + recvBuf := make([]byte, 4) + if _, err := io.ReadFull(clientConn, recvBuf); err != nil { + t.Fatalf("Client failed to read: %v", err) + } + if string(recvBuf) != "PING" { + t.Fatalf("Expected 'PING', got '%s'", string(recvBuf)) + } + case <-time.After(3 * time.Second): + t.Fatal("Timeout waiting for TLS connection accept") + } +} + +func TestTLSListenerPlaintextClientHandshakeFailure(t *testing.T) { + certFile, keyFile := generateTestCertificate(t) + + ln, err := startServerListener("127.0.0.1:0", true, certFile, keyFile) + if err != nil { + t.Fatalf("Failed to start TLS listener: %v", err) + } + defer ln.Close() + + addr := ln.Addr().String() + + handshakeErrChan := make(chan error, 1) + go func() { + conn, err := ln.Accept() + if err != nil { + return + } + defer conn.Close() + tlsConn, ok := conn.(*tls.Conn) + if !ok { + return + } + _ = tlsConn.SetDeadline(time.Now().Add(500 * time.Millisecond)) + handshakeErrChan <- tlsConn.Handshake() + }() + + plainConn, err := net.Dial("tcp", addr) + if err != nil { + t.Fatalf("Dial failed: %v", err) + } + defer plainConn.Close() + _, _ = plainConn.Write([]byte("NOT_A_TLS_HANDSHAKE")) + + select { + case err := <-handshakeErrChan: + if err == nil { + t.Fatalf("Expected TLS handshake error for plaintext client, got nil") + } + case <-time.After(2 * time.Second): + t.Fatal("Timeout waiting for handshake failure") + } +} + +func TestAuthenticateUser(t *testing.T) { + origGlobalConfig := GlobalConfig + defer func() { GlobalConfig = origGlobalConfig }() + + // 1. When GlobalConfig is nil or Users slice is empty, any login is permitted + GlobalConfig = nil + if res := authenticateAndAuthorize("anyone", "anypass"); !res.OK { + t.Fatalf("Expected true when GlobalConfig is nil") + } + + GlobalConfig = &Config{Users: nil} + if res := authenticateAndAuthorize("anyone", "anypass"); !res.OK { + t.Fatalf("Expected true when Users list is nil") + } + + GlobalConfig = &Config{Users: []UserConfig{}} + if res := authenticateAndAuthorize("anyone", "anypass"); !res.OK { + t.Fatalf("Expected true when Users list is empty") + } + + // 2. Generate bcrypt hash for "secret123" + hash, err := bcrypt.GenerateFromPassword([]byte("secret123"), bcrypt.DefaultCost) + if err != nil { + t.Fatalf("Failed to generate test hash: %v", err) + } + + GlobalConfig = &Config{ + Users: []UserConfig{ + {Username: "rudi", Hash: string(hash)}, + {Username: "admin", Password: string(hash)}, + }, + } + + // Valid user and correct password + if res := authenticateAndAuthorize("rudi", "secret123"); !res.OK { + t.Fatalf("Expected successful auth for rudi, got reason: %s", res.Reason) + } + + // Whitespace trimming preserves exact case + if res := authenticateAndAuthorize(" rudi ", "secret123"); !res.OK { + t.Fatalf("Expected whitespace-trimmed match for rudi, got reason: %s", res.Reason) + } + + // Case-sensitivity: "RUDI" is a distinct user from "rudi" + if res := authenticateAndAuthorize("RUDI", "secret123"); res.OK { + t.Fatalf("Expected auth failure for uppercase RUDI when only lowercase rudi is configured") + } + + // Password alias check + if res := authenticateAndAuthorize("admin", "secret123"); !res.OK { + t.Fatalf("Expected successful auth for admin (password alias), got reason: %s", res.Reason) + } + + // Invalid password + if res := authenticateAndAuthorize("rudi", "wrongpass"); res.OK { + t.Fatalf("Expected auth failure for incorrect password") + } + + // Unauthorized user + if res := authenticateAndAuthorize("unknownuser", "secret123"); res.OK { + t.Fatalf("Expected auth failure for unauthorized user") + } else if !strings.Contains(res.Reason, "not authorized") { + t.Fatalf("Expected reason to mention 'not authorized', got: %s", res.Reason) + } +} + +func TestPasswordGenerationBcrypt(t *testing.T) { + password := "myTN3270Password!#" + hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + if err != nil { + t.Fatalf("Failed to generate bcrypt hash: %v", err) + } + + if !strings.HasPrefix(string(hash), "$2a$") { + t.Fatalf("Expected bcrypt hash prefix $2a$, got: %s", string(hash)) + } + + if err := bcrypt.CompareHashAndPassword(hash, []byte(password)); err != nil { + t.Fatalf("CompareHashAndPassword failed: %v", err) + } + + if err := bcrypt.CompareHashAndPassword(hash, []byte("wrong")); err == nil { + t.Fatalf("Expected hash comparison failure for wrong password") + } +} + +func TestSelectionInputLength(t *testing.T) { + origConfig := GlobalConfig + origSystems := ConfiguredSystem + defer func() { + GlobalConfig = origConfig + ConfiguredSystem = origSystems + }() + + GlobalConfig = &Config{ + Menu: MenuConfig{ + Title: "TEST MENU", + PromptSelect: "Selection ===>", + Footer: "Test Footer", + }, + } + ConfiguredSystem = []TargetSystem{{Name: "SYS1"}} + + s := &UserMasterSession{ + Username: "ADMIN", + DisplayTag: "12345678", + } + + screen := buildMainMenuScreen(s) + + var optField *go3270.Field + var stopField *go3270.Field + + for i := range screen { + f := &screen[i] + if f.Row == 21 && f.Name == "opt" { + optField = f + } else if f.Row == 21 && optField != nil && f.Col > optField.Col { + stopField = f + break + } + } + + if optField == nil { + t.Fatal("Expected 'opt' field on Row 21") + } + if stopField == nil { + t.Fatal("Expected stop field on Row 21 after 'opt'") + } + + writableLen := stopField.Col - optField.Col - 1 + if writableLen < 40 { + t.Fatalf("Selection input field is too short (%d characters). Expected at least 40 characters for admin commands", writableLen) + } +} + +func TestUserAdminConfiguration(t *testing.T) { + tomlData := ` +[[users]] +username = "sysadmin" +password = "adminpass" +admin = true + +[[users]] +username = "operator" +password = "userpass" +admin = false +` + var cfg Config + if _, err := toml.Decode(tomlData, &cfg); err != nil { + t.Fatalf("Failed to decode TOML: %v", err) + } + + if len(cfg.Users) != 2 { + t.Fatalf("Expected 2 users, got %d", len(cfg.Users)) + } + if !cfg.Users[0].Admin { + t.Fatalf("Expected sysadmin admin=true, got %v", cfg.Users[0].Admin) + } + if cfg.Users[1].Admin { + t.Fatalf("Expected operator admin=false, got %v", cfg.Users[1].Admin) + } + + origGlobalConfig := GlobalConfig + defer func() { GlobalConfig = origGlobalConfig }() + GlobalConfig = &cfg + + res := authenticateAndAuthorize("sysadmin", "adminpass") + if !res.OK || !res.IsAdmin { + t.Fatalf("Expected sysadmin to be (OK=true, admin=true), got %+v", res) + } + + res = authenticateAndAuthorize("operator", "userpass") + if !res.OK || res.IsAdmin { + t.Fatalf("Expected operator to be (OK=true, admin=false), got %+v", res) + } + + res = authenticateAndAuthorize("sysadmin", "wrongpass") + if res.OK || res.IsAdmin { + t.Fatalf("Expected sysadmin with wrong password to be (OK=false, admin=false), got %+v", res) + } + + res = authenticateAndAuthorize("unknown", "any") + if res.OK || res.IsAdmin { + t.Fatalf("Expected unknown user to be (OK=false, admin=false), got %+v", res) + } +} + +func TestPublicFlagConfiguration(t *testing.T) { + origGlobalConfig := GlobalConfig + origFlag := publicFlag + defer func() { + GlobalConfig = origGlobalConfig + publicFlag = origFlag + }() + + // 1. Default when not set + publicFlag = false + GlobalConfig = &Config{} + if isPublic() { + t.Fatal("Expected isPublic() to be false by default") + } + + // 2. Top-level public = true in Config + GlobalConfig = &Config{Public: true} + if !isPublic() { + t.Fatal("Expected isPublic() to be true when Config.Public is true") + } + + // 3. ServerConfig public = true in Config + GlobalConfig = &Config{Server: ServerConfig{Public: true}} + if !isPublic() { + t.Fatal("Expected isPublic() to be true when Config.Server.Public is true") + } + + // 4. CLI flag -public / -P + GlobalConfig = &Config{} + publicFlag = true + if !isPublic() { + t.Fatal("Expected isPublic() to be true when publicFlag is true") + } + publicFlag = false + + // 5. TOML parsing of public flag + tomlStrTop := ` +public = true +[[systems]] +name = "SYS1" +` + var cfgTop Config + if _, err := toml.Decode(tomlStrTop, &cfgTop); err != nil { + t.Fatalf("Failed to decode top-level public TOML: %v", err) + } + if !cfgTop.Public { + t.Fatal("Expected cfgTop.Public to be true") + } + + tomlStrServer := ` +[server] +public = true +[[systems]] +name = "SYS1" +` + var cfgServer Config + if _, err := toml.Decode(tomlStrServer, &cfgServer); err != nil { + t.Fatalf("Failed to decode server-level public TOML: %v", err) + } + if !cfgServer.Server.Public { + t.Fatal("Expected cfgServer.Server.Public to be true") + } +} + +func TestPublicModeAuthentication(t *testing.T) { + origGlobalConfig := GlobalConfig + origFlag := publicFlag + defer func() { + GlobalConfig = origGlobalConfig + publicFlag = origFlag + }() + + publicFlag = false + + hashAdmin, _ := bcrypt.GenerateFromPassword([]byte("adminpass"), bcrypt.DefaultCost) + hashUser, _ := bcrypt.GenerateFromPassword([]byte("userpass"), bcrypt.DefaultCost) + + baseConfig := &Config{ + Users: []UserConfig{ + {Username: "admin", Hash: string(hashAdmin), Admin: true}, + {Username: "user", Hash: string(hashUser), Admin: false}, + }, + } + + // --- Test without public mode (public = false) --- + GlobalConfig = baseConfig + GlobalConfig.Public = false + + // Correct admin credentials -> OK + res := authenticateAndAuthorize("admin", "adminpass") + if !res.OK || !res.IsAdmin { + t.Fatalf("Expected admin to have OK=true, isAdmin=true; got %+v", res) + } + + // Correct user credentials -> OK + res = authenticateAndAuthorize("user", "userpass") + if !res.OK || res.IsAdmin { + t.Fatalf("Expected regular user to have OK=true, isAdmin=false; got %+v", res) + } + + // Password mismatch -> REJECTED + if res := authenticateAndAuthorize("admin", "wrongpass"); res.OK { + t.Fatal("Expected auth failure for admin with wrong password when public=false") + } + + // Unknown user -> REJECTED + if res := authenticateAndAuthorize("guest", "pass"); res.OK { + t.Fatal("Expected auth failure for unknown user when public=false") + } + + // --- Test with public mode (public = true) --- + GlobalConfig.Public = true + + // Correct admin credentials -> OK and admin=true + res = authenticateAndAuthorize("admin", "adminpass") + if !res.OK || !res.IsAdmin { + t.Fatalf("Expected admin auth to succeed with admin privileges when public=true; got %+v", res) + } + + // Correct user credentials -> OK and admin=false + res = authenticateAndAuthorize("user", "userpass") + if !res.OK || res.IsAdmin { + t.Fatalf("Expected regular user auth to succeed without admin privileges when public=true; got %+v", res) + } + + // Password mismatch in public mode -> ALLOWED, but NOT admin + res = authenticateAndAuthorize("admin", "wrongpass") + if !res.OK || res.IsAdmin { + t.Fatalf("Expected admin with wrong password in public mode to have OK=true, IsAdmin=false; got %+v", res) + } + + // Unknown user in public mode -> ALLOWED, but NOT admin + res = authenticateAndAuthorize("guest", "pass") + if !res.OK || res.IsAdmin { + t.Fatalf("Expected unknown user in public mode to have OK=true, IsAdmin=false; got %+v", res) + } +} + +func TestAdminKillCommand(t *testing.T) { + registryMu.Lock() + userSessions = make(map[string]*UserMasterSession) + registryMu.Unlock() + + adminPipeServer, adminPipeClient := net.Pipe() + defer adminPipeServer.Close() + defer adminPipeClient.Close() + + user1PipeServer, user1PipeClient := net.Pipe() + defer user1PipeServer.Close() + defer user1PipeClient.Close() + + user2PipeServer, user2PipeClient := net.Pipe() + defer user2PipeServer.Close() + defer user2PipeClient.Close() + + upServer1, upClient1 := net.Pipe() + defer upServer1.Close() + defer upClient1.Close() + + adminSess := &UserMasterSession{ + Username: "admin", + SessionKey: "key_admin_12345678", + DisplayTag: "adm12345", + ClientConn: adminPipeServer, + IsAdmin: true, + } + + user1Sess := &UserMasterSession{ + Username: "victim1", + SessionKey: "key_user1_87654321", + DisplayTag: "usr87654", + ClientConn: user1PipeServer, + Slots: []*SessionSlot{ + {System: TargetSystem{Name: "SYS1"}, Upstream: upClient1}, + }, + IsAdmin: false, + } + + user2Sess := &UserMasterSession{ + Username: "victim2", + SessionKey: "key_user2_abcdef12", + DisplayTag: "usrabcde", + ClientConn: user2PipeServer, + IsAdmin: false, + } + + registryMu.Lock() + userSessions[adminSess.SessionKey] = adminSess + userSessions[user1Sess.SessionKey] = user1Sess + userSessions[user2Sess.SessionKey] = user2Sess + registryMu.Unlock() + + origNoticeSleep := adminNoticeSleep + adminNoticeSleep = 10 * time.Millisecond + defer func() { adminNoticeSleep = origNoticeSleep }() + + // Drain messages on pipe connections in background to prevent pipe block + drain := func(c net.Conn) { + go func() { + buf := make([]byte, 1024) + for { + if _, err := c.Read(buf); err != nil { + return + } + } + }() + } + drain(adminPipeClient) + drain(user1PipeClient) + drain(user2PipeClient) + + upReadDone := make(chan []byte, 1) + go func() { + data, _ := io.ReadAll(upServer1) + upReadDone <- data + }() + + // 1. Admin kills user1 by DisplayTag + killedSelf := handleAdminKill(adminSess, "usr87654") + if killedSelf { + t.Fatal("Expected handleAdminKill to return false when killing another session") + } + + // Verify user1 session resources terminated + registryMu.Lock() + _, existsUser1 := userSessions[user1Sess.SessionKey] + registryMu.Unlock() + + if existsUser1 { + t.Fatal("Expected user1 to be deleted from registry") + } + + user1Sess.Mu.Lock() + if !user1Sess.IsDisconnected { + t.Fatal("Expected user1 to be marked disconnected") + } + if user1Sess.Slots != nil { + t.Fatal("Expected user1 slots to be cleared") + } + user1Sess.Mu.Unlock() + + // Verify upstream was cleanly unwound and closed + select { + case data := <-upReadDone: + if len(data) == 0 { + t.Fatal("Expected clean unwinding bytes on upstream before closure") + } + case <-time.After(1 * time.Second): + t.Fatal("Timeout waiting for upstream closure") + } + + // 2. Admin kills user2 by #DisplayTag + killedSelf = handleAdminKill(adminSess, "#usrabcde") + if killedSelf { + t.Fatal("Expected handleAdminKill to return false when killing another session") + } + + registryMu.Lock() + _, existsUser2 := userSessions[user2Sess.SessionKey] + registryMu.Unlock() + if existsUser2 { + t.Fatal("Expected user2 to be deleted from registry") + } + + // 3. Admin attempts to kill non-existent session + killedSelf = handleAdminKill(adminSess, "nonexistent") + if killedSelf { + t.Fatal("Expected false for non-existent session") + } + + // 4. Admin kills own session + killedSelf = handleAdminKill(adminSess, "adm12345") + if !killedSelf { + t.Fatal("Expected true when admin kills their own session") + } + + registryMu.Lock() + _, existsAdmin := userSessions[adminSess.SessionKey] + registryMu.Unlock() + if existsAdmin { + t.Fatal("Expected admin session to be deleted after self-kill") + } +} + +func TestAdminKillAllCommand(t *testing.T) { + registryMu.Lock() + userSessions = make(map[string]*UserMasterSession) + registryMu.Unlock() + + adminPipeServer, adminPipeClient := net.Pipe() + defer adminPipeServer.Close() + defer adminPipeClient.Close() + + userPipeServer, userPipeClient := net.Pipe() + defer userPipeServer.Close() + defer userPipeClient.Close() + + upServer, upClient := net.Pipe() + defer upServer.Close() + defer upClient.Close() + + adminSess := &UserMasterSession{ + Username: "admin", + SessionKey: "key_admin", + DisplayTag: "adm00001", + ClientConn: adminPipeServer, + IsAdmin: true, + } + + userSess := &UserMasterSession{ + Username: "user", + SessionKey: "key_user", + DisplayTag: "usr00001", + ClientConn: userPipeServer, + Slots: []*SessionSlot{ + {System: TargetSystem{Name: "SYS1"}, Upstream: upClient}, + }, + IsAdmin: false, + } + + registryMu.Lock() + userSessions[adminSess.SessionKey] = adminSess + userSessions[userSess.SessionKey] = userSess + registryMu.Unlock() + + origNoticeSleep := adminNoticeSleep + adminNoticeSleep = 10 * time.Millisecond + defer func() { adminNoticeSleep = origNoticeSleep }() + + // Drain messages and upstream + go func() { + buf := make([]byte, 1024) + for { + if _, err := adminPipeClient.Read(buf); err != nil { + return + } + } + }() + go func() { + buf := make([]byte, 1024) + for { + if _, err := userPipeClient.Read(buf); err != nil { + return + } + } + }() + go func() { + _, _ = io.ReadAll(upServer) + }() + + handleAdminKillAll(adminSess) + + registryMu.Lock() + count := len(userSessions) + registryMu.Unlock() + + if count != 0 { + t.Fatalf("Expected all sessions to be cleared, found %d", count) + } + + userSess.Mu.Lock() + if !userSess.IsDisconnected { + t.Fatal("Expected user session to be marked disconnected") + } + if userSess.Slots != nil { + t.Fatal("Expected user slots to be nil") + } + userSess.Mu.Unlock() + + adminSess.Mu.Lock() + if !adminSess.IsDisconnected { + t.Fatal("Expected admin session to be marked disconnected (including this one)") + } + adminSess.Mu.Unlock() +} + +func TestAdminByeCommand(t *testing.T) { + registryMu.Lock() + userSessions = make(map[string]*UserMasterSession) + registryMu.Unlock() + + adminPipeServer, adminPipeClient := net.Pipe() + defer adminPipeServer.Close() + defer adminPipeClient.Close() + + dummyListener, err := net.Listen("tcp4", "127.0.0.1:0") + if err != nil { + t.Fatalf("Failed to create dummy listener: %v", err) + } + serverListener = dummyListener + + var shutdownInvoked bool + origShutdown := shutdownFunc + defer func() { shutdownFunc = origShutdown }() + shutdownFunc = func() { + shutdownInvoked = true + } + + adminSess := &UserMasterSession{ + Username: "admin", + SessionKey: "key_admin_bye", + DisplayTag: "admbye01", + ClientConn: adminPipeServer, + IsAdmin: true, + } + + registryMu.Lock() + userSessions[adminSess.SessionKey] = adminSess + registryMu.Unlock() + + origNoticeSleep := adminNoticeSleep + adminNoticeSleep = 10 * time.Millisecond + defer func() { adminNoticeSleep = origNoticeSleep }() + + go func() { + buf := make([]byte, 1024) + for { + _ = adminPipeClient.SetReadDeadline(time.Now().Add(500 * time.Millisecond)) + if _, err := adminPipeClient.Read(buf); err != nil { + return + } + } + }() + + handleAdminBye(adminSess) + + if !shutdownInvoked { + t.Fatal("Expected shutdownFunc to be invoked on 'bye' command") + } + + // Verify listener was closed + if _, err := dummyListener.Accept(); err == nil { + t.Fatal("Expected listener to be closed") + } + + registryMu.Lock() + count := len(userSessions) + registryMu.Unlock() + if count != 0 { + t.Fatalf("Expected all sessions cleared on bye, found %d", count) + } +} + +func TestNonAdminCannotExecuteAdminCommands(t *testing.T) { + origSystems := ConfiguredSystem + defer func() { ConfiguredSystem = origSystems }() + ConfiguredSystem = []TargetSystem{{Name: "SYS1"}} + + // A non-admin session + nonAdminSess := &UserMasterSession{ + Username: "regularuser", + DisplayTag: "reg12345", + IsAdmin: false, + } + + adminSess := &UserMasterSession{ + Username: "admin", + SessionKey: "adminkey", + DisplayTag: "adm54321", + IsAdmin: true, + } + + registryMu.Lock() + userSessions = make(map[string]*UserMasterSession) + userSessions["adminkey"] = adminSess + registryMu.Unlock() + + // If a non-admin enters "kill adm54321", "bye", or "killall", + // the optStr is not recognized as a number, and admin commands are only checked if s.IsAdmin is true. + adminCmds := []string{"bye", "BYE", "killall", "KILLALL", "kill adm54321", "KILL adm54321"} + for _, cmd := range adminCmds { + // Verify that strconv.Atoi fails for these admin commands + _, err := strconv.Atoi(cmd) + if err == nil { + t.Fatalf("Admin command '%s' should not parse as integer option", cmd) + } + // And verify nonAdminSess.IsAdmin is false + if nonAdminSess.IsAdmin { + t.Fatalf("Expected non-admin session to have IsAdmin=false") + } + } + + // Verify admin session is still intact + registryMu.Lock() + _, adminStillExists := userSessions["adminkey"] + registryMu.Unlock() + if !adminStillExists { + t.Fatal("Admin session was improperly deleted") + } +} + +func TestMainMenuRowColors(t *testing.T) { + origSystems := ConfiguredSystem + defer func() { + ConfiguredSystem = origSystems + }() + + ConfiguredSystem = []TargetSystem{ + {Name: "SYS_INACTIVE", Description: "Inactive system"}, + {Name: "SYS_ACTIVE", Description: "Active system"}, + {Name: "SYS_NOTICE", Description: "Notice system"}, + } + + slotActive := &SessionSlot{ + System: ConfiguredSystem[1], + ScreenChanged: false, + } + slotNotice := &SessionSlot{ + System: ConfiguredSystem[2], + ScreenChanged: true, + } + + s := &UserMasterSession{ + Username: "TESTUSER", + DisplayTag: "12345678", + Slots: []*SessionSlot{ + nil, // idx 0: INACTIVE + slotActive, // idx 1: ACTIVE + slotNotice, // idx 2: NOTICE + }, + } + + screen := buildMainMenuScreen(s) + + // Rows are 7, 8, 9 + var rowInactive, rowActive, rowNotice *go3270.Field + for i := range screen { + f := &screen[i] + if f.Row == 7 { + rowInactive = f + } else if f.Row == 8 { + rowActive = f + } else if f.Row == 9 { + rowNotice = f + } + } + + if rowInactive == nil || rowActive == nil || rowNotice == nil { + t.Fatalf("Expected fields for rows 7, 8, and 9") + } + + // Verify INACTIVE: default color, not intense + if !strings.Contains(rowInactive.Content, "INACTIVE") { + t.Errorf("Expected row 7 to contain INACTIVE, got: %s", rowInactive.Content) + } + if rowInactive.Color != go3270.DefaultColor { + t.Errorf("Expected INACTIVE row to have DefaultColor, got: %v", rowInactive.Color) + } + if rowInactive.Intense { + t.Errorf("Expected INACTIVE row to not be Intense") + } + + // Verify ACTIVE: Yellow color, not intense + if !strings.Contains(rowActive.Content, "ACTIVE") { + t.Errorf("Expected row 8 to contain ACTIVE, got: %s", rowActive.Content) + } + if rowActive.Color != go3270.Yellow { + t.Errorf("Expected ACTIVE row to have Yellow color (%v), got: %v", go3270.Yellow, rowActive.Color) + } + if rowActive.Intense { + t.Errorf("Expected ACTIVE row to not be Intense") + } + + // Verify NOTICE: default color, intense + if !strings.Contains(rowNotice.Content, "NOTICE") { + t.Errorf("Expected row 9 to contain NOTICE, got: %s", rowNotice.Content) + } + if rowNotice.Color != go3270.DefaultColor { + t.Errorf("Expected NOTICE row to have DefaultColor, got: %v", rowNotice.Color) + } + if !rowNotice.Intense { + t.Errorf("Expected NOTICE row to be Intense") + } +} + +// TestResumeTakeoverFromAnotherTerminalOnMenu verifies that when a new terminal +// connects and takes over a session whose previous terminal was on the menu: +// 1. Closing the old terminal socket unblocks the old menu loop cleanly. +// 2. The old terminal does NOT set IsDisconnected = true on the master session. +// 3. When the new terminal resumes a slot, upstream updates are actively forwarded +// to the new client connection rather than dropped (which previously caused +// the 3270 SYSTEM X keyboard lock bug). +func TestResumeTakeoverFromAnotherTerminalOnMenu(t *testing.T) { + clientServer1, clientUser1 := net.Pipe() + defer clientServer1.Close() + defer clientUser1.Close() + + clientServer2, clientUser2 := net.Pipe() + defer clientServer2.Close() + defer clientUser2.Close() + + upServer, upClient := net.Pipe() + defer upServer.Close() + defer upClient.Close() + + slot := &SessionSlot{ + System: TargetSystem{Name: "TESTPUBVM"}, + Upstream: upClient, + LastScreen: []byte{CMD_ERASE_WRITE, 0xC3, 'I', 'N', 'I', 'T', IAC, EOR}, + } + + userSession := &UserMasterSession{ + Username: "RUDI", + SessionKey: "testkey123", + DisplayTag: "c70c9bec", + TerminalType: "IBM-3279-4-E", + ClientConn: clientServer1, + ActiveSlotIdx: -1, + Slots: []*SessionSlot{slot}, + IsDisconnected: false, + } + + go upstreamReader(userSession, slot, 0) + + // Terminal 1 is running runMenuLoop + term1Done := make(chan struct{}) + go func() { + runMenuLoop(userSession, clientServer1) + close(term1Done) + }() + + // Terminal 1 reads menu screen + menuBuf := make([]byte, 4096) + _ = clientUser1.SetReadDeadline(time.Now().Add(1 * time.Second)) + _, err := clientUser1.Read(menuBuf) + if err != nil { + t.Fatalf("Terminal 1 failed to read initial menu: %v", err) + } + + // Now Terminal 2 connects and takes over the session + userSession.Mu.Lock() + oldConn := userSession.ClientConn + userSession.ClientConn = clientServer2 + userSession.IsDisconnected = false + userSession.ActiveSlotIdx = -1 + userSession.Mu.Unlock() + + // Server closes previous client connection socket + _ = oldConn.Close() + + // Wait for Terminal 1's goroutine to cleanly terminate + select { + case <-term1Done: + // Clean exit + case <-time.After(2 * time.Second): + t.Fatal("Terminal 1 goroutine did not exit after oldConn was closed") + } + + // Verify that Terminal 1 exiting did NOT mark the session as disconnected! + userSession.Mu.Lock() + isDisc := userSession.IsDisconnected + userSession.Mu.Unlock() + if isDisc { + t.Fatal("Regression: Terminal 1 exit marked userSession.IsDisconnected = true!") + } + + // Terminal 2 resumes slot 0 + term2BridgeDone := make(chan struct{}) + go func() { + connectOrResumeSlot(userSession, 0, clientServer2) + close(term2BridgeDone) + }() + + // Terminal 2 receives buffered LastScreen from connectOrResumeSlot + lastScreenBuf := make([]byte, 4096) + _ = clientUser2.SetReadDeadline(time.Now().Add(1 * time.Second)) + n, err := clientUser2.Read(lastScreenBuf) + if err != nil || n == 0 { + t.Fatalf("Terminal 2 failed to receive refreshed screen upon resume: %v", err) + } + + // Upstream host sends an update while Terminal 2 is active + updateData := []byte{CMD_WRITE, 0xC3, 'L', 'I', 'V', 'E', IAC, EOR} + _, err = upServer.Write(updateData) + if err != nil { + t.Fatalf("Failed to write upstream update: %v", err) + } + + // Terminal 2 MUST receive the update in real-time (not dropped/locked in SYSTEM X) + liveBuf := make([]byte, len(updateData)) + _ = clientUser2.SetReadDeadline(time.Now().Add(1 * time.Second)) + _, err = io.ReadFull(clientUser2, liveBuf) + if err != nil { + t.Fatalf("Terminal 2 did not receive real-time upstream data (SYSTEM X lock bug reproduced): %v", err) + } + if !bytes.Equal(liveBuf, updateData) { + t.Fatalf("Terminal 2 received corrupted data. Got: %x, Expected: %x", liveBuf, updateData) + } + + // Terminal 2 sends PA3 to return to menu + _, _ = clientUser2.Write([]byte{byte(go3270.AIDPA3)}) + + select { + case <-term2BridgeDone: + // Bridge exited properly on PA3 + case <-time.After(2 * time.Second): + t.Fatal("Terminal 2 bridge did not exit on PA3") + } +} + +// TestResumeTakeoverFromAnotherTerminalInActiveSlot verifies that when a new terminal +// connects and takes over while the previous terminal was inside an active slot bridge: +// 1. Closing the old socket causes the old bridge to exit cleanly. +// 2. The old bridge does NOT set IsDisconnected = true or ActiveSlotIdx = -1 on the new connection. +// 3. The old bridge does NOT fall through to run the menu loop on the new connection. +// 4. The new terminal can resume the slot and receive upstream data without locks. +func TestResumeTakeoverFromAnotherTerminalInActiveSlot(t *testing.T) { + clientServer1, clientUser1 := net.Pipe() + defer clientServer1.Close() + defer clientUser1.Close() + + clientServer2, clientUser2 := net.Pipe() + defer clientServer2.Close() + defer clientUser2.Close() + + upServer, upClient := net.Pipe() + defer upServer.Close() + defer upClient.Close() + + slot := &SessionSlot{ + System: TargetSystem{Name: "TESTPUBVM"}, + Upstream: upClient, + LastScreen: []byte{CMD_ERASE_WRITE, 0xC3, 'S', 'L', 'O', 'T', IAC, EOR}, + } + + userSession := &UserMasterSession{ + Username: "RUDI", + SessionKey: "testkey456", + DisplayTag: "c70c9bec", + TerminalType: "IBM-3279-4-E", + ClientConn: clientServer1, + ActiveSlotIdx: 0, + Slots: []*SessionSlot{slot}, + IsDisconnected: false, + } + + go upstreamReader(userSession, slot, 0) + + term1BridgeDone := make(chan struct{}) + go func() { + bridgeClientToUpstream(userSession, slot, clientServer1) + close(term1BridgeDone) + }() + + // Terminal 2 connects and takes over + userSession.Mu.Lock() + oldConn := userSession.ClientConn + userSession.ClientConn = clientServer2 + userSession.IsDisconnected = false + userSession.ActiveSlotIdx = -1 + userSession.Mu.Unlock() + + // Close old socket + _ = oldConn.Close() + + select { + case <-term1BridgeDone: + // Clean exit + case <-time.After(2 * time.Second): + t.Fatal("Terminal 1 bridge failed to exit after socket closed") + } + + // Verify IsDisconnected was NOT corrupted + userSession.Mu.Lock() + if userSession.IsDisconnected { + t.Fatal("Regression: Terminal 1 exit marked userSession.IsDisconnected = true") + } + userSession.Mu.Unlock() + + // Terminal 2 resumes slot + term2BridgeDone := make(chan struct{}) + go func() { + connectOrResumeSlot(userSession, 0, clientServer2) + close(term2BridgeDone) + }() + + // Read refreshed LastScreen + screenBuf := make([]byte, 4096) + _ = clientUser2.SetReadDeadline(time.Now().Add(1 * time.Second)) + _, err := clientUser2.Read(screenBuf) + if err != nil { + t.Fatalf("Terminal 2 failed to read screen: %v", err) + } + + // Upstream sends update + updateData := []byte{CMD_WRITE, 0xC3, 'A', 'F', 'T', 'E', 'R', IAC, EOR} + _, _ = upServer.Write(updateData) + + recvBuf := make([]byte, len(updateData)) + _ = clientUser2.SetReadDeadline(time.Now().Add(1 * time.Second)) + _, err = io.ReadFull(clientUser2, recvBuf) + if err != nil { + t.Fatalf("Terminal 2 did not receive update after takeover: %v", err) + } + if !bytes.Equal(recvBuf, updateData) { + t.Fatalf("Terminal 2 received corrupted data: %x != %x", recvBuf, updateData) + } + + // Clean up + _, _ = clientUser2.Write([]byte{byte(go3270.AIDPA3)}) + select { + case <-term2BridgeDone: + case <-time.After(2 * time.Second): + t.Fatal("Terminal 2 bridge did not exit on PA3") + } +} + +func TestAuthenticateAndAuthorizeUnified(t *testing.T) { + origConfig := GlobalConfig + origPub := publicFlag + defer func() { + GlobalConfig = origConfig + publicFlag = origPub + }() + publicFlag = false + + adminHash, err := bcrypt.GenerateFromPassword([]byte("adminpass"), bcrypt.MinCost) + if err != nil { + t.Fatalf("Failed to hash admin pass: %v", err) + } + userHash, err := bcrypt.GenerateFromPassword([]byte("userpass"), bcrypt.MinCost) + if err != nil { + t.Fatalf("Failed to hash user pass: %v", err) + } + + GlobalConfig = &Config{ + Users: []UserConfig{ + {Username: "admin", Hash: string(adminHash), Admin: true}, + {Username: "regular", Hash: string(userHash), Admin: false}, + }, + } + + // 1. Admin login with correct password + res := authenticateAndAuthorize("admin", "adminpass") + if !res.OK || !res.IsAdmin { + t.Fatalf("Expected admin to be OK, Admin=true; got %+v", res) + } + + // 2. Regular user login with correct password + res = authenticateAndAuthorize("regular", "userpass") + if !res.OK || res.IsAdmin { + t.Fatalf("Expected regular user to be OK, Admin=false; got %+v", res) + } + + // 3. Regular user with wrong password + res = authenticateAndAuthorize("regular", "wrongpass") + if res.OK || res.IsAdmin { + t.Fatalf("Expected wrong password to fail; got %+v", res) + } + + // 4. Unknown user in non-public mode + res = authenticateAndAuthorize("stranger", "anypass") + if res.OK { + t.Fatalf("Expected stranger to fail; got %+v", res) + } + + // 5. Unknown user in public mode + publicFlag = true + res = authenticateAndAuthorize("stranger", "anypass") + if !res.OK || res.IsAdmin { + t.Fatalf("Expected stranger in public mode to be OK, Admin=false; got %+v", res) + } +} + +func TestCMDFieldScreenLayoutAndLength(t *testing.T) { + origConfig := GlobalConfig + origSystems := ConfiguredSystem + defer func() { + GlobalConfig = origConfig + ConfiguredSystem = origSystems + }() + + GlobalConfig = &Config{ + Menu: MenuConfig{ + Title: "TEST MENU", + PromptSelect: "Selection ===>", + Footer: "Test Footer", + }, + } + ConfiguredSystem = []TargetSystem{ + {Name: "SYS1", Description: "System 1"}, + {Name: "SYS2", Description: "System 2"}, + } + + s := &UserMasterSession{ + Username: "TESTUSER", + DisplayTag: "12345678", + } + + screen := buildMainMenuScreen(s) + + // 1. Verify headers on Row 5 and Row 6 have CMD before ID + var headerRow5, headerRow6 *go3270.Field + for i := range screen { + f := &screen[i] + if f.Row == 5 { + headerRow5 = f + } else if f.Row == 6 { + headerRow6 = f + } + } + + if headerRow5 == nil || headerRow6 == nil { + t.Fatal("Expected header rows on Row 5 and Row 6") + } + + cmdIdx5 := strings.Index(headerRow5.Content, "CMD") + idIdx5 := strings.Index(headerRow5.Content, "ID") + if cmdIdx5 == -1 || idIdx5 == -1 || cmdIdx5 >= idIdx5 { + t.Fatalf("Expected 'CMD' before 'ID' in Row 5 header, got '%s'", headerRow5.Content) + } + + cmdIdx6 := strings.Index(headerRow6.Content, "---") + idIdx6 := strings.Index(headerRow6.Content, "----") + if cmdIdx6 == -1 || idIdx6 == -1 || cmdIdx6 >= idIdx6 { + t.Fatalf("Expected '---' before '----' in Row 6 underline, got '%s'", headerRow6.Content) + } + + // 2. Verify that each system row has a 3-character writable CMD input field before ID + for sysIdx := 0; sysIdx < 2; sysIdx++ { + row := 7 + sysIdx + var cmdField, sysField *go3270.Field + expectedCmdName := fmt.Sprintf("cmd_%d", sysIdx+1) + + for i := range screen { + f := &screen[i] + if f.Row == row && f.Name == expectedCmdName { + cmdField = f + } else if f.Row == row && cmdField != nil && f.Col > cmdField.Col { + sysField = f + break + } + } + + if cmdField == nil { + t.Fatalf("Row %d: Expected writable CMD field '%s'", row, expectedCmdName) + } + if !cmdField.Write { + t.Fatalf("Row %d: CMD field must be writable (Write: true)", row) + } + if cmdField.Col != 2 { + t.Fatalf("Row %d: Expected CMD field at Col 2, got %d", row, cmdField.Col) + } + + if sysField == nil { + t.Fatalf("Row %d: Expected system description field after CMD field", row) + } + if sysField.Write { + t.Fatalf("Row %d: System description field must be protected (Write: false)", row) + } + + // In 3270, writable field length between cmdField and nextField (attribute byte) + writableLen := sysField.Col - cmdField.Col - 1 + if writableLen != 3 { + t.Fatalf("Row %d: Expected CMD field writable length to be 3, got %d (CMD Col: %d, Sys Col: %d)", + row, writableLen, cmdField.Col, sysField.Col) + } + + expectedPrefix := fmt.Sprintf("[%2d]", sysIdx+1) + if !strings.HasPrefix(sysField.Content, expectedPrefix) { + t.Fatalf("Row %d: Expected system field content to start with '%s', got '%s'", + row, expectedPrefix, sysField.Content) + } + } +} + +func TestKillSlotFunction(t *testing.T) { + upServer, upClient := net.Pipe() + defer upServer.Close() + defer upClient.Close() + + slot := &SessionSlot{ + System: TargetSystem{Name: "TEST_SYSTEM"}, + Upstream: upClient, + } + + s := &UserMasterSession{ + Username: "USER1", + DisplayTag: "12345678", + ActiveSlotIdx: 0, + Slots: []*SessionSlot{slot}, + } + + // Verify initial state + if s.CountActiveSlots() != 1 { + t.Fatalf("Expected 1 active slot, got %d", s.CountActiveSlots()) + } + + // Kill slot 0 + killSlot(s, 0) + + s.Mu.Lock() + if s.Slots[0] != nil { + t.Fatal("Expected s.Slots[0] to be nil after killSlot") + } + if s.ActiveSlotIdx != -1 { + t.Fatalf("Expected ActiveSlotIdx to be -1, got %d", s.ActiveSlotIdx) + } + s.Mu.Unlock() + + if s.CountActiveSlots() != 0 { + t.Fatalf("Expected 0 active slots after killSlot, got %d", s.CountActiveSlots()) + } + + // Verify upstream was closed + buf := make([]byte, 10) + _ = upServer.SetReadDeadline(time.Now().Add(200 * time.Millisecond)) + _, err := upServer.Read(buf) + if err == nil { + t.Fatal("Expected read error on upstream server socket after client closed") + } + + // Calling killSlot again on nil slot should be a safe no-op + killSlot(s, 0) + killSlot(s, 99) +} + +func TestRunMenuLoopCMDFieldActions(t *testing.T) { + origConfig := GlobalConfig + origSystems := ConfiguredSystem + defer func() { + GlobalConfig = origConfig + ConfiguredSystem = origSystems + }() + + GlobalConfig = &Config{ + Menu: MenuConfig{ + Title: "MENU", + PromptSelect: "Selection ===>", + Footer: "Footer", + }, + } + ConfiguredSystem = []TargetSystem{ + {Name: "SYS1"}, + {Name: "SYS2"}, + } + + clientServer, clientUser := net.Pipe() + defer clientServer.Close() + defer clientUser.Close() + + upServer1, upClient1 := net.Pipe() + defer upServer1.Close() + defer upClient1.Close() + go func() { _, _ = io.ReadAll(upServer1) }() + + slot1 := &SessionSlot{ + System: ConfiguredSystem[0], + Upstream: upClient1, + } + + s := &UserMasterSession{ + Username: "TESTUSER", + DisplayTag: "12345678", + ClientConn: clientServer, + ActiveSlotIdx: -1, + IsDisconnected: false, + MenuPage: 0, + Slots: []*SessionSlot{slot1, nil}, + } + + loopDone := make(chan struct{}) + go func() { + runMenuLoop(s, clientServer) + close(loopDone) + }() + + // Helper to read menu from server + readMenu := func() { + buf := make([]byte, 4096) + _ = clientUser.SetReadDeadline(time.Now().Add(2 * time.Second)) + _, err := clientUser.Read(buf) + if err != nil { + t.Fatalf("Failed to read menu from server: %v", err) + } + } + + // 1. Initial menu read: slot 1 is ACTIVE + readMenu() + if s.CountActiveSlots() != 1 { + t.Fatalf("Expected 1 active slot, got %d", s.CountActiveSlots()) + } + + // 2. Send 'K' in cmd_1 to kill slot 1 + // 3270 response: AIDEnter (0x7D), Cursor (0x40, 0x40), SBA (0x11), Addr for (Row 7, Col 3 = 563) = 0xC8, 0xF3, 'K' (EBCDIC 0xD2), IAC, EOR + packetKill := []byte{0x7D, 0x40, 0x40, 0x11, 0xC8, 0xF3, 0xD2, IAC, EOR} + _, err := clientUser.Write(packetKill) + if err != nil { + t.Fatalf("Failed to write packetKill: %v", err) + } + + // Menu is re-rendered by loop + readMenu() + + // Verify slot 1 was killed! + if s.CountActiveSlots() != 0 { + t.Fatalf("Expected slot 1 to be killed (0 active slots), got %d", s.CountActiveSlots()) + } + s.Mu.Lock() + slot1Killed := s.Slots[0] == nil + s.Mu.Unlock() + if !slot1Killed { + t.Fatal("Expected s.Slots[0] to be nil after 'K' command") + } + + // 3. Send lowercase 'k' in cmd_1 when already inactive -> safe no-op + // 'k' in EBCDIC 037 is 0x92 + packetKillLower := []byte{0x7D, 0x40, 0x40, 0x11, 0xC8, 0xF3, 0x92, IAC, EOR} + _, err = clientUser.Write(packetKillLower) + if err != nil { + t.Fatalf("Failed to write packetKillLower: %v", err) + } + readMenu() + if s.CountActiveSlots() != 0 { + t.Fatalf("Expected 0 active slots, got %d", s.CountActiveSlots()) + } + + // 4. Send PF3 to disconnect cleanly + packetPF3 := []byte{byte(go3270.AIDPF3), 0x40, 0x40, IAC, EOR} + _, _ = clientUser.Write(packetPF3) + discBuf := make([]byte, 2048) + _, _ = clientUser.Read(discBuf) + + select { + case <-loopDone: + // Clean exit + case <-time.After(2 * time.Second): + t.Fatal("Timeout waiting for runMenuLoop to exit") + } +} + +func TestRunMenuLoopCMDNonKConnects(t *testing.T) { + origConfig := GlobalConfig + origSystems := ConfiguredSystem + defer func() { + GlobalConfig = origConfig + ConfiguredSystem = origSystems + }() + + GlobalConfig = &Config{ + Menu: MenuConfig{ + Title: "MENU", + PromptSelect: "Selection ===>", + Footer: "Footer", + }, + } + ConfiguredSystem = []TargetSystem{ + {Name: "SYS1"}, + } + + clientServer, clientUser := net.Pipe() + defer clientServer.Close() + defer clientUser.Close() + + upServer1, upClient1 := net.Pipe() + defer upServer1.Close() + defer upClient1.Close() + + initialScreen := []byte{CMD_ERASE_WRITE, 0xC3, 'H', 'I', IAC, EOR} + slot1 := &SessionSlot{ + System: ConfiguredSystem[0], + Upstream: upClient1, + LastScreen: initialScreen, + } + + s := &UserMasterSession{ + Username: "TESTUSER", + DisplayTag: "12345678", + ClientConn: clientServer, + ActiveSlotIdx: -1, + IsDisconnected: false, + MenuPage: 0, + Slots: []*SessionSlot{slot1}, + } + + loopDone := make(chan struct{}) + go func() { + runMenuLoop(s, clientServer) + close(loopDone) + }() + + // 1. Read initial menu + buf := make([]byte, 4096) + _ = clientUser.SetReadDeadline(time.Now().Add(2 * time.Second)) + _, err := clientUser.Read(buf) + if err != nil { + t.Fatalf("Failed to read menu: %v", err) + } + + // 2. Send 'S' (EBCDIC 0xE2) in cmd_1 -> should connect / resume slot 0 + packetConnect := []byte{0x7D, 0x40, 0x40, 0x11, 0xC8, 0xF3, 0xE2, IAC, EOR} + _, err = clientUser.Write(packetConnect) + if err != nil { + t.Fatalf("Failed to write packetConnect: %v", err) + } + + // Client should now receive slot1's LastScreen from connectOrResumeSlot! + slotScreenBuf := make([]byte, 4096) + _ = clientUser.SetReadDeadline(time.Now().Add(2 * time.Second)) + n, err := clientUser.Read(slotScreenBuf) + if err != nil || n == 0 { + t.Fatalf("Failed to read slot screen upon connect: %v", err) + } + if !bytes.Equal(slotScreenBuf[:n], initialScreen) { + t.Fatalf("Expected slot screen %x, got %x", initialScreen, slotScreenBuf[:n]) + } + + s.Mu.Lock() + activeIdx := s.ActiveSlotIdx + s.Mu.Unlock() + if activeIdx != 0 { + t.Fatalf("Expected ActiveSlotIdx to be 0 upon connect, got %d", activeIdx) + } + + // Send PA3 to background session and return to menu + pa3Packet := []byte{byte(go3270.AIDPA3), 0x40, 0x40, IAC, EOR} + _, _ = clientUser.Write(pa3Packet) + + // Read returning menu + _ = clientUser.SetReadDeadline(time.Now().Add(2 * time.Second)) + _, _ = clientUser.Read(buf) + + // Send PF3 to disconnect + pf3Packet := []byte{byte(go3270.AIDPF3), 0x40, 0x40, IAC, EOR} + _, _ = clientUser.Write(pf3Packet) + discBuf := make([]byte, 2048) + _, _ = clientUser.Read(discBuf) + + select { + case <-loopDone: + case <-time.After(2 * time.Second): + t.Fatal("Timeout waiting for runMenuLoop to exit") + } +} + +func TestCleanCloseUpstreamTN3270E(t *testing.T) { + server, client := net.Pipe() + defer server.Close() + + done := make(chan struct{}) + var received []byte + go func() { + defer close(done) + received, _ = io.ReadAll(server) + }() + + cleanCloseUpstream(client, true) + + select { + case <-done: + case <-time.After(2 * time.Second): + t.Fatal("Timeout waiting for server read") + } + + expectedUNBIND := []byte{TN3270E_DATA_UNBIND, 0x00, 0x00, 0x00, 0x00, TN3270E_UNBIND_NORMAL, IAC, EOR} + expectedUnwind := []byte{ + IAC, WONT, OPT_BINARY, + IAC, DONT, OPT_BINARY, + IAC, WONT, OPT_EOR, + IAC, DONT, OPT_EOR, + IAC, WONT, OPT_TN3270E, + IAC, DONT, OPT_TN3270E, + } + expectedTotal := append(append([]byte{}, expectedUNBIND...), expectedUnwind...) + + if !bytes.Equal(received, expectedTotal) { + t.Fatalf("CleanCloseUpstream TN3270E payload mismatch.\nGot: %x\nExpected: %x", received, expectedTotal) + } +} + +func TestCleanCloseUpstreamTN3270(t *testing.T) { + server, client := net.Pipe() + defer server.Close() + + done := make(chan struct{}) + var received []byte + go func() { + defer close(done) + received, _ = io.ReadAll(server) + }() + + cleanCloseUpstream(client, false) + + select { + case <-done: + case <-time.After(2 * time.Second): + t.Fatal("Timeout waiting for server read") + } + + expectedUnwind := []byte{ + IAC, WONT, OPT_BINARY, + IAC, DONT, OPT_BINARY, + IAC, WONT, OPT_EOR, + IAC, DONT, OPT_EOR, + } + + if !bytes.Equal(received, expectedUnwind) { + t.Fatalf("CleanCloseUpstream TN3270 payload mismatch.\nGot: %x\nExpected: %x", received, expectedUnwind) + } +} + +func TestTerminateSessionCleanClose(t *testing.T) { + clientServer, clientUser := net.Pipe() + defer clientServer.Close() + defer clientUser.Close() + + upServer1, upClient1 := net.Pipe() + defer upServer1.Close() + upServer2, upClient2 := net.Pipe() + defer upServer2.Close() + + slot1 := &SessionSlot{ + System: TargetSystem{Name: "SYS1"}, + Upstream: upClient1, + IsTN3270E: true, + } + slot2 := &SessionSlot{ + System: TargetSystem{Name: "SYS2"}, + Upstream: upClient2, + IsTN3270E: false, + } + + sess := &UserMasterSession{ + Username: "TESTUSER", + DisplayTag: "12345678", + ClientConn: clientServer, + Slots: []*SessionSlot{slot1, slot2}, + } + + done1 := make(chan []byte) + go func() { + data, _ := io.ReadAll(upServer1) + done1 <- data + }() + + done2 := make(chan []byte) + go func() { + data, _ := io.ReadAll(upServer2) + done2 <- data + }() + + clientDone := make(chan struct{}) + go func() { + buf := make([]byte, 1024) + _, _ = clientUser.Read(buf) + close(clientDone) + }() + + terminateSession(sess, "Session terminated by admin") + + var data1, data2 []byte + select { + case data1 = <-done1: + case <-time.After(2 * time.Second): + t.Fatal("Timeout waiting for slot 1 clean close") + } + + select { + case data2 = <-done2: + case <-time.After(2 * time.Second): + t.Fatal("Timeout waiting for slot 2 clean close") + } + + // Slot 1 should have received UNBIND + unwinding + expectedUNBIND := []byte{TN3270E_DATA_UNBIND, 0x00, 0x00, 0x00, 0x00, TN3270E_UNBIND_NORMAL, IAC, EOR} + if !bytes.Contains(data1, expectedUNBIND) { + t.Fatalf("Slot 1 missing UNBIND: %x", data1) + } + if !bytes.Contains(data1, []byte{IAC, WONT, OPT_TN3270E}) { + t.Fatalf("Slot 1 missing WONT TN3270E: %x", data1) + } + + // Slot 2 should have received basic telnet unwinding and NO UNBIND + if bytes.Contains(data2, expectedUNBIND) { + t.Fatalf("Slot 2 unexpectedly received UNBIND: %x", data2) + } + if !bytes.Contains(data2, []byte{IAC, WONT, OPT_BINARY}) { + t.Fatalf("Slot 2 missing WONT BINARY: %x", data2) + } +} + +func TestNegotiateUpstreamTelnetTN3270E(t *testing.T) { + server, client := net.Pipe() + defer server.Close() + defer client.Close() + + serverDone := make(chan error, 1) + go func() { + defer close(serverDone) + buf := make([]byte, 1024) + + // 1. Server initiates RFC 2355: sends IAC DO TN3270E + offer := []byte{IAC, DO, OPT_TN3270E} + if _, err := server.Write(offer); err != nil { + serverDone <- err + return + } + + // 2. Read client's IAC WILL TN3270E + n, err := server.Read(buf) + if err != nil { + serverDone <- err + return + } + if !bytes.Contains(buf[:n], []byte{IAC, WILL, OPT_TN3270E}) { + serverDone <- fmt.Errorf("expected WILL TN3270E, got %x", buf[:n]) + return + } + + // 3. Server asks for DEVICE-TYPE + reply := []byte{ + IAC, SB, OPT_TN3270E, TN3270E_SEND, TN3270E_DEVICE_TYPE, IAC, SE, + } + if _, err := server.Write(reply); err != nil { + serverDone <- err + return + } + + // 4. Read client's DEVICE-TYPE REQUEST IBM-3278-2 + n, err = server.Read(buf) + if err != nil { + serverDone <- err + return + } + if !bytes.Contains(buf[:n], []byte{IAC, SB, OPT_TN3270E, TN3270E_DEVICE_TYPE, TN3270E_REQUEST}) { + serverDone <- fmt.Errorf("expected DEVICE-TYPE REQUEST, got %x", buf[:n]) + return + } + + // 5. Server accepts DEVICE-TYPE IS IBM-3278-2 + devIs := []byte{ + IAC, SB, OPT_TN3270E, TN3270E_DEVICE_TYPE, TN3270E_IS, + 'I', 'B', 'M', '-', '3', '2', '7', '8', '-', '2', + IAC, SE, + } + if _, err := server.Write(devIs); err != nil { + serverDone <- err + return + } + + // 6. Read client's FUNCTIONS REQUEST RESPONSES BIND-IMAGE + n, err = server.Read(buf) + if err != nil { + serverDone <- err + return + } + if !bytes.Contains(buf[:n], []byte{IAC, SB, OPT_TN3270E, TN3270E_FUNCTIONS, TN3270E_REQUEST}) { + serverDone <- fmt.Errorf("expected FUNCTIONS REQUEST, got %x", buf[:n]) + return + } + + // 7. Server responds FUNCTIONS IS RESPONSES BIND-IMAGE and sends initial 3270 screen + screenPayload := []byte{ + IAC, SB, OPT_TN3270E, TN3270E_FUNCTIONS, TN3270E_IS, TN3270E_FUNC_RESPONSES, TN3270E_FUNC_BIND_IMAGE, IAC, SE, + // TN3270E 5-byte header + 3270 data + TN3270E_DATA_3270, 0x00, 0x00, 0x00, 0x00, + CMD_ERASE_WRITE, 0xC3, 0x11, 0x40, 0x40, 'M', 'A', 'I', 'N', + IAC, EOR, + } + if _, err := server.Write(screenPayload); err != nil { + serverDone <- err + return + } + }() + + initData, isTN3270E, _, err := negotiateUpstreamTelnet(client, "IBM-3278-2") + if err != nil { + t.Fatalf("negotiateUpstreamTelnet failed: %v", err) + } + + if err := <-serverDone; err != nil { + t.Fatalf("mock server error: %v", err) + } + + if !isTN3270E { + t.Fatal("expected isTN3270E == true") + } + + expectedStripped := []byte{CMD_ERASE_WRITE, 0xC3, 0x11, 0x40, 0x40, 'M', 'A', 'I', 'N', IAC, EOR} + if !bytes.Equal(initData, expectedStripped) { + t.Fatalf("expected initial screen %x, got %x", expectedStripped, initData) + } +} + +func TestNegotiateUpstreamTelnetTN3270Fallback(t *testing.T) { + server, client := net.Pipe() + defer server.Close() + defer client.Close() + + serverDone := make(chan error, 1) + go func() { + defer close(serverDone) + buf := make([]byte, 1024) + + // 1. Server initiates RFC 1576: DO TERMINAL-TYPE, WILL BINARY, DO BINARY, WILL EOR, DO EOR + offer := []byte{ + IAC, DO, OPT_TERMINAL_TYPE, + IAC, WILL, OPT_BINARY, + IAC, DO, OPT_BINARY, + IAC, WILL, OPT_EOR, + IAC, DO, OPT_EOR, + } + if _, err := server.Write(offer); err != nil { + serverDone <- err + return + } + + // 2. Read client responses (WILL TERMINAL-TYPE, etc.) + n, err := server.Read(buf) + if err != nil { + serverDone <- err + return + } + + // 3. Server asks for terminal type: IAC SB TERMINAL-TYPE 1 IAC SE + askTT := []byte{IAC, SB, OPT_TERMINAL_TYPE, TTYPE_SEND, IAC, SE} + if _, err := server.Write(askTT); err != nil { + serverDone <- err + return + } + + // 4. Read client terminal type reply: IAC SB TERMINAL-TYPE 0 IBM-3278-2 IAC SE + n, err = server.Read(buf) + if err != nil { + serverDone <- err + return + } + if !bytes.Contains(buf[:n], []byte{IAC, SB, OPT_TERMINAL_TYPE, TTYPE_IS}) { + serverDone <- fmt.Errorf("expected TERMINAL-TYPE IS, got %x", buf[:n]) + return + } + + // 5. Server sends initial 3270 screen without TN3270E header + screenPayload := []byte{ + CMD_ERASE_WRITE, 0xC3, 0x11, 0x40, 0x40, 'B', 'A', 'S', 'I', 'C', + IAC, EOR, + } + if _, err := server.Write(screenPayload); err != nil { + serverDone <- err + return + } + }() + + initData, isTN3270E, _, err := negotiateUpstreamTelnet(client, "IBM-3278-2") + if err != nil { + t.Fatalf("negotiateUpstreamTelnet failed: %v", err) + } + + if isTN3270E { + t.Fatal("expected isTN3270E == false for fallback") + } + + // Any screen data sent by host after negotiation is read from client conn (e.g. by upstreamReader) + screen := initData + if len(screen) == 0 { + buf := make([]byte, 1024) + n, err := client.Read(buf) + if err != nil { + t.Fatalf("Failed to read initial screen after negotiation: %v", err) + } + screen = buf[:n] + } + + if err := <-serverDone; err != nil { + t.Fatalf("mock server error: %v", err) + } + + expectedScreen := []byte{CMD_ERASE_WRITE, 0xC3, 0x11, 0x40, 0x40, 'B', 'A', 'S', 'I', 'C', IAC, EOR} + if !bytes.Equal(screen, expectedScreen) { + t.Fatalf("expected initial screen %x, got %x", expectedScreen, screen) + } +} + +func TestModelMatrixDimensions(t *testing.T) { + tests := []struct { + termType string + expRows int + expCols int + expMax int + }{ + {"IBM-3278-2", 24, 80, 8192}, + {"IBM-3279-2-E", 24, 80, 8192}, + {"IBM-3278-3", 32, 80, 10240}, + {"IBM-3279-3-E", 32, 80, 10240}, + {"IBM-3278-4", 43, 80, 13760}, + {"IBM-3279-4-E", 43, 80, 13760}, + {"IBM-3278-5", 27, 132, 14256}, + {"IBM-3279-5-E", 27, 132, 14256}, + {"UNKNOWN-TERMINAL", 24, 80, 8192}, + } + + for _, tc := range tests { + rows, cols, maxBuf := GetTerminalMatrix(tc.termType, nil) + if rows != tc.expRows || cols != tc.expCols { + t.Errorf("[%s] Expected %dx%d, got %dx%d", tc.termType, tc.expRows, tc.expCols, rows, cols) + } + if maxBuf != tc.expMax { + t.Errorf("[%s] Expected maxBuf %d, got %d", tc.termType, tc.expMax, maxBuf) + } + } +} + +func TestHoldStatusRenderingAndColor(t *testing.T) { + origSystems := ConfiguredSystem + defer func() { + ConfiguredSystem = origSystems + }() + + ConfiguredSystem = []TargetSystem{ + {Name: "SYS_HELD", Description: "Held system"}, + } + + slotHeld := &SessionSlot{ + System: ConfiguredSystem[0], + IsHeld: true, + } + + s := &UserMasterSession{ + Username: "TESTUSER", + DisplayTag: "12345678", + Slots: []*SessionSlot{slotHeld}, + } + + screen := buildMainMenuScreen(s) + + var rowHeld *go3270.Field + for i := range screen { + f := &screen[i] + if f.Row == 7 && strings.Contains(f.Content, "SYS_HELD") { + rowHeld = f + break + } + } + + if rowHeld == nil { + t.Fatal("Expected row for SYS_HELD on row 7") + } + + if !strings.Contains(rowHeld.Content, "HELD") { + t.Fatalf("Expected row content to contain 'HELD', got: %s", rowHeld.Content) + } + + if rowHeld.Color != go3270.Red { + t.Fatalf("Expected HELD row color to be Red (%v), got: %v", go3270.Red, rowHeld.Color) + } + + if rowHeld.Intense { + t.Fatal("Expected HELD row to not be intense") + } +} + +func TestHoldAndReleaseLineCommands(t *testing.T) { + origConfig := GlobalConfig + origSystems := ConfiguredSystem + defer func() { + GlobalConfig = origConfig + ConfiguredSystem = origSystems + }() + + GlobalConfig = &Config{ + Menu: MenuConfig{ + Title: "TEST MENU", + PromptSelect: "Selection ===>", + Footer: "Footer", + }, + } + ConfiguredSystem = []TargetSystem{ + {Name: "TESTSYS"}, + } + + clientServer, clientUser := net.Pipe() + defer clientServer.Close() + defer clientUser.Close() + + upServer, upClient := net.Pipe() + defer upServer.Close() + defer upClient.Close() + + initialScreen := []byte{CMD_ERASE_WRITE, 0xC3, 0x11, 0x40, 0x40, 'H', 'E', 'L', 'L', 'O', IAC, EOR} + + slot := &SessionSlot{ + System: ConfiguredSystem[0], + Upstream: upClient, + LastScreen: initialScreen, + MaxBufferSize: 8192, + } + + s := &UserMasterSession{ + Username: "TESTUSER", + DisplayTag: "12345678", + ClientConn: clientServer, + ActiveSlotIdx: -1, + IsDisconnected: false, + MenuPage: 0, + Slots: []*SessionSlot{slot}, + } + + go upstreamReader(s, slot, 0) + + loopDone := make(chan struct{}) + go func() { + runMenuLoop(s, clientServer) + close(loopDone) + }() + + readMenu := func() { + buf := make([]byte, 4096) + _ = clientUser.SetReadDeadline(time.Now().Add(2 * time.Second)) + _, err := clientUser.Read(buf) + if err != nil { + t.Fatalf("Failed to read menu: %v", err) + } + } + + // 1. Initial menu read: slot is ACTIVE + readMenu() + if slot.IsHeld { + t.Fatal("Expected slot to initially not be held") + } + + // 2. Send 'H' in cmd_1 to hold the slot + // Row 7, Col 3 = SBA Addr 0xC8, 0xF3, 'H' (EBCDIC 0xC8) + packetHold := []byte{0x7D, 0x40, 0x40, 0x11, 0xC8, 0xF3, 0xC8, IAC, EOR} + _, err := clientUser.Write(packetHold) + if err != nil { + t.Fatalf("Failed to write packetHold: %v", err) + } + + readMenu() + + slot.Mu.Lock() + isHeld := slot.IsHeld + slot.Mu.Unlock() + if !isHeld { + t.Fatal("Expected slot.IsHeld == true after 'H' command") + } + + // 3. Send a tick from upstream while held + tickPacket := []byte{CMD_WRITE, 0xC3, 0x11, 0x40, 0x50, 'T', 'I', 'C', 'K', IAC, EOR} + _, _ = upServer.Write(tickPacket) + time.Sleep(100 * time.Millisecond) + + slot.Mu.Lock() + screenChanged := slot.ScreenChanged + slot.Mu.Unlock() + if screenChanged { + t.Fatal("Expected ScreenChanged to remain false while slot is held") + } + + // 4. Send 'R' in cmd_1 to release the slot + // 'R' in EBCDIC 037 is 0xD9 + packetRelease := []byte{0x7D, 0x40, 0x40, 0x11, 0xC8, 0xF3, 0xD9, IAC, EOR} + _, err = clientUser.Write(packetRelease) + if err != nil { + t.Fatalf("Failed to write packetRelease: %v", err) + } + + readMenu() + + slot.Mu.Lock() + isHeldAfterR := slot.IsHeld + slot.Mu.Unlock() + if isHeldAfterR { + t.Fatal("Expected slot.IsHeld == false after 'R' command") + } + + // 5. Send a tick now that it is released; it should trigger ScreenChanged + _, _ = upServer.Write(tickPacket) + time.Sleep(100 * time.Millisecond) + + slot.Mu.Lock() + screenChangedAfterR := slot.ScreenChanged + slot.Mu.Unlock() + if !screenChangedAfterR { + t.Fatal("Expected ScreenChanged to be true after tick received on released slot") + } + + // Clean exit via PF3 + packetPF3 := []byte{byte(go3270.AIDPF3), 0x40, 0x40, IAC, EOR} + _, _ = clientUser.Write(packetPF3) + discBuf := make([]byte, 2048) + _, _ = clientUser.Read(discBuf) + + select { + case <-loopDone: + case <-time.After(2 * time.Second): + t.Fatal("Timeout waiting for runMenuLoop to exit") + } +} + +func TestBackgroundStartCommand(t *testing.T) { + origConfig := GlobalConfig + origSystems := ConfiguredSystem + defer func() { + GlobalConfig = origConfig + ConfiguredSystem = origSystems + }() + + // Start a mock upstream server for SYS1 + upListener, err := net.Listen("tcp4", "127.0.0.1:0") + if err != nil { + t.Fatalf("Failed to bind mock upstream: %v", err) + } + defer upListener.Close() + + upAddr := upListener.Addr().String() + + GlobalConfig = &Config{ + Menu: MenuConfig{ + Title: "TEST MENU", + PromptSelect: "Selection ===>", + Footer: "Footer", + }, + } + ConfiguredSystem = []TargetSystem{ + {Name: "SYS_BG", HostPort: upAddr}, + } + + clientServer, clientUser := net.Pipe() + defer clientServer.Close() + defer clientUser.Close() + + s := &UserMasterSession{ + Username: "TESTUSER", + DisplayTag: "12345678", + ClientConn: clientServer, + ActiveSlotIdx: -1, + IsDisconnected: false, + MenuPage: 0, + Slots: make([]*SessionSlot, 0), + } + + // Accept loop for mock upstream + go func() { + conn, err := upListener.Accept() + if err != nil { + return + } + defer conn.Close() + // Send telnet negotiations + initial screen + _, _ = conn.Write([]byte{IAC, DO, OPT_BINARY, IAC, DO, OPT_EOR}) + buf := make([]byte, 256) + _, _ = conn.Read(buf) + _, _ = conn.Write([]byte{CMD_ERASE_WRITE, 0xC3, 'B', 'G', IAC, EOR}) + time.Sleep(1 * time.Second) + }() + + loopDone := make(chan struct{}) + go func() { + runMenuLoop(s, clientServer) + close(loopDone) + }() + + readMenu := func() { + buf := make([]byte, 4096) + _ = clientUser.SetReadDeadline(time.Now().Add(2 * time.Second)) + _, err := clientUser.Read(buf) + if err != nil { + t.Fatalf("Failed to read menu: %v", err) + } + } + + // Read initial menu: 0 active slots + readMenu() + if s.CountActiveSlots() != 0 { + t.Fatalf("Expected 0 active slots initially, got %d", s.CountActiveSlots()) + } + + // Send 'B' in cmd_1 to connect SYS_BG in background + // 'B' in EBCDIC 037 is 0xC2 + packetB := []byte{0x7D, 0x40, 0x40, 0x11, 0xC8, 0xF3, 0xC2, IAC, EOR} + _, err = clientUser.Write(packetB) + if err != nil { + t.Fatalf("Failed to write packetB: %v", err) + } + + // Re-rendered menu read + readMenu() + + // Verify slot was started in background + if s.CountActiveSlots() != 1 { + t.Fatalf("Expected 1 active slot after 'B', got %d", s.CountActiveSlots()) + } + + s.Mu.Lock() + activeIdx := s.ActiveSlotIdx + s.Mu.Unlock() + if activeIdx != -1 { + t.Fatalf("Expected ActiveSlotIdx == -1 (still on menu), got %d", activeIdx) + } + + // Clean exit via PF3 + packetPF3 := []byte{byte(go3270.AIDPF3), 0x40, 0x40, IAC, EOR} + _, _ = clientUser.Write(packetPF3) + discBuf := make([]byte, 2048) + _, _ = clientUser.Read(discBuf) + + select { + case <-loopDone: + case <-time.After(2 * time.Second): + t.Fatal("Timeout waiting for runMenuLoop to exit") + } +} + +func TestBoundedBufferPreventsRunawayTicks(t *testing.T) { + clientServer, clientUser := net.Pipe() + defer clientServer.Close() + defer clientUser.Close() + + upServer, upClient := net.Pipe() + defer upServer.Close() + defer upClient.Close() + + initialScreen := []byte{CMD_ERASE_WRITE, 0xC3, 0x11, 0x40, 0x40, 'B', 'A', 'S', 'E', IAC, EOR} + + slot := &SessionSlot{ + System: TargetSystem{Name: "TICK_SYSTEM"}, + Upstream: upClient, + LastScreen: initialScreen, + MaxBufferSize: 8192, + } + + s := &UserMasterSession{ + Username: "TESTUSER", + DisplayTag: "12345678", + TerminalType: "IBM-3278-2", + ClientConn: clientServer, + ActiveSlotIdx: -1, + Slots: []*SessionSlot{slot}, + IsDisconnected: false, + } + + go upstreamReader(s, slot, 0) + + // Stream 300 incremental write updates, each ~50 bytes (total 15,000 bytes > 8,192 MaxBufferSize) + writePayload := []byte{CMD_WRITE, 0xC3, 0x11, 0x40, 0x60} + for i := 0; i < 40; i++ { + writePayload = append(writePayload, 'A'+byte(i%26)) + } + writePayload = append(writePayload, IAC, EOR) + + for i := 0; i < 300; i++ { + _, err := upServer.Write(writePayload) + if err != nil { + t.Fatalf("Failed to write tick %d: %v", i, err) + } + } + + time.Sleep(200 * time.Millisecond) + + slot.Mu.Lock() + bufLen := len(slot.LastScreen) + screenCopy := make([]byte, bufLen) + copy(screenCopy, slot.LastScreen) + slot.Mu.Unlock() + + // Verify buffer did NOT grow unbounded and stayed strictly within MaxBufferSize + if bufLen > slot.MaxBufferSize { + t.Fatalf("Buffer grew unbounded! Expected len <= %d, got %d", slot.MaxBufferSize, bufLen) + } + + // Verify buffer still starts with Erase/Write opcode (0xF5) and ends with IAC EOR + if screenCopy[0] != CMD_ERASE_WRITE { + t.Fatalf("Expected buffer to retain Erase/Write header 0xF5, got 0x%02X", screenCopy[0]) + } + if !bytes.HasSuffix(screenCopy, telnetEOR) { + t.Fatalf("Expected buffer to end with IAC EOR, got %x", screenCopy[len(screenCopy)-2:]) + } +} + +func TestResolveTerminalType(t *testing.T) { + tests := []struct { + input string + expected string + }{ + {"IBM-3278-2", "IBM-3278-2-E"}, + {"IBM-3278-3", "IBM-3278-3-E"}, + {"IBM-3278-4", "IBM-3278-4-E"}, + {"IBM-3278-5", "IBM-3278-5-E"}, + {"IBM-3279-2", "IBM-3279-2-E"}, + {"IBM-3279-3", "IBM-3279-3-E"}, + {"IBM-3279-4", "IBM-3279-4-E"}, + {"IBM-3279-5", "IBM-3279-5-E"}, + {"IBM-3279-4-E", "IBM-3279-4-E"}, + {"unknown (IBM-3279-4)", "IBM-3279-4-E"}, + {"unknown (IBM-3278-3-E)", "IBM-3278-3-E"}, + {"", "IBM-3279-2-E"}, + } + + for _, tc := range tests { + actual := ResolveTerminalType(tc.input, nil) + if actual != tc.expected { + t.Errorf("ResolveTerminalType(%q) = %q, expected %q", tc.input, actual, tc.expected) + } + } +} + +func TestBackgroundStartWithAttachedTerminalCharacteristics(t *testing.T) { + origConfig := GlobalConfig + origSystems := ConfiguredSystem + defer func() { + GlobalConfig = origConfig + ConfiguredSystem = origSystems + }() + + upListener, err := net.Listen("tcp4", "127.0.0.1:0") + if err != nil { + t.Fatalf("Failed to bind mock upstream: %v", err) + } + defer upListener.Close() + + upAddr := upListener.Addr().String() + + GlobalConfig = &Config{ + Menu: MenuConfig{ + Title: "TEST MENU", + PromptSelect: "Selection ===>", + Footer: "Footer", + }, + } + ConfiguredSystem = []TargetSystem{ + {Name: "SYS_MODEL4", HostPort: upAddr}, + } + + clientServer, clientUser := net.Pipe() + defer clientServer.Close() + defer clientUser.Close() + + // Attached terminal is a Model 4 color terminal (43 rows, 80 cols) + s := &UserMasterSession{ + Username: "TESTUSER", + DisplayTag: "12345678", + TerminalType: "IBM-3279-4-E", + ClientConn: clientServer, + ActiveSlotIdx: -1, + IsDisconnected: false, + MenuPage: 0, + Slots: make([]*SessionSlot, 0), + } + + negotiatedTermType := make(chan string, 1) + queryReplyReceived := make(chan []byte, 1) + + // Mock upstream server simulating a mainframe + go func() { + conn, err := upListener.Accept() + if err != nil { + return + } + defer conn.Close() + + // Request Terminal Type + _, _ = conn.Write([]byte{IAC, DO, OPT_TERMINAL_TYPE, IAC, DO, OPT_BINARY, IAC, DO, OPT_EOR}) + + buf := make([]byte, 1024) + n, err := conn.Read(buf) + if err != nil { + return + } + _ = n + + // Send terminal type send request + _, _ = conn.Write([]byte{IAC, SB, OPT_TERMINAL_TYPE, TTYPE_SEND, IAC, SE}) + n, err = conn.Read(buf) + if err != nil { + return + } + data := buf[:n] + + // Extract reported terminal type + ttypeIdx := bytes.Index(data, []byte{IAC, SB, OPT_TERMINAL_TYPE, TTYPE_IS}) + if ttypeIdx != -1 { + seIdx := bytes.Index(data[ttypeIdx:], []byte{IAC, SE}) + if seIdx != -1 { + term := string(data[ttypeIdx+4 : ttypeIdx+seIdx]) + negotiatedTermType <- term + } + } + + // Now send a 3270 Query command (Write Structured Field with Read Partition Query) + queryCmd := []byte{CMD_WSF, 0x00, 0x05, SF_READ_PARTITION, SF_RP_QUERY, 0xFF, 0x02, IAC, EOR} + _, _ = conn.Write(queryCmd) + + // Read Query Reply from background session + _ = conn.SetReadDeadline(time.Now().Add(2 * time.Second)) + qrBuf := make([]byte, 2048) + qrN, err := conn.Read(qrBuf) + if err == nil && qrN > 0 { + queryReplyReceived <- qrBuf[:qrN] + } + }() + + loopDone := make(chan struct{}) + go func() { + runMenuLoop(s, clientServer) + close(loopDone) + }() + + readMenu := func() { + buf := make([]byte, 4096) + _ = clientUser.SetReadDeadline(time.Now().Add(2 * time.Second)) + _, err := clientUser.Read(buf) + if err != nil { + t.Fatalf("Failed to read menu: %v", err) + } + } + + readMenu() + + // Send 'B' in cmd_1 + packetB := []byte{0x7D, 0x40, 0x40, 0x11, 0xC8, 0xF3, 0xC2, IAC, EOR} + _, err = clientUser.Write(packetB) + if err != nil { + t.Fatalf("Failed to write packetB: %v", err) + } + + readMenu() + + // 1. Verify negotiated terminal type matches the attached Model 4 terminal + select { + case term := <-negotiatedTermType: + if term != "IBM-3279-4-E" { + t.Fatalf("Expected negotiated terminal type 'IBM-3279-4-E', got '%s'", term) + } + case <-time.After(2 * time.Second): + t.Fatal("Timeout waiting for negotiated terminal type") + } + + // 2. Verify Query Reply was sent in the background and reports Model 4 (43 rows, 80 cols) + select { + case qr := <-queryReplyReceived: + if len(qr) < 10 || qr[0] != AID_QUERY_REPLY { + t.Fatalf("Expected AID Query Reply (0x88), got: %x", qr) + } + // Check Usable Area inside Query Reply + uaIdx := bytes.Index(qr, []byte{SF_QUERY_REPLY, QR_USABLE_AREA}) + if uaIdx == -1 { + t.Fatalf("Expected Usable Area structured field in Query Reply: %x", qr) + } + cols := int(qr[uaIdx+4])<<8 | int(qr[uaIdx+5]) + rows := int(qr[uaIdx+6])<<8 | int(qr[uaIdx+7]) + if rows != 43 || cols != 80 { + t.Fatalf("Expected Query Reply to report 43x80, got %dx%d", rows, cols) + } + case <-time.After(2 * time.Second): + t.Fatal("Timeout waiting for Query Reply in background session") + } + + // Clean exit via PF3 + packetPF3 := []byte{byte(go3270.AIDPF3), 0x40, 0x40, IAC, EOR} + _, _ = clientUser.Write(packetPF3) + discBuf := make([]byte, 2048) + _, _ = clientUser.Read(discBuf) + + select { + case <-loopDone: + case <-time.After(2 * time.Second): + t.Fatal("Timeout waiting for runMenuLoop to exit") + } +} + +func TestTN3270ESSCPLUDataInputBeforeBind(t *testing.T) { + upServer, upClient := net.Pipe() + defer upServer.Close() + defer upClient.Close() + + clientTerm, sessmanTerm := net.Pipe() + defer clientTerm.Close() + defer sessmanTerm.Close() + + slot := &SessionSlot{ + System: TargetSystem{Name: "ZOS_SYSTEM"}, + Upstream: upClient, + IsTN3270E: true, + IsBound: false, // Initial state before BIND + } + + userSession := &UserMasterSession{ + Username: "TESTUSER", + DisplayTag: "12345678", + ClientConn: sessmanTerm, + ActiveSlotIdx: 0, + Slots: []*SessionSlot{slot}, + } + + upstreamGot := make(chan []byte, 1) + go func() { + buf := make([]byte, 1024) + n, err := upServer.Read(buf) + if err == nil { + upstreamGot <- append([]byte{}, buf[:n]...) + } + }() + + bridgeDone := make(chan struct{}) + go func() { + defer close(bridgeDone) + bridgeClientToUpstream(userSession, slot, sessmanTerm) + }() + + clientPacket := []byte{0x7D, 0x40, 0x40, 'L', 'O', 'G', 'O', 'N', IAC, EOR} + if _, err := clientTerm.Write(clientPacket); err != nil { + t.Fatalf("Write failed: %v", err) + } + + select { + case data := <-upstreamGot: + if len(data) < 5 { + t.Fatalf("Upstream packet too short: %x", data) + } + if data[0] != TN3270E_DATA_SSCP_LU { + t.Fatalf("Expected TN3270E_DATA_SSCP_LU (0x07), got 0x%02x", data[0]) + } + if data[3] != 0 || data[4] != 0 { + t.Fatalf("Expected seq number 0x0000 for SSCP-LU, got 0x%02x%02x", data[3], data[4]) + } + case <-time.After(2 * time.Second): + t.Fatal("Timeout waiting for upstream data") + } + + _ = clientTerm.Close() + <-bridgeDone +} + +func TestTN3270EUNBINDDoesNotClearSlotAndTransitionsToSSCP(t *testing.T) { + upServer, upClient := net.Pipe() + defer upServer.Close() + defer upClient.Close() + + clientTerm, sessmanTerm := net.Pipe() + defer clientTerm.Close() + defer sessmanTerm.Close() + + slot := &SessionSlot{ + System: TargetSystem{Name: "ZOS_SYSTEM"}, + Upstream: upClient, + IsTN3270E: true, + IsBound: true, // Initially bound + } + + userSession := &UserMasterSession{ + Username: "TESTUSER", + DisplayTag: "12345678", + ClientConn: sessmanTerm, + ActiveSlotIdx: 0, + Slots: []*SessionSlot{slot}, + } + + go upstreamReader(userSession, slot, 0) + + unbindPacket := []byte{TN3270E_DATA_UNBIND, 0x00, 0x00, 0x00, 0x00, 0x02, IAC, EOR} + if _, err := upServer.Write(unbindPacket); err != nil { + t.Fatalf("Failed to write UNBIND: %v", err) + } + + time.Sleep(50 * time.Millisecond) + + userSession.Mu.Lock() + if userSession.Slots[0] == nil { + userSession.Mu.Unlock() + t.Fatal("Slot 0 was unexpectedly cleared on UNBIND") + } + userSession.Mu.Unlock() + + slot.Mu.Lock() + if slot.Upstream == nil { + slot.Mu.Unlock() + t.Fatal("slot.Upstream was unexpectedly closed on UNBIND") + } + if slot.IsBound { + slot.Mu.Unlock() + t.Fatal("slot.IsBound should be false after UNBIND") + } + slot.Mu.Unlock() + + bindPacket := []byte{TN3270E_DATA_BIND, 0x00, 0x00, 0x00, 0x00, 0x31, IAC, EOR} + if _, err := upServer.Write(bindPacket); err != nil { + t.Fatalf("Failed to write BIND: %v", err) + } + + time.Sleep(50 * time.Millisecond) + + slot.Mu.Lock() + if !slot.IsBound { + slot.Mu.Unlock() + t.Fatal("slot.IsBound should be true after BIND") + } + slot.Mu.Unlock() + + _ = upServer.Close() +} + +func TestTN3270EBackgroundQueryReplyUsesSSCPLUWhenUnbound(t *testing.T) { + upServer, upClient := net.Pipe() + defer upServer.Close() + defer upClient.Close() + + slot := &SessionSlot{ + System: TargetSystem{Name: "ZOS_SYSTEM"}, + Upstream: upClient, + IsTN3270E: true, + IsBound: false, + } + + userSession := &UserMasterSession{ + Username: "TESTUSER", + DisplayTag: "12345678", + TerminalType: "IBM-3279-4-E", + ActiveSlotIdx: -1, // Background + Slots: []*SessionSlot{slot}, + } + + go upstreamReader(userSession, slot, 0) + + gotReply := make(chan []byte, 1) + go func() { + buf := make([]byte, 2048) + n, err := upServer.Read(buf) + if err == nil { + gotReply <- append([]byte{}, buf[:n]...) + } + }() + + queryPacket := []byte{ + TN3270E_DATA_SSCP_LU, 0x00, 0x00, 0x00, 0x00, + CMD_WSF, 0x00, 0x05, SF_READ_PARTITION, SF_RP_QUERY, 0x02, + IAC, EOR, + } + if _, err := upServer.Write(queryPacket); err != nil { + t.Fatalf("Failed to write Query: %v", err) + } + + select { + case reply := <-gotReply: + if len(reply) < 5 { + t.Fatalf("Reply too short: %x", reply) + } + if reply[0] != TN3270E_DATA_SSCP_LU { + t.Fatalf("Expected Query Reply to use TN3270E_DATA_SSCP_LU (0x07), got 0x%02x", reply[0]) + } + case <-time.After(2 * time.Second): + t.Fatal("Timeout waiting for background Query Reply") + } + + _ = upServer.Close() +} + +func TestCaseSensitiveUsernamesAndSessionKeys(t *testing.T) { + origConfig := GlobalConfig + origPub := publicFlag + defer func() { + GlobalConfig = origConfig + publicFlag = origPub + }() + publicFlag = false + + // 1. Session keys must differ for "RUDI" and "rudi" + keyLower := computeSessionKey("rudi", "samepass") + keyUpper := computeSessionKey("RUDI", "samepass") + if keyLower == keyUpper { + t.Fatalf("Expected distinct session keys for 'rudi' and 'RUDI', but both produced %s", keyLower) + } + + // 2. Authenticate distinct users "rudi" (non-admin) and "RUDI" (admin) + GlobalConfig = &Config{ + Users: []UserConfig{ + {Username: "rudi", Password: "lowerpass", Admin: false}, + {Username: "RUDI", Password: "UPPERPASS", Admin: true}, + }, + } + + resLower := authenticateAndAuthorize("rudi", "lowerpass") + if !resLower.OK || resLower.IsAdmin { + t.Fatalf("Expected 'rudi' to authenticate as non-admin, got %+v", resLower) + } + + resUpper := authenticateAndAuthorize("RUDI", "UPPERPASS") + if !resUpper.OK || !resUpper.IsAdmin { + t.Fatalf("Expected 'RUDI' to authenticate as admin, got %+v", resUpper) + } + + if res := authenticateAndAuthorize("rudi", "UPPERPASS"); res.OK { + t.Fatal("Expected 'rudi' with 'UPPERPASS' to fail authentication") + } + if res := authenticateAndAuthorize("RUDI", "lowerpass"); res.OK { + t.Fatal("Expected 'RUDI' with 'lowerpass' to fail authentication") + } + + // 3. findSession must match Username case-sensitively + sessLower := &UserMasterSession{Username: "rudi", SessionKey: keyLower, DisplayTag: keyLower[:8]} + sessUpper := &UserMasterSession{Username: "RUDI", SessionKey: keyUpper, DisplayTag: keyUpper[:8]} + + registryMu.Lock() + userSessions = map[string]*UserMasterSession{ + keyLower: sessLower, + keyUpper: sessUpper, + } + registryMu.Unlock() + + if got := findSession("rudi"); got != sessLower { + t.Fatalf("Expected findSession('rudi') to return sessLower, got %+v", got) + } + if got := findSession("RUDI"); got != sessUpper { + t.Fatalf("Expected findSession('RUDI') to return sessUpper, got %+v", got) + } + if got := findSession("Rudi"); got != nil { + t.Fatalf("Expected findSession('Rudi') to return nil, got %+v", got) + } +} + +func TestEscapedIACEORInRecordFraming(t *testing.T) { + // A 3270 record containing an escaped 0xFF 0xFF followed immediately by 0xEF, + // and then ending with a real unescaped IAC EOR (0xFF 0xEF). + record := []byte{CMD_ERASE_WRITE, 0xC3, IAC, IAC, EOR, 'O', 'K', IAC, EOR} + + idx := indexTelnetEOR(record) + if idx != len(record)-2 { + t.Fatalf("Expected indexTelnetEOR to skip escaped IAC IAC and find EOR at %d, got %d", len(record)-2, idx) + } + + lastIdx := lastIndexTelnetEOR(record) + if lastIdx != len(record)-2 { + t.Fatalf("Expected lastIndexTelnetEOR to find EOR at %d, got %d", len(record)-2, lastIdx) + } +} + +func TestCleanupSessionIfEmpty(t *testing.T) { + sess := &UserMasterSession{ + Username: "tempuser", + SessionKey: "tempkey12345678", + DisplayTag: "tempkey1", + IsDisconnected: true, + Slots: []*SessionSlot{nil}, + } + + registryMu.Lock() + userSessions = map[string]*UserMasterSession{ + sess.SessionKey: sess, + } + registryMu.Unlock() + + cleanupSessionIfEmpty(sess) + + registryMu.Lock() + _, exists := userSessions[sess.SessionKey] + registryMu.Unlock() + if exists { + t.Fatal("Expected disconnected session with 0 active slots to be removed from userSessions") + } +} + diff --git a/protocol.go b/protocol.go new file mode 100644 index 0000000..e25e382 --- /dev/null +++ b/protocol.go @@ -0,0 +1,430 @@ +package main + +import ( + "bytes" + "fmt" + "net" + "strings" + "time" + + "github.com/racingmars/go3270" +) + +func indexTelnetEOR(b []byte) int { + for i := 0; i+1 < len(b); i++ { + if b[i] == IAC { + switch b[i+1] { + case IAC: + i++ // skip second 0xFF of escaped IAC IAC pair + case EOR: + return i + } + } + } + return -1 +} + +func lastIndexTelnetEOR(b []byte) int { + last := -1 + for i := 0; i+1 < len(b); i++ { + if b[i] == IAC { + switch b[i+1] { + case IAC: + i++ + case EOR: + last = i + i++ + } + } + } + return last +} + +// negotiate RFC 2355 TN3270E or RFC 1576 TN3270 options with an upstream host +func negotiateUpstreamTelnet(conn net.Conn, termType string) ([]byte, bool, bool, error) { + _ = conn.SetDeadline(time.Now().Add(7 * time.Second)) + defer conn.SetDeadline(time.Time{}) + + var isTN3270E bool + var devTypeSent bool + var functionsDone bool + var agreedBindImage bool + + var ourBinary, hisBinary bool + var ourEOR, hisEOR bool + var ttypeSent bool + + buf := make([]byte, 8192) + var initial3270Data []byte + + for { + isTN3270EDone := isTN3270E && devTypeSent && functionsDone + isTN3270Done := !isTN3270E && ourBinary && hisBinary && ourEOR && hisEOR && ttypeSent + + if isTN3270EDone || isTN3270Done || len(initial3270Data) > 0 { + break + } + + n, err := conn.Read(buf) + if err != nil { + if (isTN3270E && functionsDone) || (ourBinary && hisBinary && ourEOR && hisEOR) { + break + } + return nil, false, false, fmt.Errorf("read during upstream negotiation: %w", err) + } + data := buf[:n] + + var replies []byte + i := 0 + for i < len(data) { + if data[i] == IAC && i+1 < len(data) { + cmd := data[i+1] + if cmd == SB { + seIdx := bytes.Index(data[i:], []byte{IAC, SE}) + if seIdx != -1 { + sbPayload := data[i+2 : i+seIdx] + if len(sbPayload) >= 2 && sbPayload[0] == OPT_TERMINAL_TYPE && sbPayload[1] == TTYPE_SEND { + replies = append(replies, IAC, SB, OPT_TERMINAL_TYPE, TTYPE_IS) + replies = append(replies, []byte(termType)...) + replies = append(replies, IAC, SE) + ttypeSent = true + } else if len(sbPayload) >= 3 && sbPayload[0] == OPT_TN3270E { + switch sbPayload[1] { + case TN3270E_SEND: + if sbPayload[2] == TN3270E_DEVICE_TYPE { + // RFC 2355: TN3270E device-type must NOT contain -E suffix + devType := strings.TrimSuffix(termType, "-E") + replies = append(replies, IAC, SB, OPT_TN3270E, TN3270E_DEVICE_TYPE, TN3270E_REQUEST) + replies = append(replies, []byte(devType)...) + replies = append(replies, IAC, SE) + devTypeSent = true + } + case TN3270E_DEVICE_TYPE: + switch sbPayload[2] { + case TN3270E_IS: + replies = append(replies, IAC, SB, OPT_TN3270E, TN3270E_FUNCTIONS, TN3270E_REQUEST, TN3270E_FUNC_RESPONSES, TN3270E_FUNC_BIND_IMAGE, IAC, SE) + case TN3270E_REJECT: + // Server rejected TN3270E device type -> fall back to basic TN3270 + isTN3270E = false + replies = append(replies, IAC, WONT, OPT_TN3270E, IAC, DONT, OPT_TN3270E) + } + case TN3270E_FUNCTIONS: + if sbPayload[2] == TN3270E_REQUEST || sbPayload[2] == TN3270E_IS { + funcs := sbPayload[3:] + if bytes.Contains(funcs, []byte{TN3270E_FUNC_BIND_IMAGE}) { + agreedBindImage = true + } + if sbPayload[2] == TN3270E_REQUEST { + replies = append(replies, IAC, SB, OPT_TN3270E, TN3270E_FUNCTIONS, TN3270E_IS) + replies = append(replies, funcs...) + replies = append(replies, IAC, SE) + } + functionsDone = true + } + } + } + i += seIdx + 2 + continue + } + } + if i+2 < len(data) { + opt := data[i+2] + switch cmd { + case DO: + switch opt { + case OPT_TN3270E: + replies = append(replies, IAC, WILL, opt) + isTN3270E = true + case OPT_BINARY: + replies = append(replies, IAC, WILL, opt) + ourBinary = true + case OPT_EOR: + replies = append(replies, IAC, WILL, opt) + ourEOR = true + case OPT_TIME_MARK, OPT_SUPPRESS_GA, OPT_TERMINAL_TYPE: + replies = append(replies, IAC, WILL, opt) + default: + replies = append(replies, IAC, WONT, opt) + } + i += 3 + continue + case WILL: + switch opt { + case OPT_TN3270E: + replies = append(replies, IAC, DO, opt) + isTN3270E = true + case OPT_BINARY: + replies = append(replies, IAC, DO, opt) + hisBinary = true + case OPT_EOR: + replies = append(replies, IAC, DO, opt) + hisEOR = true + case OPT_SUPPRESS_GA, OPT_TERMINAL_TYPE: + replies = append(replies, IAC, DO, opt) + default: + replies = append(replies, IAC, DONT, opt) + } + i += 3 + continue + case DONT: + switch opt { + case OPT_TN3270E: + isTN3270E = false + case OPT_BINARY: + ourBinary = false + case OPT_EOR: + ourEOR = false + } + replies = append(replies, IAC, WONT, opt) + i += 3 + continue + case WONT: + switch opt { + case OPT_TN3270E: + isTN3270E = false + case OPT_BINARY: + hisBinary = false + case OPT_EOR: + hisEOR = false + } + replies = append(replies, IAC, DONT, opt) + i += 3 + continue + } + } + // 2-byte Telnet commands like NOP (0xF1), GA (0xF9) + if cmd >= NOP && cmd <= GA { + i += 2 + continue + } + } + + // Non-negotiation bytes (initial 3270 data stream) + initial3270Data = append(initial3270Data, data[i:]...) + break + } + + if len(replies) > 0 { + if _, err := conn.Write(replies); err != nil { + return nil, false, false, err + } + } + } + + finalTN3270E := isTN3270E && functionsDone + + var isBound bool + if finalTN3270E && !agreedBindImage { + isBound = true + } + + // If TN3270E, parse initial3270Data records + if finalTN3270E && len(initial3270Data) >= 5 { + var displayable []byte + recBuf := initial3270Data + for len(recBuf) >= 5 { + eorIdx := indexTelnetEOR(recBuf) + var record []byte + if eorIdx != -1 { + record = recBuf[:eorIdx+2] + recBuf = recBuf[eorIdx+2:] + } else { + record = recBuf + recBuf = nil + } + if len(record) >= 5 { + dataType := record[0] + switch dataType { + case TN3270E_DATA_BIND: + isBound = true + case TN3270E_DATA_UNBIND: + isBound = false + case TN3270E_DATA_3270, TN3270E_DATA_SSCP_LU: + isBound = (dataType == TN3270E_DATA_3270) + displayable = append(displayable, record[5:]...) + if eorIdx == -1 && len(record) > 5 { + displayable = append(displayable, IAC, EOR) + } + } + } + } + initial3270Data = displayable + } + + return initial3270Data, finalTN3270E, isBound, nil +} + +func extractAID(packet []byte) go3270.AID { + for i := 0; i < len(packet); { + if packet[i] == IAC { + if i+1 < len(packet) { + switch packet[i+1] { + case DO, DONT, WILL, WONT: + i += 3 + continue + case SB: + seIdx := bytes.Index(packet[i:], []byte{IAC, SE}) + if seIdx != -1 { + i += seIdx + 2 + continue + } + } + } + i += 2 + continue + } + return go3270.AID(packet[i]) + } + return go3270.AIDNone +} + +func is3270Query(data []byte) bool { + if len(data) < 3 { + return false + } + idx := bytes.IndexByte(data, CMD_WSF) + if idx == -1 { + return false + } + return bytes.Contains(data[idx+1:], []byte{SF_READ_PARTITION, SF_RP_QUERY}) +} + +func buildQueryReply(rows, cols int, isColor bool, isTN3270E bool, isBound bool) []byte { + var reply []byte + + // 1. AID byte + reply = append(reply, AID_QUERY_REPLY) + + // 2. Structured Field: Usable Area (0x81 0x81) + cells := rows * cols + usableArea := []byte{ + 0x00, 0x1A, // Length = 26 + SF_QUERY_REPLY, QR_USABLE_AREA, // SFID = Usable Area + 0x00, 0x00, // Flags (12/14-bit addressing) + byte(cols >> 8), byte(cols & 0xFF), // Width + byte(rows >> 8), byte(rows & 0xFF), // Height + 0x01, // Units = inches (0.01 in) + 0x00, 0x0A, 0x00, 0x0A, // Xr, Yr + 0x00, 0x0A, 0x00, 0x0A, // Aw, Ah + byte(cells >> 8), byte(cells & 0xFF), // Buffer size + } + reply = append(reply, usableArea...) + + // 3. Structured Field: Color (0x81 0x86) if color + if isColor { + colorSF := []byte{ + 0x00, 0x12, // Length = 18 + SF_QUERY_REPLY, QR_COLOR, // SFID = Color + 0x00, 0x00, // Flags + 0x00, // Default color + 0x00, // Neutral color + ATTR_COLOR_BLUE, 0x00, // Blue + ATTR_COLOR_RED, 0x00, // Red + ATTR_COLOR_PINK, 0x00, // Pink + ATTR_COLOR_GREEN, 0x00, // Green + ATTR_COLOR_TURQUOISE, 0x00, // Turquoise + ATTR_COLOR_YELLOW, 0x00, // Yellow + ATTR_COLOR_WHITE, 0x00, // Neutral/White + } + reply = append(reply, colorSF...) + } + + // 4. Structured Field: Highlight (0x81 0x87) + highlightSF := []byte{ + 0x00, 0x0D, // Length = 13 + SF_QUERY_REPLY, QR_HIGHLIGHTING, // SFID = Highlight + 0x00, 0x00, // Flags + 0x00, 0x00, // Default + ATTR_HL_BLINK, 0x00, // Blink + ATTR_HL_REVERSE, 0x00, // Reverse Video + ATTR_HL_UNDERSCORE, 0x00, // Underscore + } + reply = append(reply, highlightSF...) + + // 5. Structured Field: Implicit Partition (0x81 0xA6) + implicitSF := []byte{ + 0x00, 0x08, // Length = 8 + SF_QUERY_REPLY, QR_IMPLICIT_PARTITION, // SFID = Implicit Partition + 0x00, 0x00, // Flags + byte(cols >> 8), byte(cols & 0xFF), // Alternate width + byte(rows >> 8), byte(rows & 0xFF), // Alternate height + } + reply = append(reply, implicitSF...) + + // 6. Structured Field: Null / End of Query List + nullSF := []byte{ + 0x00, 0x04, + SF_QUERY_REPLY, QR_NULL, + } + reply = append(reply, nullSF...) + + headerLen := 0 + if isTN3270E { + headerLen = 5 + } + escaped := make([]byte, headerLen, len(reply)*2+headerLen+2) + for _, b := range reply { + if b == IAC { + escaped = append(escaped, IAC, IAC) + } else { + escaped = append(escaped, b) + } + } + escaped = append(escaped, IAC, EOR) + + if isTN3270E { + dataType := TN3270E_DATA_3270 + if !isBound { + dataType = TN3270E_DATA_SSCP_LU + } + escaped[0] = dataType + escaped[1] = 0x00 + escaped[2] = 0x00 + escaped[3] = 0x00 + escaped[4] = 0x00 + } + + return escaped +} + +func GetTerminalMatrix(termType string, devInfo go3270.DevInfo) (rows, cols, maxBuf int) { + if devInfo != nil { + rows, cols = devInfo.AltDimensions() + } + if rows <= 0 || cols <= 0 { + rows, cols = 24, 80 + for _, p := range strings.Split(strings.TrimSuffix(termType, ")"), "-") { + switch p { + case "3": + rows, cols = 32, 80 + case "4": + rows, cols = 43, 80 + case "5": + rows, cols = 27, 132 + } + } + } + maxBuf = min(max(rows*cols*4, 8192), 65536) + return rows, cols, maxBuf +} + +func ResolveTerminalType(origTermType string, devInfo go3270.DevInfo) string { + rows, cols, _ := GetTerminalMatrix(origTermType, devInfo) + + model := 2 + switch { + case rows == 32 && cols == 80: + model = 3 + case rows == 43 && cols == 80: + model = 4 + case rows == 27 && cols == 132: + model = 5 + } + + family := "3279" + if strings.Contains(strings.ToUpper(origTermType), "3278") { + family = "3278" + } + + return fmt.Sprintf("IBM-%s-%d-E", family, model) +} diff --git a/screens.go b/screens.go new file mode 100644 index 0000000..0ff48ee --- /dev/null +++ b/screens.go @@ -0,0 +1,481 @@ +package main + +import ( + "fmt" + "log" + "net" + "strconv" + "strings" + "time" + + "github.com/racingmars/go3270" +) + +const screenSeparator = "===============================================================================" + +func centerCol(text string) int { + return max(0, (80-len(text))/2-1) +} + +// send an ephemeral centered status/error notification overlay to the client +func send3270Message(conn net.Conn, msg string, cp go3270.Codepage) { + if conn == nil { + return + } + col := centerCol(msg) + screen := go3270.Screen{ + {Row: 11, Col: 0, Content: screenSeparator}, + {Row: 12, Col: col, Content: msg, Intense: true}, + {Row: 13, Col: 0, Content: screenSeparator}, + } + values := make(map[string]string) + _, _ = go3270.ShowScreenOpts(screen, values, conn, go3270.ScreenOpts{ + Codepage: cp, + NoResponse: true, + CursorRow: 12, + CursorCol: col + 1, + }) +} + +func promptLogin(conn net.Conn, devInfo go3270.DevInfo) (LoginCredentials, error) { + title := GlobalConfig.Login.Title + subtitle := GlobalConfig.Login.Subtitle + instructions := GlobalConfig.Login.Instructions + promptUser := GlobalConfig.Login.PromptUsername + promptPass := GlobalConfig.Login.PromptPassword + + var cp go3270.Codepage + if devInfo != nil { + cp = devInfo.Codepage() + } + + errMsg := "" + + for { + screen := go3270.Screen{ + {Row: 0, Col: 0, Content: screenSeparator}, + {Row: 1, Col: centerCol(title), Content: title, Intense: true}, + {Row: 2, Col: centerCol(subtitle), Content: subtitle}, + {Row: 3, Col: 0, Content: screenSeparator}, + + // Username row + {Row: 7, Col: 10, Content: promptUser}, + {Row: 7, Col: 25, Name: "username", Write: true}, + {Row: 7, Col: 46, Content: ""}, + + // Password row + {Row: 9, Col: 10, Content: promptPass}, + {Row: 9, Col: 25, Name: "password", Write: true, Hidden: true}, + {Row: 9, Col: 46, Content: ""}, + + // Footer + {Row: 22, Col: 2, Content: instructions}, + } + + if errMsg != "" { + screen = append(screen, go3270.Field{ + Row: 12, + Col: centerCol(errMsg), + Content: errMsg, + Intense: true, + }) + } + + values := make(map[string]string) + resp, err := go3270.ShowScreenOpts(screen, values, conn, go3270.ScreenOpts{ + Codepage: cp, + CursorRow: 7, + CursorCol: 26, + }) + if err != nil { + return LoginCredentials{}, err + } + + if resp.AID == go3270.AIDClear { + errMsg = "" + continue + } + + user := strings.TrimSpace(resp.Values["username"]) + pass := strings.TrimSpace(resp.Values["password"]) + + if pass == "" { + errMsg = "Password is required." + continue + } + + if user == "" { + user = "IBMUSER" + } + + key := computeSessionKey(user, pass) + return LoginCredentials{ + Username: user, + Password: pass, + SessionKey: key, + }, nil + } +} + +func buildMainMenuScreen(s *UserMasterSession) go3270.Screen { + menuTitle := "" + menuFooter := "" + promptSelect := "" + if GlobalConfig != nil { + menuTitle = GlobalConfig.Menu.Title + menuFooter = GlobalConfig.Menu.Footer + promptSelect = GlobalConfig.Menu.PromptSelect + } + + total := len(ConfiguredSystem) + + s.Mu.Lock() + username := s.Username + displayTag := s.DisplayTag + activeSlots := s.countActiveSlotsLocked() + page, totalPages, startIdx, endIdx := menuPageBounds(s.MenuPage, total) + + renderedSlots := make([]slotRenderInfo, endIdx-startIdx) + for idx := startIdx; idx < endIdx; idx++ { + i := idx - startIdx + status := "INACTIVE" + var color go3270.Color + hasNotice := false + if idx < len(s.Slots) && s.Slots[idx] != nil { + slot := s.Slots[idx] + slot.Mu.Lock() + if slot.IsHeld { + status = "HELD" + color = go3270.Red + } else if slot.ScreenChanged { + status = "NOTICE" + hasNotice = true + } else { + status = "ACTIVE" + color = go3270.Yellow + } + slot.Mu.Unlock() + } + renderedSlots[i] = slotRenderInfo{status: status, color: color, hasNotice: hasNotice} + } + s.Mu.Unlock() + + screen := go3270.Screen{ + {Row: 0, Col: 0, Content: screenSeparator}, + {Row: 1, Col: centerCol(menuTitle), Content: menuTitle, Intense: true}, + {Row: 2, Col: 2, Content: fmt.Sprintf("User: %-10s Session Tag: #%-8s", username, displayTag)}, + {Row: 2, Col: 58, Content: fmt.Sprintf("Active Sessions: %d", activeSlots)}, + {Row: 3, Col: 0, Content: screenSeparator}, + {Row: 5, Col: 2, Content: "CMD ID System Name Description Status", Intense: true}, + {Row: 6, Col: 2, Content: "--- ---- --------------- ------------------------------- -----------"}, + } + + row := 7 + for idx := startIdx; idx < endIdx; idx++ { + sys := ConfiguredSystem[idx] + info := renderedSlots[idx-startIdx] + + line := fmt.Sprintf("[%2d] %-15.15s %-31.31s %s", idx+1, sys.Name, sys.Description, info.status) + screen = append(screen, + go3270.Field{ + Row: row, + Col: 2, + Name: fmt.Sprintf("cmd_%d", idx+1), + Write: true, + }, + go3270.Field{ + Row: row, + Col: 6, + Content: line, + Intense: info.hasNotice, + Color: info.color, + }, + ) + row++ + } + + screen = append(screen, + go3270.Field{Row: 21, Col: 2, Content: promptSelect}, + go3270.Field{Row: 21, Col: 18, Name: "opt", Write: true}, + go3270.Field{Row: 21, Col: 78, Content: ""}, + go3270.Field{Row: 23, Col: 2, Content: menuFooter}, + ) + + if totalPages > 1 { + pageIndicator := fmt.Sprintf("Page %d of %d", page+1, totalPages) + pageCol := max(0, 79-len(pageIndicator)) + screen = append(screen, go3270.Field{Row: 22, Col: pageCol, Content: pageIndicator}) + } + + return screen +} + +func renderMainMenu(s *UserMasterSession, clientConn net.Conn) (go3270.Response, error) { + screen := buildMainMenuScreen(s) + + values := make(map[string]string) + cp := s.Codepage() + + return go3270.ShowScreenOpts(screen, values, clientConn, go3270.ScreenOpts{ + Codepage: cp, + CursorRow: 21, + CursorCol: 19, + }) +} + +func runMenuLoop(s *UserMasterSession, clientConn net.Conn) { + s.Mu.Lock() + if s.ClientConn == clientConn { + s.IsDisconnected = false + } + s.Mu.Unlock() + + for { + s.Mu.Lock() + if s.ClientConn != clientConn { + s.Mu.Unlock() + return + } + s.Mu.Unlock() + + resp, err := renderMainMenu(s, clientConn) + if err != nil { + s.Mu.Lock() + username, displayTag := s.Username, s.DisplayTag + if s.ClientConn == clientConn { + s.IsDisconnected = true + s.Mu.Unlock() + log.Printf("[%s] '%s' Client disconnected while on system menu: %v", displayTag, username, err) + cleanupSessionIfEmpty(s) + } else { + s.Mu.Unlock() + log.Printf("[%s] '%s' Previous connection transferred to new terminal", displayTag, username) + } + return + } + + switch resp.AID { + case go3270.AIDPF3: + disconnectClient(s, clientConn, + "Disconnecting terminal... Session active in background.", + "User pressed PF3 to disconnect. Preserving session.") + return + + case go3270.AIDPF7, go3270.AIDPF19: + s.Mu.Lock() + page, _, _, _ := menuPageBounds(s.MenuPage, len(ConfiguredSystem)) + if page > 0 { + s.MenuPage = page - 1 + } else { + s.MenuPage = 0 + } + s.Mu.Unlock() + continue + + case go3270.AIDPF8, go3270.AIDPF20: + s.Mu.Lock() + page, totalPages, _, _ := menuPageBounds(s.MenuPage, len(ConfiguredSystem)) + if page < totalPages-1 { + s.MenuPage = page + 1 + } else { + s.MenuPage = page + } + s.Mu.Unlock() + continue + + case go3270.AIDEnter: + s.Mu.Lock() + page, _, startIdx, endIdx := menuPageBounds(s.MenuPage, len(ConfiguredSystem)) + s.MenuPage = page + username, displayTag := s.Username, s.DisplayTag + isAdmin := s.IsAdmin + s.Mu.Unlock() + + connectSlot := -1 + + for idx := startIdx; idx < endIdx; idx++ { + fieldName := fmt.Sprintf("cmd_%d", idx+1) + cmdVal := strings.TrimSpace(resp.Values[fieldName]) + if cmdVal == "" { + continue + } + + switch strings.ToLower(cmdVal) { + case "k": + killSlot(s, idx) + case "h", "r": + hold := strings.EqualFold(cmdVal, "h") + s.Mu.Lock() + if idx < len(s.Slots) && s.Slots[idx] != nil { + slot := s.Slots[idx] + slot.Mu.Lock() + slot.IsHeld = hold + if hold { + slot.ScreenChanged = false + } + slot.Mu.Unlock() + if hold { + log.Printf("[%s] '%s' Placed slot [%d] %s on HOLD", displayTag, username, idx+1, slot.System.Name) + } else { + log.Printf("[%s] '%s' RELEASED slot [%d] %s from hold", displayTag, username, idx+1, slot.System.Name) + } + } + s.Mu.Unlock() + case "b": + log.Printf("[%s] '%s' CMD 'B' entered -> starting [%d] %s in background", displayTag, username, idx+1, ConfiguredSystem[idx].Name) + ensureSlotConnected(s, idx, true, clientConn) + default: + if connectSlot == -1 { + connectSlot = idx + } + } + } + + if connectSlot >= 0 { + log.Printf("[%s] '%s' CMD entered -> connecting to [%d] %s", displayTag, username, connectSlot+1, ConfiguredSystem[connectSlot].Name) + } else { + optStr := strings.TrimSpace(resp.Values["opt"]) + if optStr == "" { + continue + } + + parts := strings.Fields(optStr) + cmd := strings.ToLower(parts[0]) + + if isAdmin { + switch cmd { + case "bye": + handleAdminBye(s) + return + case "killall": + handleAdminKillAll(s) + return + case "kill": + if len(parts) < 2 { + send3270Message(clientConn, "Usage: kill ", s.Codepage()) + time.Sleep(adminNoticeSleep) + continue + } + if handleAdminKill(s, parts[1]) { + return + } + continue + } + } + + selNum, err := strconv.Atoi(optStr) + if err != nil || selNum < 1 || selNum > len(ConfiguredSystem) { + log.Printf("[%s] '%s' Invalid option '%s' entered by user", displayTag, username, optStr) + send3270Message(clientConn, fmt.Sprintf("Invalid option '%s'. Please enter a number between 1 and %d.", optStr, len(ConfiguredSystem)), s.Codepage()) + time.Sleep(adminNoticeSleep) + continue + } + connectSlot = selNum - 1 + log.Printf("[%s] '%s' User selected option %d -> connecting to [%d] %s", displayTag, username, selNum, selNum, ConfiguredSystem[connectSlot].Name) + } + + connectOrResumeSlot(s, connectSlot, clientConn) + s.Mu.Lock() + if s.ClientConn != clientConn || s.IsDisconnected { + s.Mu.Unlock() + return + } + s.Mu.Unlock() + } + } +} + +func disconnectClient(s *UserMasterSession, clientConn net.Conn, msg, logMsg string) { + s.Mu.Lock() + if s.ClientConn == clientConn { + s.IsDisconnected = true + s.ActiveSlotIdx = -1 + username, displayTag := s.Username, s.DisplayTag + cp := s.codepageLocked() + s.Mu.Unlock() + log.Printf("[%s] '%s' %s", displayTag, username, logMsg) + send3270Message(clientConn, msg, cp) + _ = clientConn.Close() + cleanupSessionIfEmpty(s) + } else { + s.Mu.Unlock() + } +} + +func handleResumeSession(s *UserMasterSession, clientConn net.Conn) { + s.Mu.Lock() + if s.ClientConn == clientConn { + s.IsDisconnected = false + } + username, displayTag := s.Username, s.DisplayTag + activeSlots := s.countActiveSlotsLocked() + cp := s.codepageLocked() + s.Mu.Unlock() + + log.Printf("[%s] '%s' has active background sessions. Presenting resume", displayTag, username) + + menuTitle := "" + if GlobalConfig != nil { + menuTitle = GlobalConfig.Menu.Title + } + + screen := go3270.Screen{ + {Row: 0, Col: 0, Content: screenSeparator}, + {Row: 1, Col: centerCol(menuTitle), Content: menuTitle, Intense: true}, + {Row: 2, Col: 2, Content: fmt.Sprintf("User: %-10s Session Tag: #%-8s", username, displayTag)}, + {Row: 2, Col: 58, Content: fmt.Sprintf("Active Sessions: %2d", activeSlots)}, + {Row: 3, Col: 0, Content: screenSeparator}, + + {Row: 10, Col: 14, Content: fmt.Sprintf("Welcome back, %s!", username)}, + + {Row: 22, Col: 2, Content: "[Enter] System Menu [PF3] Disconnect"}, + } + + values := make(map[string]string) + resp, err := go3270.ShowScreenOpts(screen, values, clientConn, go3270.ScreenOpts{ + Codepage: cp, + CursorRow: 22, + CursorCol: 2, + }) + if err != nil { + s.Mu.Lock() + if s.ClientConn == clientConn { + s.IsDisconnected = true + s.ActiveSlotIdx = -1 + s.Mu.Unlock() + log.Printf("[%s] '%s' disconnected during resume prompt (%v)", displayTag, username, err) + cleanupSessionIfEmpty(s) + } else { + s.Mu.Unlock() + log.Printf("[%s] '%s' Connection transferred during resume prompt", displayTag, username) + } + return + } + + if resp.AID == go3270.AIDPF3 { + disconnectClient(s, clientConn, "Disconnecting... ", "PF3 disconnect during resume prompt") + return + } + + runMenuLoop(s, clientConn) +} + +func menuPageBounds(menuPage, totalSystems int) (page, totalPages, startIdx, endIdx int) { + totalPages = (totalSystems + 12) / 13 + if totalPages == 0 { + totalPages = 1 + } + if menuPage < 0 { + page = 0 + } else if menuPage >= totalPages { + page = totalPages - 1 + } else { + page = menuPage + } + startIdx = page * 13 + endIdx = startIdx + 13 + if endIdx > totalSystems { + endIdx = totalSystems + } + return page, totalPages, startIdx, endIdx +} diff --git a/types.go b/types.go new file mode 100644 index 0000000..c43360a --- /dev/null +++ b/types.go @@ -0,0 +1,229 @@ +package main + +import ( + "net" + "sync" + + "github.com/racingmars/go3270" +) + +// Telnet Command Constants (RFC 854 / RFC 885) +const ( + IAC byte = 0xFF + DONT byte = 0xFE + DO byte = 0xFD + WONT byte = 0xFC + WILL byte = 0xFB + SB byte = 0xFA + GA byte = 0xF9 + NOP byte = 0xF1 + SE byte = 0xF0 + EOR byte = 0xEF + + OPT_BINARY byte = 0x00 + OPT_SUPPRESS_GA byte = 0x03 + OPT_TIME_MARK byte = 0x06 + OPT_TERMINAL_TYPE byte = 0x18 + OPT_EOR byte = 0x19 + OPT_TN3270E byte = 0x28 + + // Terminal-Type Subnegotiation Codes (RFC 1091) + TTYPE_IS byte = 0x00 + TTYPE_SEND byte = 0x01 + + // TN3270E Subnegotiation Command Codes (RFC 2355) + TN3270E_DEVICE_TYPE byte = 0x02 + TN3270E_FUNCTIONS byte = 0x03 + TN3270E_IS byte = 0x04 + TN3270E_REJECT byte = 0x06 + TN3270E_REQUEST byte = 0x07 + TN3270E_SEND byte = 0x08 + + // Data Types in TN3270E Header + TN3270E_DATA_3270 byte = 0x00 + TN3270E_DATA_RESP byte = 0x02 + TN3270E_DATA_BIND byte = 0x03 + TN3270E_DATA_UNBIND byte = 0x04 + TN3270E_DATA_SSCP_LU byte = 0x07 + + // Functions & Header Flags in RFC 2355 + TN3270E_FUNC_BIND_IMAGE byte = 0x00 + TN3270E_FUNC_RESPONSES byte = 0x02 + TN3270E_RESP_ALWAYS byte = 0x02 + TN3270E_UNBIND_NORMAL byte = 0x01 + TN3270E_MAX_SEQ uint16 = 0x7FFF + + // 3270 Command Codes + CMD_WRITE byte = 0xF1 + CMD_WSF byte = 0xF3 + CMD_ERASE_WRITE byte = 0xF5 + CMD_ERASE_WRITE_SNA byte = 0x05 + CMD_ERASE_WRITE_ALT byte = 0x7E + CMD_ERASE_WRITE_ALT_SNA byte = 0x0D + CMD_ERASE_ALL_UNPROTECTED byte = 0x6F + + // 3270 Structured Field & Query Reply Constants + AID_QUERY_REPLY byte = 0x88 + SF_READ_PARTITION byte = 0x01 + SF_RP_QUERY byte = 0xFF + SF_QUERY_REPLY byte = 0x81 + QR_USABLE_AREA byte = 0x81 + QR_COLOR byte = 0x86 + QR_HIGHLIGHTING byte = 0x87 + QR_IMPLICIT_PARTITION byte = 0xA6 + QR_NULL byte = 0xFF + + // 3270 Extended Color & Highlighting Attribute Constants + ATTR_COLOR_BLUE byte = 0xF1 + ATTR_COLOR_RED byte = 0xF2 + ATTR_COLOR_PINK byte = 0xF3 + ATTR_COLOR_GREEN byte = 0xF4 + ATTR_COLOR_TURQUOISE byte = 0xF5 + ATTR_COLOR_YELLOW byte = 0xF6 + ATTR_COLOR_WHITE byte = 0xF7 + + ATTR_HL_BLINK byte = 0xF1 + ATTR_HL_REVERSE byte = 0xF2 + ATTR_HL_UNDERSCORE byte = 0xF4 +) + +type LoginCredentials struct { + Username string + Password string + SessionKey string +} + +type AuthResult struct { + OK bool + Reason string + IsAdmin bool +} + +type TargetSystem struct { + Name string `toml:"name"` + Description string `toml:"description"` + HostPort string `toml:"host_port"` + TLS bool `toml:"tls"` + InsecureSkipVerify bool `toml:"insecure_skip_verify"` +} + +type ServerConfig struct { + BindAddress string `toml:"bind_address"` + Codepage string `toml:"codepage"` + TLS bool `toml:"tls"` + CertFile string `toml:"cert_file"` + KeyFile string `toml:"key_file"` + Public bool `toml:"public"` +} + +type LoginConfig struct { + Title string `toml:"title"` + Subtitle string `toml:"subtitle"` + PromptUsername string `toml:"prompt_username"` + PromptPassword string `toml:"prompt_password"` + Instructions string `toml:"instructions"` +} + +type MenuConfig struct { + Title string `toml:"title"` + PromptSelect string `toml:"prompt_select"` + Footer string `toml:"footer"` +} + +type UserConfig struct { + Username string `toml:"username"` + Hash string `toml:"hash"` + Password string `toml:"password"` + Admin bool `toml:"admin"` +} + +type Config struct { + Public bool `toml:"public"` + Server ServerConfig `toml:"server"` + Login LoginConfig `toml:"login"` + Menu MenuConfig `toml:"menu"` + Systems []TargetSystem `toml:"systems"` + Users []UserConfig `toml:"users"` +} + +type SessionSlot struct { + System TargetSystem + Upstream net.Conn + LastScreen []byte + ScreenChanged bool + IsTN3270E bool + IsBound bool + SeqNumber uint16 + IsHeld bool + MaxBufferSize int + Mu sync.Mutex +} + +type slotRenderInfo struct { + status string + color go3270.Color + hasNotice bool +} + +type UserMasterSession struct { + Username string + SessionKey string + DisplayTag string + TerminalType string + DevInfo go3270.DevInfo + ClientConn net.Conn + ActiveSlotIdx int + Slots []*SessionSlot + IsDisconnected bool + MenuPage int + IsAdmin bool + Mu sync.Mutex +} + +func (u *UserConfig) GetHash() string { + if u.Hash != "" { + return u.Hash + } + return u.Password +} + +func (s *UserMasterSession) Identity() (username, displayTag string) { + s.Mu.Lock() + defer s.Mu.Unlock() + return s.Username, s.DisplayTag +} + +func (s *UserMasterSession) Codepage() go3270.Codepage { + s.Mu.Lock() + defer s.Mu.Unlock() + return s.codepageLocked() +} + +func (s *UserMasterSession) codepageLocked() go3270.Codepage { + if s.DevInfo != nil { + return s.DevInfo.Codepage() + } + return nil +} + +func (s *UserMasterSession) CountActiveSlots() int { + s.Mu.Lock() + defer s.Mu.Unlock() + return s.countActiveSlotsLocked() +} + +func (s *UserMasterSession) countActiveSlotsLocked() int { + count := 0 + for _, slot := range s.Slots { + if slot != nil { + count++ + } + } + return count +} + +func (s *UserMasterSession) GetMatrix() (rows, cols, maxBuf int) { + s.Mu.Lock() + defer s.Mu.Unlock() + return GetTerminalMatrix(s.TerminalType, s.DevInfo) +}