initial commit
This commit is contained in:
@@ -0,0 +1,3 @@
|
|||||||
|
*.pem
|
||||||
|
*.toml
|
||||||
|
sessman3270
|
||||||
@@ -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/>
|
||||||
@@ -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/)
|
||||||
|
|
||||||
@@ -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
|
||||||
|
|
||||||
@@ -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
|
||||||
|
)
|
||||||
@@ -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=
|
||||||
+3637
File diff suppressed because it is too large
Load Diff
+430
@@ -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
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user