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() if hold { slot.IsHeld = true slot.ScreenChanged = false } else { slot.resetHoldAndLoopCountersLocked() } 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 }