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
+108
View File
@@ -0,0 +1,108 @@
#include "ssh_hosts.h"
#include <algorithm>
namespace roro::term {
namespace {
std::vector<std::string> linesOf(const std::string& text) {
std::vector<std::string> out;
for (size_t at = 0; at < text.size();) {
size_t end = text.find('\n', at);
if (end == std::string::npos) end = text.size();
if (end > at) out.push_back(text.substr(at, end - at));
at = end + 1;
}
return out;
}
bool hostChars(const std::string& s) {
if (s.empty() || s.size() > 253) return false;
for (char c : s)
if (!((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '.' || c == '-')) return false;
return s.front() != '.' && s.front() != '-';
}
} // namespace
std::string SshTarget::text() const { return user + "@" + host + (port == 22 ? "" : ":" + std::to_string(port)); }
std::string SshTarget::hostPort() const { return host + ":" + std::to_string(port); }
std::string parseSshTarget(const std::string& text, SshTarget& out) {
size_t at = text.find('@');
if (at == std::string::npos || at == 0) return "user@host, please";
SshTarget t;
t.user = text.substr(0, at);
std::string rest = text.substr(at + 1);
if (t.user.size() > 32 || t.user.find_first_of(" @:/") != std::string::npos) return "that isn't a user name";
size_t colon = rest.rfind(':');
if (colon != std::string::npos) {
std::string port = rest.substr(colon + 1);
long n = 0;
if (port.empty() || port.size() > 5) return "a port from 1 to 65535";
for (char c : port) {
if (c < '0' || c > '9') return "a port from 1 to 65535";
n = n * 10 + (c - '0');
}
if (n < 1 || n > 65535) return "a port from 1 to 65535";
t.port = static_cast<uint16_t>(n);
rest.resize(colon);
}
if (!hostChars(rest)) return "that isn't a host";
t.host = rest;
out = t;
return "";
}
SshHosts::SshHosts(const std::string& stored) {
for (auto& line : linesOf(stored)) {
SshTarget t;
if (hosts_.size() < kMax && parseSshTarget(line, t).empty()) hosts_.push_back(t.text());
}
}
void SshHosts::used(const SshTarget& target) {
std::string text = target.text();
hosts_.erase(std::remove(hosts_.begin(), hosts_.end(), text), hosts_.end());
hosts_.insert(hosts_.begin(), text);
if (hosts_.size() > kMax) hosts_.resize(kMax);
}
void SshHosts::remove(size_t index) {
if (index < hosts_.size()) hosts_.erase(hosts_.begin() + static_cast<long>(index));
}
std::string SshHosts::stored() const {
std::string s;
for (auto& h : hosts_) s += h + "\n";
return s;
}
SshKnownHosts::SshKnownHosts(const std::string& stored) {
for (auto& line : linesOf(stored)) {
size_t space = line.find(' ');
if (space != std::string::npos && space > 0 && space + 1 < line.size() && known_.size() < kMax) known_.emplace_back(line.substr(0, space), line.substr(space + 1));
}
}
std::string SshKnownHosts::fingerprintOf(const std::string& hostPort) const {
for (auto& k : known_)
if (k.first == hostPort) return k.second;
return "";
}
void SshKnownHosts::remember(const std::string& hostPort, const std::string& fingerprint) {
known_.erase(std::remove_if(known_.begin(), known_.end(), [&](const std::pair<std::string, std::string>& k) { return k.first == hostPort; }), known_.end());
known_.emplace_back(hostPort, fingerprint);
if (known_.size() > kMax) known_.erase(known_.begin());
}
void SshKnownHosts::forget(const std::string& hostPort) {
known_.erase(std::remove_if(known_.begin(), known_.end(), [&](const std::pair<std::string, std::string>& k) { return k.first == hostPort; }), known_.end());
}
std::string SshKnownHosts::stored() const {
std::string s;
for (auto& k : known_) s += k.first + " " + k.second + "\n";
return s;
}
} // namespace roro::term