B4J Library An SSH library courtesy of MS Copilot - B4JSSH - now the Ferrari version

PREAMBLE

For some time now I have had a B4J app that manages a number of Netonix (any) and Ubiquiti (USW-FLEX) WISP switches that turn on/off a fleet of high end Hikvision cameras in a remote location.

This app has used the SSHJ library which has worked flawlessly.

I have decided to replace the Netonix switches with Planet (WGS-5225-8UP2SV) switches.

Unfortunately I discovered that SSHJ can't communicate with Planet switches, Copilot's explanation follows:

===========================================
Planet Switch SSH Compatibility — Technical Limitation Summary
Overview

Planet‑brand Ethernet switches require an interactive, PTY‑allocated SSH shell for authentication and CLI access. Their SSH server does not support non‑interactive exec channels for login or command execution.
Root Cause
The stock B4J SSHJ library (as distributed for B4J) only exposes non‑interactive exec‑style channels and does not provide a mechanism to open a PTY‑based interactive shell.
Planet switches rely on PTY allocation to present their username/password prompts and to maintain an authenticated session.
Failure Mode
When connecting without a PTY:
  • Planet still emits a Username: prompt
  • The switch rejects all submitted credentials
  • The login state machine resets
  • The session loops indefinitely with repeated Username: prompts
  • No CLI prompt (> or #) is ever reached
This behaviour is consistent across all Planet models that use the same SSH daemon.
Conclusion
Planet switches cannot be automated using the stock B4J SSHJ library because the library lacks PTY‑shell support, which Planet requires for interactive authentication and command execution.

===========================================

After a fruitless search for an existing solution I decided to have a crack at developing an SSH library with the assistance of Copilot.

This was a two-fold exercise - see what I could really do with Copilot and (if lucky) find a solution.

I FOUND IT!!!!!

EDIT - Check out the
Ferrari Version 1.1

EDIT 2 - Check out the enhanced error trapping Version 1.2

EDIT 3 - Check out the massively improved Version 3.0 by pitting Copilot against Claude

EDIT 4 - Check out the
fully self contained Version 3.1 (no external jsch-0.1.55.jar required) courtesy of Claude

EDIT 5 - Check out the
fully self contained Version 3.2 (no external jsch-0.1.55.jar required with pixet's fix)
 
Last edited:

JackKirk

Well-Known Member
Licensed User
Longtime User
Version 3.2 (Fully self contained version - no external jsch-0.1.55.jar required with pixet's fix) - PREAMBLE/CODE/INSTALLATION/EXAMPLE/DOCUMENTATION/ADDITIONAL NOTES

PREAMBLE

This update purportedly handles the problem reported by pixet above.

CODE
B4JSSH.java is changed to:
B4X:
package b4j.ssh;

import anywheresoftware.b4a.BA.ShortName;
import anywheresoftware.b4a.BA.Version;

import com.jcraft.jsch.*;

import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.nio.ByteBuffer;
import java.nio.CharBuffer;
import java.nio.charset.*;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import java.util.Properties;
import java.util.concurrent.LinkedBlockingDeque;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicLong;

/**
 * Minimal deterministic SSH shell wrapper for B4J.
 * Provides a simple, safe, predictable API for interactive SSH automation.
 */
@ShortName("B4JSSH")
@Version(3.2f)
public class B4JSSH {

    // -------------------------------------------------------------------------
    // Fields
    // -------------------------------------------------------------------------

    private JSch jsch;
    private Session session;
    private ChannelShell shell;
    private InputStream in;
    private OutputStream out;

    private final LinkedBlockingDeque<Chunk> queue = new LinkedBlockingDeque<>();

    private final byte[] readerBuf = new byte[4096];
    private final CharsetDecoder decoder =
        StandardCharsets.UTF_8.newDecoder()
            .onMalformedInput(CodingErrorAction.REPORT)
            .onUnmappableCharacter(CodingErrorAction.REPORT);
    private final ByteBuffer decodeBytes = ByteBuffer.allocate(4096 + 3);
    private final CharBuffer decodeChars = CharBuffer.allocate(8192);

    private Thread readerThread;

    private final AtomicLong generation = new AtomicLong(0);

    private static final class Chunk {
        final String data;
        final boolean eof;
        final long gen;

        Chunk(String d, boolean eof, long gen) {
            this.data = d;
            this.eof = eof;
            this.gen = gen;
        }

        static Chunk data(String s, long g) { return new Chunk(s, false, g); }
        static Chunk eof(long g)           { return new Chunk(null, true, g); }
    }

    private static final int INTER_CHUNK_IDLE_MS = 150;

    // -------------------------------------------------------------------------
    // Lifecycle
    // -------------------------------------------------------------------------

    public void Initialize() {
        if (jsch != null)
            throw new SshException(SshException.ErrorKind.INTERNAL,
                "Initialize() called more than once.");
        jsch = new JSch();
    }

    public void Connect(String host, int port, String user, String pass, int timeoutMs) {
        Objects.requireNonNull(host, "host");
        Objects.requireNonNull(user, "user");
        Objects.requireNonNull(pass, "pass");

        if (port <= 0 || port > 65535)
            throw new SshException(SshException.ErrorKind.INTERNAL,
                "Invalid SSH port: " + port);
        if (timeoutMs <= 0)
            throw new SshException(SshException.ErrorKind.INTERNAL,
                "timeoutMs must be > 0. Value=" + timeoutMs);

        if (jsch == null)
            throw new SshException(SshException.ErrorKind.INTERNAL,
                "Connect() called before Initialize().");

        try {
            session = jsch.getSession(user, host, port);
            session.setPassword(pass);

            Properties cfg = new Properties();
            cfg.put("StrictHostKeyChecking", "no");
            cfg.put("PreferredAuthentications", "password");
            session.setConfig(cfg);

            session.connect(timeoutMs);

        } catch (JSchException ex) {
            safeDisconnect();
            throw new SshException(SshException.ErrorKind.CONNECT,
                "SSH connection failed: " + ex.getMessage(), ex);
        }
    }

    public void OpenShell() {
        if (session == null || !session.isConnected())
            throw new SshException(SshException.ErrorKind.INTERNAL,
                "OpenShell() called when session is not connected.");

        try {
            Channel ch = session.openChannel("shell");

            if (!(ch instanceof ChannelShell)) {
                ch.disconnect();
                throw new SshException(SshException.ErrorKind.PROTOCOL,
                    "Expected ChannelShell but got: " + ch.getClass().getName());
            }

            shell = (ChannelShell) ch;
            shell.setPty(true);

            in  = shell.getInputStream();
            out = shell.getOutputStream();

            shell.connect();

        } catch (JSchException | IOException ex) {
            safeCloseShell();
            throw new SshException(SshException.ErrorKind.PROTOCOL,
                "Failed to open shell: " + ex.getMessage(), ex);
        }

        queue.clear();

        long myGen = generation.incrementAndGet();
        if (myGen == Long.MAX_VALUE)
            generation.set(1);

        startReader(myGen);
    }

    public void Disconnect() {
        safeDisconnect();
    }

    // -------------------------------------------------------------------------
    // I/O
    // -------------------------------------------------------------------------

    public void Write(String cmd) {
        Objects.requireNonNull(cmd, "cmd");
        ensureShell();
        try {
            out.write(cmd.getBytes(StandardCharsets.UTF_8));
            out.write('\n');
            out.flush();
        } catch (IOException ex) {
            throw new SshException(SshException.ErrorKind.INTERNAL,
                "Write() I/O error: " + ex.getMessage(), ex);
        }
    }

    public void WriteRaw(String data) {
        Objects.requireNonNull(data, "data");
        ensureShell();
        try {
            out.write(data.getBytes(StandardCharsets.UTF_8));
            out.flush();
        } catch (IOException ex) {
            throw new SshException(SshException.ErrorKind.INTERNAL,
                "WriteRaw() I/O error: " + ex.getMessage(), ex);
        }
    }

    public String ReadWindow(int timeoutMs) {
        ensureShell();
        if (timeoutMs <= 0)
            throw new SshException(SshException.ErrorKind.INTERNAL,
                "timeoutMs must be > 0. Value=" + timeoutMs);

        StringBuilder sb = new StringBuilder(512);
        long myGen = generation.get();
        long deadline = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(timeoutMs);
        boolean gotAny = false;

        while (true) {
            long now = System.nanoTime();
            if (now >= deadline)
                return sb.toString();

            long remMs = TimeUnit.NANOSECONDS.toMillis(deadline - now);
            long pollMs = gotAny ? Math.min(INTER_CHUNK_IDLE_MS, remMs) : remMs;
            if (pollMs < 1) pollMs = 1;

            Chunk c;
            try {
                c = queue.poll(pollMs, TimeUnit.MILLISECONDS);
            } catch (InterruptedException ie) {
                Thread.currentThread().interrupt();
                throw new SshException(SshException.ErrorKind.TIMEOUT,
                    "ReadWindow interrupted. Buffer: [" + sb + "]", ie);
            }

            if (c == null)
                return sb.toString();

            if (c.gen != myGen)
                continue;

            if (c.eof)
                throw new SshException(SshException.ErrorKind.REMOTE_CLOSED,
                    "Remote closed during ReadWindow. Buffer: [" + sb + "]");

            sb.append(c.data);
            gotAny = true;
        }
    }

    public String ReadUntil(String[] prompts, int timeoutMs) {
        ensureShell();

        if (prompts == null || prompts.length == 0)
            throw new SshException(SshException.ErrorKind.INTERNAL,
                "prompts must not be null or empty.");
        if (timeoutMs <= 0)
            throw new SshException(SshException.ErrorKind.INTERNAL,
                "timeoutMs must be > 0. Value=" + timeoutMs);

        List<String> list = new ArrayList<>();
        int maxLen = 0;

        for (String p : prompts) {
            if (p == null || p.isEmpty())
                throw new SshException(SshException.ErrorKind.INTERNAL,
                    "prompts must not contain null or empty strings.");
            list.add(p);
            if (p.length() > maxLen) maxLen = p.length();
        }

        StringBuilder sb = new StringBuilder(512);
        long myGen = generation.get();
        long deadline = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(timeoutMs);

        while (true) {
            long now = System.nanoTime();
            if (now >= deadline)
                throw new SshException(SshException.ErrorKind.TIMEOUT,
                    "ReadUntil timed out. Buffer: [" + sb + "]");

            long remMs = TimeUnit.NANOSECONDS.toMillis(deadline - now);
            if (remMs < 1) remMs = 1;

            Chunk c;
            try {
                c = queue.poll(remMs, TimeUnit.MILLISECONDS);
            } catch (InterruptedException ie) {
                Thread.currentThread().interrupt();
                throw new SshException(SshException.ErrorKind.TIMEOUT,
                    "ReadUntil interrupted. Buffer: [" + sb + "]", ie);
            }

            if (c == null)
                throw new SshException(SshException.ErrorKind.TIMEOUT,
                    "ReadUntil timed out. Buffer: [" + sb + "]");

            if (c.gen != myGen)
                continue;

            if (c.eof)
                throw new SshException(SshException.ErrorKind.REMOTE_CLOSED,
                    "Remote closed during ReadUntil. Buffer: [" + sb + "]");

            int prev = sb.length();
            sb.append(c.data);

            int searchFrom = Math.max(0, prev - (maxLen - 1));
            for (String p : list) {
                if (sb.indexOf(p, searchFrom) >= 0)
                    return sb.toString();
            }
        }
    }

    // -------------------------------------------------------------------------
    // State
    // -------------------------------------------------------------------------

    public boolean IsConnected() {
        return session != null && session.isConnected();
    }

    public boolean IsShellOpen() {
        return shell != null && shell.isConnected();
    }

    // -------------------------------------------------------------------------
    // Helpers
    // -------------------------------------------------------------------------

    private void ensureShell() {
        if (shell == null || !shell.isConnected())
            throw new SshException(SshException.ErrorKind.INTERNAL,
                "Shell not open. Call OpenShell() first.");
    }

    private void safeCloseShell() {
        try {
            if (shell != null)
                shell.disconnect();
        } catch (Exception ignored) {
        } finally {
            shell = null;
        }

        try {
            if (in != null) in.close();
        } catch (Exception ignored) {}
        in = null;

        out = null;

        if (readerThread != null)
            readerThread.interrupt();
        readerThread = null;

        queue.clear();
    }

    private void safeDisconnect() {
        safeCloseShell();

        try {
            if (session != null)
                session.disconnect();
        } catch (Exception ignored) {
        } finally {
            session = null;
        }
    }

    private void startReader(long myGen) {
        decoder.reset();
        decodeBytes.clear();
        decodeChars.clear();

        readerThread = new Thread(() -> {
            while (true) {
                int len;
                try {
                    len = in.read(readerBuf);
                } catch (IOException e) {
                    queue.offer(Chunk.eof(myGen));
                    break;
                }

                if (len < 0) {
                    queue.offer(Chunk.eof(myGen));
                    break;
                }

                try {
                    String s = decodeUtf8(readerBuf, 0, len);
                    if (!s.isEmpty())
                        queue.offer(Chunk.data(s, myGen));
                } catch (Exception ex) {
                    queue.offer(Chunk.eof(myGen));
                    break;
                }
            }
        }, "B4JSSH-Reader");

        readerThread.setDaemon(true);
        readerThread.start();
    }

    private String decodeUtf8(byte[] buf, int off, int len) {
        if (len > decodeBytes.remaining()) {
            throw new SshException(SshException.ErrorKind.INTERNAL,
                "UTF-8 decode overflow: len=" + len +
                " remaining=" + decodeBytes.remaining());
        }

        decodeBytes.put(buf, off, len);
        decodeBytes.flip();

        StringBuilder sb = new StringBuilder(len);

        while (true) {
            CoderResult r = decoder.decode(decodeBytes, decodeChars, false);
            if (r.isError())
                throw new SshException(SshException.ErrorKind.INTERNAL,
                    "Invalid UTF-8 sequence received from remote.");

            decodeChars.flip();
            if (decodeChars.hasRemaining())
                sb.append(decodeChars);
            decodeChars.clear();

            if (r.isUnderflow())
                break;
        }

        decodeBytes.compact();
        return sb.toString();
    }
}

INSTALLATION
Load the attached files into your B4J Additional Libraries folder.

You don't have to bother with jsch-0.1.55.jar. If jsch-0.1.55.jar is in your B4j Additional Libraries folder then delete it.

In your B4J project select the B4JSSH library (make sure it is 3.2)

EXAMPLE

As per this post.

DOCUMENTATION
As per this post.

ADDITIONAL NOTES
-
 

Attachments

  • B4JSSH.jar
    286.5 KB · Views: 10
  • B4JSSH.xml
    7.7 KB · Views: 9
Top