From 466290d316c26a83d364d581be82be485ca1f0d7 Mon Sep 17 00:00:00 2001 From: Rudi Date: Sat, 17 Oct 2026 21:12:15 +0000 Subject: [PATCH] initial commit --- .gitignore | 3 + LICENSE | 24 + README.md | 82 ++ config.toml.dist | 48 + go.mod | 9 + go.sum | 6 + main.go | 1049 +++++++++++++ main_test.go | 3637 ++++++++++++++++++++++++++++++++++++++++++++++ protocol.go | 430 ++++++ screens.go | 481 ++++++ types.go | 229 +++ 11 files changed, 5998 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 config.toml.dist create mode 100644 go.mod create mode 100644 go.sum create mode 100644 main.go create mode 100644 main_test.go create mode 100644 protocol.go create mode 100644 screens.go create mode 100644 types.go 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) +}