initial commit

This commit is contained in:
2026-10-17 21:12:15 +00:00
commit 466290d316
11 changed files with 5998 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
*.pem
*.toml
sessman3270
+24
View File
@@ -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 <https://unlicense.org/>
+82
View File
@@ -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/)
+48
View File
@@ -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
+9
View File
@@ -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
)
+6
View File
@@ -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=
+1049
View File
File diff suppressed because it is too large Load Diff
+3637
View File
File diff suppressed because it is too large Load Diff
+430
View File
@@ -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)
}
+481
View File
@@ -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 <sessionid>", 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
}
+229
View File
@@ -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)
}