Compare commits

...
6 Commits
Author SHA1 Message Date
twisla 861a60b73d Merge pull request 'uname, /uname, scp in the Shell, firmware version in CTCP VERSION' (#95) from uname-scp into main
Site / build (push) Successful in 17s
CI / build (push) Successful in 3m7s
2026-10-09 17:20:13 +00:00
twislaandClaude Sonnet 5.5 4c4d7fc467 Pin libsodium to 1.10021.11: 1.10021.12 no longer links with LibSSH
CI / build (pull_request) Successful in 1m40s
Site / build (pull_request) Successful in 10s
WireGuard brings libsodium in unpinned. 1.10021.12 defines
crypto_sign_ed25519_open, as LibSSH-ESP32 does: a clean build failed with
a multiple definition. A cached build kept linking, which hid it.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0162FokPdvY2KsS4NBfwyWPk
2026-10-09 19:17:37 +02:00
twislaandClaude Sonnet 5.5 18285c3212 Add uname, /uname and scp (key or masked password) in the Shell
CI / build (pull_request) Failing after 1m59s
Site / build (pull_request) Successful in 9s
- uname in the console and /uname in IRC: the firmware, its version and the chip
- scp: one file between the card and a trusted server, over SSH, with the
  device's key or a password asked (masked) in the Shell
- shared libssh helpers in src/platform/libssh_util
- README, user guide, command reference and a how-to updated

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0162FokPdvY2KsS4NBfwyWPk
2026-10-09 19:01:36 +02:00
twislaandClaude Sonnet 5.5 e707a5f2d4 IRC: add the firmware version to the CTCP VERSION reply
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0162FokPdvY2KsS4NBfwyWPk
2026-10-09 19:01:36 +02:00
twisla 5bec851a1a Remove unwanted file
CI / build (push) Successful in 1m1s
2026-10-08 11:26:17 +02:00
twisla 9dfe675db7 Merge pull request 'SSH client: a terminal on another machine (#2)' (#94) from ssh-client into main
Site / build (push) Successful in 13s
CI / build (push) Successful in 3m4s
2026-10-08 07:09:11 +00:00
27 changed files with 736 additions and 37 deletions
+2
View File
@@ -252,6 +252,8 @@ The Shell App (docs/milestones/S1.md) runs the commands below on the device's ow
| `tls <host> [port]` / `ntp [server]` | A TLS handshake that checks nothing, then the certificate said in words: who it is for, who signed it, until when, its SHA-256, and whether this device's roots and the name asked for accept it (about 52 KB of heap while it runs; refused under 70 KB free). A time server's clock against the device's, with the round trip | | `tls <host> [port]` / `ntp [server]` | A TLS handshake that checks nothing, then the certificate said in words: who it is for, who signed it, until when, its SHA-256, and whether this device's roots and the name asked for accept it (about 52 KB of heap while it runs; refused under 70 KB free). A time server's clock against the device's, with the round trip |
| `ifconfig` / `arp` / `netstat` | The interfaces (Wi-Fi and the VPN) with their addresses, MTU, which is the default route, and the DNS servers; the neighbours heard on the Wi-Fi; what listens and what is connected | | `ifconfig` / `arp` / `netstat` | The interfaces (Wi-Fi and the VPN) with their addresses, MTU, which is the default route, and the DNS servers; the neighbours heard on the Wi-Fi; what listens and what is connected |
| `ssh user@host[:port]` / `ssh status` / `ssh stop` | Opens the SSH App and connects (the password is asked there, never on a console); the session's state and this device's public key; end the session | | `ssh user@host[:port]` / `ssh status` / `ssh stop` | Opens the SSH App and connects (the password is asked there, never on a console); the session's state and this device's public key; end the session |
| `scp [-f] [-P port] <file> user@host:path` / `scp [-f] [-P port] user@host:path <file>` | One file between the card and a server, with the device's key, or a password asked (masked) in the Shell, to a server the SSH App already trusts; `-f` replaces a file on the card; `cancel` stops it |
| `uname` | The firmware, its version and the chip it is built for (IRC has `/uname`) |
| `vpn status` / `vpn up [seconds]` / `vpn down` / `vpn import [path]` / `vpn forget` / `vpn auto on\|off` | The WireGuard tunnel: its state, on (for that many seconds, then off by itself: for trying a configuration from afar), off, read a `.conf` from the card (`/vpn/wg0.conf`), erase it, start with Wi-Fi. No key is ever printed | | `vpn status` / `vpn up [seconds]` / `vpn down` / `vpn import [path]` / `vpn forget` / `vpn auto on\|off` | The WireGuard tunnel: its state, on (for that many seconds, then off by itself: for trying a configuration from afar), off, read a `.conf` from the card (`/vpn/wg0.conf`), erase it, start with Wi-Fi. No key is ever printed |
| `debug status` / `debug off [seconds]` | The Debug Console: whether it's on, has a token and a client; switch it off. With a number of seconds, it comes back by itself after that long | | `debug status` / `debug off [seconds]` | The Debug Console: whether it's on, has a token and a client; switch it off. With a number of seconds, it comes back by itself after that long |
| `debug on` / `debug token <value>` / `debug token new` | USB serial only: switch it on (making a token if there's none), give it a token of 16 to 64 characters, or make a new one. The token is never printed | | `debug on` / `debug token <value>` / `debug token new` | USB serial only: switch it on (making a token if there's none), give it a token of 16 to 64 characters, or make a new one. The token is never printed |
+5 -2
View File
@@ -4,6 +4,7 @@
#include <cctype> #include <cctype>
#include "base64.h" #include "base64.h"
#include "version.h"
namespace roro { namespace roro {
@@ -284,7 +285,7 @@ void IrcSession::onPrivmsg(const IrcMessage& m, int64_t utc, bool notice) {
action = true; action = true;
text = rest; text = rest;
} else { } else {
if (verb == "VERSION" && !notice) send(std::string("NOTICE ") + from + " :" + kCtcp + "VERSION roro9stack" + kCtcp); if (verb == "VERSION" && !notice) send(std::string("NOTICE ") + from + " :" + kCtcp + "VERSION " + kProductName + " " + versionString() + kCtcp);
return; return;
} }
} }
@@ -359,10 +360,12 @@ void IrcSession::command(int b, const std::string& text, int64_t utc) {
} else if (verb == "quit") { } else if (verb == "quit") {
quit_ = true; quit_ = true;
send(IrcMessage::serialize("QUIT", {rest.empty() ? "roro9stack" : rest})); send(IrcMessage::serialize("QUIT", {rest.empty() ? "roro9stack" : rest}));
} else if (verb == "uname") { // shown here, not said to anyone
info(b, unameString(), utc);
} else if (verb == "raw" || verb == "quote") { } else if (verb == "raw" || verb == "quote") {
if (!rest.empty()) send(rest); if (!rest.empty()) send(rest);
} else { } else {
info(b, "Unknown command /" + verb + " (try /join or /j, /part /msg /me /nick /topic /names /quit /raw)", utc); info(b, "Unknown command /" + verb + " (try /join or /j, /part /msg /me /nick /topic /names /uname /quit /raw)", utc);
} }
} }
+81
View File
@@ -0,0 +1,81 @@
#include "scp_args.h"
#include <vector>
namespace roro::term {
namespace {
std::vector<std::string> words(const std::string& text) {
std::vector<std::string> out;
for (size_t at = 0; at < text.size();) {
size_t end = text.find(' ', 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;
}
// user@host:path, as opposed to a path on the card.
bool isRemote(const std::string& w) {
if (w.empty() || w[0] == '/') return false;
size_t at = w.find('@'), colon = w.find(':');
return at != std::string::npos && colon != std::string::npos && at < colon;
}
std::string baseName(const std::string& path) {
size_t slash = path.rfind('/');
return slash == std::string::npos ? path : path.substr(slash + 1);
}
} // namespace
std::string parseScp(const std::string& args, ScpArgs& out) {
static const char* kUsage = "scp [-f] [-P port] <file on the card> user@host:path | scp [-f] [-P port] user@host:path <file on the card>";
std::vector<std::string> w = words(args);
long port = 0;
bool replace = false;
while (!w.empty() && w[0][0] == '-') {
if (w[0] == "-f") {
replace = true;
w.erase(w.begin());
} else if (w[0] == "-P" && w.size() >= 2) {
port = 0;
for (char c : w[1]) {
if (c < '0' || c > '9' || port > 65535) return "a port from 1 to 65535";
port = port * 10 + (c - '0');
}
if (port < 1 || port > 65535) return "a port from 1 to 65535";
w.erase(w.begin(), w.begin() + 2);
} else {
return kUsage;
}
}
if (w.size() != 2) return kUsage;
bool firstRemote = isRemote(w[0]), secondRemote = isRemote(w[1]);
if (firstRemote == secondRemote) return firstRemote ? "one side has to be on the card, not both on servers" : kUsage;
ScpArgs a;
a.upload = secondRemote;
a.replace = replace;
const std::string& remote = a.upload ? w[1] : w[0];
std::string local = a.upload ? w[0] : w[1];
size_t colon = remote.find(':');
std::string why = parseSshTarget(remote.substr(0, colon), a.target);
if (!why.empty()) return why;
if (port) a.target.port = static_cast<uint16_t>(port);
a.remote = remote.substr(colon + 1);
if (a.remote.empty()) return "say where on " + a.target.host + ": user@host:path";
if (local.empty() || local[0] != '/') return "a path on the card starts with a slash";
if (local.back() == '/') {
if (a.upload) return "that's a folder: scp copies one file";
std::string name = baseName(a.remote);
if (name.empty()) return "name the file to fetch";
local += name;
}
a.local = local;
out = a;
return "";
}
} // namespace roro::term
+23
View File
@@ -0,0 +1,23 @@
#pragma once
#include <string>
#include "ssh_hosts.h"
namespace roro::term {
// What `scp` was asked: one file to the card from a server, or from the card to a server.
// scp [-f] [-P port] /notes/a.txt user@host:path (upload)
// scp [-f] [-P port] user@host:path /notes/a.txt (download; a destination ending in / gets the remote file's name)
// The card's paths start with a slash; the server's are the server's own (relative ones start at the home folder).
struct ScpArgs {
bool upload = false;
bool replace = false; // -f: a download may replace a file on the card
SshTarget target;
std::string local, remote;
};
// "" or what is wrong with it.
std::string parseScp(const std::string& args, ScpArgs& out);
} // namespace roro::term
+18
View File
@@ -8,8 +8,26 @@
#define RORO_VERSION "unknown" #define RORO_VERSION "unknown"
#endif #endif
#if __has_include("sdkconfig.h")
#include "sdkconfig.h"
#endif
namespace roro { namespace roro {
const char* versionString() { return RORO_VERSION; } const char* versionString() { return RORO_VERSION; }
const char* architectureString() {
#if defined(CONFIG_IDF_TARGET_ESP32S3)
return "xtensa-lx7 esp32s3";
#elif defined(CONFIG_IDF_TARGET_ESP32)
return "xtensa-lx6 esp32";
#elif defined(CONFIG_IDF_TARGET)
return CONFIG_IDF_TARGET;
#else
return "host";
#endif
}
std::string unameString() { return std::string(kProductName) + " " + versionString() + " " + architectureString(); }
} // namespace roro } // namespace roro
+8
View File
@@ -1,5 +1,7 @@
#pragma once #pragma once
#include <string>
namespace roro { namespace roro {
constexpr const char* kProductName = "roro9stack"; constexpr const char* kProductName = "roro9stack";
@@ -9,4 +11,10 @@ constexpr const char* kProductName = "roro9stack";
// that one file, and everything else comes from the build cache (issue #74). // that one file, and everything else comes from the build cache (issue #74).
const char* versionString(); const char* versionString();
// The chip this firmware is built for: "xtensa-lx7 esp32s3". "host" in the native tests.
const char* architectureString();
// What `uname` and IRC's /uname report: "roro9stack v0.22.0 xtensa-lx7 esp32s3".
std::string unameString();
} // namespace roro } // namespace roro
+2
View File
@@ -26,6 +26,8 @@ lib_deps =
jgromes/RadioLib @ 7.8.1 jgromes/RadioLib @ 7.8.1
esphome/wireguard @ 0.4.8 esphome/wireguard @ 0.4.8
ewpa/LibSSH-ESP32 @ 5.10.0 ewpa/LibSSH-ESP32 @ 5.10.0
; WireGuard brings libsodium in; 1.10021.12 defines crypto_sign_ed25519_open as LibSSH does, and the two do not link
esphome/libsodium @ 1.10021.11
test_ignore = * test_ignore = *
; Smaller TLS buffers (M2): the framework is rebuilt with these settings (pioarduino "hybrid ; Smaller TLS buffers (M2): the framework is rebuilt with these settings (pioarduino "hybrid
; compile"). Receive stays 16 KB (servers send full TLS records); send drops to 4 KB (IRC lines are ; compile"). Receive stays 16 KB (servers send full TLS records); send drops to 4 KB (IRC lines are
-1
View File
@@ -1 +0,0 @@
claude --resume a3bf56ca-1259-41f1-99f6-9acea510e771
+5 -1
View File
@@ -42,6 +42,8 @@ Irc | Wifi | Gnss | Gemini | Lora | Storage | Notes | Shell | System | Settings
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 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
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 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
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 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
uname the firmware, its version and the chip it is built for
scp [-f] [-P port] <file on the card> user@host:path | scp [-f] [-P port] user@host:path <file on the card> one file over SSH, with this device's key or, in the Shell, a password, to a server the SSH App already trusts; -f replaces a file on the card; `cancel` stops it
ssh user@host[:port] | ssh status | ssh stop a terminal on another machine, in the SSH App; the password is asked there, never here ssh user@host[:port] | ssh status | ssh stop a terminal on another machine, in the SSH App; the password is asked there, never here
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 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
debug status | debug off [seconds] the Debug Console over Wi-Fi (Settings > Debug Console); with seconds, it comes back debug status | debug off [seconds] the Debug Console over Wi-Fi (Settings > Debug Console); with seconds, it comes back
@@ -58,7 +60,7 @@ get <path> | put <path> <size> <sha256> | screenshot (Debug Console only) bina
quit close the Debug Console connection quit close the Debug Console connection
``` ```
In **Safe Mode** (see [Crashes and Safe Mode](/dev/debug/crashes/)) only a few run: `help`, `info`, `tasks`, `net`, `reboot`, `boot other`, `wifi status`, and anything starting with `log level`, `crash`, `coredump`, `wifi add`, `debug`. Anything else answers `not available in Safe Mode`. In **Safe Mode** (see [Crashes and Safe Mode](/dev/debug/crashes/)) only a few run: `help`, `uname`, `info`, `tasks`, `net`, `reboot`, `boot other`, `wifi status`, and anything starting with `log level`, `crash`, `coredump`, `wifi add`, `debug`. Anything else answers `not available in Safe Mode`.
## What they do ## What they do
@@ -118,6 +120,8 @@ In **Safe Mode** (see [Crashes and Safe Mode](/dev/debug/crashes/)) only a few r
| `tls <host> [port]` / `ntp [server]` | A TLS handshake that checks nothing, then the certificate said in words: who it is for, who signed it, until when, its SHA-256, and whether this device's roots and the name asked for accept it (about 52 KB of heap while it runs; refused under 70 KB free). A time server's clock against the device's, with the round trip | | `tls <host> [port]` / `ntp [server]` | A TLS handshake that checks nothing, then the certificate said in words: who it is for, who signed it, until when, its SHA-256, and whether this device's roots and the name asked for accept it (about 52 KB of heap while it runs; refused under 70 KB free). A time server's clock against the device's, with the round trip |
| `ifconfig` / `arp` / `netstat` | The interfaces (Wi-Fi and the VPN) with their addresses, MTU, which is the default route, and the DNS servers; the neighbours heard on the Wi-Fi; what listens and what is connected | | `ifconfig` / `arp` / `netstat` | The interfaces (Wi-Fi and the VPN) with their addresses, MTU, which is the default route, and the DNS servers; the neighbours heard on the Wi-Fi; what listens and what is connected |
| `ssh user@host[:port]` / `ssh status` / `ssh stop` | Opens the SSH App and connects (the password is asked there, never on a console); the session's state and this device's public key; end the session | | `ssh user@host[:port]` / `ssh status` / `ssh stop` | Opens the SSH App and connects (the password is asked there, never on a console); the session's state and this device's public key; end the session |
| `scp [-f] [-P port] <file> user@host:path` / `scp [-f] [-P port] user@host:path <file>` | One file between the card and a server, with the device's key, or a password asked (masked) in the Shell, to a server the SSH App already trusts; `-f` replaces a file on the card; `cancel` stops it |
| `uname` | The firmware, its version and the chip it is built for (IRC has `/uname`) |
| `vpn status` / `vpn up [seconds]` / `vpn down` / `vpn import [path]` / `vpn forget` / `vpn auto on\|off` | The WireGuard tunnel: its state, on (for that many seconds, then off by itself: for trying a configuration from afar), off, read a `.conf` from the card (`/vpn/wg0.conf`), erase it, start with Wi-Fi. No key is ever printed | | `vpn status` / `vpn up [seconds]` / `vpn down` / `vpn import [path]` / `vpn forget` / `vpn auto on\|off` | The WireGuard tunnel: its state, on (for that many seconds, then off by itself: for trying a configuration from afar), off, read a `.conf` from the card (`/vpn/wg0.conf`), erase it, start with Wi-Fi. No key is ever printed |
| `debug status` / `debug off [seconds]` | The Debug Console: whether it's on, has a token and a client; switch it off. With a number of seconds, it comes back by itself after that long | | `debug status` / `debug off [seconds]` | The Debug Console: whether it's on, has a token and a client; switch it off. With a number of seconds, it comes back by itself after that long |
| `debug on` / `debug token <value>` / `debug token new` | USB serial only: switch it on (making a token if there's none), give it a token of 16 to 64 characters, or make a new one. The token is never printed | | `debug on` / `debug token <value>` / `debug token new` | USB serial only: switch it on (making a token if there's none), give it a token of 16 to 64 characters, or make a new one. The token is never printed |
+1 -1
View File
@@ -67,7 +67,7 @@ Yes, WireGuard: one tunnel to one server, set up by copying the client's `.conf`
## Can it log in to my server? ## Can it log in to my server?
Yes, over SSH: the [SSH App](/guide/ssh/) is a terminal on another machine, with a password or with a key the device makes for itself. `vim`, `top` and `less` work. One session at a time, and not at the same time as IRC: there isn't the memory for both. Yes, over SSH: the [SSH App](/guide/ssh/) is a terminal on another machine, with a password or with a key the device makes for itself. `vim`, `top` and `less` work. One session at a time, and not at the same time as IRC: there isn't the memory for both. `scp` in the [Shell](/guide/shell/) copies a file between the card and a server.
## How do I copy files to and from my phone? ## How do I copy files to and from my phone?
+3
View File
@@ -31,10 +31,13 @@ You type at the bottom; <kbd>Enter</kbd> sends. A line starting with `/` is a co
| `/nick newnick` | Changes your nick | | `/nick newnick` | Changes your nick |
| `/topic [text]` | Shows or sets the channel topic | | `/topic [text]` | Shows or sets the channel topic |
| `/names [#channel]` | Lists who is there | | `/names [#channel]` | Lists who is there |
| `/uname` | Shows the firmware, its version and the chip, in this buffer only: nothing is sent |
| `/quit [message]` | Disconnects and **stays disconnected** until you type something again | | `/quit [message]` | Disconnects and **stays disconnected** until you type something again |
| `/raw …` (or `/quote`) | Sends a line to the server as it is | | `/raw …` (or `/quote`) | Sends a line to the server as it is |
| `/settings` | The settings page | | `/settings` | The settings page |
Anyone who asks the device for its CTCP `VERSION` (`/ctcp roro VERSION` in most clients) is told `roro9stack` and the firmware version.
Each server, channel and private chat is a **buffer**. <kbd>Tab</kbd> moves to the next one, each shows how many messages are unread, and your own lines are in the accent colour. Scroll back with <kbd>Alt</kbd> + <kbd>;</kbd> (older) and <kbd>Alt</kbd> + <kbd>.</kbd> (newer). The up and down arrows (<kbd>Fn</kbd> + <kbd>;</kbd> and <kbd>.</kbd>) recall lines you sent. Each server, channel and private chat is a **buffer**. <kbd>Tab</kbd> moves to the next one, each shows how many messages are unread, and your own lines are in the accent colour. Scroll back with <kbd>Alt</kbd> + <kbd>;</kbd> (older) and <kbd>Alt</kbd> + <kbd>.</kbd> (newer). The up and down arrows (<kbd>Fn</kbd> + <kbd>;</kbd> and <kbd>.</kbd>) recall lines you sent.
## Mentions and the unread count ## Mentions and the unread count
+3 -1
View File
@@ -21,6 +21,7 @@ Type a command and press <kbd>Enter</kbd>. `help` lists them all; the [command r
| `wifi status` | The network, the address, and where the DNS and time servers came from | | `wifi status` | The network, the address, and where the DNS and time servers came from |
| `ls /notes` | A folder of the SD card, with sizes and dates | | `ls /notes` | A folder of the SD card, with sizes and dates |
| `crash` | The last crash, if there was one | | `crash` | The last crash, if there was one |
| `uname` | The firmware, its version and the chip it is built for: `roro9stack v0.22.0 xtensa-lx7 esp32s3` |
| `update check` | Whether a newer release exists | | `update check` | Whether a newer release exists |
| `lora status` | What the radio is set to and what it has heard | | `lora status` | What the radio is set to and what it has heard |
@@ -45,7 +46,7 @@ The capital is the difference: every command is in small letters, every App star
## The network ## The network
For the day the network doesn't do what it should. Each of the first six takes a moment and prints as it goes; one runs at a time, and `cancel` stops it. For the day the network doesn't do what it should. Each of the first six takes a moment and prints as it goes, and so does `scp`; one runs at a time, and `cancel` stops it.
| Command | Tells you | | Command | Tells you |
|---|---| |---|---|
@@ -56,6 +57,7 @@ For the day the network doesn't do what it should. Each of the first six takes a
| `tls git.twis.la` | A secure connection's certificate: who it is for, who signed it, until when, and **whether this device trusts it**. A port may follow (443 if not) | | `tls git.twis.la` | A secure connection's certificate: who it is for, who signed it, until when, and **whether this device trusts it**. A port may follow (443 if not) |
| `ntp` | A time server's clock against this device's, and how far the server is. Another server may follow | | `ntp` | A time server's clock against this device's, and how far the server is. Another server may follow |
| `ssh user@host` | Opens the [SSH App](/guide/ssh/) and connects; `ssh status` and `ssh stop` for the session | | `ssh user@host` | Opens the [SSH App](/guide/ssh/) and connects; `ssh status` and `ssh stop` for the session |
| `scp /notes/a.txt user@host:docs/` | One file between the card and a server, over [SSH](/guide/ssh/#copying-a-file-scp): with the device's key, or a password asked in the Shell, masked. `cancel` stops it |
| `ifconfig` | The interfaces (Wi-Fi, and the [VPN](/guide/vpn/) when it is up), their addresses, **which one is the default route**, and the DNS servers | | `ifconfig` | The interfaces (Wi-Fi, and the [VPN](/guide/vpn/) when it is up), their addresses, **which one is the default route**, and the DNS servers |
| `arp` | The neighbours heard on the Wi-Fi: is the gateway there at all | | `arp` | The neighbours heard on the Wi-Fi: is the gateway there at all |
| `netstat` | What the device **listens** on (updates, the Debug Console, sharing) and what is connected to it now | | `netstat` | What the device **listens** on (updates, the Debug Console, sharing) and what is connected to it now |
+22 -1
View File
@@ -9,7 +9,7 @@ screens = ["ssh.png", "ssh-trust.png", "ssh-terminal.png", "ssh-key.png", "ssh-c
The SSH App opens a **terminal on another machine**: a server, a Raspberry Pi, a router. What you type goes there, and what its programs print is drawn here: a shell, `less`, `top`, `nano`, `vim`. The SSH App opens a **terminal on another machine**: a server, a Raspberry Pi, a router. What you type goes there, and what its programs print is drawn here: a shell, `less`, `top`, `nano`, `vim`.
It is a client and nothing more: one session at a time, to a shell. No file transfer, no port forwarding, no jump hosts. It is a client and nothing more: one session at a time, to a shell. No port forwarding, no jump hosts. Files go one at a time with [`scp` from the Shell](#copying-a-file-scp).
## Connecting ## Connecting
@@ -89,6 +89,26 @@ A session takes about **50 KB** of the device's 100 KB while it is open, and giv
See [When a connection says "not enough memory"](/howto/not-enough-memory/). See [When a connection says "not enough memory"](/howto/not-enough-memory/).
## Copying a file: scp
From the [Shell](/guide/shell/), `scp` copies **one file** between the SD card and a server, over the same SSH:
```
scp /notes/a.txt user@host:docs/a.txt the card to the server
scp user@host:/var/log/syslog /logs/ the server to the card, keeping its name
scp -P 2222 /notes/a.txt user@host: another port (-P first); a path is needed after the colon
scp -f user@host:x.txt /notes/x.txt replace a file that is on the card already
```
Paths on the card start with a slash. A path on the server is the server's own, and starts in the user's home folder when it has no slash. Only a file: no folders, no `*`.
- **The server has to be one you have already trusted** in this App (the fingerprint question above). `scp` can't ask that question, and refuses a server it doesn't know, or one whose key changed.
- **It logs in with the device's key, and asks for the password** if the server doesn't accept the key: the Shell shows the question and what you type is masked (<kbd>Back</kbd> cancels). The password is asked only in the Shell, not from a PC on the USB port or the Debug Console.
- **A download arrives under a temporary name** and takes its real one when it is all there, so a cut connection never leaves half a file. A file already there is not replaced without `-f`.
- It prints how long it took. `cancel` stops it. It needs the same 75 KB of free memory as a session, and **not while a session is open**.
See [Copy a file to or from a server](/howto/scp/).
## From the Shell ## From the Shell
``` ```
@@ -96,6 +116,7 @@ ssh user@host connect, in the SSH App
ssh user@host:2222 on another port ssh user@host:2222 on another port
ssh status the session, and this device's public key ssh status the session, and this device's public key
ssh stop end the session ssh stop end the session
scp ... copy one file, see above
``` ```
## Keys ## Keys
+1 -1
View File
@@ -1,6 +1,6 @@
+++ +++
title = "How-tos" title = "How-tos"
description = "Short recipes for things you will want to do: move files with your phone, set up the VPN, log in to a server with the device's SSH key, take a screenshot, find out why the network doesn't work, record a track, capture radio packets, and what to try when something does not work." description = "Short recipes for things you will want to do: move files with your phone, set up the VPN, log in to a server with the device's SSH key, copy a file to or from a server, take a screenshot, find out why the network doesn't work, record a track, capture radio packets, and what to try when something does not work."
template = "guide-index.html" template = "guide-index.html"
page_template = "guide-page.html" page_template = "guide-page.html"
sort_by = "weight" sort_by = "weight"
+40
View File
@@ -0,0 +1,40 @@
+++
title = "Copy a file to or from a server"
description = "Send a note from the SD card to a server, or fetch a log from it, with scp in the Shell."
weight = 14
[extra]
tag = "SSH"
+++
One file at a time, between the SD card and a server you can log in to over SSH.
1. **Connect to the server once in the SSH App** (**SSH → New connection**, `user@host`) and trust its fingerprint. `scp` can't ask that question, so it only goes to servers it has met there.
2. For no password to type, put the device's key on the server: [Log in to a server without a password](/howto/ssh-key/). Without it, `scp` asks for the password.
3. Open the **Shell** and type, for a file going **to** the server:
```
scp /notes/a.txt user@host:docs/a.txt
```
or one coming **from** it, into a folder on the card (it keeps its name):
```
scp user@host:/var/log/syslog /logs/
```
You should see `scp: ... (1234 bytes) to ...`, then `scp: sent 1234 bytes in 310 ms` (or `received`).
4. If it says `password for host`, type it and press <kbd>Enter</kbd>: it shows as stars and is kept nowhere. <kbd>Back</kbd> cancels.
## If it doesn't work
| It says | Do |
|---|---|
| `is not a server this device trusts yet` | Step 1: connect once in the SSH App |
| `showed a different key than the one remembered` | The server was reinstalled, or something answers in its place. Check in the SSH App, which asks what to do |
| `exists already` | The file is on the card: add `-f` (`scp -f user@host:x /notes/x`) to replace it |
| `An SSH session is open` or `Not enough memory` | End the session (`ssh stop`), or close IRC or a Gemini page: [not enough memory](/howto/not-enough-memory/) |
| `not logged in`, `wrong password` | Three tries, then it stops. Check the user, or the key on the server |
| `a password is only asked in the Shell` | You typed it from a PC on the USB port or the Debug Console: use the Shell, or put the key on the server |
| `that isn't a plain file` | `scp` copies one file, not a folder |
Another port goes first: `scp -P 2222 /notes/a.txt user@host:a.txt`. `cancel` stops a copy that is running.
+1 -1
View File
@@ -17,7 +17,7 @@ Typing a password on a small keyboard, every time, gets old. With a key, the ser
chmod 600 ~/.ssh/authorized_keys chmod 600 ~/.ssh/authorized_keys
``` ```
4. On the Cardputer, connect: **SSH → New connection**, `user@host`. It logs in without asking for anything. 4. On the Cardputer, connect: **SSH → New connection**, `user@host`. It logs in without asking for anything. The same key is what [`scp`](/howto/scp/) uses.
## If it still asks for a password ## If it still asks for a password
+31
View File
@@ -33,6 +33,9 @@ void ShellApp::onEnter() {
// Nothing is kept once it's left: the ring, the lines and the list of commands all go (Q207). // Nothing is kept once it's left: the ring, the lines and the list of commands all go (Q207).
void ShellApp::onExit() { void ShellApp::onExit() {
if (password_ && abort) abort();
password_ = false;
input_.setText("");
console.closeShellRing(); console.closeShellRing();
console.shellShowsAll(false); console.shellShowsAll(false);
open_ = false; open_ = false;
@@ -49,6 +52,11 @@ void ShellApp::update(uint32_t) {
if (skipped) log_.add("[... " + std::to_string(skipped) + " bytes lost: more was printed than fits]"); if (skipped) log_.add("[... " + std::to_string(skipped) + " bytes lost: more was printed than fits]");
log_.feed(reinterpret_cast<const char*>(buf), n); log_.feed(reinterpret_cast<const char*>(buf), n);
} }
if (!password_ && asking && asking()) { // after the question has been printed above
password_ = true;
input_.setText("");
requestRedraw();
}
if (log_.revision() != seenRevision_) { if (log_.revision() != seenRevision_) {
seenRevision_ = log_.revision(); seenRevision_ = log_.revision();
requestRedraw(); requestRedraw();
@@ -114,6 +122,23 @@ bool ShellApp::onKey(const KeyEvent& e) {
log_.add(console.shellShowsAll() ? "Showing everything the console prints." : "Showing only the replies to your commands."); log_.add(console.shellShowsAll() ? "Showing everything the console prints." : "Showing only the replies to your commands.");
return true; return true;
} }
if (password_) { // masked, kept nowhere: not in the history, not in the log
switch (e.key) {
case Key::Char: input_.insert(e.ch); break;
case Key::Delete: input_.backspace(); break;
case Key::Select: {
std::string typed = input_.text();
input_.setText("");
password_ = false;
log_.add("> " + std::string(typed.size(), '*'));
if (answer) answer(typed);
typed.assign(typed.size(), '\0');
break;
}
default: return false; // Back leaves the Shell, which cancels the copy
}
return true;
}
std::string recalled; std::string recalled;
switch (e.key) { switch (e.key) {
case Key::Char: input_.insert(e.ch); break; case Key::Char: input_.insert(e.ch); break;
@@ -197,7 +222,13 @@ void ShellApp::draw(Canvas& c) {
} }
c.setTextDatum(top_left); c.setTextDatum(top_left);
if (password_) {
LineEditor stars(240);
stars.setText(std::string(input_.text().size(), '*'));
widgets::lineEditor(c, stars, {2, area.y + area.h - inputH, area.w - 4, 0});
} else {
widgets::lineEditor(c, input_, {2, area.y + area.h - inputH, area.w - 4, 0}); widgets::lineEditor(c, input_, {2, area.y + area.h - inputH, area.w - 4, 0});
}
if (confirm_) widgets::dialog(c, "Delete?", question_, *confirm_); if (confirm_) widgets::dialog(c, "Delete?", question_, *confirm_);
} }
+7
View File
@@ -36,6 +36,12 @@ class ShellApp : public App {
ShellApp(Run run, Probe probe, List list, Count count, const char* helpText, AppManager& apps) ShellApp(Run run, Probe probe, List list, Count count, const char* helpText, AppManager& apps)
: run_(std::move(run)), probe_(std::move(probe)), list_(std::move(list)), count_(std::move(count)), helpText_(helpText), apps_(apps) {} : run_(std::move(run)), probe_(std::move(probe)), list_(std::move(list)), count_(std::move(count)), helpText_(helpText), apps_(apps) {}
// `scp` asking for a password: while asking() is true, the next line typed is masked and goes to
// answer() instead of the console; leaving the Shell calls abort().
std::function<bool()> asking;
std::function<void(const std::string&)> answer;
std::function<void()> abort;
void onEnter() override; void onEnter() override;
void onExit() override; void onExit() override;
bool onKey(const KeyEvent& e) override; bool onKey(const KeyEvent& e) override;
@@ -63,6 +69,7 @@ class ShellApp : public App {
uint32_t ringPos_ = 0, seenRevision_ = 0; uint32_t ringPos_ = 0, seenRevision_ = 0;
int scroll_ = 0; // wrapped lines scrolled back from the bottom int scroll_ = 0; // wrapped lines scrolled back from the bottom
bool open_ = false; bool open_ = false;
bool password_ = false; // the line being typed is a password
}; };
} // namespace roro } // namespace roro
+26 -2
View File
@@ -14,6 +14,7 @@
#include "apps/shell_app.h" #include "apps/shell_app.h"
#include "apps/ssh_app.h" #include "apps/ssh_app.h"
#include "services/net_tools.h" #include "services/net_tools.h"
#include "services/scp_service.h"
#include "services/ssh_service.h" #include "services/ssh_service.h"
#include "services/vpn_service.h" #include "services/vpn_service.h"
#include "services/web_share.h" #include "services/web_share.h"
@@ -63,6 +64,7 @@
#include "storage_paths.h" #include "storage_paths.h"
#include "ui/notifier.h" #include "ui/notifier.h"
#include "ui/screen.h" #include "ui/screen.h"
#include "scp_args.h"
#include "version.h" #include "version.h"
using namespace roro; using namespace roro;
@@ -95,6 +97,8 @@ static VpnService* vpnService; // not in Safe Mode
static NetTools netTools; // ping, nslookup and the rest (issue #90) static NetTools netTools; // ping, nslookup and the rest (issue #90)
static SshService* sshService; // not in Safe Mode (issue #2) static SshService* sshService; // not in Safe Mode (issue #2)
static SshApp* sshApp; static SshApp* sshApp;
static ShellApp* shellApp;
static ScpService* scpService; // not in Safe Mode
static Notifier* notifier; static Notifier* notifier;
static LauncherApp launcher; static LauncherApp launcher;
static AppManager* apps; static AppManager* apps;
@@ -235,8 +239,13 @@ void setup() {
apps->registerApp({"notes", "Notes", false, new NotesApp(*fileOps, *storageService, *clockService, *power)}); apps->registerApp({"notes", "Notes", false, new NotesApp(*fileOps, *storageService, *clockService, *power)});
sshService = new SshService(settings); sshService = new SshService(settings);
sshApp = new SshApp(*sshService, settings, *storageService); sshApp = new SshApp(*sshService, settings, *storageService);
scpService = new ScpService(settings, *storageService, *fileOps);
apps->registerApp({"ssh", "SSH", false, sshApp}); apps->registerApp({"ssh", "SSH", false, sshApp});
apps->registerApp({"shell", "Shell", false, new ShellApp(shellRun, shellProbe, shellList, shellCount, helpText(), *apps)}); shellApp = new ShellApp(shellRun, shellProbe, shellList, shellCount, helpText(), *apps);
apps->registerApp({"shell", "Shell", false, shellApp});
shellApp->asking = [] { return scpService->asking(); };
shellApp->answer = [](const std::string& password) { scpService->answer(password); };
shellApp->abort = [] { scpService->cancel(); };
// Leaving the foreground App makes it save: a note being typed, when the device is powered off. // Leaving the foreground App makes it save: a note being typed, when the device is powered off.
power->beforePowerOff = []() { apps->home(); }; power->beforePowerOff = []() { apps->home(); };
netTools.ntpServer = []() { return settings.getString(Setting::Ntp1); }; netTools.ntpServer = []() { return settings.getString(Setting::Ntp1); };
@@ -688,6 +697,8 @@ 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" "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" "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" "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"
"uname the firmware, its version and the chip it is built for\n"
"scp [-f] [-P port] <file on the card> user@host:path | scp [-f] [-P port] user@host:path <file on the card> one file over SSH, with this device's key or, in the Shell, a password, to a server the SSH App already trusts; -f replaces a file on the card; `cancel` stops it\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" "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" "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 status | debug off [seconds] the Debug Console over Wi-Fi (Settings > Debug Console); with seconds, it comes back\n"
@@ -771,6 +782,16 @@ static void sshCommand(const String& arg) {
} }
} }
// `scp ...`: see ScpService. It needs the key and the trusted server the SSH App sets up.
static void scpCommand(const String& arg) {
if (!scpService) return (void)console.println("scp: not available in Safe Mode");
term::ScpArgs args;
std::string why = term::parseScp(arg.c_str(), args);
if (!why.empty()) return (void)console.printf("scp: %s\n", why.c_str());
why = scpService->start(args, sshService && sshService->state() != SshService::State::Idle && sshService->state() != SshService::State::Ended);
if (!why.empty()) console.printf("scp: error %s\n", why.c_str());
}
// `vpn ...` (issue #8). Nothing here prints a key. // `vpn ...` (issue #8). Nothing here prints a key.
static void vpnCommand(const String& arg) { static void vpnCommand(const String& arg) {
if (!vpnService) return (void)console.println("vpn: not available in Safe Mode"); if (!vpnService) return (void)console.println("vpn: not available in Safe Mode");
@@ -840,7 +861,7 @@ static void screenshotStep() {
// Commands that only touch what Safe Mode starts. // Commands that only touch what Safe Mode starts.
static bool safeModeCommand(const String& line) { static bool safeModeCommand(const String& line) {
return line == "help" || line == "info" || line == "tasks" || line == "net" || line == "reboot" || line == "boot other" || return line == "help" || line == "uname" || line == "info" || line == "tasks" || line == "net" || line == "reboot" || line == "boot other" ||
line.startsWith("log level ") || line.startsWith("crash") || line.startsWith("coredump") || line.startsWith("log level ") || line.startsWith("crash") || line.startsWith("coredump") ||
line == "wifi status" || line.startsWith("wifi add ") || line.startsWith("debug "); line == "wifi status" || line.startsWith("wifi add ") || line.startsWith("debug ");
} }
@@ -905,6 +926,9 @@ static void runCommand(String line, bool fromSerial = false) {
} }
if (netTools.command(line.c_str())) return; if (netTools.command(line.c_str())) return;
if (line == "cancel") netTools.cancel(); // and a copy or a delete, below if (line == "cancel") netTools.cancel(); // and a copy or a delete, below
if (line == "uname" || line == "uname -a") return (void)console.println(unameString().c_str());
if (line.startsWith("scp ")) return scpCommand(line.substring(4));
if (line == "cancel" && scpService) scpService->cancel();
if (line == "ssh" || line.startsWith("ssh ")) return sshCommand(line.length() > 4 ? line.substring(4) : String("status")); 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 == "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.startsWith("debug ")) return debugCommand(line.substring(6), fromSerial);
+29
View File
@@ -0,0 +1,29 @@
#include "platform/libssh_util.h"
#include <libssh_esp32.h>
namespace roro::sshutil {
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 roro::sshutil
+15
View File
@@ -0,0 +1,15 @@
#pragma once
#include <string>
#include <libssh/libssh.h>
// What the SSH Service and the Scp Service both do with libssh.
namespace roro::sshutil {
// libssh_begin(), once.
void startLibrary();
// "SHA256:..." of the key the server showed, or "".
std::string fingerprintOf(ssh_session s);
} // namespace roro::sshutil
+269
View File
@@ -0,0 +1,269 @@
#include "services/scp_service.h"
#include <Arduino.h>
#include <SD.h>
#include <algorithm>
#include <libssh/libssh.h>
#include "platform/libssh_util.h"
namespace roro {
namespace {
constexpr uint32_t kStack = 20480; // as the SSH Service's
constexpr size_t kPiece = 4096;
std::string baseName(const std::string& path) {
size_t slash = path.rfind('/');
return slash == std::string::npos ? path : path.substr(slash + 1);
}
} // namespace
struct ScpService::Job {
ScpService* owner;
Console::Origin from;
term::ScpArgs args;
std::string privateKey, known;
bool stopped() const { return owner->stop_; }
bool onCard(const std::function<void()>& work) { return owner->storage_.runAndWait(work); }
void run();
bool login(ssh_session s);
void upload(ssh_session s);
void download(ssh_session s);
};
std::string ScpService::start(const term::ScpArgs& args, bool sshOpen) {
if (busy_) return "A copy is running: `cancel` stops it";
if (sshOpen) return "An SSH session is open: close it first, there isn't memory for both";
if (ESP.getFreeHeap() < kNeedFree) return "Not enough memory: close IRC or a Gemini page";
std::string key = settings_.getString(Setting::SshKey);
if (!args.upload) {
if (std::string why = files_.whyReadOnly(args.local, false); !why.empty()) return why;
bool exists = false;
if (!storage_.runAndWait([&]() { exists = SD.exists(args.local.c_str()); })) return "The card isn't answering";
if (exists && !args.replace) return args.local + " exists already: scp -f replaces it";
}
auto* job = new Job{this, console.origin(), args, key, term::SshKnownHosts(settings_.getString(Setting::SshKnown)).fingerprintOf(args.target.hostPort())};
stop_ = asking_ = answered_ = false;
busy_ = true;
if (xTaskCreate(task, "scp", kStack, job, 1, nullptr) != pdPASS) {
busy_ = false;
job->privateKey.assign(job->privateKey.size(), '\0');
delete job;
return "Not enough memory for it";
}
return "";
}
void ScpService::task(void* arg) {
Job* job = static_cast<Job*>(arg);
{
Console::As as(job->from);
job->run();
}
job->privateKey.assign(job->privateKey.size(), '\0');
ScpService* owner = job->owner;
delete job;
owner->busy_ = false;
vTaskDelete(nullptr);
}
void ScpService::answer(const std::string& password) {
if (!asking_) return;
password_ = password;
asking_ = false;
answered_ = true;
}
// This device's key first, then the password, three tries: asked in the Shell that typed the command.
bool ScpService::Job::login(ssh_session s) {
int rc = ssh_userauth_none(s, nullptr);
if (rc == SSH_AUTH_SUCCESS) return true;
int methods = ssh_userauth_list(s, nullptr);
if (!privateKey.empty() && (methods & SSH_AUTH_METHOD_PUBLICKEY)) {
ssh_key key = nullptr;
if (ssh_pki_import_privkey_base64(privateKey.c_str(), nullptr, nullptr, nullptr, &key) == SSH_OK) {
rc = ssh_userauth_publickey(s, nullptr, key);
ssh_key_free(key);
}
}
privateKey.assign(privateKey.size(), '\0');
if (rc == SSH_AUTH_SUCCESS) return true;
if (!(methods & (SSH_AUTH_METHOD_PASSWORD | SSH_AUTH_METHOD_INTERACTIVE))) {
console.println("scp: error the server takes neither this device's key nor a password");
return false;
}
if (from != Console::Origin::Shell) {
console.println("scp: error the server doesn't know this device's key, and a password is only asked in the Shell");
return false;
}
for (int tries = 0; tries < 3 && !stopped(); tries++) {
console.printf("scp: %s's password for %s (Back cancels):\n", args.target.user.c_str(), args.target.host.c_str());
owner->answered_ = false;
owner->asking_ = true;
while (!owner->answered_ && !stopped()) vTaskDelay(pdMS_TO_TICKS(50));
owner->asking_ = false;
if (stopped()) break;
std::string password;
password.swap(owner->password_);
if (methods & SSH_AUTH_METHOD_PASSWORD) rc = ssh_userauth_password(s, nullptr, password.c_str());
else { // asked as questions: every one 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_SUCCESS) return true;
if (rc == SSH_AUTH_ERROR) break;
console.println("scp: wrong password");
}
console.println(stopped() ? "scp: stopped" : "scp: error not logged in");
return false;
}
void ScpService::Job::run() {
sshutil::startLibrary();
ssh_session s = ssh_new();
if (!s) return (void)console.println("scp: error not enough memory");
int verbosity = SSH_LOG_NOLOG, port = args.target.port;
long timeout = 10;
ssh_options_set(s, SSH_OPTIONS_HOST, args.target.host.c_str());
ssh_options_set(s, SSH_OPTIONS_PORT, &port);
ssh_options_set(s, SSH_OPTIONS_USER, args.target.user.c_str());
ssh_options_set(s, SSH_OPTIONS_TIMEOUT, &timeout);
ssh_options_set(s, SSH_OPTIONS_LOG_VERBOSITY, &verbosity);
if (ssh_connect(s) != SSH_OK) {
console.printf("scp: error no connection to %s: %s\n", args.target.hostPort().c_str(), ssh_get_error(s));
} else if (known.empty()) {
console.printf("scp: error %s is not a server this device trusts yet: connect with the SSH App once\n", args.target.hostPort().c_str());
} else if (sshutil::fingerprintOf(s) != known) {
console.printf("scp: error %s showed a different key than the one remembered: not connected\n", args.target.hostPort().c_str());
} else if (login(s)) {
if (args.upload) upload(s);
else download(s);
}
ssh_disconnect(s);
ssh_free(s);
}
void ScpService::Job::upload(ssh_session s) {
// The remote path is where the file goes; the name pushed is only used if that is a folder.
ssh_scp scp = ssh_scp_new(s, SSH_SCP_WRITE, args.remote.c_str());
if (!scp || ssh_scp_init(scp) != SSH_OK) {
console.printf("scp: error %s\n", ssh_get_error(s));
if (scp) ssh_scp_free(scp);
return;
}
File f;
size_t size = 0;
bool opened = onCard([&]() {
f = SD.open(args.local.c_str());
if (f && !f.isDirectory()) size = f.size();
else if (f) f.close();
});
if (!opened || !f) {
console.printf("scp: error cannot read %s\n", args.local.c_str());
ssh_scp_close(scp);
ssh_scp_free(scp);
return;
}
console.printf("scp: %s (%u bytes) to %s:%s\n", args.local.c_str(), (unsigned)size, args.target.text().c_str(), args.remote.c_str());
bool ok = ssh_scp_push_file(scp, baseName(args.local).c_str(), size, 0644) == SSH_OK;
uint8_t buf[kPiece];
uint32_t started = millis();
size_t sent = 0;
while (ok && sent < size && !stopped()) {
int n = 0;
onCard([&]() { n = f.read(buf, std::min(sizeof buf, size - sent)); });
if (n <= 0) {
console.printf("scp: error the card stopped reading at %u of %u\n", (unsigned)sent, (unsigned)size);
ok = false;
break;
}
if (ssh_scp_write(scp, buf, static_cast<size_t>(n)) != SSH_OK) {
console.printf("scp: error %s\n", ssh_get_error(s));
ok = false;
break;
}
sent += static_cast<size_t>(n);
}
onCard([&]() { f.close(); });
if (ok && stopped()) console.printf("scp: stopped at %u of %u bytes\n", (unsigned)sent, (unsigned)size);
else if (ok) console.printf("scp: sent %u bytes in %lu ms\n", (unsigned)sent, (unsigned long)(millis() - started));
else if (!ok && sent == 0) console.printf("scp: error the server refused it: %s\n", ssh_get_error(s));
ssh_scp_close(scp);
ssh_scp_free(scp);
}
void ScpService::Job::download(ssh_session s) {
ssh_scp scp = ssh_scp_new(s, SSH_SCP_READ, args.remote.c_str());
if (!scp || ssh_scp_init(scp) != SSH_OK) {
console.printf("scp: error %s\n", ssh_get_error(s));
if (scp) ssh_scp_free(scp);
return;
}
// Into a part file, renamed once it is all there: a failure never leaves half a file under the real name.
std::string part = args.local + ".part";
File f;
bool ok = false;
size_t got = 0, size = 0;
uint32_t started = millis();
if (ssh_scp_pull_request(scp) != SSH_SCP_REQUEST_NEWFILE) {
const char* why = ssh_get_error(s);
console.printf("scp: error %s\n", why && *why ? why : "that isn't a plain file: scp copies one file");
} else {
size = static_cast<size_t>(ssh_scp_request_get_size64(scp));
ssh_scp_accept_request(scp);
console.printf("scp: %s:%s (%u bytes) to %s\n", args.target.text().c_str(), args.remote.c_str(), (unsigned)size, args.local.c_str());
bool opened = false;
onCard([&]() {
size_t slash = args.local.rfind('/');
if (slash > 0 && !SD.exists(args.local.substr(0, slash).c_str())) SD.mkdir(args.local.substr(0, slash).c_str());
if (SD.exists(part.c_str())) SD.remove(part.c_str());
f = SD.open(part.c_str(), FILE_WRITE);
opened = static_cast<bool>(f);
});
if (!opened) console.printf("scp: error cannot write %s\n", args.local.c_str());
ok = opened;
uint8_t buf[kPiece];
while (ok && got < size && !stopped()) {
int n = ssh_scp_read(scp, buf, std::min(sizeof buf, size - got));
if (n <= 0) {
console.printf("scp: error the connection ended at %u of %u bytes\n", (unsigned)got, (unsigned)size);
ok = false;
break;
}
size_t written = 0;
onCard([&]() { written = f.write(buf, static_cast<size_t>(n)); });
if (written != static_cast<size_t>(n)) {
console.println("scp: error the card refused the write: is it full?");
ok = false;
break;
}
got += static_cast<size_t>(n);
}
if (ok && got < size) ok = false, console.printf("scp: stopped at %u of %u bytes\n", (unsigned)got, (unsigned)size);
}
bool renamed = false;
onCard([&]() {
if (f) f.close();
if (ok) {
if (SD.exists(args.local.c_str())) SD.remove(args.local.c_str());
renamed = SD.rename(part.c_str(), args.local.c_str());
} else if (SD.exists(part.c_str())) {
SD.remove(part.c_str());
}
});
if (ok && !renamed) console.println("scp: error the card refused the new name");
else if (ok) console.printf("scp: received %u bytes in %lu ms\n", (unsigned)got, (unsigned long)(millis() - started));
ssh_scp_close(scp);
ssh_scp_free(scp);
}
} // namespace roro
+50
View File
@@ -0,0 +1,50 @@
#pragma once
#include <atomic>
#include <memory>
#include <string>
#include "platform/console.h"
#include "scp_args.h"
#include "services/file_ops.h"
#include "services/storage_service.h"
#include "settings.h"
namespace roro {
// `scp`: one file between the card and a server, over SSH (libssh's SCP), on a task of its own that
// prints to the console that asked. It logs in with this device's key and, if the server doesn't
// know it, asks for the password: but only when the command was typed in the Shell, which draws
// the question masked (asking(), answer()). It goes only to servers the SSH App has already been
// told to trust. One at a time, and not while an SSH session is open (they don't fit in memory
// together).
class ScpService {
public:
static constexpr size_t kNeedFree = 75 * 1024;
ScpService(Settings& settings, StorageService& storage, FileOps& files) : settings_(settings), storage_(storage), files_(files) {}
// "" when it started, or why not.
std::string start(const term::ScpArgs& args, bool sshOpen);
void cancel() {
stop_ = true;
asking_ = false;
answered_ = true; // whatever it was waiting for
}
bool busy() const { return busy_; }
// The password is wanted (for user@host, in `who`); the Shell calls answer() with what was typed.
bool asking() const { return asking_; }
void answer(const std::string& password);
private:
struct Job;
static void task(void* arg);
Settings& settings_;
StorageService& storage_;
FileOps& files_;
std::atomic<bool> busy_{false}, stop_{false}, asking_{false}, answered_{false};
std::string password_; // written by answer() before answered_ is set, read by the task after
};
} // namespace roro
+5 -24
View File
@@ -5,6 +5,8 @@
#include <libssh_esp32.h> #include <libssh_esp32.h>
#include <libssh/libssh.h> #include <libssh/libssh.h>
#include "platform/libssh_util.h"
namespace roro { namespace roro {
namespace { namespace {
@@ -17,27 +19,6 @@ struct Locked {
SemaphoreHandle_t 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 } // namespace
struct SshService::Run { struct SshService::Run {
@@ -170,7 +151,7 @@ void SshService::task(void* arg) {
} }
void SshService::session(Run& run) { void SshService::session(Run& run) {
startLibrary(); sshutil::startLibrary();
ssh_session s = ssh_new(); ssh_session s = ssh_new();
if (!s) return end("Not enough memory for the session"); if (!s) return end("Not enough memory for the session");
int verbosity = SSH_LOG_NOLOG; int verbosity = SSH_LOG_NOLOG;
@@ -204,7 +185,7 @@ void SshService::session(Run& run) {
if (stop_) return fail("Stopped"); if (stop_) return fail("Stopped");
// Is it the server it was last time? The first time, and when it has changed, the user decides. // Is it the server it was last time? The first time, and when it has changed, the user decides.
std::string seen = fingerprintOf(s); std::string seen = sshutil::fingerprintOf(s);
if (seen.empty()) return fail("The server showed no key"); if (seen.empty()) return fail("The server showed no key");
if (seen != run.known) { if (seen != run.known) {
{ {
@@ -333,7 +314,7 @@ std::string SshService::makeKey() {
auto work = [](void* p) { auto work = [](void* p) {
std::shared_ptr<Made> m = *static_cast<std::shared_ptr<Made>*>(p); std::shared_ptr<Made> m = *static_cast<std::shared_ptr<Made>*>(p);
delete static_cast<std::shared_ptr<Made>*>(p); delete static_cast<std::shared_ptr<Made>*>(p);
startLibrary(); sshutil::startLibrary();
ssh_key key = nullptr, pub = nullptr; ssh_key key = nullptr, pub = nullptr;
char *b64 = nullptr, *pub64 = 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"; if (ssh_pki_generate(SSH_KEYTYPE_ED25519, 0, &key) != SSH_OK) m->why = "The key couldn't be made";
+15 -1
View File
@@ -5,6 +5,7 @@
#include "../memory_store.h" #include "../memory_store.h"
#include "irc_session.h" #include "irc_session.h"
#include "version.h"
using namespace roro; using namespace roro;
@@ -326,7 +327,19 @@ void test_action_and_ctcp_version() {
f.take(); f.take();
f.recv(":alice!u@h PRIVMSG roro :\x01VERSION\x01"); f.recv(":alice!u@h PRIVMSG roro :\x01VERSION\x01");
f.take(); f.take();
TEST_ASSERT_TRUE(f.sent("NOTICE alice :\x01VERSION roro9stack\x01")); TEST_ASSERT_TRUE(f.sent(std::string("NOTICE alice :\x01VERSION roro9stack ") + versionString() + "\x01"));
}
void test_uname_shows_the_firmware_here_and_sends_nothing() {
Fixture f;
f.registerNow();
f.recv(":roro!u@h JOIN #roro");
f.take();
f.session->input(f.buffer("#roro"), "/uname", 0);
const auto& line = f.session->buffer(f.buffer("#roro")).lines.back();
TEST_ASSERT_EQUAL(static_cast<int>(IrcLine::Kind::Info), static_cast<int>(line.kind));
TEST_ASSERT_EQUAL_STRING(unameString().c_str(), line.text.c_str());
TEST_ASSERT_EQUAL(0, f.take().send.size());
} }
void test_topic_is_kept() { void test_topic_is_kept() {
@@ -491,6 +504,7 @@ int main() {
RUN_TEST(test_private_message_opens_a_query_buffer_and_notifies); RUN_TEST(test_private_message_opens_a_query_buffer_and_notifies);
RUN_TEST(test_no_notification_while_viewing_that_buffer); RUN_TEST(test_no_notification_while_viewing_that_buffer);
RUN_TEST(test_action_and_ctcp_version); RUN_TEST(test_action_and_ctcp_version);
RUN_TEST(test_uname_shows_the_firmware_here_and_sends_nothing);
RUN_TEST(test_topic_is_kept); RUN_TEST(test_topic_is_kept);
RUN_TEST(test_input_message_and_commands); RUN_TEST(test_input_message_and_commands);
RUN_TEST(test_j_is_short_for_join); RUN_TEST(test_j_is_short_for_join);
+66
View File
@@ -0,0 +1,66 @@
#include <unity.h>
#include "scp_args.h"
using roro::term::ScpArgs;
using roro::term::parseScp;
void setUp() {}
void tearDown() {}
void test_upload() {
ScpArgs a;
TEST_ASSERT_EQUAL_STRING("", parseScp("/notes/a.txt bob@nas.lan:docs/a.txt", a).c_str());
TEST_ASSERT_TRUE(a.upload);
TEST_ASSERT_EQUAL_STRING("bob", a.target.user.c_str());
TEST_ASSERT_EQUAL_STRING("nas.lan", a.target.host.c_str());
TEST_ASSERT_EQUAL(22, a.target.port);
TEST_ASSERT_EQUAL_STRING("/notes/a.txt", a.local.c_str());
TEST_ASSERT_EQUAL_STRING("docs/a.txt", a.remote.c_str());
}
void test_download_and_port() {
ScpArgs a;
TEST_ASSERT_EQUAL_STRING("", parseScp("-P 2222 bob@10.9.0.1:/var/log/syslog /logs/syslog", a).c_str());
TEST_ASSERT_FALSE(a.upload);
TEST_ASSERT_EQUAL(2222, a.target.port);
TEST_ASSERT_EQUAL_STRING("/var/log/syslog", a.remote.c_str());
TEST_ASSERT_EQUAL_STRING("/logs/syslog", a.local.c_str());
}
void test_download_into_a_folder_takes_the_remote_name() {
ScpArgs a;
TEST_ASSERT_EQUAL_STRING("", parseScp("bob@h:/etc/motd /notes/", a).c_str());
TEST_ASSERT_EQUAL_STRING("/notes/motd", a.local.c_str());
}
void test_replace_flag() {
ScpArgs a;
TEST_ASSERT_EQUAL_STRING("", parseScp("-f -P 22 bob@h:x /a", a).c_str());
TEST_ASSERT_TRUE(a.replace);
TEST_ASSERT_EQUAL_STRING("", parseScp("bob@h:x /a", a).c_str());
TEST_ASSERT_FALSE(a.replace);
TEST_ASSERT_TRUE(parseScp("-r bob@h:x /a", a).size() > 0);
}
void test_refusals() {
ScpArgs a;
TEST_ASSERT_TRUE(parseScp("", a).size() > 0);
TEST_ASSERT_TRUE(parseScp("/a.txt /b.txt", a).size() > 0); // neither is a server
TEST_ASSERT_TRUE(parseScp("a@h:x b@h:y", a).size() > 0); // both are
TEST_ASSERT_TRUE(parseScp("a.txt bob@h:x", a).size() > 0); // not a card path
TEST_ASSERT_TRUE(parseScp("/a.txt bob@h:", a).size() > 0); // no remote path
TEST_ASSERT_TRUE(parseScp("/notes/ bob@h:x", a).size() > 0); // a folder
TEST_ASSERT_TRUE(parseScp("-P 0 /a bob@h:x", a).size() > 0);
TEST_ASSERT_TRUE(parseScp("/a bob@bad_host:x", a).size() > 0);
}
int main() {
UNITY_BEGIN();
RUN_TEST(test_upload);
RUN_TEST(test_download_and_port);
RUN_TEST(test_download_into_a_folder_takes_the_remote_name);
RUN_TEST(test_replace_flag);
RUN_TEST(test_refusals);
return UNITY_END();
}
+7
View File
@@ -14,9 +14,16 @@ void test_product_name() {
TEST_ASSERT_EQUAL_STRING("roro9stack", roro::kProductName); TEST_ASSERT_EQUAL_STRING("roro9stack", roro::kProductName);
} }
void test_uname_has_name_version_and_architecture() {
std::string expected = std::string("roro9stack ") + roro::versionString() + " " + roro::architectureString();
TEST_ASSERT_EQUAL_STRING(expected.c_str(), roro::unameString().c_str());
TEST_ASSERT_TRUE(std::strlen(roro::architectureString()) > 0);
}
int main() { int main() {
UNITY_BEGIN(); UNITY_BEGIN();
RUN_TEST(test_version_is_never_empty); RUN_TEST(test_version_is_never_empty);
RUN_TEST(test_product_name); RUN_TEST(test_product_name);
RUN_TEST(test_uname_has_name_version_and_architecture);
return UNITY_END(); return UNITY_END();
} }