More functional changes and UI adaptations from overnight

This commit is contained in:
2026-08-31 09:16:32 -04:00
parent a43969da08
commit a9956298e2
33 changed files with 3817 additions and 667 deletions
@@ -5,6 +5,10 @@ package haus.nightmare.lib3270j;
*/
public class ConnectionConfig {
public enum ProxyType {
NONE, HTTP, SOCKS4, SOCKS5
}
private String host;
private int port = 23;
private TerminalModel model = TerminalModel.IBM_3279_4;
@@ -28,6 +32,21 @@ public class ConnectionConfig {
private haus.nightmare.lib3270j.graphics.GraphicsMode graphicsMode = haus.nightmare.lib3270j.graphics.GraphicsMode.BOTH;
private String codePage = "037";
private String associatedPrinterLu = null;
private boolean nvtLocalEcho = false;
// Proxy configuration
private ProxyType proxyType = ProxyType.NONE;
private String proxyHost = null;
private int proxyPort = 0;
private String proxyUsername = null;
private String proxyPassword = null;
// STARTTLS (Telnet Option 46) dynamic socket elevation
private boolean startTlsEnabled = true;
// RFC 1572 / RFC 2877 Environment variables (Express Logon)
private java.util.Map<String, String> environmentVariables = new java.util.LinkedHashMap<>();
private java.util.Map<String, String> userVariables = new java.util.LinkedHashMap<>();
public ConnectionConfig() {}
@@ -101,6 +120,9 @@ public class ConnectionConfig {
this.codePage = (codePage != null && !codePage.trim().isEmpty()) ? codePage.trim() : "037";
}
public boolean isNvtLocalEcho() { return nvtLocalEcho; }
public void setNvtLocalEcho(boolean nvtLocalEcho) { this.nvtLocalEcho = nvtLocalEcho; }
public String getTerminalName() { return terminalName; }
public void setTerminalName(String name) { this.terminalName = name; }
@@ -134,9 +156,58 @@ public class ConnectionConfig {
this.dynamicCols = cols;
}
public ProxyType getProxyType() { return proxyType; }
public void setProxyType(ProxyType proxyType) { this.proxyType = proxyType != null ? proxyType : ProxyType.NONE; }
public String getProxyHost() { return proxyHost; }
public void setProxyHost(String proxyHost) { this.proxyHost = proxyHost; }
public int getProxyPort() { return proxyPort; }
public void setProxyPort(int proxyPort) { this.proxyPort = proxyPort; }
public String getProxyUsername() { return proxyUsername; }
public void setProxyUsername(String proxyUsername) { this.proxyUsername = proxyUsername; }
public String getProxyPassword() { return proxyPassword; }
public void setProxyPassword(String proxyPassword) { this.proxyPassword = proxyPassword; }
public void setProxy(ProxyType type, String host, int port, String username, String password) {
this.proxyType = type != null ? type : ProxyType.NONE;
this.proxyHost = host;
this.proxyPort = port;
this.proxyUsername = username;
this.proxyPassword = password;
}
public boolean isStartTlsEnabled() { return startTlsEnabled; }
public void setStartTlsEnabled(boolean enabled) { this.startTlsEnabled = enabled; }
public java.util.Map<String, String> getEnvironmentVariables() { return environmentVariables; }
public void setEnvironmentVariables(java.util.Map<String, String> vars) {
this.environmentVariables = (vars != null) ? new java.util.LinkedHashMap<>(vars) : new java.util.LinkedHashMap<>();
}
public void setEnvironmentVariable(String name, String value) {
if (name != null) {
if (value != null) this.environmentVariables.put(name, value);
else this.environmentVariables.remove(name);
}
}
public java.util.Map<String, String> getUserVariables() { return userVariables; }
public void setUserVariables(java.util.Map<String, String> vars) {
this.userVariables = (vars != null) ? new java.util.LinkedHashMap<>(vars) : new java.util.LinkedHashMap<>();
}
public void setUserVariable(String name, String value) {
if (name != null) {
if (value != null) this.userVariables.put(name, value);
else this.userVariables.remove(name);
}
}
/**
* Parse a host connection string which may include prefixes for TLS (e.g. "L:host:port", "ssl:host:port", "y:host:port"),
* plain TN3270 (e.g. "P:host:port", "plain:host:port", "non-e:host:port"), or standard "host:port" formats.
* plain TN3270 (e.g. "P:host:port", "plain:host:port", "non-e:host:port"), proxy flags (e.g. "--proxy=http://proxy:8080 host:23"),
* or standard "host:port" formats.
*/
public static ConnectionConfig parseHostString(String hostStr, int defaultPort, TerminalModel defaultModel) {
if (hostStr == null || hostStr.trim().isEmpty()) {
@@ -146,6 +217,50 @@ public class ConnectionConfig {
boolean tls = false;
boolean tn3270e = true;
// Parse --proxy=<url> or -proxy=<url> flags
ProxyType pType = ProxyType.NONE;
String pHost = null;
int pPort = 0;
String pUser = null;
String pPass = null;
String[] tokens = s.split("\\s+");
StringBuilder remaining = new StringBuilder();
for (String tok : tokens) {
if (tok.startsWith("--proxy=") || tok.startsWith("-proxy=")) {
String proxyUrl = tok.substring(tok.indexOf('=') + 1).trim();
try {
java.net.URI uri = new java.net.URI(proxyUrl);
String scheme = uri.getScheme() != null ? uri.getScheme().toLowerCase() : "http";
if (scheme.equals("http") || scheme.equals("https")) {
pType = ProxyType.HTTP;
pPort = uri.getPort() > 0 ? uri.getPort() : 8080;
} else if (scheme.equals("socks4") || scheme.equals("socks4a")) {
pType = ProxyType.SOCKS4;
pPort = uri.getPort() > 0 ? uri.getPort() : 1080;
} else if (scheme.equals("socks5") || scheme.equals("socks")) {
pType = ProxyType.SOCKS5;
pPort = uri.getPort() > 0 ? uri.getPort() : 1080;
}
pHost = uri.getHost();
String userInfo = uri.getUserInfo();
if (userInfo != null) {
int colon = userInfo.indexOf(':');
if (colon >= 0) {
pUser = userInfo.substring(0, colon);
pPass = userInfo.substring(colon + 1);
} else {
pUser = userInfo;
}
}
} catch (Exception ignored) {}
} else {
if (remaining.length() > 0) remaining.append(" ");
remaining.append(tok);
}
}
s = remaining.toString().trim();
// Parse chained x3270-style prefixes (e.g. "L:P:host:port" or "P:host:port")
boolean prefixFound = true;
while (prefixFound) {
@@ -200,6 +315,9 @@ public class ConnectionConfig {
ConnectionConfig config = new ConnectionConfig(host, port, defaultModel != null ? defaultModel : TerminalModel.IBM_3279_4);
config.setUseTls(tls);
config.setTn3270eEnabled(tn3270e);
if (pType != ProxyType.NONE && pHost != null) {
config.setProxy(pType, pHost, pPort, pUser, pPass);
}
return config;
}
@@ -118,6 +118,14 @@ public class Telnet3270Client {
dsProcessor.addScreenUpdateListener(l);
}
public void addSCSInboundListener(haus.nightmare.lib3270j.listener.SCSInboundListener l) {
fsm.addSCSInboundListener(l);
}
public void removeSCSInboundListener(haus.nightmare.lib3270j.listener.SCSInboundListener l) {
fsm.removeSCSInboundListener(l);
}
// ========== Screen access ==========
/** Get the screen buffer for rendering. */
@@ -144,6 +144,9 @@ public class DataStreamProcessor {
int oldCols = screen.getCols();
screen.erase(false);
graphicsPlane.clear();
if (gocaDecoder != null) {
gocaDecoder.setGraphicsCursorActive(false);
}
processWrite(data, offset, length, true);
if (oldRows != screen.getRows() || oldCols != screen.getCols()) {
notifyScreenSizeChanged();
@@ -161,6 +164,9 @@ public class DataStreamProcessor {
int oldCols = screen.getCols();
screen.erase(true);
graphicsPlane.clear();
if (gocaDecoder != null) {
gocaDecoder.setGraphicsCursorActive(false);
}
processWrite(data, offset, length, true);
if (oldRows != screen.getRows() || oldCols != screen.getCols()) {
notifyScreenSizeChanged();
@@ -33,6 +33,19 @@ public class ECLPS implements ECLConstants {
return fieldList;
}
private boolean nvtMode = false;
public boolean isNVTmode() {
if (inputProcessor != null && inputProcessor.isNvtMode()) {
return true;
}
return nvtMode;
}
public void setNVTmode(boolean nvt) {
this.nvtMode = nvt;
}
public int getSize() { return screen.getRows() * screen.getCols(); }
public int getRows() { return screen.getRows(); }
public int getCols() { return screen.getCols(); }
@@ -8,6 +8,7 @@ import haus.nightmare.lib3270j.protocol.TelnetConstants;
import static haus.nightmare.lib3270j.protocol.DS3270Constants.*;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.util.logging.Logger;
/**
@@ -31,6 +32,14 @@ public class InputProcessor {
this.fsm = fsm;
}
public TelnetFSM getFsm() {
return fsm;
}
public boolean isNvtMode() {
return fsm != null && fsm.getConnectionState() != null && fsm.getConnectionState().isNvt();
}
private haus.nightmare.lib3270j.graphics.GraphicsPlane graphicsPlane;
private haus.nightmare.lib3270j.graphics.GocaDecoder gocaDecoder;
@@ -95,6 +104,15 @@ public class InputProcessor {
public void typeCharacter(char ch) {
if (keyboardLocked) return;
if (isNvtMode()) {
try {
fsm.sendNVTChar(ch);
} catch (IOException e) {
log.warning("Failed to send NVT character: " + e.getMessage());
}
return;
}
int size = screen.getRows() * screen.getCols();
if (size <= 0) return;
int baddr = screen.getCursorAddress();
@@ -237,86 +255,6 @@ public class InputProcessor {
return;
}
if (gocaDecoder != null && gocaDecoder.isGraphicsCursorActive()) {
int gx = gocaDecoder.getGraphicCursorX();
int gy = gocaDecoder.getGraphicCursorY();
int cols = (screen != null && screen.getCols() > 0) ? screen.getCols() : 80;
int numRows = (screen != null && screen.getRows() > 0) ? screen.getRows() : 24;
int cursorAddr = screen != null ? screen.getCursorAddress() : 0;
int row = cursorAddr / cols;
int col = cursorAddr % cols;
byte[] sf = haus.nightmare.lib3270j.graphics.GraphicInputBuilder.buildGraphicInput(
gx, gy, row, col, aidCode, false, false, false
);
StringBuilder sfHex = new StringBuilder();
for (byte b : sf) {
sfHex.append(String.format("%02X ", b & 0xFF));
}
log.info(String.format(
"sendAid (graphic): goca=(%d, %d) row=%d col=%d cursorAddr=%d aid=0x%02X SF_HEX=[%s]",
gx, gy, row, col, cursorAddr, aidCode, sfHex.toString().trim()
));
ByteArrayOutputStream out = new ByteArrayOutputStream();
// Structured Field AID (0x88) + 56-byte Graphic Input SF
out.write(AID_SF);
try {
out.write(sf);
} catch (java.io.IOException ignored) {}
// Trailing AID + cursor address
out.write(aidCode);
byte[] caddr = encodeAddress(cursorAddr, numRows, cols);
out.write(caddr[0] & 0xFF);
out.write(caddr[1] & 0xFF);
if (aidCode == AID_PA1 || aidCode == AID_PA2 || aidCode == AID_PA3) {
sendAidResponse(out.toByteArray());
return;
}
if (screen.isFormatted()) {
int size = screen.getRows() * screen.getCols();
for (int i = 0; i < size; i++) {
ExtendedAttribute ea = screen.getCell(i);
if (ea.isFieldAttribute() && faIsModified(ea.fa & 0xFF)) {
int fieldStart = (i + 1) % size;
// Always send SBA and address of first character in field
out.write(ORDER_SBA);
byte[] addr = encodeAddress(fieldStart, screen.getRows(), screen.getCols());
out.write(addr[0] & 0xFF);
out.write(addr[1] & 0xFF);
// Send all non-null characters in field (suppressing 0x00)
int pos = fieldStart;
while (!screen.getCell(pos).isFieldAttribute()) {
int b = screen.getCell(pos).ec & 0xFF;
if (b != 0x00) {
out.write(b);
}
pos = (pos + 1) % size;
if (pos == fieldStart) break;
}
}
}
} else {
int size = screen.getRows() * screen.getCols();
for (int i = 0; i < size; i++) {
int b = screen.getCell(i).ec & 0xFF;
if (b != 0x00) {
out.write(b);
}
}
}
sendAidResponse(out.toByteArray());
return;
}
if (aidCode == AID_PA1 || aidCode == AID_PA2 || aidCode == AID_PA3) {
// PA keys: send AID + optional PID + cursor address only (no modified data)
ByteArrayOutputStream out = new ByteArrayOutputStream();
@@ -863,6 +801,16 @@ public class InputProcessor {
* Equivalent to emulate_input() in x3270.
*/
public void emulateInput(String text) {
if (text == null) return;
if (isNvtMode()) {
try {
fsm.sendNVTString(text);
} catch (IOException e) {
log.warning("Failed to send NVT input: " + e.getMessage());
}
return;
}
// Type each character
for (int i = 0; i < text.length(); i++) {
char ch = text.charAt(i);
@@ -967,6 +915,93 @@ public class InputProcessor {
}
private void executeMnemonicToken(String token) {
if (isNvtMode()) {
try {
switch (token) {
case "enter":
case "return":
fsm.sendNVTString("\r\n");
break;
case "clear":
fsm.sendNVTChar('\u000C');
break;
case "tab":
fsm.sendNVTChar('\t');
break;
case "backtab":
case "btab":
fsm.sendNVTString("\u001B[Z");
break;
case "newline":
case "nl":
fsm.sendNVTString("\r\n");
break;
case "home":
fsm.sendNVTString("\u001B[H");
break;
case "end":
fsm.sendNVTString("\u001B[F");
break;
case "up":
case "curup":
fsm.sendNVTString("\u001B[A");
break;
case "down":
case "curdown":
fsm.sendNVTString("\u001B[B");
break;
case "left":
case "curleft":
fsm.sendNVTString("\u001B[D");
break;
case "right":
case "curright":
fsm.sendNVTString("\u001B[C");
break;
case "pageup":
case "pgup":
fsm.sendNVTString("\u001B[5~");
break;
case "pagedown":
case "pgdn":
fsm.sendNVTString("\u001B[6~");
break;
case "delete":
case "del":
fsm.sendNVTString("\u001B[3~");
break;
case "backspace":
case "bs":
fsm.sendNVTChar('\b');
break;
case "attn":
case "break":
fsm.sendNVTChar('\u0003');
break;
case "sysreq":
case "escape":
case "esc":
fsm.sendNVTChar('\u001B');
break;
case "reset":
setKeyboardLocked(false);
break;
default:
if (token.startsWith("pf") || token.startsWith("f")) {
try {
String numStr = token.startsWith("pf") ? token.substring(2) : token.substring(1);
int fn = Integer.parseInt(numStr);
sendNvtFunctionKey(fn);
} catch (NumberFormatException ignored) {}
}
break;
}
} catch (IOException e) {
log.warning("Failed to send NVT mnemonic token: " + e.getMessage());
}
return;
}
switch (token) {
case "enter":
case "return":
@@ -1068,6 +1103,24 @@ public class InputProcessor {
}
}
private void sendNvtFunctionKey(int fn) throws IOException {
switch (fn) {
case 1: fsm.sendNVTString("\u001BOP"); break;
case 2: fsm.sendNVTString("\u001BOQ"); break;
case 3: fsm.sendNVTString("\u001BOR"); break;
case 4: fsm.sendNVTString("\u001BOS"); break;
case 5: fsm.sendNVTString("\u001B[15~"); break;
case 6: fsm.sendNVTString("\u001B[17~"); break;
case 7: fsm.sendNVTString("\u001B[18~"); break;
case 8: fsm.sendNVTString("\u001B[19~"); break;
case 9: fsm.sendNVTString("\u001B[20~"); break;
case 10: fsm.sendNVTString("\u001B[21~"); break;
case 11: fsm.sendNVTString("\u001B[23~"); break;
case 12: fsm.sendNVTString("\u001B[24~"); break;
default: break;
}
}
/**
* Jump cursor to next or previous word boundary.
*/
@@ -0,0 +1,17 @@
package haus.nightmare.lib3270j.listener;
/**
* Listener interface for receiving inbound SCS (SNA Character String) data streams
* transmitted by the host in TN3270E mode (Data Type DT_SCS_DATA = 0x01).
*/
public interface SCSInboundListener {
/**
* Called when an inbound SCS record is received from the host.
*
* @param data Raw SCS record payload bytes
* @param offset Start offset within buffer
* @param length Number of bytes in record
*/
void onSCSDataReceived(byte[] data, int offset, int length);
}
@@ -9,7 +9,7 @@ import static haus.nightmare.lib3270j.protocol.DS3270Constants.*;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.logging.Logger;
@@ -17,7 +17,7 @@ import java.util.logging.Logger;
/**
* Network Virtual Terminal (NVT) processor.
* Handles ASCII / ANSI VT100 character stream processing, cursor positioning,
* escape sequence decoding, and NVT character/string transmission.
* escape sequence decoding, terminal capability reports, and NVT transmission.
*/
public class NvtProcessor {
@@ -28,9 +28,10 @@ public class NvtProcessor {
private final List<ScreenUpdateListener> screenListeners = new CopyOnWriteArrayList<>();
// Escape sequence parser states
private static final int STATE_NORMAL = 0;
private static final int STATE_ESC = 1;
private static final int STATE_CSI = 2;
private static final int STATE_NORMAL = 0;
private static final int STATE_ESC = 1;
private static final int STATE_CSI = 2;
private static final int STATE_CHARSET = 3;
private int parseState = STATE_NORMAL;
private final ByteArrayOutputStream escBuffer = new ByteArrayOutputStream();
@@ -47,6 +48,21 @@ public class NvtProcessor {
private int savedCursorRow = 0;
private int savedCursorCol = 0;
// Scrolling margins (0-indexed, inclusive)
private int scrollTop = 0;
private int scrollBottom = -1; // -1 means default (rows - 1)
// Tab stops
private boolean[] tabStops;
// Cursor visibility
private boolean cursorVisible = true;
// Line drawing mode
private boolean lineDrawingG0 = false;
private boolean lineDrawingG1 = false;
private boolean activeCharsetG1 = false;
@FunctionalInterface
public interface OutputSender {
void sendRaw(byte[] data) throws IOException;
@@ -55,6 +71,15 @@ public class NvtProcessor {
public NvtProcessor(ScreenBuffer screenBuffer, EbcdicTranslator translator) {
this.screenBuffer = screenBuffer;
this.translator = translator;
initTabStops();
}
private void initTabStops() {
int cols = screenBuffer.getCols();
tabStops = new boolean[cols];
for (int i = 0; i < cols; i++) {
tabStops[i] = (i % 8 == 0);
}
}
public void setOutputSender(OutputSender outputSender) {
@@ -65,6 +90,14 @@ public class NvtProcessor {
screenListeners.add(l);
}
public void removeScreenUpdateListener(ScreenUpdateListener l) {
screenListeners.remove(l);
}
public boolean isCursorVisible() {
return cursorVisible;
}
/**
* Process incoming ASCII NVT data bytes.
*/
@@ -73,6 +106,12 @@ public class NvtProcessor {
int rows = screenBuffer.getRows();
int cols = screenBuffer.getCols();
if (tabStops == null || tabStops.length != cols) {
initTabStops();
}
int effectiveScrollBottom = (scrollBottom >= 0 && scrollBottom < rows) ? scrollBottom : rows - 1;
int effectiveScrollTop = Math.max(0, Math.min(scrollTop, effectiveScrollBottom));
int size = rows * cols;
int curAddr = screenBuffer.getCursorAddress();
@@ -90,27 +129,60 @@ public class NvtProcessor {
} else if (b == 0x0A) { // LF
int r = curAddr / cols;
int c = curAddr % cols;
r++;
if (r >= rows) {
scrollUp();
r = rows - 1;
if (r == effectiveScrollBottom) {
scrollUpRegion(effectiveScrollTop, effectiveScrollBottom);
} else if (r < rows - 1) {
r++;
}
curAddr = r * cols + c;
} else if (b == 0x08 || b == 0x7F) { // BS or DEL
} else if (b == 0x08) { // BS
int c = curAddr % cols;
if (c > 0) {
curAddr--;
}
} else if (b == 0x7F) { // DEL
// Ignore or backspace per NVT convention
} else if (b == 0x09) { // TAB
int r = curAddr / cols;
int c = curAddr % cols;
int nextTab = ((c / 8) + 1) * 8;
if (nextTab >= cols) nextTab = cols - 1;
curAddr = (curAddr / cols) * cols + nextTab;
int nextTab = cols - 1;
for (int tc = c + 1; tc < cols; tc++) {
if (tc < tabStops.length && tabStops[tc]) {
nextTab = tc;
break;
}
}
curAddr = r * cols + nextTab;
} else if (b == 0x0C) { // FF
screenBuffer.clear();
curAddr = 0;
} else if (b >= 0x20 && b < 0xFF) { // Printable ASCII
} else if (b == 0x07) { // BEL
for (ScreenUpdateListener l : screenListeners) {
l.onSoundAlarm();
}
} else if (b == 0x0E) { // SO (Select G1 charset)
activeCharsetG1 = true;
} else if (b == 0x0F) { // SI (Select G0 charset)
activeCharsetG1 = false;
} else if (b >= 0x20 && b <= 0xFF) { // Printable character
char ch = (char) b;
if (activeCharsetG1 ? lineDrawingG1 : lineDrawingG0) {
ch = mapVt100SpecialGraphics(ch);
}
int r = curAddr / cols;
int c = curAddr % cols;
if (c >= cols) {
c = 0;
if (r == effectiveScrollBottom) {
scrollUpRegion(effectiveScrollTop, effectiveScrollBottom);
} else if (r < rows - 1) {
r++;
}
curAddr = r * cols + c;
}
int ebc = translator.unicodeToEbcdic(ch);
ExtendedAttribute cell = screenBuffer.getCell(curAddr);
cell.clear();
@@ -120,37 +192,98 @@ public class NvtProcessor {
cell.bg = currentBg;
cell.gr = currentGr;
curAddr++;
if (curAddr >= size) {
scrollUp();
curAddr = (rows - 1) * cols;
c++;
if (c >= cols) {
if (r < rows - 1) {
if (r == effectiveScrollBottom) {
scrollUpRegion(effectiveScrollTop, effectiveScrollBottom);
c = 0;
} else {
r++;
c = 0;
}
} else {
scrollUpRegion(effectiveScrollTop, effectiveScrollBottom);
c = 0;
}
}
curAddr = r * cols + c;
}
} else if (parseState == STATE_ESC) {
escBuffer.write(b);
if (b == '[') {
parseState = STATE_CSI;
} else if (b == '7') { // Save cursor
} else if (b == '(' || b == ')') {
parseState = STATE_CHARSET;
} else if (b == '7') { // DECSC - Save cursor
savedCursorRow = curAddr / cols;
savedCursorCol = curAddr % cols;
parseState = STATE_NORMAL;
} else if (b == '8') { // Restore cursor
} else if (b == '8') { // DECRC - Restore cursor
curAddr = Math.min(rows - 1, savedCursorRow) * cols + Math.min(cols - 1, savedCursorCol);
parseState = STATE_NORMAL;
} else if (b == 'D') { // IND - Index (down 1 line)
int r = curAddr / cols;
int c = curAddr % cols;
if (r == effectiveScrollBottom) {
scrollUpRegion(effectiveScrollTop, effectiveScrollBottom);
} else if (r < rows - 1) {
r++;
}
curAddr = r * cols + c;
parseState = STATE_NORMAL;
} else if (b == 'M') { // RI - Reverse Index (up 1 line)
int r = curAddr / cols;
int c = curAddr % cols;
if (r == effectiveScrollTop) {
scrollDownRegion(effectiveScrollTop, effectiveScrollBottom);
} else if (r > 0) {
r--;
}
curAddr = r * cols + c;
parseState = STATE_NORMAL;
} else if (b == 'E') { // NEL - Next Line (CR + LF)
int r = curAddr / cols;
if (r == effectiveScrollBottom) {
scrollUpRegion(effectiveScrollTop, effectiveScrollBottom);
} else if (r < rows - 1) {
r++;
}
curAddr = r * cols;
parseState = STATE_NORMAL;
} else if (b == 'H') { // HTS - Horizontal Tab Set
int c = curAddr % cols;
if (c < tabStops.length) {
tabStops[c] = true;
}
parseState = STATE_NORMAL;
} else if (b == 'c') { // RIS - Reset to Initial State
screenBuffer.clear();
curAddr = 0;
currentFg = 0;
currentBg = 0;
currentGr = 0;
scrollTop = 0;
scrollBottom = rows - 1;
cursorVisible = true;
initTabStops();
parseState = STATE_NORMAL;
} else {
// Unknown 2-byte escape, finish
// Unknown 2-byte escape, return to normal
parseState = STATE_NORMAL;
}
} else if (parseState == STATE_CHARSET) {
byte[] seq = escBuffer.toByteArray();
if (seq.length >= 2) {
boolean isG1 = (seq[1] == ')');
boolean isLineDraw = (b == '0');
if (isG1) lineDrawingG1 = isLineDraw;
else lineDrawingG0 = isLineDraw;
}
parseState = STATE_NORMAL;
} else if (parseState == STATE_CSI) {
escBuffer.write(b);
// CSI parameter/intermediate bytes: 0x20..0x3F, final bytes: 0x40..0x7E
// CSI final bytes are in the range 0x40..0x7E
if (b >= 0x40 && b <= 0x7E) {
byte[] seq = escBuffer.toByteArray();
curAddr = processAnsiEscapeSequence(seq, curAddr, rows, cols);
@@ -159,7 +292,7 @@ public class NvtProcessor {
}
}
screenBuffer.setCursorAddress(curAddr);
screenBuffer.setCursorAddress(Math.max(0, Math.min(size - 1, curAddr)));
screenBuffer.markAllChanged();
screenBuffer.updateDisplaySnapshot();
notifyScreenUpdated();
@@ -175,6 +308,9 @@ public class NvtProcessor {
String paramStr = new String(seq, 2, seq.length - 3, StandardCharsets.US_ASCII);
String[] params = paramStr.split(";");
int effectiveScrollBottom = (scrollBottom >= 0 && scrollBottom < rows) ? scrollBottom : rows - 1;
int effectiveScrollTop = Math.max(0, Math.min(scrollTop, effectiveScrollBottom));
int r = curAddr / cols;
int c = curAddr % cols;
@@ -212,6 +348,31 @@ public class NvtProcessor {
c = Math.max(0, c - count);
return r * cols + c;
}
case 'E': // CNL - Cursor Next Line
{
int count = parseParam(params, 0, 1);
r = Math.min(rows - 1, r + count);
return r * cols; // column 0
}
case 'F': // CPL - Cursor Previous Line
{
int count = parseParam(params, 0, 1);
r = Math.max(0, r - count);
return r * cols; // column 0
}
case 'G': // CHA - Cursor Horizontal Absolute
case '`': // HPA - Horizontal Position Absolute
{
int p = parseParam(params, 0, 1) - 1;
c = Math.max(0, Math.min(cols - 1, p));
return r * cols + c;
}
case 'd': // VPA - Vertical Position Absolute
{
int p = parseParam(params, 0, 1) - 1;
r = Math.max(0, Math.min(rows - 1, p));
return r * cols + c;
}
case 'J': // ED - Erase in Display
{
int mode = parseParam(params, 0, 0);
@@ -239,6 +400,84 @@ public class NvtProcessor {
}
return curAddr;
}
case 'L': // IL - Insert Line
{
int count = parseParam(params, 0, 1);
for (int n = 0; n < count; n++) {
scrollDownRegion(r, effectiveScrollBottom);
}
return r * cols;
}
case 'M': // DL - Delete Line
{
int count = parseParam(params, 0, 1);
for (int n = 0; n < count; n++) {
scrollUpRegion(r, effectiveScrollBottom);
}
return r * cols;
}
case '@': // ICH - Insert Character
{
int count = parseParam(params, 0, 1);
int lineStart = r * cols;
for (int col = cols - 1; col >= c + count; col--) {
screenBuffer.getCell(lineStart + col).copyFrom(screenBuffer.getCell(lineStart + col - count));
}
for (int col = c; col < Math.min(cols, c + count); col++) {
clearCell(lineStart + col);
}
return curAddr;
}
case 'P': // DCH - Delete Character
{
int count = parseParam(params, 0, 1);
int lineStart = r * cols;
for (int col = c; col < cols - count; col++) {
screenBuffer.getCell(lineStart + col).copyFrom(screenBuffer.getCell(lineStart + col + count));
}
for (int col = cols - count; col < cols; col++) {
clearCell(lineStart + col);
}
return curAddr;
}
case 'X': // ECH - Erase Character
{
int count = parseParam(params, 0, 1);
int end = Math.min((r + 1) * cols, curAddr + count);
for (int i = curAddr; i < end; i++) {
clearCell(i);
}
return curAddr;
}
case 'S': // SU - Scroll Up
{
int count = parseParam(params, 0, 1);
for (int n = 0; n < count; n++) {
scrollUpRegion(effectiveScrollTop, effectiveScrollBottom);
}
return curAddr;
}
case 'T': // SD - Scroll Down
{
int count = parseParam(params, 0, 1);
for (int n = 0; n < count; n++) {
scrollDownRegion(effectiveScrollTop, effectiveScrollBottom);
}
return curAddr;
}
case 'r': // DECSTBM - Set Top and Bottom Margins (Scrolling Region)
{
int top = parseParam(params, 0, 1) - 1;
int bottom = parseParam(params, 1, rows) - 1;
if (top >= 0 && bottom < rows && top < bottom) {
scrollTop = top;
scrollBottom = bottom;
} else {
scrollTop = 0;
scrollBottom = rows - 1;
}
return 0; // Move cursor to home
}
case 'm': // SGR - Select Graphic Rendition
{
if (params.length == 0 || (params.length == 1 && params[0].isEmpty())) {
@@ -256,6 +495,57 @@ public class NvtProcessor {
}
return curAddr;
}
case 'n': // DSR - Device Status Report
{
int code = parseParam(params, 0, 0);
if (code == 6) { // Cursor position request
// Reply: ESC [ <row> ; <col> R (1-indexed)
String response = String.format("\u001B[%d;%dR", r + 1, c + 1);
sendResponseString(response);
} else if (code == 5) { // Status report request
sendResponseString("\u001B[0n"); // OK
}
return curAddr;
}
case 'c': // DA - Device Attributes
{
int code = parseParam(params, 0, 0);
if (code == 0) {
// Identify as standard VT100 with Advanced Video Option
sendResponseString("\u001B[?1;2c");
}
return curAddr;
}
case 'g': // TBC - Tab Clear
{
int mode = parseParam(params, 0, 0);
if (mode == 0) {
if (c < tabStops.length) tabStops[c] = false;
} else if (mode == 3) {
Arrays.fill(tabStops, false);
}
return curAddr;
}
case 'h': // Set Mode / Private Mode
{
if (paramStr.startsWith("?")) {
String sub = paramStr.substring(1).trim();
if ("25".equals(sub)) {
cursorVisible = true;
}
}
return curAddr;
}
case 'l': // Reset Mode / Private Mode
{
if (paramStr.startsWith("?")) {
String sub = paramStr.substring(1).trim();
if ("25".equals(sub)) {
cursorVisible = false;
}
}
return curAddr;
}
case 's': // Save cursor
savedCursorRow = r;
savedCursorCol = c;
@@ -269,10 +559,22 @@ public class NvtProcessor {
}
}
private void sendResponseString(String s) {
if (outputSender != null) {
try {
outputSender.sendRaw(s.getBytes(StandardCharsets.US_ASCII));
} catch (IOException e) {
log.warning("Failed to send ANSI response: " + e.getMessage());
}
}
}
private int parseParam(String[] params, int idx, int defaultVal) {
if (params != null && idx < params.length && !params[idx].trim().isEmpty()) {
try {
return Integer.parseInt(params[idx].trim());
String val = params[idx].trim();
if (val.startsWith("?")) val = val.substring(1);
return Integer.parseInt(val);
} catch (NumberFormatException ignored) {}
}
return defaultVal;
@@ -281,27 +583,73 @@ public class NvtProcessor {
private void clearCell(int addr) {
ExtendedAttribute cell = screenBuffer.getCell(addr);
cell.clear();
cell.ec = 0;
cell.ec = (byte) 0x40; // EBCDIC space
cell.ucs4 = ' ';
cell.fg = 0;
cell.bg = 0;
cell.gr = 0;
}
private void scrollUp() {
private void scrollUpRegion(int top, int bottom) {
int rows = screenBuffer.getRows();
int cols = screenBuffer.getCols();
for (int r = 0; r < rows - 1; r++) {
top = Math.max(0, Math.min(rows - 1, top));
bottom = Math.max(top, Math.min(rows - 1, bottom));
for (int r = top; r < bottom; r++) {
for (int c = 0; c < cols; c++) {
int dst = r * cols + c;
int src = (r + 1) * cols + c;
screenBuffer.getCell(dst).copyFrom(screenBuffer.getCell(src));
}
}
// Clear last line
int lastRowStart = (rows - 1) * cols;
int lastRowStart = bottom * cols;
for (int c = 0; c < cols; c++) {
clearCell(lastRowStart + c);
}
}
private void scrollDownRegion(int top, int bottom) {
int rows = screenBuffer.getRows();
int cols = screenBuffer.getCols();
top = Math.max(0, Math.min(rows - 1, top));
bottom = Math.max(top, Math.min(rows - 1, bottom));
for (int r = bottom; r > top; r--) {
for (int c = 0; c < cols; c++) {
int dst = r * cols + c;
int src = (r - 1) * cols + c;
screenBuffer.getCell(dst).copyFrom(screenBuffer.getCell(src));
}
}
int topRowStart = top * cols;
for (int c = 0; c < cols; c++) {
clearCell(topRowStart + c);
}
}
private char mapVt100SpecialGraphics(char c) {
switch (c) {
case 'j': return '┘';
case 'k': return '┐';
case 'l': return '┌';
case 'm': return '└';
case 'n': return '┼';
case 'q': return '─';
case 't': return '├';
case 'u': return '┤';
case 'v': return '┴';
case 'w': return '┬';
case 'x': return '│';
case '`': return '◆';
case 'a': return '▒';
case 'f': return '°';
case 'g': return '±';
case '~': return '•';
default: return c;
}
}
private void applySgr(int code) {
switch (code) {
case 0: // Reset
@@ -115,4 +115,23 @@ public final class TelnetConstants {
default: return "CMD-" + cmd;
}
}
public static String qualifierName(int qual) {
switch (qual) {
case TELQUAL_IS: return "IS";
case TELQUAL_SEND: return "SEND";
case TELQUAL_INFO: return "INFO";
default: return "QUAL-" + qual;
}
}
/** NEW-ENVIRON object name lookup. */
public static String environObjectName(int obj) {
switch (obj) {
case TELOBJ_VAR: return "VAR";
case TELOBJ_VALUE: return "VALUE";
case TELOBJ_ESC: return "ESC";
case TELOBJ_USERVAR: return "USERVAR";
default: return "OBJ-" + obj;
}
}
}
@@ -40,44 +40,71 @@ public class TelnetConnection {
}
/**
* Connect to the host. Blocks until connection is established or fails.
* Connect to the host (optionally through a proxy). Blocks until connection is established or fails.
*/
public void connect() throws IOException {
ConnectionConfig.ProxyType proxyType = config.getProxyType();
boolean hasProxy = proxyType != null && proxyType != ConnectionConfig.ProxyType.NONE &&
config.getProxyHost() != null && !config.getProxyHost().trim().isEmpty();
String connectHost = hasProxy ? config.getProxyHost().trim() : config.getHost();
int connectPort = hasProxy ? (config.getProxyPort() > 0 ? config.getProxyPort() : (proxyType == ConnectionConfig.ProxyType.HTTP ? 8080 : 1080)) : config.getPort();
log.info("Connecting TCP socket to " + connectHost + ":" + connectPort +
(hasProxy ? " (via " + proxyType + " proxy for " + config.getHost() + ":" + config.getPort() + ")" : "") +
(config.isUseTls() ? " with TLS" : ""));
Socket rawSocket = new Socket();
rawSocket.setKeepAlive(config.isSoKeepAlive());
rawSocket.setOOBInline(true);
rawSocket.setTcpNoDelay(config.isTcpNoDelay());
if (config.getSoTimeoutMs() > 0) {
rawSocket.setSoTimeout(config.getSoTimeoutMs());
}
rawSocket.connect(new InetSocketAddress(connectHost, connectPort), config.getConnectTimeoutMs());
// Perform proxy handshake if configured
if (hasProxy) {
switch (proxyType) {
case HTTP:
establishHttpProxy(rawSocket, config.getHost(), config.getPort(), config.getProxyUsername(), config.getProxyPassword());
break;
case SOCKS4:
establishSocks4Proxy(rawSocket, config.getHost(), config.getPort(), config.getProxyUsername());
break;
case SOCKS5:
establishSocks5Proxy(rawSocket, config.getHost(), config.getPort(), config.getProxyUsername(), config.getProxyPassword());
break;
default:
break;
}
}
if (config.isUseTls()) {
log.info("Connecting with TLS to " + config.getHost() + ":" + config.getPort() +
log.info("Performing TLS handshake with " + config.getHost() + ":" + config.getPort() +
" (verifyCert=" + config.isTlsVerifyCert() + ")");
try {
javax.net.ssl.SSLContext sslContext = haus.nightmare.lib3270j.tls.TlsTrustManager.createSSLContext(config);
javax.net.ssl.SSLSocketFactory ssf = sslContext.getSocketFactory();
javax.net.ssl.SSLSocket sslSocket = (javax.net.ssl.SSLSocket) ssf.createSocket();
javax.net.ssl.SSLSocket sslSocket = (javax.net.ssl.SSLSocket) ssf.createSocket(
rawSocket, config.getHost(), config.getPort(), true);
sslSocket.setKeepAlive(config.isSoKeepAlive());
sslSocket.setTcpNoDelay(config.isTcpNoDelay());
if (config.getSoTimeoutMs() > 0) {
sslSocket.setSoTimeout(config.getSoTimeoutMs());
}
sslSocket.connect(new InetSocketAddress(config.getHost(), config.getPort()),
config.getConnectTimeoutMs());
sslSocket.startHandshake();
socket = sslSocket;
sslSession = sslSocket.getSession();
log.info("TLS session active: protocol=" + sslSession.getProtocol() +
" cipher=" + sslSession.getCipherSuite());
" cipher=" + sslSession.getCipherSuite());
} catch (IOException e) {
throw e;
} catch (Exception e) {
throw new IOException("TLS setup failure: " + e.getMessage(), e);
}
} else {
log.info("Connecting to " + config.getHost() + ":" + config.getPort());
socket = new Socket();
socket.setKeepAlive(config.isSoKeepAlive());
socket.setOOBInline(true);
socket.setTcpNoDelay(config.isTcpNoDelay());
if (config.getSoTimeoutMs() > 0) {
socket.setSoTimeout(config.getSoTimeoutMs());
}
socket.connect(new InetSocketAddress(config.getHost(), config.getPort()),
config.getConnectTimeoutMs());
socket = rawSocket;
}
inputStream = new BufferedInputStream(socket.getInputStream(), READ_BUFFER_SIZE);
@@ -91,6 +118,235 @@ public class TelnetConnection {
readerThread.start();
}
/**
* Dynamically elevate active socket to TLS in-band (STARTTLS / Option 46).
*/
public synchronized void upgradeToTls() throws IOException {
if (socket == null || !socket.isConnected() || socket.isClosed()) {
throw new IOException("Cannot upgrade disconnected socket to TLS");
}
log.info("Elevating active connection to TLS via STARTTLS");
try {
javax.net.ssl.SSLContext sslContext = haus.nightmare.lib3270j.tls.TlsTrustManager.createSSLContext(config);
javax.net.ssl.SSLSocketFactory ssf = sslContext.getSocketFactory();
javax.net.ssl.SSLSocket sslSocket = (javax.net.ssl.SSLSocket) ssf.createSocket(
socket, config.getHost(), config.getPort(), true);
sslSocket.setKeepAlive(config.isSoKeepAlive());
sslSocket.setTcpNoDelay(config.isTcpNoDelay());
if (config.getSoTimeoutMs() > 0) {
sslSocket.setSoTimeout(config.getSoTimeoutMs());
}
sslSocket.startHandshake();
this.socket = sslSocket;
this.sslSession = sslSocket.getSession();
this.inputStream = new BufferedInputStream(sslSocket.getInputStream(), READ_BUFFER_SIZE);
this.outputStream = new BufferedOutputStream(sslSocket.getOutputStream());
log.info("STARTTLS session active: protocol=" + sslSession.getProtocol() +
" cipher=" + sslSession.getCipherSuite());
} catch (IOException e) {
throw e;
} catch (Exception e) {
throw new IOException("STARTTLS setup failure: " + e.getMessage(), e);
}
}
private void establishHttpProxy(Socket s, String targetHost, int targetPort, String user, String pass) throws IOException {
OutputStream out = s.getOutputStream();
InputStream in = s.getInputStream();
StringBuilder req = new StringBuilder();
req.append("CONNECT ").append(targetHost).append(":").append(targetPort).append(" HTTP/1.1\r\n");
req.append("Host: ").append(targetHost).append(":").append(targetPort).append("\r\n");
if (user != null && !user.isEmpty()) {
String auth = user + ":" + (pass != null ? pass : "");
String encoded = java.util.Base64.getEncoder().encodeToString(auth.getBytes(java.nio.charset.StandardCharsets.UTF_8));
req.append("Proxy-Authorization: Basic ").append(encoded).append("\r\n");
}
req.append("Proxy-Connection: Keep-Alive\r\n\r\n");
out.write(req.toString().getBytes(java.nio.charset.StandardCharsets.US_ASCII));
out.flush();
// Read HTTP status line
ByteArrayOutputStream lineBuf = new ByteArrayOutputStream();
int b;
while ((b = in.read()) != -1) {
if (b == '\n') break;
if (b != '\r') lineBuf.write(b);
}
String statusLine = new String(lineBuf.toByteArray(), java.nio.charset.StandardCharsets.US_ASCII);
if (!statusLine.contains(" 200")) {
throw new IOException("HTTP proxy connection failed: " + statusLine);
}
// Consume remaining response headers until empty line
while (true) {
lineBuf.reset();
while ((b = in.read()) != -1) {
if (b == '\n') break;
if (b != '\r') lineBuf.write(b);
}
if (lineBuf.size() == 0) break; // empty line terminates headers
}
log.info("HTTP proxy tunnel established to " + targetHost + ":" + targetPort);
}
private void establishSocks4Proxy(Socket s, String targetHost, int targetPort, String user) throws IOException {
OutputStream out = s.getOutputStream();
InputStream in = s.getInputStream();
byte[] ip = new byte[4];
boolean isSocks4a = false;
try {
InetAddress addr = InetAddress.getByName(targetHost);
if (addr instanceof Inet4Address) {
ip = addr.getAddress();
} else {
isSocks4a = true;
ip = new byte[] { 0, 0, 0, 1 };
}
} catch (Exception e) {
isSocks4a = true;
ip = new byte[] { 0, 0, 0, 1 };
}
ByteArrayOutputStream req = new ByteArrayOutputStream();
req.write(0x04); // SOCKS version 4
req.write(0x01); // CONNECT command
req.write((targetPort >> 8) & 0xFF);
req.write(targetPort & 0xFF);
req.write(ip);
if (user != null && !user.isEmpty()) {
req.write(user.getBytes(java.nio.charset.StandardCharsets.ISO_8859_1));
}
req.write(0x00); // Null terminator for userid
if (isSocks4a) {
req.write(targetHost.getBytes(java.nio.charset.StandardCharsets.ISO_8859_1));
req.write(0x00); // Null terminator for domain name
}
out.write(req.toByteArray());
out.flush();
byte[] resp = new byte[8];
int read = 0;
while (read < 8) {
int n = in.read(resp, read, 8 - read);
if (n < 0) throw new IOException("Unexpected EOF reading SOCKS4 response");
read += n;
}
int status = resp[1] & 0xFF;
if (status != 0x5A) {
throw new IOException("SOCKS4 proxy request rejected, status=0x" + Integer.toHexString(status));
}
log.info("SOCKS4 proxy tunnel established to " + targetHost + ":" + targetPort);
}
private void establishSocks5Proxy(Socket s, String targetHost, int targetPort, String user, String pass) throws IOException {
OutputStream out = s.getOutputStream();
InputStream in = s.getInputStream();
boolean hasAuth = user != null && !user.isEmpty();
if (hasAuth) {
out.write(new byte[] { 0x05, 0x02, 0x00, 0x02 }); // SOCKS5, 2 methods: NO_AUTH(0x00), USER_PASS(0x02)
} else {
out.write(new byte[] { 0x05, 0x01, 0x00 }); // SOCKS5, 1 method: NO_AUTH(0x00)
}
out.flush();
byte[] methodResp = new byte[2];
readFully(in, methodResp);
if ((methodResp[0] & 0xFF) != 0x05) {
throw new IOException("Invalid SOCKS5 version response: " + (methodResp[0] & 0xFF));
}
int authMethod = methodResp[1] & 0xFF;
if (authMethod == 0x02) {
// RFC 1929 Username/Password Authentication
byte[] uBytes = user.getBytes(java.nio.charset.StandardCharsets.UTF_8);
byte[] pBytes = (pass != null ? pass : "").getBytes(java.nio.charset.StandardCharsets.UTF_8);
ByteArrayOutputStream authReq = new ByteArrayOutputStream();
authReq.write(0x01); // Auth subnegotiation version
authReq.write(uBytes.length);
authReq.write(uBytes);
authReq.write(pBytes.length);
authReq.write(pBytes);
out.write(authReq.toByteArray());
out.flush();
byte[] authResp = new byte[2];
readFully(in, authResp);
if (authResp[1] != 0x00) {
throw new IOException("SOCKS5 username/password authentication failed");
}
} else if (authMethod != 0x00) {
throw new IOException("SOCKS5 proxy authentication method rejected: 0x" + Integer.toHexString(authMethod));
}
// Send CONNECT request
ByteArrayOutputStream connReq = new ByteArrayOutputStream();
connReq.write(0x05); // SOCKS5
connReq.write(0x01); // CONNECT
connReq.write(0x00); // Reserved
try {
InetAddress addr = InetAddress.getByName(targetHost);
if (addr instanceof Inet4Address) {
connReq.write(0x01); // ATYP IPv4
connReq.write(addr.getAddress());
} else if (addr instanceof Inet6Address) {
connReq.write(0x04); // ATYP IPv6
connReq.write(addr.getAddress());
} else {
byte[] dBytes = targetHost.getBytes(java.nio.charset.StandardCharsets.ISO_8859_1);
connReq.write(0x03); // ATYP Domain
connReq.write(dBytes.length);
connReq.write(dBytes);
}
} catch (Exception e) {
byte[] dBytes = targetHost.getBytes(java.nio.charset.StandardCharsets.ISO_8859_1);
connReq.write(0x03); // ATYP Domain
connReq.write(dBytes.length);
connReq.write(dBytes);
}
connReq.write((targetPort >> 8) & 0xFF);
connReq.write(targetPort & 0xFF);
out.write(connReq.toByteArray());
out.flush();
byte[] connResp = new byte[4];
readFully(in, connResp);
int rep = connResp[1] & 0xFF;
if (rep != 0x00) {
throw new IOException("SOCKS5 connect command failed, rep=0x" + Integer.toHexString(rep));
}
int atyp = connResp[3] & 0xFF;
if (atyp == 0x01) {
byte[] bnd = new byte[4 + 2]; // IPv4 + Port
readFully(in, bnd);
} else if (atyp == 0x03) {
int len = in.read();
if (len < 0) throw new IOException("Unexpected EOF in SOCKS5 domain response");
byte[] bnd = new byte[len + 2]; // Domain + Port
readFully(in, bnd);
} else if (atyp == 0x04) {
byte[] bnd = new byte[16 + 2]; // IPv6 + Port
readFully(in, bnd);
}
log.info("SOCKS5 proxy tunnel established to " + targetHost + ":" + targetPort);
}
private static void readFully(InputStream in, byte[] buf) throws IOException {
int read = 0;
while (read < buf.length) {
int n = in.read(buf, read, buf.length - read);
if (n < 0) throw new IOException("Unexpected EOF reading proxy response");
read += n;
}
}
/**
* Send raw bytes to the host.
*/
@@ -107,19 +107,20 @@ public class TelnetFSM {
// Listeners
private final List<ConnectionListener> connectionListeners = new CopyOnWriteArrayList<>();
private final List<ScreenUpdateListener> screenListeners = new CopyOnWriteArrayList<>();
private final List<SCSInboundListener> scsListeners = new CopyOnWriteArrayList<>();
// Connected LU info
private String connectedLu;
private String connectedType;
enum TN3270ESubmode { UNBOUND, E_3270, E_NVT, E_SSCP }
public enum TN3270ESubmode { UNBOUND, E_3270, E_NVT, E_SSCP }
public TelnetFSM(ConnectionConfig config, ScreenBuffer screenBuffer, DataStreamProcessor dsProcessor) {
this.config = config;
this.screenBuffer = screenBuffer;
this.dsProcessor = dsProcessor;
this.nvtProcessor = new haus.nightmare.lib3270j.nvt.NvtProcessor(screenBuffer, new haus.nightmare.lib3270j.charset.EbcdicTranslator());
this.nvtProcessor.setOutputSender(this::sendBytes);
this.nvtProcessor.setOutputSender(this::sendNvtData);
}
public haus.nightmare.lib3270j.nvt.NvtProcessor getNvtProcessor() {
@@ -131,10 +132,25 @@ public class TelnetFSM {
}
public void addConnectionListener(ConnectionListener l) { connectionListeners.add(l); }
public void removeConnectionListener(ConnectionListener l) { connectionListeners.remove(l); }
public void addScreenUpdateListener(ScreenUpdateListener l) {
screenListeners.add(l);
nvtProcessor.addScreenUpdateListener(l);
}
public void removeScreenUpdateListener(ScreenUpdateListener l) {
screenListeners.remove(l);
nvtProcessor.removeScreenUpdateListener(l);
}
public void addSCSInboundListener(SCSInboundListener l) {
if (l != null && !scsListeners.contains(l)) {
scsListeners.add(l);
}
}
public void removeSCSInboundListener(SCSInboundListener l) {
scsListeners.remove(l);
}
public ConnectionState getConnectionState() { return connectionState; }
public boolean[] getMyOpts() { return myOpts; }
@@ -189,9 +205,11 @@ public class TelnetFSM {
if (connectionState == ConnectionState.TELNET_PENDING) {
changeState(ConnectionState.CONNECTED_NVT);
}
if (connectionState.isNvt()) {
boolean isPlainNvt = (connectionState == ConnectionState.CONNECTED_NVT || connectionState == ConnectionState.CONNECTED_NVT_CHAR)
&& !(hisOpts[TELOPT_BINARY] && hisOpts[TELOPT_EOR]);
if (isPlainNvt) {
nvtProcessor.processNVTData(buf, start, i - start);
} else if (connectionState.is3270() || connectionState.isTn3270e() || connectionState == ConnectionState.TELNET_PENDING) {
} else if (connectionState.is3270() || connectionState.isTn3270e() || connectionState == ConnectionState.TELNET_PENDING || hisOpts[TELOPT_BINARY] || hisOpts[TELOPT_EOR]) {
ibuf.write(buf, start, i - start);
}
}
@@ -260,13 +278,15 @@ public class TelnetFSM {
changeState(ConnectionState.CONNECTED_NVT);
}
if (connectionState.isNvt()) {
boolean isPlainNvt = (connectionState == ConnectionState.CONNECTED_NVT || connectionState == ConnectionState.CONNECTED_NVT_CHAR)
&& !(hisOpts[TELOPT_BINARY] && hisOpts[TELOPT_EOR]);
if (isPlainNvt) {
nvtProcessor.processNVTData(new byte[] { (byte) c }, 0, 1);
return;
}
// Accumulate data for 3270, TN3270E (including SSCP-LU and unbound states, and pending)
if (connectionState.is3270() || connectionState.isTn3270e() || connectionState == ConnectionState.TELNET_PENDING) {
// Accumulate data for 3270, TN3270E (including CONNECTED_E_NVT, SSCP-LU and unbound states, and pending)
if (connectionState.is3270() || connectionState.isTn3270e() || connectionState == ConnectionState.TELNET_PENDING || hisOpts[TELOPT_BINARY] || hisOpts[TELOPT_EOR]) {
ibuf.write(c);
}
}
@@ -336,6 +356,13 @@ public class TelnetFSM {
sendCommand(DO, opt);
break;
case TELOPT_STARTTLS:
if (config.isStartTlsEnabled() && !hisOpts[opt]) {
hisOpts[opt] = true;
sendCommand(DO, opt);
}
break;
case TELOPT_TN3270E:
if (!config.isTn3270eEnabled()) {
sendCommand(DONT, opt);
@@ -384,6 +411,13 @@ public class TelnetFSM {
sendCommand(WILL, opt);
break;
case TELOPT_STARTTLS:
if (config.isStartTlsEnabled() && !myOpts[opt]) {
myOpts[opt] = true;
sendCommand(WILL, opt);
}
break;
case TELOPT_TTYPE:
if (!myOpts[opt]) {
myOpts[opt] = true;
@@ -489,12 +523,37 @@ public class TelnetFSM {
case TELOPT_NEW_ENVIRON:
handleNewEnvironSB(data);
break;
case TELOPT_STARTTLS:
handleStartTlsSB(data);
break;
default:
log.info("Ignoring SB for option " + opt);
break;
}
}
// ========== STARTTLS sub-negotiation ==========
private void handleStartTlsSB(byte[] data) {
if (data.length >= 2 && (data[1] & 0xFF) == TLS_FOLLOWS) {
log.info("RCVD SB STARTTLS FOLLOWS (1) - Initiating TLS elevation");
processStartTls();
}
}
public void processStartTls() {
log.info("Elevating active connection to TLS via STARTTLS (Option 46)");
try {
if (connection != null) {
connection.upgradeToTls();
statusDisplay(STATUS_SECURITY, "TLS socket elevated via STARTTLS");
}
} catch (IOException e) {
log.log(Level.SEVERE, "Failed to elevate socket to TLS via STARTTLS", e);
onError("STARTTLS elevation failed: " + e.getMessage());
}
}
// ========== TTYPE sub-negotiation ==========
private void handleTTypeSB(byte[] data) {
@@ -522,15 +581,117 @@ public class TelnetFSM {
}
}
// ========== NEW_ENVIRON sub-negotiation ==========
// ========== NEW_ENVIRON sub-negotiation (RFC 1572 / RFC 2877) ==========
private void handleNewEnvironSB(byte[] data) {
if (data.length >= 2 && data[1] == TELQUAL_SEND) {
log.info("RCVD SB NEW-ENVIRON SEND - Responding with empty IS");
byte[] response = { (byte) IAC, (byte) SB, (byte) TELOPT_NEW_ENVIRON,
(byte) TELQUAL_IS, (byte) IAC, (byte) SE };
sendBytes(response);
log.info("SENT SB NEW-ENVIRON IS SE");
if (data.length < 2) return;
int qual = data[1] & 0xFF;
if (qual == TELQUAL_SEND) {
log.info("RCVD SB NEW-ENVIRON SEND (" + (data.length - 2) + " bytes)");
if (data.length == 2) {
// Empty SEND: send all configured variables
sendNewEnvironmentVariables(config.getEnvironmentVariables(), config.getUserVariables());
return;
}
// Parse requested variable names
java.util.Map<String, String> respVars = new java.util.LinkedHashMap<>();
java.util.Map<String, String> respUserVars = new java.util.LinkedHashMap<>();
int idx = 2;
while (idx < data.length) {
int objType = data[idx++] & 0xFF;
ByteArrayOutputStream nameBuf = new ByteArrayOutputStream();
boolean escaped = false;
while (idx < data.length) {
int b = data[idx] & 0xFF;
if (!escaped && (b == TELOBJ_VAR || b == TELOBJ_USERVAR)) {
break;
}
idx++;
if (!escaped && b == TELOBJ_ESC) {
escaped = true;
continue;
}
nameBuf.write(b);
escaped = false;
}
String varName = new String(nameBuf.toByteArray(), java.nio.charset.StandardCharsets.US_ASCII);
if (objType == TELOBJ_VAR) {
if (varName.isEmpty()) {
respVars.putAll(config.getEnvironmentVariables());
} else if (config.getEnvironmentVariables().containsKey(varName)) {
respVars.put(varName, config.getEnvironmentVariables().get(varName));
}
} else if (objType == TELOBJ_USERVAR) {
if (varName.isEmpty()) {
respUserVars.putAll(config.getUserVariables());
} else if (config.getUserVariables().containsKey(varName)) {
respUserVars.put(varName, config.getUserVariables().get(varName));
}
}
}
sendNewEnvironmentVariables(respVars, respUserVars);
}
}
public void sendNewEnvironmentVariables(java.util.Properties props) {
java.util.Map<String, String> vars = new java.util.LinkedHashMap<>();
java.util.Map<String, String> uVars = new java.util.LinkedHashMap<>();
if (props != null) {
for (String k : props.stringPropertyNames()) {
if (k.startsWith("USERVAR_") || k.startsWith("USER_")) {
uVars.put(k, props.getProperty(k));
} else {
vars.put(k, props.getProperty(k));
}
}
}
sendNewEnvironmentVariables(vars, uVars);
}
public void sendNewEnvironmentVariables(java.util.Map<String, String> vars, java.util.Map<String, String> userVars) {
ByteArrayOutputStream out = new ByteArrayOutputStream();
out.write(IAC);
out.write(SB);
out.write(TELOPT_NEW_ENVIRON);
out.write(TELQUAL_IS);
if (vars != null) {
for (java.util.Map.Entry<String, String> e : vars.entrySet()) {
out.write(TELOBJ_VAR);
writeEscapedEnvironString(out, e.getKey());
out.write(TELOBJ_VALUE);
writeEscapedEnvironString(out, e.getValue());
}
}
if (userVars != null) {
for (java.util.Map.Entry<String, String> e : userVars.entrySet()) {
out.write(TELOBJ_USERVAR);
writeEscapedEnvironString(out, e.getKey());
out.write(TELOBJ_VALUE);
writeEscapedEnvironString(out, e.getValue());
}
}
out.write(IAC);
out.write(SE);
sendBytes(out.toByteArray());
log.info("SENT SB NEW-ENVIRON IS (" + ((vars != null ? vars.size() : 0) + (userVars != null ? userVars.size() : 0)) + " vars) SE");
}
private void writeEscapedEnvironString(ByteArrayOutputStream out, String s) {
if (s == null) return;
for (byte b : s.getBytes(java.nio.charset.StandardCharsets.US_ASCII)) {
int ub = b & 0xFF;
if (ub == TELOBJ_VAR || ub == TELOBJ_VALUE || ub == TELOBJ_ESC || ub == TELOBJ_USERVAR) {
out.write(TELOBJ_ESC);
} else if (ub == IAC) {
out.write(IAC);
}
out.write(ub);
}
}
@@ -816,8 +977,10 @@ public class TelnetFSM {
ibuf.reset();
if (data.length == 0) return;
if (connectionState == ConnectionState.TELNET_PENDING && !tn3270eNegotiated) {
log.info("Received EOR during TELNET_PENDING - transitioning to plain TN3270 mode");
if ((connectionState == ConnectionState.TELNET_PENDING ||
connectionState == ConnectionState.CONNECTED_NVT ||
connectionState == ConnectionState.CONNECTED_NVT_CHAR) && !tn3270eNegotiated) {
log.info("Received EOR during NVT/pending - transitioning to plain TN3270 mode");
changeState(ConnectionState.CONNECTED_3270);
}
@@ -831,6 +994,10 @@ public class TelnetFSM {
}
}
public void processTn3270eHeader(byte[] data) {
processTN3270ERecord(data);
}
private void processTN3270ERecord(byte[] data) {
if (data.length < EH_SIZE) {
log.warning("TN3270E record too short: " + data.length);
@@ -850,11 +1017,10 @@ public class TelnetFSM {
switch (dataType) {
case DT_3270_DATA:
if (data.length > EH_SIZE) {
// Transition to 3270 mode
if (connectionState == ConnectionState.CONNECTED_UNBOUND ||
connectionState == ConnectionState.CONNECTED_SSCP) {
// Clear screen on transition to 3270 mode from unbound/SSCP
// This ensures old SSCP-LU data or stale content doesn't persist
// Transition to 3270 mode from any non-3270 state (E_NVT, UNBOUND, SSCP)
if (connectionState != ConnectionState.CONNECTED_TN3270E) {
// Clear screen on transition to 3270 mode from unbound/SSCP/NVT
// This ensures old SSCP-LU or NVT data doesn't persist
screenBuffer.erase(false);
changeState(ConnectionState.CONNECTED_TN3270E);
tn3270eSubmode = TN3270ESubmode.E_3270;
@@ -879,9 +1045,30 @@ public class TelnetFSM {
}
break;
case DT_SCS_DATA:
if (data.length > EH_SIZE) {
try {
processSCSInbound(data, EH_SIZE, data.length - EH_SIZE);
if (eFuncs[FUNC_RESPONSES] && responseFlag == RSF_ALWAYS_RESPONSE) {
sendTN3270EPositiveResponse(seqNumber);
}
} catch (Exception e) {
log.log(Level.WARNING, "Error processing SCS record", e);
if (eFuncs[FUNC_RESPONSES] && (responseFlag == RSF_ALWAYS_RESPONSE || responseFlag == RSF_ERROR_RESPONSE)) {
sendTN3270ENegativeResponse(seqNumber, NEG_OPERATION_CHECK);
}
}
} else {
if (eFuncs[FUNC_RESPONSES] && responseFlag == RSF_ALWAYS_RESPONSE) {
sendTN3270EPositiveResponse(seqNumber);
}
}
break;
case DT_SSCP_LU_DATA:
if (connectionState != ConnectionState.CONNECTED_SSCP) {
if (connectionState == ConnectionState.CONNECTED_UNBOUND) {
if (connectionState == ConnectionState.CONNECTED_UNBOUND ||
connectionState == ConnectionState.CONNECTED_E_NVT) {
// Clear screen on first SSCP-LU transition to remove stale data
screenBuffer.clear();
}
@@ -919,8 +1106,13 @@ public class TelnetFSM {
case DT_NVT_DATA:
// NVT data in TN3270E mode
changeState(ConnectionState.CONNECTED_E_NVT);
tn3270eSubmode = TN3270ESubmode.E_NVT;
if (connectionState != ConnectionState.CONNECTED_E_NVT) {
changeState(ConnectionState.CONNECTED_E_NVT);
tn3270eSubmode = TN3270ESubmode.E_NVT;
}
if (dsProcessor != null && dsProcessor.getInputProcessor() != null) {
dsProcessor.getInputProcessor().setKeyboardLocked(false);
}
if (data.length > EH_SIZE) {
try {
processNVTData(data, EH_SIZE, data.length - EH_SIZE);
@@ -938,6 +1130,7 @@ public class TelnetFSM {
sendTN3270EPositiveResponse(seqNumber);
}
}
notifyScreenUpdate();
break;
case DT_REQUEST:
@@ -957,6 +1150,16 @@ public class TelnetFSM {
}
break;
case DT_BID:
process_BID(responseFlag, seqNumber);
break;
case DT_PRINT_EOJ:
if (eFuncs[FUNC_RESPONSES] && responseFlag == RSF_ALWAYS_RESPONSE) {
sendTN3270EPositiveResponse(seqNumber);
}
break;
case DT_RESPONSE:
lastRcvSeq = seqNumber;
log.fine("Received response, seq=" + seqNumber);
@@ -966,8 +1169,8 @@ public class TelnetFSM {
// Check if the host sent a raw 3270 data stream (e.g. 0xF5 EraseWrite, 0x7E EW Alternate, 0xF1 Write, etc.)
// This happens when the server ignores or drops TN3270E framing and speaks plain 3270 data stream.
if (dataType == 0xF5 || dataType == 0x7E || dataType == 0xF1 || dataType == 0x6F ||
dataType == 0x6E || dataType == 0xF2 || dataType == 0xF6 || dataType == 0x05 ||
dataType == 0x0D || dataType == 0x01 || (data.length >= 2 && (data[0] & 0xFF) == 0x11)) {
dataType == 0x6E || dataType == 0xF2 || dataType == 0xF6 ||
dataType == 0x0D || (data.length >= 2 && (data[0] & 0xFF) == 0x11)) {
log.warning("Received plain 3270 command (0x" + Integer.toHexString(dataType) +
") in TN3270E mode — automatically switching to plain TN3270 mode");
tn3270eNegotiated = false;
@@ -981,28 +1184,56 @@ public class TelnetFSM {
}
}
public void sendTN3270EPositiveResponse(int seqNumber) {
public void processSCSInbound(byte[] data) {
if (data == null) return;
processSCSInbound(data, 0, data.length);
}
public void processSCSInbound(byte[] data, int offset, int length) {
log.fine("Processing SCS inbound data (" + length + " bytes)");
for (SCSInboundListener l : scsListeners) {
try {
l.onSCSDataReceived(data, offset, length);
} catch (Exception e) {
log.log(Level.WARNING, "Error in SCS inbound listener", e);
}
}
}
/**
* Send 5-byte/6-byte TN3270E response packet (DT_RESPONSE = 0x02).
*/
public void sendTn3270eResponse(byte responseFlag, byte responseData, int seq) {
byte[] resp = new byte[EH_SIZE + 1];
resp[0] = (byte) DT_RESPONSE;
resp[1] = 0;
resp[2] = (byte) RSF_POSITIVE_RESPONSE;
resp[3] = (byte) ((seqNumber >> 8) & 0xFF);
resp[4] = (byte) (seqNumber & 0xFF);
resp[5] = (byte) POS_DEVICE_END;
resp[2] = responseFlag;
resp[3] = (byte) ((seq >> 8) & 0xFF);
resp[4] = (byte) (seq & 0xFF);
resp[5] = responseData;
sendRecord(resp);
}
public void sendTN3270ENegativeResponse(int seqNumber, int negCode) {
byte[] resp = new byte[EH_SIZE + 1];
resp[0] = (byte) DT_RESPONSE;
resp[1] = 0;
resp[2] = (byte) RSF_NEGATIVE_RESPONSE;
resp[3] = (byte) ((seqNumber >> 8) & 0xFF);
resp[4] = (byte) (seqNumber & 0xFF);
resp[5] = (byte) (negCode & 0xFF);
/**
* HoD 5-byte send_response compatible signature (com.ibm.eNetwork.ECL.tn3270.Telnet3270E).
*/
public void send_response(short s, short s2, int n) {
byte[] byArray = new byte[5];
byArray[0] = (byte) DT_RESPONSE;
byArray[1] = (byte) s;
byArray[2] = (byte) s2;
byArray[3] = (byte) ((n >> 8) & 0xFF);
byArray[4] = (byte) (n & 0xFF);
sendRecord(byArray);
}
sendRecord(resp);
public void sendTN3270EPositiveResponse(int seqNumber) {
sendTn3270eResponse((byte) RSF_POSITIVE_RESPONSE, (byte) POS_DEVICE_END, seqNumber);
}
public void sendTN3270ENegativeResponse(int seqNumber, int negCode) {
sendTn3270eResponse((byte) RSF_NEGATIVE_RESPONSE, (byte) (negCode & 0xFF), seqNumber);
}
public void sendTN3270ESnaSenseResponse(int seqNumber, int sense1, int sense2) {
@@ -1021,7 +1252,11 @@ public class TelnetFSM {
// ========== Check if we should transition to 3270 mode ==========
private void checkIn3270() {
if (connectionState != ConnectionState.TELNET_PENDING) return;
if (connectionState != ConnectionState.TELNET_PENDING &&
connectionState != ConnectionState.CONNECTED_NVT &&
connectionState != ConnectionState.CONNECTED_NVT_CHAR) {
return;
}
// For TN3270E, we wait for TN3270E negotiation to complete
if (config.isTn3270eEnabled() && myOpts[TELOPT_TN3270E] && hisOpts[TELOPT_TN3270E]) {
@@ -1032,7 +1267,11 @@ public class TelnetFSM {
if (myOpts[TELOPT_BINARY] && hisOpts[TELOPT_BINARY] &&
myOpts[TELOPT_EOR] && hisOpts[TELOPT_EOR]) {
log.info("Transitioning to plain TN3270 mode");
changeState(ConnectionState.CONNECTED_3270);
if (connectionState != ConnectionState.CONNECTED_3270) {
screenBuffer.erase(false);
changeState(ConnectionState.CONNECTED_3270);
notifyScreenUpdate();
}
}
}
@@ -1130,6 +1369,32 @@ public class TelnetFSM {
}
}
/**
* Send NVT data record (with TN3270E header and EOR framing if in TN3270E mode, or raw bytes).
*/
public void sendNvtData(byte[] data) {
if (data == null || data.length == 0) return;
if (tn3270eNegotiated && (connectionState == ConnectionState.CONNECTED_E_NVT || tn3270eSubmode == TN3270ESubmode.E_NVT)) {
ByteArrayOutputStream out = new ByteArrayOutputStream(data.length + EH_SIZE);
out.write(DT_NVT_DATA);
out.write(0);
out.write(0);
out.write((eXmitSeq >> 8) & 0xFF);
out.write(eXmitSeq & 0xFF);
eXmitSeq = (eXmitSeq + 1) & 0xFFFF;
for (byte b : data) {
out.write(b & 0xFF);
}
sendRecord(out.toByteArray());
} else {
sendBytes(data);
if (config != null && config.isNvtLocalEcho()) {
nvtProcessor.processNVTData(data, 0, data.length);
}
}
}
private void sendBytes(byte[] data) {
try {
connection.sendRaw(data);
@@ -1170,6 +1435,7 @@ public class TelnetFSM {
public boolean isTn3270eNegotiated() { return tn3270eNegotiated; }
public String getConnectedLu() { return connectedLu; }
public String getConnectedType() { return connectedType; }
public TN3270ESubmode getTn3270eSubmode() { return tn3270eSubmode; }
// ========== SNA BIND, UNBIND, BID, DFC Handlers (Phase 2) ==========
@@ -1434,6 +1700,10 @@ public class TelnetFSM {
return tn3270eBound;
}
public void processSysReq() {
handleSysReq();
}
public void handleSysReq() {
if (tn3270eNegotiated) {
byte[] ao = new byte[] { (byte) IAC, (byte) AO };
@@ -114,4 +114,14 @@ public class ECLPSTest implements ECLConstants {
int count = ps.pasteLineWrap("ABC\nDEF", 1, 80, false);
assertEquals(6, count);
}
@Test
public void testNVTModeAndSendKeys() {
assertFalse(ps.isNVTmode());
ps.setNVTmode(true);
assertTrue(ps.isNVTmode());
ps.setNVTmode(false);
assertFalse(ps.isNVTmode());
}
}
@@ -453,17 +453,15 @@ public class InputProcessorTest {
}
@Test
public void testSendAidWhenGraphicsCursorActiveFraming() {
public void testSendAidAlwaysSendsStandard3270StreamEvenIfGraphicCursorActive() {
screen.erase(false);
screen.setCellFA(0, (byte) (FA_PRINTABLE | FA_MODIFY));
screen.getCell(1).ec = (byte) 0xC1; // 'A'
screen.setCellFA(5, (byte) (FA_PRINTABLE | FA_PROTECT));
screen.setCursorAddress(2);
haus.nightmare.lib3270j.graphics.GraphicsPlane plane = new haus.nightmare.lib3270j.graphics.GraphicsPlane(800, 600);
haus.nightmare.lib3270j.graphics.GocaDecoder goca = new haus.nightmare.lib3270j.graphics.GocaDecoder(plane);
goca.setGraphicsCursorActive(true);
goca.setGraphicCursorPosition(150, -80);
java.util.concurrent.atomic.AtomicReference<byte[]> sent = new java.util.concurrent.atomic.AtomicReference<>();
InputProcessor input = new InputProcessor(screen, translator, null) {
@@ -478,64 +476,10 @@ public class InputProcessorTest {
byte[] result = sent.get();
assertNotNull(result);
// Total expected length:
// 1 (AID_SF 0x88) + 56 (SF) + 1 (AID_ENTER 0x7D) + 2 (Cursor Addr) + 1 (SBA) + 2 (Field Addr) + 1 (Data 'A') = 64 bytes
assertEquals(64, result.length);
assertEquals((byte) AID_SF, result[0]);
// SF length = 52 (0x00 0x34) per IBM HOD / GOCA specification
assertEquals(0x00, result[1]);
assertEquals(0x34, result[2]);
// SF ID = 0x0F0F
assertEquals(0x0F, result[3]);
assertEquals(0x0F, result[4]);
// Coordinates in SF at index 1 + 24 = 25
int gx = (result[25] << 8) | (result[26] & 0xFF);
int gy = (result[27] << 8) | (result[28] & 0xFF);
assertEquals(150, (short) gx);
assertEquals(-80, (short) gy);
// Keyboard constants at index 1 + 31 = 32 and 1 + 33 = 34
assertEquals(0x07, result[32]);
assertEquals(0x07, result[34]);
assertEquals((byte) 0xFF, result[35]);
assertEquals((byte) AID_ENTER, result[36]);
// Trailing AID at index 57
assertEquals((byte) AID_ENTER, result[57]);
// Trailing SBA at 60
assertEquals((byte) ORDER_SBA, result[60]);
// Trailing field content 'A' at 63
assertEquals((byte) 0xC1, result[63]);
}
@Test
public void testSendAidPAWhenGraphicsCursorActiveFraming() {
screen.erase(false);
screen.setCellFA(0, (byte) (FA_PRINTABLE | FA_MODIFY));
screen.getCell(1).ec = (byte) 0xC1;
screen.setCursorAddress(2);
haus.nightmare.lib3270j.graphics.GraphicsPlane plane = new haus.nightmare.lib3270j.graphics.GraphicsPlane(800, 600);
haus.nightmare.lib3270j.graphics.GocaDecoder goca = new haus.nightmare.lib3270j.graphics.GocaDecoder(plane);
goca.setGraphicsCursorActive(true);
goca.setGraphicCursorPosition(100, 200);
java.util.concurrent.atomic.AtomicReference<byte[]> sent = new java.util.concurrent.atomic.AtomicReference<>();
InputProcessor input = new InputProcessor(screen, translator, null) {
@Override
protected void sendAidResponse(byte[] data) {
sent.set(data);
}
};
input.setGocaDecoder(goca);
input.sendAid(AID_PA1);
byte[] result = sent.get();
assertNotNull(result);
// 1 (AID_SF 0x88) + 56 (SF) + 1 (AID_PA1 0x6C) + 2 (Cursor Addr) = 60 bytes (no modified field data)
assertEquals(60, result.length);
assertEquals((byte) AID_SF, result[0]);
assertEquals((byte) AID_PA1, result[36]); // Keyboard AID in SF
assertEquals((byte) AID_PA1, result[57]); // Trailing AID
// Standard 3270 stream: AID_ENTER (1) + Cursor Addr (2) + SBA (1) + Field Addr (2) + Data 'A' (1) = 7 bytes
assertEquals(7, result.length);
assertEquals((byte) AID_ENTER, result[0]);
assertEquals((byte) ORDER_SBA, result[3]);
assertEquals((byte) 0xC1, result[6]);
}
}
@@ -138,4 +138,125 @@ public class NvtProcessorTest {
assertEquals('\r', (char) sent[5]);
assertEquals('\n', (char) sent[6]);
}
@Test
public void testCursorPositionReportDSR() {
// Move to row 12, col 34 (0-indexed: row 11, col 33)
byte[] move = "\u001B[12;34H".getBytes();
processor.processNVTData(move, 0, move.length);
output.reset();
// Send DSR Cursor Position Request: ESC [ 6 n
byte[] dsr = "\u001B[6n".getBytes();
processor.processNVTData(dsr, 0, dsr.length);
assertEquals("\u001B[12;34R", output.toString(java.nio.charset.StandardCharsets.US_ASCII));
}
@Test
public void testDeviceAttributesDA() {
output.reset();
// Send DA Request: ESC [ c
byte[] da1 = "\u001B[c".getBytes();
processor.processNVTData(da1, 0, da1.length);
assertEquals("\u001B[?1;2c", output.toString(java.nio.charset.StandardCharsets.US_ASCII));
output.reset();
// Send DA Request: ESC [ 0 c
byte[] da2 = "\u001B[0c".getBytes();
processor.processNVTData(da2, 0, da2.length);
assertEquals("\u001B[?1;2c", output.toString(java.nio.charset.StandardCharsets.US_ASCII));
}
@Test
public void testExtendedAnsiSequences() {
// CHA: Cursor Horizontal Absolute -> ESC [ 20 G (moves to col 20 -> 0-indexed col 19)
byte[] cha = "\u001B[20G".getBytes();
processor.processNVTData(cha, 0, cha.length);
assertEquals(19, screen.getCursorAddress());
// CNL: Cursor Next Line -> ESC [ 2 E (down 2 rows, col 0)
byte[] cnl = "\u001B[2E".getBytes();
processor.processNVTData(cnl, 0, cnl.length);
assertEquals(2 * 80, screen.getCursorAddress());
// CPL: Cursor Previous Line -> ESC [ 1 F (up 1 row, col 0)
byte[] cpl = "\u001B[1F".getBytes();
processor.processNVTData(cpl, 0, cpl.length);
assertEquals(1 * 80, screen.getCursorAddress());
// Write line "ABCDE"
byte[] text = "\u001B[1;1HABCDE".getBytes();
processor.processNVTData(text, 0, text.length);
assertEquals('A', screen.getCell(0).ucs4);
assertEquals('E', screen.getCell(4).ucs4);
// Move to pos 2 ('C') and ECH (Erase Character): ESC [ 2 X
byte[] ech = "\u001B[1;3H\u001B[2X".getBytes();
processor.processNVTData(ech, 0, ech.length);
assertEquals('A', screen.getCell(0).ucs4);
assertEquals('B', screen.getCell(1).ucs4);
assertEquals(' ', screen.getCell(2).ucs4);
assertEquals(' ', screen.getCell(3).ucs4);
assertEquals('E', screen.getCell(4).ucs4);
// DCH: Delete Character at pos 0 -> ESC [ 1;1H ESC [ 1 P
byte[] dch = "\u001B[1;1H\u001B[1P".getBytes();
processor.processNVTData(dch, 0, dch.length);
assertEquals('B', screen.getCell(0).ucs4);
// ICH: Insert Character at pos 0 -> ESC [ 1;1H ESC [ 1 @
byte[] ich = "\u001B[1;1H\u001B[1@".getBytes();
processor.processNVTData(ich, 0, ich.length);
assertEquals(' ', screen.getCell(0).ucs4);
assertEquals('B', screen.getCell(1).ucs4);
}
@Test
public void testScrollingRegionAndSpecialMovement() {
// Set scrolling region lines 2..4 (1-indexed): ESC [ 2 ; 4 r
byte[] decstbm = "\u001B[2;4r".getBytes();
processor.processNVTData(decstbm, 0, decstbm.length);
assertEquals(0, screen.getCursorAddress());
// Move to row 4 (bottom of region), col 1
byte[] line4 = "\u001B[4;1HLine4".getBytes();
processor.processNVTData(line4, 0, line4.length);
assertEquals('L', screen.getCell(3 * 80).ucs4);
// Index (line feed down at bottom of region): ESC D -> scrolls region up
byte[] ind = "\u001BD".getBytes();
processor.processNVTData(ind, 0, ind.length);
assertEquals('L', screen.getCell(2 * 80).ucs4); // Moved to row 3
// Reverse Index (line feed up at top of region): ESC [ 2;1H ESC M -> scrolls region down
byte[] ri = "\u001B[2;1H\u001BM".getBytes();
processor.processNVTData(ri, 0, ri.length);
assertEquals('L', screen.getCell(3 * 80).ucs4); // Moved back down to row 4
}
@Test
public void testCursorVisibilityAndVt100Graphics() {
assertTrue(processor.isCursorVisible());
// Hide cursor: ESC [ ? 25 l
byte[] hide = "\u001B[?25l".getBytes();
processor.processNVTData(hide, 0, hide.length);
assertFalse(processor.isCursorVisible());
// Show cursor: ESC [ ? 25 h
byte[] show = "\u001B[?25h".getBytes();
processor.processNVTData(show, 0, show.length);
assertTrue(processor.isCursorVisible());
// Enable VT100 line drawing on G0: ESC ( 0
byte[] g0 = "\u001B(0q".getBytes();
processor.processNVTData(g0, 0, g0.length);
assertEquals('─', screen.getCell(0).ucs4);
// Reset G0 to ASCII: ESC ( B
byte[] g0Ascii = "\u001B(Bq".getBytes();
processor.processNVTData(g0Ascii, 0, g0Ascii.length);
assertEquals('q', screen.getCell(1).ucs4);
}
}
@@ -0,0 +1,244 @@
package haus.nightmare.lib3270j.telnet;
import org.junit.jupiter.api.Test;
import haus.nightmare.lib3270j.ConnectionConfig;
import haus.nightmare.lib3270j.TerminalModel;
import java.io.*;
import java.net.*;
import java.nio.charset.StandardCharsets;
import java.util.concurrent.*;
import static org.junit.jupiter.api.Assertions.*;
public class ProxyConnectionTest {
@Test
public void testParseHostStringWithProxyFlags() {
// HTTP proxy without auth
ConnectionConfig c1 = ConnectionConfig.parseHostString("--proxy=http://proxy.corp.com:8080 mainframe.net:23", 23, TerminalModel.IBM_3279_4);
assertEquals(ConnectionConfig.ProxyType.HTTP, c1.getProxyType());
assertEquals("proxy.corp.com", c1.getProxyHost());
assertEquals(8080, c1.getProxyPort());
assertNull(c1.getProxyUsername());
assertEquals("mainframe.net", c1.getHost());
assertEquals(23, c1.getPort());
// HTTP proxy with auth
ConnectionConfig c2 = ConnectionConfig.parseHostString("--proxy=http://alice:secret123@10.0.0.1:3128 zos.ibm.com:2323", 23, TerminalModel.IBM_3279_4);
assertEquals(ConnectionConfig.ProxyType.HTTP, c2.getProxyType());
assertEquals("10.0.0.1", c2.getProxyHost());
assertEquals(3128, c2.getProxyPort());
assertEquals("alice", c2.getProxyUsername());
assertEquals("secret123", c2.getProxyPassword());
assertEquals("zos.ibm.com", c2.getHost());
assertEquals(2323, c2.getPort());
// SOCKS4 proxy
ConnectionConfig c3 = ConnectionConfig.parseHostString("--proxy=socks4://socks.local:1080 L:secure.mvs.com:992", 23, TerminalModel.IBM_3279_4);
assertEquals(ConnectionConfig.ProxyType.SOCKS4, c3.getProxyType());
assertEquals("socks.local", c3.getProxyHost());
assertEquals(1080, c3.getProxyPort());
assertTrue(c3.isUseTls());
assertEquals("secure.mvs.com", c3.getHost());
assertEquals(992, c3.getPort());
// SOCKS5 proxy with auth
ConnectionConfig c4 = ConnectionConfig.parseHostString("--proxy=socks5://bob:pass55@127.0.0.1:9050 P:vm.host:23", 23, TerminalModel.IBM_3279_4);
assertEquals(ConnectionConfig.ProxyType.SOCKS5, c4.getProxyType());
assertEquals("127.0.0.1", c4.getProxyHost());
assertEquals(9050, c4.getProxyPort());
assertEquals("bob", c4.getProxyUsername());
assertEquals("pass55", c4.getProxyPassword());
assertFalse(c4.isTn3270eEnabled());
assertEquals("vm.host", c4.getHost());
}
@Test
public void testHttpProxyConnectHandshake() throws Exception {
try (ServerSocket proxyServer = new ServerSocket(0)) {
int proxyPort = proxyServer.getLocalPort();
CountDownLatch serverHandshakeDone = new CountDownLatch(1);
CompletableFuture<String> receivedRequest = new CompletableFuture<>();
Thread serverThread = new Thread(() -> {
try (Socket clientSock = proxyServer.accept()) {
BufferedReader reader = new BufferedReader(new InputStreamReader(clientSock.getInputStream(), StandardCharsets.US_ASCII));
StringBuilder req = new StringBuilder();
String line;
while ((line = reader.readLine()) != null) {
if (line.isEmpty()) break;
req.append(line).append("\n");
}
receivedRequest.complete(req.toString());
// Respond 200 Connection established
OutputStream out = clientSock.getOutputStream();
out.write("HTTP/1.1 200 Connection established\r\n\r\n".getBytes(StandardCharsets.US_ASCII));
out.flush();
serverHandshakeDone.countDown();
// Echo back test data
int b;
while ((b = clientSock.getInputStream().read()) != -1) {
out.write(b);
out.flush();
}
} catch (Exception e) {
receivedRequest.completeExceptionally(e);
}
});
serverThread.setDaemon(true);
serverThread.start();
ConnectionConfig config = new ConnectionConfig("target.mainframe.org", 23);
config.setProxy(ConnectionConfig.ProxyType.HTTP, "127.0.0.1", proxyPort, "testuser", "testpass");
config.setConnectTimeoutMs(5000);
TelnetFSM fsm = new TelnetFSM(config, new haus.nightmare.lib3270j.screen.ScreenBuffer(TerminalModel.IBM_3279_4, new haus.nightmare.lib3270j.charset.EbcdicTranslator()), null);
TelnetConnection connection = new TelnetConnection(config, fsm);
connection.connect();
assertTrue(connection.isConnected());
String req = receivedRequest.get(3, TimeUnit.SECONDS);
assertTrue(req.startsWith("CONNECT target.mainframe.org:23 HTTP/1.1"), "Expected CONNECT request");
assertTrue(req.contains("Proxy-Authorization: Basic "), "Expected Basic auth in proxy request");
connection.disconnect();
}
}
@Test
public void testSocks4ProxyHandshake() throws Exception {
try (ServerSocket proxyServer = new ServerSocket(0)) {
int proxyPort = proxyServer.getLocalPort();
CountDownLatch handshakeDone = new CountDownLatch(1);
Thread serverThread = new Thread(() -> {
try (Socket clientSock = proxyServer.accept()) {
InputStream in = clientSock.getInputStream();
OutputStream out = clientSock.getOutputStream();
// Read SOCKS4 request header
byte[] req = new byte[8];
in.read(req);
assertEquals(0x04, req[0]); // SOCKS4
assertEquals(0x01, req[1]); // CONNECT
// Read null-terminated username
ByteArrayOutputStream userBuf = new ByteArrayOutputStream();
int b;
while ((b = in.read()) != 0 && b != -1) {
userBuf.write(b);
}
assertEquals("user4", new String(userBuf.toByteArray()));
// Reply 0x00 0x5A (Request granted)
byte[] resp = new byte[] { 0x00, 0x5A, 0x00, 0x17, 127, 0, 0, 1 };
out.write(resp);
out.flush();
handshakeDone.countDown();
while (in.read() != -1) {}
} catch (Exception ignored) {}
});
serverThread.setDaemon(true);
serverThread.start();
ConnectionConfig config = new ConnectionConfig("127.0.0.1", 23);
config.setProxy(ConnectionConfig.ProxyType.SOCKS4, "127.0.0.1", proxyPort, "user4", null);
config.setConnectTimeoutMs(5000);
TelnetFSM fsm = new TelnetFSM(config, new haus.nightmare.lib3270j.screen.ScreenBuffer(TerminalModel.IBM_3279_4, new haus.nightmare.lib3270j.charset.EbcdicTranslator()), null);
TelnetConnection connection = new TelnetConnection(config, fsm);
connection.connect();
assertTrue(connection.isConnected());
assertTrue(handshakeDone.await(3, TimeUnit.SECONDS));
connection.disconnect();
}
}
@Test
public void testSocks5ProxyHandshakeWithAuth() throws Exception {
try (ServerSocket proxyServer = new ServerSocket(0)) {
int proxyPort = proxyServer.getLocalPort();
CountDownLatch handshakeDone = new CountDownLatch(1);
Thread serverThread = new Thread(() -> {
try (Socket clientSock = proxyServer.accept()) {
InputStream in = clientSock.getInputStream();
OutputStream out = clientSock.getOutputStream();
// 1. Read method selection
int ver = in.read();
int nmethods = in.read();
byte[] methods = new byte[nmethods];
in.read(methods);
assertEquals(0x05, ver);
// Select USER_PASS (0x02)
out.write(new byte[] { 0x05, 0x02 });
out.flush();
// 2. Read auth request (RFC 1929)
int authVer = in.read();
int ulen = in.read();
byte[] u = new byte[ulen];
in.read(u);
int plen = in.read();
byte[] p = new byte[plen];
in.read(p);
assertEquals(1, authVer);
assertEquals("admin", new String(u));
assertEquals("pass123", new String(p));
// Auth success: 0x01 0x00
out.write(new byte[] { 0x01, 0x00 });
out.flush();
// 3. Read connect command
byte[] cmd = new byte[4];
in.read(cmd);
assertEquals(0x05, cmd[0]);
assertEquals(0x01, cmd[1]); // CONNECT
int atyp = cmd[3];
if (atyp == 0x03) { // Domain name
int dlen = in.read();
byte[] d = new byte[dlen];
in.read(d);
} else if (atyp == 0x01) { // IPv4
in.read(new byte[4]);
}
in.read(new byte[2]); // Port
// Reply success: 0x05 0x00 0x00 0x01 127.0.0.1:port
out.write(new byte[] { 0x05, 0x00, 0x00, 0x01, 127, 0, 0, 1, 0, 23 });
out.flush();
handshakeDone.countDown();
while (in.read() != -1) {}
} catch (Exception ignored) {}
});
serverThread.setDaemon(true);
serverThread.start();
ConnectionConfig config = new ConnectionConfig("mvs.corp.local", 23);
config.setProxy(ConnectionConfig.ProxyType.SOCKS5, "127.0.0.1", proxyPort, "admin", "pass123");
config.setConnectTimeoutMs(5000);
TelnetFSM fsm = new TelnetFSM(config, new haus.nightmare.lib3270j.screen.ScreenBuffer(TerminalModel.IBM_3279_4, new haus.nightmare.lib3270j.charset.EbcdicTranslator()), null);
TelnetConnection connection = new TelnetConnection(config, fsm);
connection.connect();
assertTrue(connection.isConnected());
assertTrue(handshakeDone.await(3, TimeUnit.SECONDS));
connection.disconnect();
}
}
}
@@ -0,0 +1,346 @@
package haus.nightmare.lib3270j.telnet;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import haus.nightmare.lib3270j.ConnectionConfig;
import haus.nightmare.lib3270j.ConnectionState;
import haus.nightmare.lib3270j.TerminalModel;
import haus.nightmare.lib3270j.charset.EbcdicTranslator;
import haus.nightmare.lib3270j.datastream.DataStreamProcessor;
import haus.nightmare.lib3270j.input.InputProcessor;
import haus.nightmare.lib3270j.listener.SCSInboundListener;
import haus.nightmare.lib3270j.protocol.TelnetConstants;
import haus.nightmare.lib3270j.protocol.TN3270EConstants;
import haus.nightmare.lib3270j.screen.ScreenBuffer;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import java.util.Properties;
import static org.junit.jupiter.api.Assertions.*;
import static haus.nightmare.lib3270j.protocol.TelnetConstants.*;
import static haus.nightmare.lib3270j.protocol.TN3270EConstants.*;
public class TelnetFSMPhase1FullTest {
private ConnectionConfig config;
private ScreenBuffer screenBuffer;
private DataStreamProcessor dsProcessor;
private InputProcessor inputProcessor;
private TelnetFSM fsm;
private MockConnection connection;
private EbcdicTranslator translator;
private static class MockConnection extends TelnetConnection {
final List<byte[]> sentData = new ArrayList<>();
boolean tlsUpgraded = false;
MockConnection(ConnectionConfig config, TelnetFSM fsm) {
super(config, fsm);
}
@Override
public synchronized void sendRaw(byte[] data) {
sentData.add(data.clone());
}
@Override
public synchronized void sendRaw(byte[] data, int offset, int length) {
byte[] b = new byte[length];
System.arraycopy(data, offset, b, 0, length);
sentData.add(b);
}
@Override
public synchronized void upgradeToTls() throws IOException {
tlsUpgraded = true;
}
}
@BeforeEach
public void setup() {
translator = new EbcdicTranslator();
config = new ConnectionConfig("localhost", 23, TerminalModel.IBM_3279_4);
screenBuffer = new ScreenBuffer(TerminalModel.IBM_3279_4, translator);
dsProcessor = new DataStreamProcessor(screenBuffer, translator);
fsm = new TelnetFSM(config, screenBuffer, dsProcessor);
inputProcessor = new InputProcessor(screenBuffer, translator, fsm);
dsProcessor.setInputProcessor(inputProcessor);
connection = new MockConnection(config, fsm);
fsm.setConnection(connection);
}
private void feedBytes(int... bytes) {
for (int b : bytes) {
fsm.feedByte(b & 0xFF);
}
}
@Test
public void testSendTn3270eResponseAndHoD5ByteSignature() {
connection.sentData.clear();
// 1. Standard 6-byte RFC 2355 response
fsm.sendTn3270eResponse((byte) RSF_POSITIVE_RESPONSE, (byte) POS_DEVICE_END, 0x1234);
assertEquals(1, connection.sentData.size());
byte[] resp6 = connection.sentData.get(0);
assertEquals(6 + 2, resp6.length); // 6 bytes header/trailer + IAC EOR
assertEquals(DT_RESPONSE, resp6[0] & 0xFF);
assertEquals(0, resp6[1] & 0xFF);
assertEquals(RSF_POSITIVE_RESPONSE, resp6[2] & 0xFF);
assertEquals(0x12, resp6[3] & 0xFF);
assertEquals(0x34, resp6[4] & 0xFF);
assertEquals(POS_DEVICE_END, resp6[5] & 0xFF);
assertEquals(IAC, resp6[6] & 0xFF);
assertEquals(EOR, resp6[7] & 0xFF);
// 2. HoD 5-byte send_response
connection.sentData.clear();
fsm.send_response((short) 0, (short) RSF_POSITIVE_RESPONSE, 0x5678);
assertEquals(1, connection.sentData.size());
byte[] resp5 = connection.sentData.get(0);
assertEquals(5 + 2, resp5.length); // 5 bytes + IAC EOR
assertEquals(DT_RESPONSE, resp5[0] & 0xFF);
assertEquals(0, resp5[1] & 0xFF);
assertEquals(RSF_POSITIVE_RESPONSE, resp5[2] & 0xFF);
assertEquals(0x56, resp5[3] & 0xFF);
assertEquals(0x78, resp5[4] & 0xFF);
}
@Test
public void testStartTlsOption46NegotiationAndElevation() {
config.setStartTlsEnabled(true);
fsm.onConnected();
connection.sentData.clear();
// Server sends DO STARTTLS (Option 46)
feedBytes(IAC, DO, TELOPT_STARTTLS);
// Client must reply WILL STARTTLS
assertEquals(1, connection.sentData.size());
assertArrayEquals(new byte[] { (byte) IAC, (byte) WILL, (byte) TELOPT_STARTTLS }, connection.sentData.get(0));
assertTrue(fsm.getMyOpts()[TELOPT_STARTTLS]);
connection.sentData.clear();
// Server sends SB STARTTLS 1 (TLS_FOLLOWS) SE
feedBytes(IAC, SB, TELOPT_STARTTLS, TLS_FOLLOWS, IAC, SE);
// Connection must have been upgraded to TLS
assertTrue(connection.tlsUpgraded, "Expected upgradeToTls() to be invoked on STARTTLS_FOLLOWS");
}
@Test
public void testRfc1572NewEnvironEmptySendReturnsAllVariables() {
config.setEnvironmentVariable("USER", "MAINFRAME_USER");
config.setEnvironmentVariable("SYSTEMTYPE", "MVS");
config.setUserVariable("IBM_EXPRESS_LOGON", "CERT_AUTH_ENABLED");
fsm.onConnected();
connection.sentData.clear();
// Server sends DO NEW_ENVIRON
feedBytes(IAC, DO, TELOPT_NEW_ENVIRON);
assertEquals(1, connection.sentData.size());
assertArrayEquals(new byte[] { (byte) IAC, (byte) WILL, (byte) TELOPT_NEW_ENVIRON }, connection.sentData.get(0));
connection.sentData.clear();
// Server sends SB NEW-ENVIRON SEND SE (empty query -> all variables)
feedBytes(IAC, SB, TELOPT_NEW_ENVIRON, TELQUAL_SEND, IAC, SE);
assertEquals(1, connection.sentData.size());
byte[] pkt = connection.sentData.get(0);
assertTrue(pkt.length >= 6);
assertEquals(IAC, pkt[0] & 0xFF);
assertEquals(SB, pkt[1] & 0xFF);
assertEquals(TELOPT_NEW_ENVIRON, pkt[2] & 0xFF);
assertEquals(TELQUAL_IS, pkt[3] & 0xFF);
String pktStr = new String(pkt);
assertTrue(pktStr.contains("USER"));
assertTrue(pktStr.contains("MAINFRAME_USER"));
assertTrue(pktStr.contains("SYSTEMTYPE"));
assertTrue(pktStr.contains("MVS"));
assertTrue(pktStr.contains("IBM_EXPRESS_LOGON"));
assertTrue(pktStr.contains("CERT_AUTH_ENABLED"));
}
@Test
public void testRfc1572NewEnvironSelectiveSend() throws Exception {
config.setEnvironmentVariable("USER", "ALICE");
config.setEnvironmentVariable("JOB", "DAILY_REPORT");
config.setUserVariable("SEC_TOKEN", "XYZ123");
fsm.onConnected();
connection.sentData.clear();
// Server sends SB NEW-ENVIRON SEND VAR "USER" USERVAR "SEC_TOKEN" SE
ByteArrayOutputStream query = new ByteArrayOutputStream();
query.write(IAC);
query.write(SB);
query.write(TELOPT_NEW_ENVIRON);
query.write(TELQUAL_SEND);
query.write(TELOBJ_VAR);
query.write("USER".getBytes());
query.write(TELOBJ_USERVAR);
query.write("SEC_TOKEN".getBytes());
query.write(IAC);
query.write(SE);
for (byte b : query.toByteArray()) {
fsm.feedByte(b & 0xFF);
}
assertEquals(1, connection.sentData.size());
String respStr = new String(connection.sentData.get(0));
assertTrue(respStr.contains("USER"));
assertTrue(respStr.contains("ALICE"));
assertTrue(respStr.contains("SEC_TOKEN"));
assertTrue(respStr.contains("XYZ123"));
assertFalse(respStr.contains("JOB"), "Unrequested variable JOB should not be sent");
}
@Test
public void testSendNewEnvironmentVariablesFromProperties() {
Properties props = new Properties();
props.setProperty("VAR_TEST", "VAL1");
props.setProperty("USERVAR_TOKEN", "VAL2");
connection.sentData.clear();
fsm.sendNewEnvironmentVariables(props);
assertEquals(1, connection.sentData.size());
byte[] pkt = connection.sentData.get(0);
assertEquals(IAC, pkt[0] & 0xFF);
assertEquals(SB, pkt[1] & 0xFF);
assertEquals(TELOPT_NEW_ENVIRON, pkt[2] & 0xFF);
assertEquals(TELQUAL_IS, pkt[3] & 0xFF);
String pktStr = new String(pkt);
assertTrue(pktStr.contains("VAR_TEST"));
assertTrue(pktStr.contains("VAL1"));
assertTrue(pktStr.contains("USERVAR_TOKEN"));
assertTrue(pktStr.contains("VAL2"));
}
@Test
public void testInboundSCSDataHandlingAndAutoPositiveResponse() throws Exception {
config.setTn3270eEnabled(true);
fsm.onConnected();
// Negotiate TN3270E with RESPONSES and SCS_CTL_CODES
feedBytes(IAC, DO, TELOPT_TN3270E);
feedBytes(IAC, SB, TELOPT_TN3270E, OP_SEND, OP_DEVICE_TYPE, IAC, SE);
byte[] devTypeIs = new byte[]{
(byte) IAC, (byte) SB, (byte) TELOPT_TN3270E,
0x02, 0x04, 'I', 'B', 'M', '-', '3', '2', '8', '7', '-', '1',
(byte) IAC, (byte) SE
};
for (byte b : devTypeIs) fsm.feedByte(b & 0xFF);
byte[] funcsReq = new byte[]{
(byte) IAC, (byte) SB, (byte) TELOPT_TN3270E,
(byte) OP_FUNCTIONS, (byte) OP_REQUEST,
(byte) FUNC_BIND_IMAGE, (byte) FUNC_RESPONSES, (byte) FUNC_SCS_CTL_CODES,
(byte) IAC, (byte) SE
};
for (byte b : funcsReq) fsm.feedByte(b & 0xFF);
List<byte[]> scsReceived = new ArrayList<>();
fsm.addSCSInboundListener(new SCSInboundListener() {
@Override
public void onSCSDataReceived(byte[] data, int offset, int length) {
byte[] copy = new byte[length];
System.arraycopy(data, offset, copy, 0, length);
scsReceived.add(copy);
}
});
connection.sentData.clear();
// Send DT_SCS_DATA (0x01) record with ALWAYS_RESPONSE
int seq = 0x00A1;
byte[] scsPayload = new byte[] { 0x15, 0x2B, (byte) 0xD2, 0x04, 0x15 }; // SCS orders
ByteArrayOutputStream scsRecord = new ByteArrayOutputStream();
scsRecord.write(DT_SCS_DATA);
scsRecord.write(0x00); // Request flag
scsRecord.write(RSF_ALWAYS_RESPONSE);
scsRecord.write((seq >> 8) & 0xFF);
scsRecord.write(seq & 0xFF);
scsRecord.write(scsPayload);
scsRecord.write(IAC);
scsRecord.write(EOR);
for (byte b : scsRecord.toByteArray()) {
fsm.feedByte(b & 0xFF);
}
// Verify SCS listener was notified with exact payload
assertEquals(1, scsReceived.size());
assertArrayEquals(scsPayload, scsReceived.get(0));
// Verify positive response was sent
boolean foundPositive = false;
for (byte[] pkt : connection.sentData) {
if (pkt.length >= 8 &&
(pkt[0] & 0xFF) == DT_RESPONSE &&
(pkt[2] & 0xFF) == RSF_POSITIVE_RESPONSE &&
((((pkt[3] & 0xFF) << 8) | (pkt[4] & 0xFF)) == seq) &&
(pkt[5] & 0xFF) == POS_DEVICE_END) {
foundPositive = true;
}
}
assertTrue(foundPositive, "Expected positive response for DT_SCS_DATA with RSF_ALWAYS_RESPONSE");
}
@Test
public void testProcessSysReqMethod() {
config.setTn3270eEnabled(true);
fsm.onConnected();
// Negotiate TN3270E
feedBytes(IAC, DO, TELOPT_TN3270E);
feedBytes(IAC, SB, TELOPT_TN3270E, OP_SEND, OP_DEVICE_TYPE, IAC, SE);
byte[] devTypeIs = new byte[]{
(byte) IAC, (byte) SB, (byte) TELOPT_TN3270E,
0x02, 0x04, 'I', 'B', 'M', '-', '3', '2', '7', '9', '-', '4', '-', 'E',
(byte) IAC, (byte) SE
};
for (byte b : devTypeIs) fsm.feedByte(b & 0xFF);
byte[] funcsReq = new byte[]{
(byte) IAC, (byte) SB, (byte) TELOPT_TN3270E,
(byte) OP_FUNCTIONS, (byte) OP_REQUEST,
(byte) FUNC_BIND_IMAGE, (byte) FUNC_RESPONSES, (byte) FUNC_SYSREQ,
(byte) IAC, (byte) SE
};
for (byte b : funcsReq) fsm.feedByte(b & 0xFF);
// Bind session
byte[] bindPacket = new byte[EH_SIZE + 35];
bindPacket[0] = DT_BIND_IMAGE;
bindPacket[1] = 0; bindPacket[2] = 0; bindPacket[3] = 0; bindPacket[4] = 1;
bindPacket[EH_SIZE + 24] = 0x02;
ByteArrayOutputStream bStream = new ByteArrayOutputStream();
bStream.write(bindPacket, 0, bindPacket.length);
bStream.write(IAC);
bStream.write(EOR);
for (byte b : bStream.toByteArray()) fsm.feedByte(b & 0xFF);
assertEquals(ConnectionState.CONNECTED_TN3270E, fsm.getConnectionState());
connection.sentData.clear();
// Call processSysReq()
fsm.processSysReq();
// State changes to CONNECTED_SSCP and IAC AO sent
assertEquals(ConnectionState.CONNECTED_SSCP, fsm.getConnectionState());
boolean foundAo = false;
for (byte[] pkt : connection.sentData) {
if (pkt.length == 2 && (pkt[0] & 0xFF) == IAC && (pkt[1] & 0xFF) == AO) {
foundAo = true;
}
}
assertTrue(foundAo, "Expected IAC AO out-of-band telnet abort");
}
}
@@ -50,6 +50,9 @@ public class TelnetFSMTest {
screenBuffer = new ScreenBuffer(TerminalModel.IBM_3279_4, new EbcdicTranslator());
dsProcessor = new DataStreamProcessor(screenBuffer, new EbcdicTranslator());
fsm = new TelnetFSM(config, screenBuffer, dsProcessor);
haus.nightmare.lib3270j.input.InputProcessor inputProcessor =
new haus.nightmare.lib3270j.input.InputProcessor(screenBuffer, new EbcdicTranslator(), fsm);
dsProcessor.setInputProcessor(inputProcessor);
connection = new MockConnection(config, fsm);
fsm.setConnection(connection);
}
@@ -294,4 +297,212 @@ public class TelnetFSMTest {
String resp3 = new String(lastPkt, 4, lastPkt.length - 6);
assertEquals("IBM-3278-4-E", resp3);
}
@Test
public void testTn3270eNvtInboundAndOutbound() throws Exception {
fsm.onConnected();
connection.sentData.clear();
// Negotiate TN3270E without BIND-IMAGE
feedBytes(TelnetConstants.IAC, TelnetConstants.DO, TelnetConstants.TELOPT_TN3270E);
feedBytes(TelnetConstants.IAC, TelnetConstants.WILL, TelnetConstants.TELOPT_TN3270E);
// Host sends DEVICE-TYPE SEND
feedBytes(TelnetConstants.IAC, TelnetConstants.SB, TelnetConstants.TELOPT_TN3270E,
0x08, 0x01, TelnetConstants.IAC, TelnetConstants.SE);
// Host sends DEVICE-TYPE IS IBM-3279-4-E
feedBytes(TelnetConstants.IAC, TelnetConstants.SB, TelnetConstants.TELOPT_TN3270E,
0x02, 0x04, 'I', 'B', 'M', '-', '3', '2', '7', '9', '-', '4', '-', 'E',
TelnetConstants.IAC, TelnetConstants.SE);
// Host sends FUNCTIONS IS (no BIND-IMAGE)
feedBytes(TelnetConstants.IAC, TelnetConstants.SB, TelnetConstants.TELOPT_TN3270E,
0x03, 0x04, TelnetConstants.IAC, TelnetConstants.SE);
assertEquals(ConnectionState.CONNECTED_TN3270E, fsm.getConnectionState());
// Host sends DT_NVT_DATA (0x05) record with text "PROMPT: "
byte[] nvtRecord = {
0x05, // DT_NVT_DATA
0x00, // request flag
0x00, // response flag
0x00, 0x01, // seq 1
'P', 'R', 'O', 'M', 'P', 'T', ':', ' ',
(byte) TelnetConstants.IAC, (byte) TelnetConstants.EOR
};
for (byte b : nvtRecord) {
fsm.feedByte(b & 0xFF);
}
// FSM must transition to CONNECTED_E_NVT
assertEquals(ConnectionState.CONNECTED_E_NVT, fsm.getConnectionState());
assertTrue(fsm.getConnectionState().isNvt());
// Verify screen buffer contains the NVT text
assertEquals('P', screenBuffer.getCell(0).ucs4);
assertEquals('R', screenBuffer.getCell(1).ucs4);
assertEquals('O', screenBuffer.getCell(2).ucs4);
assertEquals('M', screenBuffer.getCell(3).ucs4);
assertEquals('P', screenBuffer.getCell(4).ucs4);
assertEquals('T', screenBuffer.getCell(5).ucs4);
assertEquals(':', screenBuffer.getCell(6).ucs4);
assertEquals(' ', screenBuffer.getCell(7).ucs4);
assertEquals(8, screenBuffer.getCursorAddress());
// Test Outbound NVT sending in TN3270E mode
connection.sentData.clear();
fsm.sendNVTString("OK\n");
assertFalse(connection.sentData.isEmpty());
byte[] sentPkt = connection.sentData.get(connection.sentData.size() - 1);
// Sent packet should be TN3270E record: 5-byte header + "OK\r\n" + IAC EOR
assertEquals(5 + 4 + 2, sentPkt.length);
assertEquals(0x05, sentPkt[0]); // DT_NVT_DATA
assertEquals('O', sentPkt[5]);
assertEquals('K', sentPkt[6]);
assertEquals('\r', sentPkt[7]);
assertEquals('\n', sentPkt[8]);
assertEquals((byte) TelnetConstants.IAC, sentPkt[9]);
assertEquals((byte) TelnetConstants.EOR, sentPkt[10]);
}
@Test
public void testPlainTelnetNvtStreamingAndLocalEcho() throws Exception {
fsm.onConnected();
config.setNvtLocalEcho(true);
// Receiving raw bytes in TELNET_PENDING automatically transitions to CONNECTED_NVT
feedBytes('L', 'O', 'G', 'I', 'N', '>');
assertEquals(ConnectionState.CONNECTED_NVT, fsm.getConnectionState());
assertEquals('L', screenBuffer.getCell(0).ucs4);
assertEquals('>', screenBuffer.getCell(5).ucs4);
// Sending NVT data with local echo enabled
connection.sentData.clear();
fsm.sendNVTString("TEST");
// Raw bytes sent over TCP
assertEquals(1, connection.sentData.size());
byte[] sent = connection.sentData.get(0);
assertArrayEquals("TEST".getBytes(), sent);
// Local echo placed "TEST" on screen starting at address 6
assertEquals('T', screenBuffer.getCell(6).ucs4);
assertEquals('E', screenBuffer.getCell(7).ucs4);
assertEquals('S', screenBuffer.getCell(8).ucs4);
assertEquals('T', screenBuffer.getCell(9).ucs4);
assertEquals(10, screenBuffer.getCursorAddress());
}
@Test
public void testVmConmode3270TransitionTn3270e() throws Exception {
fsm.onConnected();
connection.sentData.clear();
// 1. Negotiate TN3270E
feedBytes(TelnetConstants.IAC, TelnetConstants.DO, TelnetConstants.TELOPT_TN3270E);
feedBytes(TelnetConstants.IAC, TelnetConstants.WILL, TelnetConstants.TELOPT_TN3270E);
feedBytes(TelnetConstants.IAC, TelnetConstants.SB, TelnetConstants.TELOPT_TN3270E,
0x08, 0x01, TelnetConstants.IAC, TelnetConstants.SE);
feedBytes(TelnetConstants.IAC, TelnetConstants.SB, TelnetConstants.TELOPT_TN3270E,
0x02, 0x04, 'I', 'B', 'M', '-', '3', '2', '7', '9', '-', '4', '-', 'E',
TelnetConstants.IAC, TelnetConstants.SE);
feedBytes(TelnetConstants.IAC, TelnetConstants.SB, TelnetConstants.TELOPT_TN3270E,
0x03, 0x04, TelnetConstants.IAC, TelnetConstants.SE);
assertEquals(ConnectionState.CONNECTED_TN3270E, fsm.getConnectionState());
// 2. VM console in line-mode sends DT_NVT_DATA (e.g. CP prompt)
byte[] nvtRecord = {
0x05, 0x00, 0x00, 0x00, 0x01,
'C', 'P', ' ', 'R', 'E', 'A', 'D', 'Y', '\r', '\n',
(byte) TelnetConstants.IAC, (byte) TelnetConstants.EOR
};
for (byte b : nvtRecord) fsm.feedByte(b & 0xFF);
assertEquals(ConnectionState.CONNECTED_E_NVT, fsm.getConnectionState());
assertEquals(TelnetFSM.TN3270ESubmode.E_NVT, fsm.getTn3270eSubmode());
// 3. VM executes "cp term conmode 3270" and sends 3270 full screen (DT_3270_DATA)
EbcdicTranslator trans = new EbcdicTranslator();
String banner = "z/VM 3270 FULLSCREEN";
byte[] stream3270 = new byte[5 + 5 + banner.length() + 2];
// 5-byte TN3270E header
stream3270[0] = 0x00; // DT_3270_DATA
stream3270[1] = 0x00;
stream3270[2] = 0x00;
stream3270[3] = 0x00; stream3270[4] = 0x02; // seq 2
// 3270 Orders
stream3270[5] = (byte) 0xF5; // EraseWrite
stream3270[6] = (byte) 0xC3; // WCC
stream3270[7] = 0x11; // SBA
stream3270[8] = 0x40; stream3270[9] = 0x40; // pos 0
for (int i = 0; i < banner.length(); i++) {
stream3270[10 + i] = (byte) trans.unicodeToEbcdic(banner.charAt(i));
}
stream3270[stream3270.length - 2] = (byte) TelnetConstants.IAC;
stream3270[stream3270.length - 1] = (byte) TelnetConstants.EOR;
for (byte b : stream3270) fsm.feedByte(b & 0xFF);
// Verify transition from E_NVT to E_3270 (CONNECTED_TN3270E)
assertEquals(ConnectionState.CONNECTED_TN3270E, fsm.getConnectionState());
assertEquals(TelnetFSM.TN3270ESubmode.E_3270, fsm.getTn3270eSubmode());
assertFalse(fsm.getConnectionState().isNvt());
// Verify screen displays 3270 content
assertEquals('z', trans.ebcdicToUnicode(screenBuffer.getCellEC(0)));
assertEquals('/', trans.ebcdicToUnicode(screenBuffer.getCellEC(1)));
assertEquals('V', trans.ebcdicToUnicode(screenBuffer.getCellEC(2)));
assertEquals('M', trans.ebcdicToUnicode(screenBuffer.getCellEC(3)));
// 4. Outbound 3270 AID transmission should now use DT_3270_DATA
connection.sentData.clear();
dsProcessor.getInputProcessor().sendAid(0x7D);
assertFalse(connection.sentData.isEmpty());
byte[] sentAid = connection.sentData.get(connection.sentData.size() - 1);
assertEquals(0x00, sentAid[0]); // DT_3270_DATA header
assertEquals((byte) 0x7D, sentAid[5]); // Enter AID code
}
@Test
public void testVmConmode3270TransitionPlainTelnet() throws Exception {
fsm.onConnected();
config.setTn3270eEnabled(false);
// 1. Connected in line-mode (NVT)
feedBytes('C', 'P', '>');
assertEquals(ConnectionState.CONNECTED_NVT, fsm.getConnectionState());
// 2. VM sends BINARY and EOR options upon CONMODE 3270
feedBytes(TelnetConstants.IAC, TelnetConstants.DO, TelnetConstants.TELOPT_BINARY);
feedBytes(TelnetConstants.IAC, TelnetConstants.WILL, TelnetConstants.TELOPT_BINARY);
feedBytes(TelnetConstants.IAC, TelnetConstants.DO, TelnetConstants.TELOPT_EOR);
feedBytes(TelnetConstants.IAC, TelnetConstants.WILL, TelnetConstants.TELOPT_EOR);
// Verify transition to CONNECTED_3270
assertEquals(ConnectionState.CONNECTED_3270, fsm.getConnectionState());
// 3. VM sends 3270 data stream
EbcdicTranslator trans = new EbcdicTranslator();
String msg = "VM 3270";
byte[] rawStream = new byte[5 + msg.length() + 2];
rawStream[0] = (byte) 0xF5; // EraseWrite
rawStream[1] = (byte) 0xC3; // WCC
rawStream[2] = 0x11; // SBA
rawStream[3] = 0x40; rawStream[4] = 0x40;
for (int i = 0; i < msg.length(); i++) {
rawStream[5 + i] = (byte) trans.unicodeToEbcdic(msg.charAt(i));
}
rawStream[rawStream.length - 2] = (byte) TelnetConstants.IAC;
rawStream[rawStream.length - 1] = (byte) TelnetConstants.EOR;
for (byte b : rawStream) fsm.feedByte(b & 0xFF);
assertEquals('V', trans.ebcdicToUnicode(screenBuffer.getCellEC(0)));
assertEquals('M', trans.ebcdicToUnicode(screenBuffer.getCellEC(1)));
}
}