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
+4 -1
View File
@@ -238,7 +238,10 @@ void IrcService::loop() {
retryAtMs_ = now; // reconnect as soon as Wi-Fi is back
} else if (!open_) {
if (static_cast<int32_t>(now - retryAtMs_) >= 0) {
if (!open()) scheduleRetry("could not connect to " + draftConfig().host);
// A secure connection peaks at 52 KB. Under an open SSH session there isn't that much, and
// trying anyway takes the heap down to nothing (seen on the device, issue #2).
if (esp_get_free_heap_size() < kNeedFree) scheduleRetry("not enough memory: close the SSH session");
else if (!open()) scheduleRetry("could not connect to " + draftConfig().host);
}
} else if (!conn_->connected()) {
close("");
+1
View File
@@ -25,6 +25,7 @@ namespace roro {
// session is shared with the IRC App under a lock.
class IrcService : public Service {
public:
static constexpr size_t kNeedFree = 60 * 1024; // free memory a connection attempt wants
enum class Status { Stopped, WaitingForWifi, Connecting, Registering, Online, Paused, Retrying };
IrcService(KeyValueStore& store, const std::string& defaultNick, WifiService& wifi, StorageService& storage,
+365
View File
@@ -0,0 +1,365 @@
#include "services/ssh_service.h"
#include <Arduino.h>
#include <libssh_esp32.h>
#include <libssh/libssh.h>
namespace roro {
namespace {
constexpr uint32_t kStack = 20480; // a session used 13 KB of it at most (measured)
constexpr int kPasswordTries = 3;
struct Locked {
explicit Locked(void* lock) : lock_(static_cast<SemaphoreHandle_t>(lock)) { xSemaphoreTake(lock_, portMAX_DELAY); }
~Locked() { xSemaphoreGive(lock_); }
SemaphoreHandle_t lock_;
};
std::string fingerprintOf(ssh_session s) {
ssh_key key = nullptr;
unsigned char* hash = nullptr;
size_t len = 0;
std::string out;
if (ssh_get_server_publickey(s, &key) == SSH_OK && ssh_get_publickey_hash(key, SSH_PUBLICKEY_HASH_SHA256, &hash, &len) == SSH_OK) {
if (char* text = ssh_get_fingerprint_hash(SSH_PUBLICKEY_HASH_SHA256, hash, len)) {
out = text; // "SHA256:..."
ssh_string_free_char(text);
}
ssh_clean_pubkey_hash(&hash);
}
if (key) ssh_key_free(key);
return out;
}
void startLibrary() {
static bool started = false;
if (!started) libssh_begin();
started = true;
}
} // namespace
struct SshService::Run {
SshService* service;
std::string host, user, privateKey, known;
int port, cols, rows;
};
SshService::SshService(Settings& settings) : settings_(settings), lock_(xSemaphoreCreateMutex()) {}
std::string SshService::status() const {
Locked l(lock_);
return status_;
}
std::string SshService::fingerprint() const {
Locked l(lock_);
return fingerprint_;
}
std::string SshService::remembered() const {
Locked l(lock_);
return remembered_;
}
void SshService::say(const std::string& what) {
Locked l(lock_);
status_ = what;
revision_++;
}
void SshService::end(const std::string& why) {
{
Locked l(lock_);
status_ = why;
password_.assign(password_.size(), '\0');
password_.clear();
outgoing_.clear();
}
state_ = State::Ended;
revision_++;
}
std::string SshService::connect(const term::SshTarget& target, int cols, int rows) {
if (running_) return "A session is still closing: a moment";
if (ESP.getFreeHeap() < kNeedFree) return "Not enough memory: close IRC or a Gemini page";
target_ = target;
{
Locked l(lock_);
term_.reset(new term::Terminal(cols, rows, kHistory));
term_->reply = [this](const std::string& s) { outgoing_ += s; }; // called with the lock held, from feed()
status_ = "Connecting to " + target.host;
fingerprint_.clear();
remembered_.clear();
outgoing_.clear();
resized_ = false;
}
stop_ = answered_ = trusted_ = opened_ = false;
auto* run = new Run{this, target.host, target.user, settings_.getString(Setting::SshKey),
term::SshKnownHosts(settings_.getString(Setting::SshKnown)).fingerprintOf(target.hostPort()), target.port, cols, rows};
state_ = State::Connecting;
running_ = true;
if (xTaskCreate(task, "ssh", kStack, run, 1, nullptr) != pdPASS) {
running_ = false;
delete run;
end("Not enough memory for the session");
return "Not enough memory for the session";
}
return "";
}
void SshService::clear() {
if (state_ != State::Ended || running_) return;
Locked l(lock_);
term_.reset();
status_.clear();
state_ = State::Idle;
}
void SshService::disconnect() {
stop_ = true;
answered_ = true; // whatever it was waiting for
}
void SshService::answerTrust(bool yes) {
if (state_ != State::AskTrust) return;
if (yes) { // remembered from here on; on the main loop, where settings are written
term::SshKnownHosts known(settings_.getString(Setting::SshKnown));
known.remember(target_.hostPort(), fingerprint());
settings_.setString(Setting::SshKnown, known.stored());
}
trusted_ = yes;
answered_ = true;
}
void SshService::answerPassword(const std::string& password) {
if (state_ != State::AskPassword) return;
{
Locked l(lock_);
password_ = password;
}
answered_ = true;
}
void SshService::withTerminal(const std::function<void(term::Terminal&)>& use) {
Locked l(lock_);
if (term_) use(*term_);
}
void SshService::send(const std::string& bytes) {
if (state_ != State::Open) return;
Locked l(lock_);
if (outgoing_.size() < 4096) outgoing_ += bytes;
}
void SshService::resize(int cols, int rows) {
Locked l(lock_);
if (term_) term_->resize(cols, rows);
wantCols_ = cols;
wantRows_ = rows;
resized_ = true;
revision_++;
}
void SshService::task(void* arg) {
std::unique_ptr<Run> run(static_cast<Run*>(arg));
SshService* self = run->service;
self->session(*run);
run.reset();
self->running_ = false;
vTaskDelete(nullptr);
}
void SshService::session(Run& run) {
startLibrary();
ssh_session s = ssh_new();
if (!s) return end("Not enough memory for the session");
int verbosity = SSH_LOG_NOLOG;
long timeout = 10;
ssh_options_set(s, SSH_OPTIONS_HOST, run.host.c_str());
ssh_options_set(s, SSH_OPTIONS_PORT, &run.port);
ssh_options_set(s, SSH_OPTIONS_USER, run.user.c_str());
ssh_options_set(s, SSH_OPTIONS_TIMEOUT, &timeout);
ssh_options_set(s, SSH_OPTIONS_LOG_VERBOSITY, &verbosity);
auto fail = [&](const std::string& what) {
std::string why = what;
const char* detail = ssh_get_error(s);
if (detail && *detail && what.back() == ':') {
// lwIP reports a refused connection as one reset by the peer.
std::string d = detail;
if (d.find("reset by peer") != std::string::npos || d.find("refused") != std::string::npos) why = "Nothing listens there: the connection was refused";
else if (d.find("imeout") != std::string::npos || d.find("timed out") != std::string::npos) why = "No answer from " + run.host;
else why += " " + d;
}
ssh_disconnect(s);
ssh_free(s);
end(why);
};
auto waitForAnswer = [&]() {
while (!answered_ && !stop_) vTaskDelay(pdMS_TO_TICKS(50));
answered_ = false;
return !stop_;
};
if (ssh_connect(s) != SSH_OK) return fail("No connection:");
if (stop_) return fail("Stopped");
// Is it the server it was last time? The first time, and when it has changed, the user decides.
std::string seen = fingerprintOf(s);
if (seen.empty()) return fail("The server showed no key");
if (seen != run.known) {
{
Locked l(lock_);
fingerprint_ = seen;
remembered_ = run.known;
}
say("Is this the right server?");
state_ = State::AskTrust;
revision_++;
if (!waitForAnswer() || !trusted_) return fail(stop_ ? "Stopped" : "Not trusted: not connected");
state_ = State::Connecting;
}
say("Logging in as " + run.user);
// This device's key first, if it has one and the server takes keys; then a password.
int rc = ssh_userauth_none(s, nullptr);
int methods = ssh_userauth_list(s, nullptr);
if (rc != SSH_AUTH_SUCCESS && !run.privateKey.empty() && (methods & SSH_AUTH_METHOD_PUBLICKEY)) {
ssh_key key = nullptr;
if (ssh_pki_import_privkey_base64(run.privateKey.c_str(), nullptr, nullptr, nullptr, &key) == SSH_OK) {
rc = ssh_userauth_publickey(s, nullptr, key);
ssh_key_free(key);
}
}
run.privateKey.assign(run.privateKey.size(), '\0');
for (int tries = 0; rc != SSH_AUTH_SUCCESS && tries < kPasswordTries; tries++) {
if (!(methods & (SSH_AUTH_METHOD_PASSWORD | SSH_AUTH_METHOD_INTERACTIVE))) return fail("The server takes neither this key nor a password");
say(tries ? "Wrong password" : "");
state_ = State::AskPassword;
revision_++;
if (!waitForAnswer()) return fail("Stopped");
state_ = State::Connecting;
say("Logging in as " + run.user);
std::string password;
{
Locked l(lock_);
password.swap(password_);
}
if (methods & SSH_AUTH_METHOD_PASSWORD) rc = ssh_userauth_password(s, nullptr, password.c_str());
else { // asked as questions: every one that hides its answer gets the password
rc = ssh_userauth_kbdint(s, nullptr, nullptr);
for (int round = 0; rc == SSH_AUTH_INFO && round < 4; round++) {
int n = ssh_userauth_kbdint_getnprompts(s);
for (int i = 0; i < n; i++) ssh_userauth_kbdint_setanswer(s, static_cast<unsigned>(i), password.c_str());
rc = ssh_userauth_kbdint(s, nullptr, nullptr);
}
}
password.assign(password.size(), '\0');
if (rc == SSH_AUTH_ERROR) return fail("Login failed:");
}
if (rc != SSH_AUTH_SUCCESS) return fail("Wrong password, three times");
ssh_channel ch = ssh_channel_new(s);
int cols, rows;
{
Locked l(lock_);
cols = resized_ ? wantCols_ : run.cols;
rows = resized_ ? wantRows_ : run.rows;
resized_ = false;
}
if (!ch || ssh_channel_open_session(ch) != SSH_OK || ssh_channel_request_pty_size(ch, "xterm", cols, rows) != SSH_OK ||
ssh_channel_request_shell(ch) != SSH_OK) {
if (ch) ssh_channel_free(ch);
return fail("No shell:");
}
say("");
opened_ = true;
state_ = State::Open;
revision_++;
std::string why = "The session ended";
uint8_t buf[512];
while (!stop_) {
int waiting = ssh_channel_poll_timeout(ch, 20, 0); // also where it sleeps when nothing happens
if (waiting == SSH_ERROR || waiting == SSH_EOF) break;
bool busy = false;
for (int std_err = 0; std_err < 2; std_err++) {
int n = ssh_channel_read_nonblocking(ch, buf, sizeof buf, std_err);
if (n > 0) {
Locked l(lock_);
term_->feed(buf, static_cast<size_t>(n));
revision_++;
busy = true;
}
}
std::string out;
int newCols = 0, newRows = 0;
{
Locked l(lock_);
out.swap(outgoing_);
if (resized_) {
newCols = wantCols_;
newRows = wantRows_;
resized_ = false;
}
}
if (newCols) ssh_channel_change_pty_size(ch, newCols, newRows);
if (!out.empty() && ssh_channel_write(ch, out.data(), static_cast<uint32_t>(out.size())) < 0) {
why = "The connection was lost";
break;
}
if (ssh_channel_is_eof(ch) || !ssh_channel_is_open(ch)) break;
if (!ssh_is_connected(s)) {
why = "The connection was lost";
break;
}
if (!busy && out.empty()) vTaskDelay(pdMS_TO_TICKS(5));
}
if (stop_) why = "Disconnected";
ssh_channel_close(ch);
ssh_channel_free(ch);
ssh_disconnect(s);
ssh_free(s);
end(why);
}
// On a task of its own for its stack; the main loop waits the moment it takes.
std::string SshService::makeKey() {
struct Made {
std::string priv, pub, why;
std::atomic<bool> done{false};
};
auto made = std::make_shared<Made>();
auto* arg = new std::shared_ptr<Made>(made);
auto work = [](void* p) {
std::shared_ptr<Made> m = *static_cast<std::shared_ptr<Made>*>(p);
delete static_cast<std::shared_ptr<Made>*>(p);
startLibrary();
ssh_key key = nullptr, pub = nullptr;
char *b64 = nullptr, *pub64 = nullptr;
if (ssh_pki_generate(SSH_KEYTYPE_ED25519, 0, &key) != SSH_OK) m->why = "The key couldn't be made";
else if (ssh_pki_export_privkey_base64(key, nullptr, nullptr, nullptr, &b64) != SSH_OK || ssh_pki_export_privkey_to_pubkey(key, &pub) != SSH_OK ||
ssh_pki_export_pubkey_base64(pub, &pub64) != SSH_OK)
m->why = "The key couldn't be written out";
else {
m->priv = b64;
m->pub = std::string("ssh-ed25519 ") + pub64 + " roro9stack";
}
if (b64) ssh_string_free_char(b64);
if (pub64) ssh_string_free_char(pub64);
if (pub) ssh_key_free(pub);
if (key) ssh_key_free(key);
m->done = true;
vTaskDelete(nullptr);
};
if (xTaskCreate(work, "sshkey", 16384, arg, 1, nullptr) != pdPASS) {
delete arg;
return "Not enough memory to make a key";
}
for (int waited = 0; !made->done && waited < 10000; waited += 20) delay(20);
if (!made->done) return "Making the key took too long";
if (!made->why.empty()) return made->why;
if (!settings_.setString(Setting::SshKey, made->priv) || !settings_.setString(Setting::SshPublic, made->pub)) return "The key couldn't be stored";
return "";
}
} // namespace roro
+79
View File
@@ -0,0 +1,79 @@
#pragma once
#include <atomic>
#include <functional>
#include <memory>
#include <string>
#include "event_bus.h"
#include "settings.h"
#include "ssh_hosts.h"
#include "terminal.h"
namespace roro {
// One SSH session to a shell (issue #2, docs/milestones/N1.md): the protocol is libssh's, on a
// task of its own; what it prints goes into a term::Terminal, which the SSH App draws. The
// session lasts until the far end closes it or disconnect() is called, whether the App is in
// front or not.
//
// The task and the main loop share the terminal, the bytes waiting to be sent and the state,
// under one lock. Questions for the user (is this the right server? what is the password?) are
// states the task waits in until the App answers.
class SshService {
public:
enum class State { Idle, Connecting, AskTrust, AskPassword, Open, Ended };
static constexpr size_t kNeedFree = 75 * 1024; // a session peaks at about 63 KB (measured)
static constexpr int kHistory = 100;
explicit SshService(Settings& settings);
// "" when the connection is on its way, or why not.
std::string connect(const term::SshTarget& target, int cols, int rows);
void disconnect(); // from any state; the terminal stays to be read until the next connect
void clear(); // Ended, and read: the terminal and its history are given back
State state() const { return state_; }
bool active() const { return state_ == State::Open; } // for the Status Bar
bool opened() const { return opened_; } // this session got as far as a shell
std::string status() const; // what it is doing, or why it ended
const term::SshTarget& target() const { return target_; }
// AskTrust: the server's fingerprint, and the one remembered if this is a different one.
std::string fingerprint() const;
std::string remembered() const;
void answerTrust(bool yes);
// AskPassword.
void answerPassword(const std::string& password);
// Open (and Ended, to read what is left).
bool hasTerminal() const { return static_cast<bool>(term_); }
void withTerminal(const std::function<void(term::Terminal&)>& use);
uint32_t revision() const { return revision_; }
void send(const std::string& bytes);
void resize(int cols, int rows);
// This device's own key: made once, its public half is what goes on servers.
bool hasKey() const { return !settings_.getString(Setting::SshKey).empty(); }
std::string publicKey() const { return settings_.getString(Setting::SshPublic); }
std::string makeKey(); // "" or why not
private:
struct Run; // what one connection's task works with
static void task(void* arg);
void session(Run& run);
void end(const std::string& why);
void say(const std::string& what);
Settings& settings_;
void* lock_; // SemaphoreHandle_t
std::atomic<State> state_{State::Idle};
std::atomic<bool> stop_{false}, answered_{false}, trusted_{false}, running_{false}, opened_{false};
std::atomic<uint32_t> revision_{0};
term::SshTarget target_;
std::unique_ptr<term::Terminal> term_;
std::string status_, fingerprint_, remembered_, password_, outgoing_;
int wantCols_ = 0, wantRows_ = 0;
bool resized_ = false;
};
} // namespace roro