SSH client: a terminal on another machine (#2)
CI / build (pull_request) Successful in 2m22s
Site / build (pull_request) Successful in 10s

The SSH App opens one session to a shell, over libssh (LibSSH-ESP32 5.10.0)
on mbedTLS. A server is trusted the first time on its fingerprint, and a
changed key is a warning with Cancel selected. The password is typed each
time and kept nowhere; or the device makes itself an Ed25519 key, whose
public half is shown, written to /ssh/id_ed25519.pub and printed by
`ssh status`.

lib/term is the terminal: what a shell, less, top, nano and vim send, with
sixteen colours, scroll regions, the alternate screen and 100 lines of
scrollback. Five text sizes with Ctrl and + or -, from 60x20 to 26x8, told
to the far end. The session goes on when the App is left; SSH shows in the
Status Bar. `ssh user@host` in the Shell opens the App.

Also:
- Keys that aren't characters carry Shift, Ctrl and Alt. The terminal needs
  it, and it makes Ctrl+Fn+up/down in a note and Shift+Tab in Gemini work
  from the real keyboard.
- IRC doesn't try to connect under 60 KB free: started with a session open,
  its TLS handshake took the heap down to 236 bytes.
- libssh's own curve25519 is left out of the build (scripts/libssh_filter.py):
  libsodium's has the same names.

Costs 292 KB of flash and about 50 KB of heap while a session is open; not
started under 75 KB free.

Docs: guide page, how-to, FAQ, home page, Status Bar, SD card folders, the
memory how-to, README, glossary, N1 notes with Q254 to Q266 and the checks.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT
This commit is contained in:
2026-10-08 04:38:36 +02:00
co-authored by Claude Opus 5.5
parent 7223147f26
commit 6b71aace4f
47 changed files with 2801 additions and 20 deletions
+32
View File
@@ -12,7 +12,9 @@
#include "apps/demo_app.h"
#include "apps/note_editor.h"
#include "apps/shell_app.h"
#include "apps/ssh_app.h"
#include "services/net_tools.h"
#include "services/ssh_service.h"
#include "services/vpn_service.h"
#include "services/web_share.h"
#include "apps/gemini_app.h"
@@ -91,6 +93,8 @@ static UpdateService* update;
static DebugConsole* debugConsole;
static VpnService* vpnService; // not in Safe Mode
static NetTools netTools; // ping, nslookup and the rest (issue #90)
static SshService* sshService; // not in Safe Mode (issue #2)
static SshApp* sshApp;
static Notifier* notifier;
static LauncherApp launcher;
static AppManager* apps;
@@ -139,6 +143,7 @@ static StatusInfo currentStatus() {
s.capturing = loraCapture && loraCapture->capturing();
if (vpnService && vpnService->wanted())
s.vpn = vpnService->state() == VpnService::State::Up ? StatusInfo::Vpn::Up : StatusInfo::Vpn::Trying;
s.ssh = sshService && sshService->active();
s.debug = !debugConsole->on() ? StatusInfo::Debug::Off : debugConsole->clientConnected() ? StatusInfo::Debug::Client : StatusInfo::Debug::On;
using WifiState = WifiController::State;
switch (wifi->state()) {
@@ -228,6 +233,9 @@ void setup() {
apps->registerApp({"lora", "LoRa Scanner", false, new LoraScannerApp(*radioService, *loraCapture, settings, *clockService)});
apps->registerApp({"storage", "Storage", false, new StorageApp(*fileOps, *storageService, *clockService, *update, *power, bus, *new WebShare(*storageService, *fileOps, *wifi))});
apps->registerApp({"notes", "Notes", false, new NotesApp(*fileOps, *storageService, *clockService, *power)});
sshService = new SshService(settings);
sshApp = new SshApp(*sshService, settings, *storageService);
apps->registerApp({"ssh", "SSH", false, sshApp});
apps->registerApp({"shell", "Shell", false, new ShellApp(shellRun, shellProbe, shellList, shellCount, helpText(), *apps)});
// Leaving the foreground App makes it save: a note being typed, when the device is powered off.
power->beforePowerOff = []() { apps->home(); };
@@ -680,6 +688,7 @@ static const char* const kHelp =
"ping <host> [count] [size] | nslookup <name> [server] | port <host> <port> | traceroute <host> | cancel is it there, does its name resolve, is its port open, which way; one at a time\n"
"tls <host> [port] | ntp [server] a TLS handshake: who the certificate is for, by whom, until when, and whether this device trusts it; a time server's clock against this one\n"
"ifconfig | arp | netstat the interfaces (Wi-Fi and the VPN), their addresses, the default route and the DNS servers; the neighbours heard; what listens and what is connected\n"
"ssh user@host[:port] | ssh status | ssh stop a terminal on another machine, in the SSH App; the password is asked there, never here\n"
"vpn status | vpn up [seconds] | vpn down | vpn import [path] | vpn forget | vpn auto on|off the WireGuard tunnel (Settings > VPN); import reads /vpn/wg0.conf; with seconds, it goes down by itself\n"
"debug status | debug off [seconds] the Debug Console over Wi-Fi (Settings > Debug Console); with seconds, it comes back\n"
"debug on | debug token <16 to 64 characters> | debug token new (USB serial only) switch it on, set its token\n"
@@ -740,6 +749,28 @@ static void saveScreenshot() {
});
}
// `ssh ...` (issue #2, Q265): the session is the SSH App's; this opens it there.
static void sshCommand(const String& arg) {
if (!sshService) return (void)console.println("ssh: not available in Safe Mode");
SshService& s = *sshService;
if (arg == "status") {
bool live = s.state() != SshService::State::Idle && s.state() != SshService::State::Ended;
if (s.state() == SshService::State::Idle) console.println("ssh: no session");
else console.printf("ssh: %s%s%s\n", s.target().text().c_str(), live ? ", " : ": ", s.state() == SshService::State::Open ? "open" : s.status().c_str());
console.printf("ssh: this device's key: %s\n", s.hasKey() ? s.publicKey().c_str() : "none (the SSH App makes one)");
} else if (arg == "stop") {
s.disconnect();
console.println("ssh: stopped");
} else if (arg.indexOf('@') > 0) {
std::string why = sshApp->connectTo(arg.c_str());
if (!why.empty()) return (void)console.printf("ssh: error %s\n", why.c_str());
if (!apps->open("ssh")) return (void)console.println("ssh: connecting, but Setup is running");
console.printf("ssh: connecting to %s, in the SSH App\n", s.target().text().c_str());
} else {
console.println("ssh: user@host[:port] | status | stop");
}
}
// `vpn ...` (issue #8). Nothing here prints a key.
static void vpnCommand(const String& arg) {
if (!vpnService) return (void)console.println("vpn: not available in Safe Mode");
@@ -874,6 +905,7 @@ static void runCommand(String line, bool fromSerial = false) {
}
if (netTools.command(line.c_str())) return;
if (line == "cancel") netTools.cancel(); // and a copy or a delete, below
if (line == "ssh" || line.startsWith("ssh ")) return sshCommand(line.length() > 4 ? line.substring(4) : String("status"));
if (line == "vpn" || line.startsWith("vpn ")) return vpnCommand(line.length() > 4 ? line.substring(4) : String("status"));
if (line.startsWith("debug ")) return debugCommand(line.substring(6), fromSerial);
if (line == "screenshot" || line.startsWith("screenshot ")) {