Public Access
SSH client: a terminal on another machine (#2)
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:
@@ -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("");
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user