Public Access
This commit was merged in pull request #94.
This commit is contained in:
@@ -114,6 +114,14 @@ _Avoid_: terminal, command line, REPL
|
||||
The WireGuard connection to one server, over whatever Wi-Fi the device is on. It carries either everything or the one subnet the device's address in it belongs to. Wanted or not is the user's switch; up or not depends on Wi-Fi, the clock and the server.
|
||||
_Avoid_: VPN connection, link, session
|
||||
|
||||
**Session**:
|
||||
The one SSH connection to a shell on another machine, from login until either side ends it. It belongs to the SSH Service, not to the SSH App: it goes on while another App is in front.
|
||||
_Avoid_: connection, tunnel, terminal (the terminal is what draws it)
|
||||
|
||||
**Device Key**:
|
||||
The Ed25519 key pair the device makes for itself to log in over SSH. The private half never leaves the device and is never shown; the public half is meant to be copied to servers.
|
||||
_Avoid_: identity, SSH key file, certificate
|
||||
|
||||
**Sharing**:
|
||||
Serving the SD card as a web page to a browser on the same network, for as long as the Storage App's Share screen is open, to whoever typed the code that screen shows.
|
||||
_Avoid_: file server, web server, FTP, upload mode
|
||||
@@ -193,6 +201,7 @@ _Avoid_: telnet, remote shell, Debug Build (there is one firmware)
|
||||
- **Services** keep running underneath, regardless of which **App** is in the foreground.
|
||||
- The **Mesh Service** speaks one or more **Mesh Protocols** and tracks the known **Nodes**.
|
||||
- The **Wi-Fi Service** is either Connected or Monitoring, never both. Monitoring pauses the **IRC Service**, which reconnects and rejoins its **Buffers** afterwards.
|
||||
- The SSH App draws the one **Session**; the **Session** and the **IRC Service**'s connection don't fit in memory together, so each waits for the other.
|
||||
- **Services** raise **Notifications**; the **Status Bar** summarises **Service** state.
|
||||
- The **Radio Service** owns the radio; the **Mesh Service** and the LoRa Scanner use it.
|
||||
- A **Sweep** pauses the **Mesh Service**; a **Sniffer** does not.
|
||||
|
||||
@@ -15,6 +15,7 @@ What it does today:
|
||||
- **Storage:** the SD card: copy, move, rename, delete; viewers for text, hex, pictures (PNG, JPEG, BMP, GIF), tracks, captures and update files; **sharing with a phone's browser**.
|
||||
- **Shell:** the firmware's commands on the device itself, with completion, including `ping`, `nslookup`, `port`, `traceroute`, `tls` and `ifconfig`.
|
||||
- **System:** load, tasks, memory, network, battery, live.
|
||||
- **SSH:** a terminal on another machine, with a password or the device's own key.
|
||||
- **VPN:** a WireGuard tunnel.
|
||||
- **Everywhere:** Fn+h lists the keys of the screen you are on; Fn+p takes a screenshot.
|
||||
- **Updates:** signed, from the project's server, the card or a PC, with a rollback if the new firmware fails.
|
||||
@@ -177,6 +178,12 @@ A new note has no file until something is typed; its file is then named after it
|
||||
|
||||
**A note can be any size** (issue #47): the editor keeps a window of about 8 KB around the cursor in memory and the rest on the card, so a megabyte opens as fast as a line and uses the same 17 KB. Up to 64 KB a save rewrites the file. Above, the five-second save writes only what changed to a side file, `<note>.edit`, and the file itself is rewritten when the note is left, with a progress bar (about 450 KB a second). After a power cut, opening the note picks the edit up where it was saved. Saving needs room on the card for a second copy. The Storage App's text viewer has `e` to edit a file with the same editor, anywhere on the card, unless the file is read-only.
|
||||
|
||||
## SSH
|
||||
|
||||
The SSH App (docs/milestones/N1.md, issue #2) is a terminal on another machine: one session to a shell, over libssh (`ewpa/LibSSH-ESP32`) on mbedTLS. `user@host[:port]`, typed in the App or as `ssh user@host` in the Shell; up to eight hosts are remembered. **A server is trusted the first time, on its fingerprint, and a changed key is a warning** whose selected answer is Cancel. **The password is typed each time and kept nowhere;** or the device makes itself an Ed25519 key (kept in the device, never shown, no passphrase) whose public half is shown, written to `/ssh/id_ed25519.pub` and printed by `ssh status`, for a server's `authorized_keys`.
|
||||
|
||||
The terminal (`lib/term`, host-tested) understands what a shell, `less`, `top`, `nano` and `vim` send: the cursor, erasing, sixteen colours, scroll regions, the alternate screen; no mouse. Five text sizes with Ctrl and + or -, from 60 x 20 to 26 x 8, told to the far end; 100 lines of scrollback with Alt and up or down. The backtick key sends Esc. **Leaving the App doesn't end the session:** `SSH` shows in the Status Bar and the App finds it again. It costs 292 KB of flash (109 KB of it one table, for signing with the device's key) and about 50 KB of memory while a session is open, so it isn't started under 75 KB free and IRC doesn't connect while one is open.
|
||||
|
||||
## VPN
|
||||
|
||||
A WireGuard tunnel (docs/milestones/N1.md), over whatever Wi-Fi the device is on: one peer, IPv4. Copy a client's `.conf` to the card as `/vpn/wg0.conf` and import it in Settings > VPN (or `vpn import`); the configuration, private key included, is then kept in the device and never shown, and Settings offers to delete the file. A switch brings the tunnel up until the next restart; "Start with Wi-Fi" does it every time. It waits for the clock, which WireGuard needs. `VPN` shows in the Status Bar, bright once the server has answered.
|
||||
@@ -185,7 +192,7 @@ A WireGuard tunnel (docs/milestones/N1.md), over whatever Wi-Fi the device is on
|
||||
|
||||
## Shell
|
||||
|
||||
The Shell App (docs/milestones/S1.md) runs the commands below on the device's own screen and keyboard: no PC, no cable, no Wi-Fi. **It shows the replies to its own commands and nothing else**: the console knows who each line is printed for, so a listing read by another task a moment later is still the Shell's, and what USB or the Debug Console asked for is not. Ctrl+b shows everything instead. Tab completes a command word by word (`lora st` gives `lora status`) and, past it, a path on the SD card (`ls /no` gives `ls /notes/`), Fn with up and down recalls earlier lines, Alt with up and down scrolls back. **An App's name with a capital opens it** (`Notes`, `Irc`, `Wifi`, `Gnss`, `Gemini`, `Lora`, `Storage`, `System`, `Settings`), from the consoles too. `rm` is Unix's, with a question: a folder needs `-r`; a file, or a folder with something in it, is asked about unless `-f` (`rm -rf`); an empty folder goes without a word. `*` and `?` in a name stand for several files (`rm /notes/*.txt` asks once, with the count; 64 at most). `clear` empties the screen and `quit` leaves. It is trusted like the USB port: `debug on` and `debug token` work from it. It uses about 7 KB of memory while it is open, and none otherwise. **For the network:** `ping`, `nslookup`, `port`, `traceroute`, `tls`, `ntp`, `ifconfig`, `arp` and `netstat` (issue #90).
|
||||
The Shell App (docs/milestones/S1.md) runs the commands below on the device's own screen and keyboard: no PC, no cable, no Wi-Fi. **It shows the replies to its own commands and nothing else**: the console knows who each line is printed for, so a listing read by another task a moment later is still the Shell's, and what USB or the Debug Console asked for is not. Ctrl+b shows everything instead. Tab completes a command word by word (`lora st` gives `lora status`) and, past it, a path on the SD card (`ls /no` gives `ls /notes/`), Fn with up and down recalls earlier lines, Alt with up and down scrolls back. **An App's name with a capital opens it** (`Notes`, `Irc`, `Wifi`, `Gnss`, `Gemini`, `Lora`, `Storage`, `Ssh`, `System`, `Settings`), from the consoles too. `rm` is Unix's, with a question: a folder needs `-r`; a file, or a folder with something in it, is asked about unless `-f` (`rm -rf`); an empty folder goes without a word. `*` and `?` in a name stand for several files (`rm /notes/*.txt` asks once, with the count; 64 at most). `clear` empties the screen and `quit` leaves. It is trusted like the USB port: `debug on` and `debug token` work from it. It uses about 7 KB of memory while it is open, and none otherwise. **For the network:** `ping`, `nslookup`, `port`, `traceroute`, `tls`, `ntp`, `ifconfig`, `arp` and `netstat` (issue #90).
|
||||
|
||||
## Development aids
|
||||
|
||||
@@ -244,6 +251,7 @@ The Shell App (docs/milestones/S1.md) runs the commands below on the device's ow
|
||||
| `ping <host> [count] [size]` / `nslookup <name> [server]` / `port <host> <port>` / `traceroute <host>` / `cancel` | Network troubleshooting (issue #90): does a host answer and how fast; a name's addresses, from which DNS server and in how long; is a TCP port open, refused or silent; the routers on the way. Each runs on a task of its own and prints as it goes, one at a time; `cancel` stops it |
|
||||
| `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 |
|
||||
| `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 |
|
||||
| `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 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 |
|
||||
|
||||
+80
-1
@@ -1,6 +1,6 @@
|
||||
# N1 — Network tools
|
||||
|
||||
**Status:** in progress. The WireGuard tunnel (issue #8) shipped as **v0.19.0**. The network troubleshooting commands (issue #90) shipped in two parts: `ping`, `nslookup`, `port`, `traceroute`, `ifconfig` and `arp` as **v0.20.0**; `tls`, `ntp` and `netstat` as **v0.21.0**. SSH (#2) is not started.
|
||||
**Status:** in progress. The WireGuard tunnel (issue #8) shipped as **v0.19.0**. The network troubleshooting commands (issue #90) shipped in two parts: `ping`, `nslookup`, `port`, `traceroute`, `ifconfig` and `arp` as **v0.20.0**; `tls`, `ntp` and `netstat` as **v0.21.0**. The SSH client (issue #2) shipped as **v0.22.0**.
|
||||
|
||||
**Goal:** reach things from the device that aren't on the Wi-Fi it happens to be on, and keep its traffic private on a network that isn't yours.
|
||||
|
||||
@@ -141,3 +141,82 @@ With a tunnel, fixed addresses and a file server on the device, "is it the netwo
|
||||
| Memory during a `tls` | 44.5 KB free at the lowest, from 104 KB |
|
||||
|
||||
**Not checked:** `tls` with IRC connected (it should refuse for lack of memory); `ntp` against a clock that is wrong; `netstat` while sharing.
|
||||
|
||||
## The SSH client (issue #2)
|
||||
|
||||
A terminal on another machine: one session to a shell, from the SSH App.
|
||||
|
||||
### Measured before deciding (2026-10-08)
|
||||
|
||||
`ewpa/LibSSH-ESP32` 5.10.0 (libssh on mbedTLS) was built into a trial firmware and a session opened against OpenSSH in a container, with a password.
|
||||
|
||||
| | Measured in the trial | As built |
|
||||
|---|---|---|
|
||||
| Flash | 120 KB | **292 KB** |
|
||||
| Static RAM | 1.2 KB | |
|
||||
| The session's task stack | 13 KB used | 13.7 KB used of 20 KB |
|
||||
| Free heap with a session open | | 49 KB of 99 KB: it costs about 50 KB, the stack included |
|
||||
| Lowest free heap during a login | | 30 KB |
|
||||
| Key exchange (curve25519, ed25519 host key) | 227 ms | |
|
||||
|
||||
- **The trial undercounted the flash.** It logged in with a password. Signing with a key of the device's own (Q255) links libssh's table of multiples of the Ed25519 base point: `ge25519.c.o` alone is 109 KB. The rest of the difference is the public-key code, the terminal and three fonts. The firmware is at 71% of its slot.
|
||||
- **libssh carries its own curve25519** (`src/external/curve25519_ref.c`) with the names libsodium uses, and libsodium is already here for WireGuard: two definitions don't link. A build script (`scripts/libssh_filter.py`) leaves libssh's copy out, and it uses libsodium's. It has to be a `pre:` script: libraries are built before any `post:` one runs.
|
||||
- A session and a TLS connection don't fit together: IRC holds 40 KB.
|
||||
|
||||
### Decisions (design round 2026-10-08)
|
||||
|
||||
| # | Decision |
|
||||
|---|---|
|
||||
| Q254 | **LibSSH-ESP32 5.10.0**, with its duplicate curve file left out of the build. |
|
||||
| Q255 | **A password, typed each time and never stored**, or **a key the device makes for itself** (Ed25519, no passphrase, kept in the settings store). Its public half is shown, written to `/ssh/id_ed25519.pub` and printed by `ssh status`. Keys made elsewhere aren't imported. |
|
||||
| Q256 | **A terminal good enough for a shell, `less`, `top`, `nano` and `vim`:** cursor movement, erasing, sixteen colours, scroll regions, the alternate screen, the cursor keys' two modes. `TERM=xterm`. No mouse. |
|
||||
| Q257 | **Five text sizes, changed with Ctrl and + or -** (the user's change to the round: the proposal was a setting). 4x6, 5x8, 6x10, 6x13 and 9x15 pixels: from 60 x 20 to 26 x 8 characters. The far end is told the new size; the choice is kept. |
|
||||
| Q258 | **100 lines of scrollback**, as text, with Alt and up or down, as in the Shell. Not on the alternate screen. |
|
||||
| Q259 | **Keys:** Ctrl with a letter; Tab; the backtick key sends Esc, as it is printed; Alt with it types a backtick; Fn with the arrow keys; Shift with those for Page Up and Down; Ctrl+Alt+q disconnects. Fn with backtick is Home, as everywhere. |
|
||||
| Q260 | **The session outlives the App's time in front.** `SSH` in the Status Bar while one is open. |
|
||||
| Q261 | **Not started under 75 KB free**, with the reason in words. |
|
||||
| Q262 | **Up to eight hosts remembered**, the last used first, once a login has succeeded. Forgetting one forgets its server's fingerprint too, unless another remembered host is the same server. |
|
||||
| Q263 | **Trust on first use,** on the SHA-256 fingerprint; sixteen servers remembered. **A changed key is a warning**, with Cancel selected. |
|
||||
| Q264 | **UTF-8 in, the fonts' Latin-1 out:** what they lack is `?`, box-drawing lines are `+ - \|`. |
|
||||
| Q265 | **`ssh user@host` in the Shell opens the App** and connects there. The password is never asked on a console. |
|
||||
| Q266 | **Not built:** port forwarding, SFTP and scp, jump hosts, agent forwarding, keys with a passphrase, more than one session. |
|
||||
|
||||
### As built
|
||||
|
||||
- **`lib/term`** (host-tested, 12 tests in `test/test_terminal`): `Terminal`, the screen a program's output makes, with its history and the replies a program asks for; `encodeKey`, what a key sends; `SshHosts` and `SshKnownHosts`, the two lists kept in the settings store as lines of text.
|
||||
- **`SshService`** (`src/services/ssh_service`): one session on a task of its own (20 KB of stack). The task and the main loop share the terminal, the bytes to send and the state under one lock. Its questions (is this the right server? the password?) are states it waits in until the App answers; the settings store is only written from the main loop. The password and the private key are overwritten after use.
|
||||
- **`SshApp`** (`src/apps/ssh_app`): the hosts, the entry, the session, the key page. It draws the grid a run of same-coloured cells at a time, at most every 60 ms.
|
||||
- **Keys that aren't characters now say what was held with them** (`lib/input/src/key_mapper.cpp`): the arrows, Enter, Del, Tab and Back carry Shift, Ctrl and Alt. The terminal needs it for Page Up and Alt+backtick. It also makes two documented keys work from the real keyboard, which until now only worked from the Debug Console's `key` command: Ctrl with Fn and up or down in a note, and Shift+Tab in Gemini.
|
||||
- **IRC doesn't try to connect with less than 60 KB free** (`IrcService::kNeedFree`): see below.
|
||||
- Settings: `SshHosts`, `SshKnown`, `SshKey`, `SshPublic`, `SshFont`.
|
||||
|
||||
### Checks on the device (2026-10-08, against OpenSSH 9.7 in a container on the same network)
|
||||
|
||||
| Check | Result |
|
||||
|---|---|
|
||||
| `ssh tester@host:2222` from the Debug Console | The App opens; the fingerprint shown is the one `ssh-keygen -lf` prints on the server |
|
||||
| Trust it, a password | A shell; `stty size` says 15 48, `$TERM` is xterm |
|
||||
| `ls -la`, `top`, `vim` (insert, Esc, `:wq`) | Drawn right: `top`'s reverse-video header, `vim`'s alternate screen and what was there before coming back; the file is on the server |
|
||||
| Ctrl with + and - | `stty size` says 12 40, then 20 60; `top` redraws for it |
|
||||
| `seq 1 60`, Alt with up | The history, in grey, with how far back in the corner |
|
||||
| Fn+backtick, then the App again | The Launcher with `SSH` in the Status Bar; the session as it was |
|
||||
| `sleep 100`, Ctrl+C; Alt+backtick | Interrupted; `` echo `id -u` `` prints 1000 |
|
||||
| Ctrl+Alt+q; `exit` | "Disconnected"; "The session ended" |
|
||||
| This device's key, its public half in `authorized_keys` | "Accepted publickey" in the server's log; no password asked |
|
||||
| The server's host keys replaced | "THE SERVER'S KEY CHANGED" with the new fingerprint, Cancel selected. Cancel: "Not trusted: not connected". Replace: it connects, and doesn't ask again |
|
||||
| A wrong password | "Wrong password", and asked again; Back gives up |
|
||||
| A port nothing listens on | "Nothing listens there: the connection was refused" |
|
||||
| `ssh nobody`, `ssh a@`, a port of 99999 | Refused, each with its reason |
|
||||
| Forgetting a host | Asked, then gone from the list |
|
||||
| `irc start` with a session open | "not enough memory: close the SSH session, retrying in 5 s", and no attempt: the lowest free heap doesn't move |
|
||||
| Memory | 99 KB free before, 49 KB with a session open, 30 KB at the lowest during a login, 99 KB again after |
|
||||
| Stacks | `ssh` 6.8 KB free of 20 KB; `loopTask` 1.9 KB free, as before |
|
||||
|
||||
**Not checked:** the refusal under 75 KB free (it is one comparison, and wasn't provoked). A server on the internet, or through the VPN. Wi-Fi lost in the middle of a session. Servers other than OpenSSH. `nano`, `less`, `htop`, `tmux`. Keyboard-interactive logins (two-factor prompts). A session left open for hours.
|
||||
|
||||
### What went wrong while building it
|
||||
|
||||
- **IRC, started with a session open, took the free heap down to 236 bytes.** A test script's keys went to the Launcher instead of the terminal and opened the IRC App, which connects when opened. Its TLS handshake found no memory, failed, and tried again with its usual back-off, six times; nothing crashed and it never connected, but 236 bytes is no margin at all. IRC now looks at the free heap before each attempt and says "not enough memory: close the SSH session" instead of trying.
|
||||
- **The build script did nothing as a `post:` script:** the libraries were already built when it ran.
|
||||
- **A failed connection was first shown as an empty terminal** with its reason squeezed on the last line, and a host was remembered before anyone had logged in to it. Both changed: the reason has a page, and a host is remembered once a login succeeds.
|
||||
- **The trust question didn't fit its dialog:** the fingerprint is 50 characters. It is now split over two lines, under one line of words.
|
||||
|
||||
@@ -313,6 +313,34 @@ inline constexpr KeyHelp kVpn[] = {
|
||||
{"; .", "up, down"},
|
||||
};
|
||||
|
||||
// ssh: SSH, the hosts
|
||||
inline constexpr KeyHelp kSsh[] = {
|
||||
{"Enter", "connect, open"},
|
||||
{"; .", "up, down"},
|
||||
{"n", "a new connection"},
|
||||
{"d", "forget this host"},
|
||||
};
|
||||
|
||||
// ssh-terminal: SSH, the terminal
|
||||
inline constexpr KeyHelp kSshTerminal[] = {
|
||||
{"`", "Esc"},
|
||||
{"Alt `", "a backtick"},
|
||||
{"Fn ; . , /", "the arrows"},
|
||||
{"Fn Shift ; .", "Page Up, Page Down"},
|
||||
{"Ctrl a..z", "Ctrl+C and the rest"},
|
||||
{"Alt ; .", "scroll back, forward"},
|
||||
{"Ctrl + -", "larger, smaller text"},
|
||||
{"Ctrl Alt q", "disconnect"},
|
||||
{"Fn `", "leave it running"},
|
||||
};
|
||||
|
||||
// ssh-key: SSH, this device's key
|
||||
inline constexpr KeyHelp kSshKey[] = {
|
||||
{"Enter", "make a key, or a new one"},
|
||||
{"w", "write the public half to the card"},
|
||||
{"`", "back"},
|
||||
};
|
||||
|
||||
// notes: Notes, the list
|
||||
inline constexpr KeyHelp kNotes[] = {
|
||||
{"; .", "up, down"},
|
||||
|
||||
@@ -43,6 +43,15 @@ KeyEvent charEvent(uint32_t cp, const RawKeys& keys) {
|
||||
return e;
|
||||
}
|
||||
|
||||
// A key that isn't a character, with what was held: a terminal tells Alt+Enter from Enter (issue #2).
|
||||
KeyEvent keyEvent(Key key, const RawKeys& keys) {
|
||||
KeyEvent e = KeyEvent::of(key);
|
||||
e.shift = keys.shift;
|
||||
e.ctrl = keys.ctrl;
|
||||
e.alt = keys.alt;
|
||||
return e;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
std::vector<KeyEvent> KeyMapper::update(const RawKeys& keys) {
|
||||
@@ -51,9 +60,9 @@ std::vector<KeyEvent> KeyMapper::update(const RawKeys& keys) {
|
||||
if (keys.opt && !previous_.opt && keys.chars.empty()) {
|
||||
compose_ = compose_ ? 0 : kArmed; // a second opt cancels
|
||||
}
|
||||
if (keys.enter && !previous_.enter) out.push_back(KeyEvent::of(Key::Select));
|
||||
if (keys.del && !previous_.del) out.push_back(KeyEvent::of(Key::Delete));
|
||||
if (keys.tab && !previous_.tab) out.push_back(KeyEvent::of(Key::Tab));
|
||||
if (keys.enter && !previous_.enter) out.push_back(keyEvent(Key::Select, keys));
|
||||
if (keys.del && !previous_.del) out.push_back(keyEvent(Key::Delete, keys));
|
||||
if (keys.tab && !previous_.tab) out.push_back(keyEvent(Key::Tab, keys));
|
||||
|
||||
for (char c : keys.chars) {
|
||||
bool wasHeld = std::find(previous_.chars.begin(), previous_.chars.end(), c) != previous_.chars.end();
|
||||
@@ -89,10 +98,10 @@ void KeyMapper::onChar(char c, const RawKeys& keys, std::vector<KeyEvent>& out)
|
||||
|
||||
if (keys.fn || !textEntry_) {
|
||||
switch (c) {
|
||||
case ';': out.push_back(KeyEvent::of(Key::Up)); return;
|
||||
case '.': out.push_back(KeyEvent::of(Key::Down)); return;
|
||||
case ',': out.push_back(KeyEvent::of(Key::Left)); return;
|
||||
case '/': out.push_back(KeyEvent::of(Key::Right)); return;
|
||||
case ';': out.push_back(keyEvent(Key::Up, keys)); return;
|
||||
case '.': out.push_back(keyEvent(Key::Down, keys)); return;
|
||||
case ',': out.push_back(keyEvent(Key::Left, keys)); return;
|
||||
case '/': out.push_back(keyEvent(Key::Right, keys)); return;
|
||||
case '`':
|
||||
if (keys.fn) {
|
||||
out.push_back(KeyEvent::of(Key::Home));
|
||||
@@ -126,7 +135,7 @@ void KeyMapper::onChar(char c, const RawKeys& keys, std::vector<KeyEvent>& out)
|
||||
}
|
||||
}
|
||||
if (c == '`') {
|
||||
out.push_back(KeyEvent::of(Key::Back));
|
||||
out.push_back(keyEvent(Key::Back, keys));
|
||||
return;
|
||||
}
|
||||
out.push_back(charEvent(static_cast<unsigned char>(c), keys));
|
||||
|
||||
@@ -48,6 +48,11 @@ const Definition kDefinitions[] = {
|
||||
{"help_told", Kind::Bool, 0, nullptr, 0, 1},
|
||||
{"vpn_config", Kind::String, 0, "", 0, 900}, // empty, or a .conf that parses
|
||||
{"vpn_auto", Kind::Bool, 0, nullptr, 0, 1},
|
||||
{"ssh_hosts", Kind::String, 0, "", 0, 800},
|
||||
{"ssh_known", Kind::String, 0, "", 0, 2400},
|
||||
{"ssh_key", Kind::String, 0, "", 0, 800},
|
||||
{"ssh_public", Kind::String, 0, "", 0, 200},
|
||||
{"ssh_font", Kind::Int, 1, nullptr, 0, 4},
|
||||
};
|
||||
static_assert(sizeof(kDefinitions) / sizeof(kDefinitions[0]) == static_cast<size_t>(Setting::Count),
|
||||
"every Setting needs a definition");
|
||||
|
||||
@@ -36,6 +36,11 @@ enum class Setting : uint8_t {
|
||||
HelpTold, // bool: this device has been told about the help key once (issue #69, Q201)
|
||||
VpnConfig, // string: the WireGuard tunnel as a .conf (wg_config.h), private key included: never shown (issue #8)
|
||||
VpnAuto, // bool: the tunnel starts whenever Wi-Fi is connected (Q247: off unless switched on)
|
||||
SshHosts, // string: the SSH App's saved hosts, one user@host[:port] a line (issue #2, ssh_hosts.h)
|
||||
SshKnown, // string: the fingerprint each server showed first, one "host:port fingerprint" a line
|
||||
SshKey, // string: this device's own SSH private key, as OpenSSH writes one: never shown
|
||||
SshPublic, // string: its public half, the line to put in a server's authorized_keys
|
||||
SshFont, // int: the terminal's font, 0 (smallest) to 4
|
||||
Count
|
||||
};
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1,49 @@
|
||||
#pragma once
|
||||
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
// Who the SSH App connects to, and which servers it has met (issue #2, N1 Q262 and Q263): kept
|
||||
// in the device's settings as a few lines of text.
|
||||
namespace roro::term {
|
||||
|
||||
struct SshTarget {
|
||||
std::string user, host;
|
||||
uint16_t port = 22;
|
||||
std::string text() const; // user@host, with :port when it isn't 22
|
||||
std::string hostPort() const; // host:port, always: what a host key is remembered under
|
||||
};
|
||||
// "user@host", "user@host:2222". "" or what is wrong with it.
|
||||
std::string parseSshTarget(const std::string& text, SshTarget& out);
|
||||
|
||||
// Up to eight, the last used first; one a line.
|
||||
class SshHosts {
|
||||
public:
|
||||
static constexpr size_t kMax = 8;
|
||||
explicit SshHosts(const std::string& stored = "");
|
||||
const std::vector<std::string>& list() const { return hosts_; }
|
||||
void used(const SshTarget& target); // to the front, added if it's new
|
||||
void remove(size_t index);
|
||||
std::string stored() const;
|
||||
|
||||
private:
|
||||
std::vector<std::string> hosts_;
|
||||
};
|
||||
|
||||
// The fingerprint each server showed the first time: "host:port SHA256:...", one a line, sixteen
|
||||
// at most (the oldest goes).
|
||||
class SshKnownHosts {
|
||||
public:
|
||||
static constexpr size_t kMax = 16;
|
||||
explicit SshKnownHosts(const std::string& stored = "");
|
||||
std::string fingerprintOf(const std::string& hostPort) const; // "" if never met
|
||||
void remember(const std::string& hostPort, const std::string& fingerprint);
|
||||
void forget(const std::string& hostPort);
|
||||
std::string stored() const;
|
||||
|
||||
private:
|
||||
std::vector<std::pair<std::string, std::string>> known_;
|
||||
};
|
||||
|
||||
} // namespace roro::term
|
||||
@@ -0,0 +1,495 @@
|
||||
#include "terminal.h"
|
||||
|
||||
#include <algorithm>
|
||||
|
||||
namespace roro::term {
|
||||
|
||||
namespace {
|
||||
// A character the screen's font has, for one it may not.
|
||||
uint8_t glyphFor(uint32_t cp) {
|
||||
if (cp >= 0x20 && cp <= 0x7E) return static_cast<uint8_t>(cp);
|
||||
if (cp >= 0xA0 && cp <= 0xFF) return static_cast<uint8_t>(cp);
|
||||
if (cp >= 0x2500 && cp <= 0x257F) { // box drawing
|
||||
switch (cp) {
|
||||
case 0x2500: case 0x2501: case 0x2504: case 0x2505: case 0x2508: case 0x2509: case 0x254C: case 0x254D: case 0x2550: return '-';
|
||||
case 0x2502: case 0x2503: case 0x2506: case 0x2507: case 0x250A: case 0x250B: case 0x254E: case 0x254F: case 0x2551: return '|';
|
||||
default: return '+';
|
||||
}
|
||||
}
|
||||
switch (cp) {
|
||||
case 0x2018: case 0x2019: return '\'';
|
||||
case 0x201C: case 0x201D: return '"';
|
||||
case 0x2010: case 0x2011: case 0x2012: case 0x2013: case 0x2014: return '-';
|
||||
case 0x2022: case 0x25CF: return '*';
|
||||
case 0x2026: return '.';
|
||||
case 0x2190: return '<';
|
||||
case 0x2192: return '>';
|
||||
case 0x2191: return '^';
|
||||
case 0x2193: return 'v';
|
||||
case 0x2588: case 0x2593: case 0x2592: case 0x2591: return '#';
|
||||
default: return '?';
|
||||
}
|
||||
}
|
||||
// The DEC "special graphics" set: lines drawn with the letters j to x.
|
||||
uint8_t lineGlyph(uint8_t c) {
|
||||
switch (c) {
|
||||
case 'q': return '-';
|
||||
case 'x': return '|';
|
||||
case 'j': case 'k': case 'l': case 'm': case 'n': case 't': case 'u': case 'v': case 'w': return '+';
|
||||
case '`': return '*';
|
||||
case 'a': return '#';
|
||||
case '~': return '*';
|
||||
default: return c;
|
||||
}
|
||||
}
|
||||
// One of 256 colours, or a colour given as red, green and blue, as the nearest of the sixteen.
|
||||
int nearest16(int r, int g, int b) {
|
||||
int most = std::max(r, std::max(g, b));
|
||||
if (most < 48) return 0;
|
||||
int half = most / 2;
|
||||
int colour = (r > half ? 1 : 0) | (g > half ? 2 : 0) | (b > half ? 4 : 0);
|
||||
if (colour == 7 && most < 200) return most < 110 ? 8 : 7;
|
||||
return most > 170 ? colour | 8 : colour;
|
||||
}
|
||||
int from256(int n) {
|
||||
if (n < 16) return n;
|
||||
if (n >= 232) return nearest16(8 + (n - 232) * 10, 8 + (n - 232) * 10, 8 + (n - 232) * 10);
|
||||
n -= 16;
|
||||
static const int kSteps[6] = {0, 95, 135, 175, 215, 255};
|
||||
return nearest16(kSteps[n / 36], kSteps[(n / 6) % 6], kSteps[n % 6]);
|
||||
}
|
||||
} // namespace
|
||||
|
||||
void colourRgb(int index, uint8_t& r, uint8_t& g, uint8_t& b) {
|
||||
static const uint8_t kTable[16][3] = {{0, 0, 0}, {205, 49, 49}, {13, 188, 121}, {229, 229, 16}, {36, 114, 200}, {188, 63, 188},
|
||||
{17, 168, 205}, {204, 204, 204}, {102, 102, 102}, {241, 76, 76}, {35, 209, 139}, {245, 245, 67},
|
||||
{59, 142, 234}, {214, 112, 214}, {41, 184, 219}, {255, 255, 255}};
|
||||
r = kTable[index & 15][0];
|
||||
g = kTable[index & 15][1];
|
||||
b = kTable[index & 15][2];
|
||||
}
|
||||
|
||||
Terminal::Terminal(int cols, int rows, int historyLines)
|
||||
: cols_(std::max(2, cols)), rows_(std::max(2, rows)), historyMax_(std::max(0, historyLines)), mainGrid_(static_cast<size_t>(cols_ * rows_)),
|
||||
altGrid_(static_cast<size_t>(cols_ * rows_)), bottom_(rows_ - 1) {}
|
||||
|
||||
Cell Terminal::blank() const {
|
||||
Cell c;
|
||||
c.attr = static_cast<uint8_t>((bg_ << 4) | 7);
|
||||
return c;
|
||||
}
|
||||
|
||||
bool Terminal::takeBell() {
|
||||
bool b = bell_;
|
||||
bell_ = false;
|
||||
return b;
|
||||
}
|
||||
|
||||
std::string Terminal::rowText(int row) const {
|
||||
std::string s;
|
||||
for (int c = 0; c < cols_; c++) s += static_cast<char>(cell(row, c).ch);
|
||||
size_t end = s.find_last_not_of(' ');
|
||||
s.resize(end == std::string::npos ? 0 : end + 1);
|
||||
return s;
|
||||
}
|
||||
|
||||
int Terminal::param(size_t i, int fallback) const { return i < params_.size() && params_[i] > 0 ? params_[i] : fallback; }
|
||||
|
||||
void Terminal::moveTo(int row, int col) {
|
||||
row_ = std::clamp(row, 0, rows_ - 1);
|
||||
col_ = std::clamp(col, 0, cols_ - 1);
|
||||
wrapPending_ = false;
|
||||
}
|
||||
|
||||
void Terminal::eraseCells(int row, int from, int to) {
|
||||
Cell b = blank();
|
||||
for (int c = std::max(0, from); c <= std::min(cols_ - 1, to); c++) grid()[static_cast<size_t>(row * cols_ + c)] = b;
|
||||
}
|
||||
|
||||
void Terminal::scrollUp(int top, int bottom, int n, bool toHistory) {
|
||||
n = std::min(n, bottom - top + 1);
|
||||
auto& g = grid();
|
||||
for (int i = 0; i < n; i++) {
|
||||
if (toHistory && !alt_ && top == 0 && historyMax_ > 0) { // off the top of the real screen: kept, as text
|
||||
history_.push_back(rowText(0));
|
||||
if (static_cast<int>(history_.size()) > historyMax_) history_.pop_front();
|
||||
}
|
||||
std::move(g.begin() + (top + 1) * cols_, g.begin() + (bottom + 1) * cols_, g.begin() + top * cols_);
|
||||
eraseCells(bottom, 0, cols_ - 1);
|
||||
}
|
||||
}
|
||||
|
||||
void Terminal::scrollDown(int top, int bottom, int n) {
|
||||
n = std::min(n, bottom - top + 1);
|
||||
auto& g = grid();
|
||||
for (int i = 0; i < n; i++) {
|
||||
std::move_backward(g.begin() + top * cols_, g.begin() + bottom * cols_, g.begin() + (bottom + 1) * cols_);
|
||||
eraseCells(top, 0, cols_ - 1);
|
||||
}
|
||||
}
|
||||
|
||||
void Terminal::lineFeed() {
|
||||
wrapPending_ = false;
|
||||
if (row_ == bottom_) scrollUp(top_, bottom_, 1);
|
||||
else if (row_ < rows_ - 1) row_++;
|
||||
}
|
||||
|
||||
void Terminal::reverseIndex() {
|
||||
wrapPending_ = false;
|
||||
if (row_ == top_) scrollDown(top_, bottom_, 1);
|
||||
else if (row_ > 0) row_--;
|
||||
}
|
||||
|
||||
void Terminal::put(uint32_t cp) {
|
||||
uint8_t ch = lineDrawing_ && cp < 0x80 ? lineGlyph(static_cast<uint8_t>(cp)) : glyphFor(cp);
|
||||
if (wrapPending_) {
|
||||
col_ = 0;
|
||||
lineFeed();
|
||||
}
|
||||
uint8_t fg = static_cast<uint8_t>(bold_ && fg_ < 8 ? fg_ | 8 : fg_), bg = bg_;
|
||||
if (inverse_) std::swap(fg, bg);
|
||||
Cell& c = grid()[static_cast<size_t>(row_ * cols_ + col_)];
|
||||
c.ch = ch;
|
||||
c.attr = static_cast<uint8_t>((bg << 4) | (fg & 15));
|
||||
if (col_ == cols_ - 1) wrapPending_ = autoWrap_;
|
||||
else col_++;
|
||||
}
|
||||
|
||||
void Terminal::control(uint8_t c) {
|
||||
switch (c) {
|
||||
case 0x07: bell_ = true; break;
|
||||
case 0x08:
|
||||
if (col_ > 0) col_--;
|
||||
wrapPending_ = false;
|
||||
break;
|
||||
case 0x09: moveTo(row_, std::min(cols_ - 1, (col_ / 8 + 1) * 8)); break;
|
||||
case 0x0A: case 0x0B: case 0x0C: lineFeed(); break;
|
||||
case 0x0D:
|
||||
col_ = 0;
|
||||
wrapPending_ = false;
|
||||
break;
|
||||
default: break;
|
||||
}
|
||||
}
|
||||
|
||||
void Terminal::reset() {
|
||||
alt_ = false;
|
||||
Cell b;
|
||||
std::fill(mainGrid_.begin(), mainGrid_.end(), b);
|
||||
std::fill(altGrid_.begin(), altGrid_.end(), b);
|
||||
row_ = col_ = top_ = 0;
|
||||
bottom_ = rows_ - 1;
|
||||
fg_ = 7;
|
||||
bg_ = 0;
|
||||
bold_ = inverse_ = wrapPending_ = appCursor_ = lineDrawing_ = false;
|
||||
autoWrap_ = cursorShown_ = true;
|
||||
}
|
||||
|
||||
void Terminal::escape(uint8_t c) {
|
||||
state_ = State::Ground;
|
||||
switch (c) {
|
||||
case '[':
|
||||
state_ = State::Csi;
|
||||
params_.clear();
|
||||
paramStarted_ = private_ = false;
|
||||
break;
|
||||
case ']': case 'P': case '^': case '_': state_ = State::Osc; break; // a title, or something this doesn't read: skipped to its end
|
||||
case '(': case ')': case '*': case '+': state_ = State::Charset; break;
|
||||
case '7':
|
||||
savedRow_ = row_;
|
||||
savedCol_ = col_;
|
||||
savedAttr_ = static_cast<uint8_t>((bg_ << 4) | fg_);
|
||||
break;
|
||||
case '8':
|
||||
moveTo(savedRow_, savedCol_);
|
||||
fg_ = savedAttr_ & 15;
|
||||
bg_ = savedAttr_ >> 4;
|
||||
break;
|
||||
case 'D': lineFeed(); break;
|
||||
case 'E':
|
||||
col_ = 0;
|
||||
lineFeed();
|
||||
break;
|
||||
case 'M': reverseIndex(); break;
|
||||
case 'c': reset(); break;
|
||||
default: break; // = and >, the keypad's modes, among others
|
||||
}
|
||||
}
|
||||
|
||||
void Terminal::sgr() {
|
||||
if (params_.empty()) params_.push_back(0);
|
||||
for (size_t i = 0; i < params_.size(); i++) {
|
||||
int p = params_[i];
|
||||
if (p == 0) {
|
||||
fg_ = 7;
|
||||
bg_ = 0;
|
||||
bold_ = inverse_ = false;
|
||||
} else if (p == 1) bold_ = true;
|
||||
else if (p == 7) inverse_ = true;
|
||||
else if (p == 22) bold_ = false;
|
||||
else if (p == 27) inverse_ = false;
|
||||
else if (p >= 30 && p <= 37) fg_ = static_cast<uint8_t>(p - 30);
|
||||
else if (p == 39) fg_ = 7;
|
||||
else if (p >= 40 && p <= 47) bg_ = static_cast<uint8_t>(p - 40);
|
||||
else if (p == 49) bg_ = 0;
|
||||
else if (p >= 90 && p <= 97) fg_ = static_cast<uint8_t>(p - 90 + 8);
|
||||
else if (p >= 100 && p <= 107) bg_ = static_cast<uint8_t>(p - 100 + 8);
|
||||
else if ((p == 38 || p == 48) && i + 1 < params_.size()) {
|
||||
int colour = -1;
|
||||
if (params_[i + 1] == 5 && i + 2 < params_.size()) {
|
||||
colour = from256(params_[i + 2] & 255);
|
||||
i += 2;
|
||||
} else if (params_[i + 1] == 2 && i + 4 < params_.size()) {
|
||||
colour = nearest16(params_[i + 2] & 255, params_[i + 3] & 255, params_[i + 4] & 255);
|
||||
i += 4;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
(p == 38 ? fg_ : bg_) = static_cast<uint8_t>(colour);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void Terminal::mode(bool on) {
|
||||
for (int p : params_) {
|
||||
if (!private_) continue;
|
||||
switch (p) {
|
||||
case 1: appCursor_ = on; break;
|
||||
case 7: autoWrap_ = on; break;
|
||||
case 25: cursorShown_ = on; break;
|
||||
case 47: case 1047: case 1049:
|
||||
if (on == alt_) break;
|
||||
if (on && p == 1049) {
|
||||
savedRow_ = row_;
|
||||
savedCol_ = col_;
|
||||
}
|
||||
alt_ = on;
|
||||
if (on) std::fill(altGrid_.begin(), altGrid_.end(), Cell());
|
||||
top_ = 0;
|
||||
bottom_ = rows_ - 1;
|
||||
if (!on && p == 1049) moveTo(savedRow_, savedCol_);
|
||||
else if (on) moveTo(0, 0);
|
||||
break;
|
||||
default: break; // the mouse, bracketed paste and the rest
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void Terminal::csi(uint8_t final) {
|
||||
int n = param(0, 1);
|
||||
switch (final) {
|
||||
case 'A': moveTo(std::max(row_ - n, row_ >= top_ ? top_ : 0), col_); break;
|
||||
case 'B': case 'e': moveTo(std::min(row_ + n, row_ <= bottom_ ? bottom_ : rows_ - 1), col_); break;
|
||||
case 'C': case 'a': moveTo(row_, col_ + n); break;
|
||||
case 'D': moveTo(row_, col_ - n); break;
|
||||
case 'E': moveTo(row_ + n, 0); break;
|
||||
case 'F': moveTo(row_ - n, 0); break;
|
||||
case 'G': case '`': moveTo(row_, n - 1); break;
|
||||
case 'd': moveTo(n - 1, col_); break;
|
||||
case 'H': case 'f': moveTo(param(0, 1) - 1, param(1, 1) - 1); break;
|
||||
case 'J': {
|
||||
int what = params_.empty() ? 0 : params_[0];
|
||||
if (what == 0) {
|
||||
eraseCells(row_, col_, cols_ - 1);
|
||||
for (int r = row_ + 1; r < rows_; r++) eraseCells(r, 0, cols_ - 1);
|
||||
} else if (what == 1) {
|
||||
for (int r = 0; r < row_; r++) eraseCells(r, 0, cols_ - 1);
|
||||
eraseCells(row_, 0, col_);
|
||||
} else {
|
||||
for (int r = 0; r < rows_; r++) eraseCells(r, 0, cols_ - 1);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'K': {
|
||||
int what = params_.empty() ? 0 : params_[0];
|
||||
eraseCells(row_, what == 0 ? col_ : 0, what == 1 ? col_ : cols_ - 1);
|
||||
break;
|
||||
}
|
||||
case 'L':
|
||||
if (row_ >= top_ && row_ <= bottom_) scrollDown(row_, bottom_, n);
|
||||
break;
|
||||
case 'M':
|
||||
if (row_ >= top_ && row_ <= bottom_) scrollUp(row_, bottom_, n, false); // deleted, not scrolled away: not history
|
||||
break;
|
||||
case 'P': {
|
||||
n = std::min(n, cols_ - col_);
|
||||
auto row = grid().begin() + row_ * cols_;
|
||||
std::move(row + col_ + n, row + cols_, row + col_);
|
||||
eraseCells(row_, cols_ - n, cols_ - 1);
|
||||
break;
|
||||
}
|
||||
case '@': {
|
||||
n = std::min(n, cols_ - col_);
|
||||
auto row = grid().begin() + row_ * cols_;
|
||||
std::move_backward(row + col_, row + cols_ - n, row + cols_);
|
||||
eraseCells(row_, col_, col_ + n - 1);
|
||||
break;
|
||||
}
|
||||
case 'X': eraseCells(row_, col_, col_ + n - 1); break;
|
||||
case 'S': scrollUp(top_, bottom_, n); break;
|
||||
case 'T': scrollDown(top_, bottom_, n); break;
|
||||
case 'm': sgr(); break;
|
||||
case 'r': {
|
||||
int top = param(0, 1) - 1, bottom = param(1, rows_) - 1;
|
||||
if (top < bottom && bottom < rows_) {
|
||||
top_ = top;
|
||||
bottom_ = bottom;
|
||||
} else {
|
||||
top_ = 0;
|
||||
bottom_ = rows_ - 1;
|
||||
}
|
||||
moveTo(0, 0);
|
||||
break;
|
||||
}
|
||||
case 's':
|
||||
savedRow_ = row_;
|
||||
savedCol_ = col_;
|
||||
break;
|
||||
case 'u': moveTo(savedRow_, savedCol_); break;
|
||||
case 'h': mode(true); break;
|
||||
case 'l': mode(false); break;
|
||||
case 'n':
|
||||
if (!reply) break;
|
||||
if (param(0, 0) == 6) reply("\x1b[" + std::to_string(row_ + 1) + ";" + std::to_string(col_ + 1) + "R");
|
||||
else if (param(0, 0) == 5) reply("\x1b[0n");
|
||||
break;
|
||||
case 'c':
|
||||
if (reply && !private_) reply("\x1b[?6c"); // "a VT102"
|
||||
break;
|
||||
default: break;
|
||||
}
|
||||
}
|
||||
|
||||
void Terminal::feed(const uint8_t* data, size_t len) {
|
||||
for (size_t i = 0; i < len; i++) {
|
||||
uint8_t c = data[i];
|
||||
if (state_ == State::Osc || state_ == State::OscEsc) { // skipped: to a bell, or to ESC backslash
|
||||
if (c == 0x07 || (state_ == State::OscEsc && c == '\\')) state_ = State::Ground;
|
||||
else state_ = c == 0x1B ? State::OscEsc : State::Osc;
|
||||
continue;
|
||||
}
|
||||
if (c == 0x1B) {
|
||||
state_ = State::Esc;
|
||||
utf8Left_ = 0;
|
||||
continue;
|
||||
}
|
||||
if (c < 0x20) { // these act wherever they come, in the middle of a sequence too
|
||||
if (c == 0x0E) lineDrawing_ = true;
|
||||
else if (c == 0x0F) lineDrawing_ = false;
|
||||
else control(c);
|
||||
continue;
|
||||
}
|
||||
switch (state_) {
|
||||
case State::Esc: escape(c); break;
|
||||
case State::Charset:
|
||||
lineDrawing_ = c == '0';
|
||||
state_ = State::Ground;
|
||||
break;
|
||||
case State::Csi:
|
||||
if (c >= '0' && c <= '9') {
|
||||
if (!paramStarted_) {
|
||||
if (params_.size() < 16) params_.push_back(0);
|
||||
paramStarted_ = true;
|
||||
}
|
||||
if (!params_.empty() && params_.back() < 10000) params_.back() = params_.back() * 10 + (c - '0');
|
||||
} else if (c == ';' || c == ':') {
|
||||
if (!paramStarted_ && params_.size() < 16) params_.push_back(0);
|
||||
paramStarted_ = false;
|
||||
} else if (c == '?' || c == '>' || c == '=' || c == '<') {
|
||||
private_ = true;
|
||||
} else if (c >= 0x40 && c <= 0x7E) {
|
||||
state_ = State::Ground;
|
||||
csi(c);
|
||||
} // anything else is an in-between byte: passed over
|
||||
break;
|
||||
default:
|
||||
if (c == 0x7F) break;
|
||||
if (c < 0x80) {
|
||||
utf8Left_ = 0;
|
||||
put(c);
|
||||
} else if (c >= 0xC0) { // the first byte of a longer character
|
||||
utf8Left_ = c >= 0xF0 ? 3 : c >= 0xE0 ? 2 : 1;
|
||||
utf8_ = c & (c >= 0xF0 ? 0x07 : c >= 0xE0 ? 0x0F : 0x1F);
|
||||
} else if (utf8Left_ > 0) {
|
||||
utf8_ = (utf8_ << 6) | (c & 0x3F);
|
||||
if (--utf8Left_ == 0) put(utf8_);
|
||||
} else {
|
||||
put('?'); // a byte that belongs to nothing
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
revision_++;
|
||||
}
|
||||
|
||||
void Terminal::resize(int cols, int rows) {
|
||||
cols = std::max(2, cols);
|
||||
rows = std::max(2, rows);
|
||||
if (cols == cols_ && rows == rows_) return;
|
||||
// The cursor's line stays on screen: what is above it goes to the history if it has to.
|
||||
int shift = alt_ ? 0 : std::max(0, row_ - (rows - 1));
|
||||
if (shift) {
|
||||
int oldTop = top_, oldBottom = bottom_;
|
||||
top_ = 0;
|
||||
bottom_ = rows_ - 1;
|
||||
scrollUp(0, rows_ - 1, shift);
|
||||
top_ = oldTop;
|
||||
bottom_ = oldBottom;
|
||||
}
|
||||
auto copy = [&](std::vector<Cell>& g) {
|
||||
std::vector<Cell> fresh(static_cast<size_t>(cols * rows));
|
||||
for (int r = 0; r < std::min(rows, rows_); r++)
|
||||
for (int c = 0; c < std::min(cols, cols_); c++) fresh[static_cast<size_t>(r * cols + c)] = g[static_cast<size_t>(r * cols_ + c)];
|
||||
g.swap(fresh);
|
||||
};
|
||||
copy(mainGrid_);
|
||||
copy(altGrid_);
|
||||
cols_ = cols;
|
||||
rows_ = rows;
|
||||
top_ = 0;
|
||||
bottom_ = rows_ - 1;
|
||||
moveTo(row_ - shift, col_);
|
||||
savedRow_ = std::min(savedRow_, rows_ - 1);
|
||||
savedCol_ = std::min(savedCol_, cols_ - 1);
|
||||
revision_++;
|
||||
}
|
||||
|
||||
std::string encodeKey(TermKey key, uint32_t ch, bool ctrl, bool alt, bool appCursorKeys) {
|
||||
std::string out;
|
||||
auto arrow = [&](char letter) { return std::string(appCursorKeys ? "\x1bO" : "\x1b[") + letter; };
|
||||
switch (key) {
|
||||
case TermKey::Up: out = arrow('A'); break;
|
||||
case TermKey::Down: out = arrow('B'); break;
|
||||
case TermKey::Right: out = arrow('C'); break;
|
||||
case TermKey::Left: out = arrow('D'); break;
|
||||
case TermKey::Enter: out = "\r"; break;
|
||||
case TermKey::Backspace: out = "\x7f"; break;
|
||||
case TermKey::Tab: out = "\t"; break;
|
||||
case TermKey::Escape: out = "\x1b"; break;
|
||||
case TermKey::PageUp: out = "\x1b[5~"; break;
|
||||
case TermKey::PageDown: out = "\x1b[6~"; break;
|
||||
case TermKey::Char:
|
||||
if (ctrl) {
|
||||
uint32_t c = ch >= 'a' && ch <= 'z' ? ch - 32 : ch;
|
||||
if (c >= '@' && c <= '_') out = std::string(1, static_cast<char>(c - '@'));
|
||||
else if (c == ' ' || c == '2') out = std::string(1, '\0');
|
||||
else if (c == '?' || c == '8') out = "\x7f";
|
||||
else if (c == '3') out = "\x1b";
|
||||
else if (c == '4') out = "\x1c";
|
||||
else if (c == '5') out = "\x1d";
|
||||
else if (c == '6') out = "\x1e";
|
||||
else if (c == '7' || c == '/') out = "\x1f";
|
||||
break;
|
||||
}
|
||||
if (ch < 0x80) out = std::string(1, static_cast<char>(ch));
|
||||
else if (ch < 0x800) out = {static_cast<char>(0xC0 | (ch >> 6)), static_cast<char>(0x80 | (ch & 0x3F))};
|
||||
else if (ch < 0x10000) out = {static_cast<char>(0xE0 | (ch >> 12)), static_cast<char>(0x80 | ((ch >> 6) & 0x3F)), static_cast<char>(0x80 | (ch & 0x3F))};
|
||||
else out = {static_cast<char>(0xF0 | (ch >> 18)), static_cast<char>(0x80 | ((ch >> 12) & 0x3F)), static_cast<char>(0x80 | ((ch >> 6) & 0x3F)),
|
||||
static_cast<char>(0x80 | (ch & 0x3F))};
|
||||
break;
|
||||
}
|
||||
if (alt && !out.empty() && key != TermKey::Escape) out.insert(0, 1, '\x1b');
|
||||
return out;
|
||||
}
|
||||
|
||||
} // namespace roro::term
|
||||
@@ -0,0 +1,96 @@
|
||||
#pragma once
|
||||
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <deque>
|
||||
#include <functional>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
// A terminal's screen (issue #2, N1 Q256): what a remote program's output makes of a grid of
|
||||
// characters. It understands what a shell, `less`, `top`, `nano` and plain `vim` send: the cursor,
|
||||
// erasing, sixteen colours, scroll regions, the alternate screen. No mouse. Characters are kept
|
||||
// as the screen's font has them (Latin-1); what it lacks becomes `?`, and box-drawing lines
|
||||
// become + - |.
|
||||
namespace roro::term {
|
||||
|
||||
struct Cell {
|
||||
uint8_t ch = ' ';
|
||||
uint8_t attr = 0x07; // low four bits: the colour of the character, high four: of what is behind it
|
||||
};
|
||||
|
||||
class Terminal {
|
||||
public:
|
||||
Terminal(int cols, int rows, int historyLines);
|
||||
|
||||
void feed(const uint8_t* data, size_t len);
|
||||
// A new size: what is on screen stays where it is, from the top left; if the cursor would fall
|
||||
// off the bottom, the top lines go to the history.
|
||||
void resize(int cols, int rows);
|
||||
// What the program asked to be told (where the cursor is, what kind of terminal this is).
|
||||
std::function<void(const std::string&)> reply;
|
||||
|
||||
int cols() const { return cols_; }
|
||||
int rows() const { return rows_; }
|
||||
const Cell& cell(int row, int col) const { return grid()[static_cast<size_t>(row * cols_ + col)]; }
|
||||
int cursorRow() const { return row_; }
|
||||
int cursorCol() const { return col_; }
|
||||
bool cursorVisible() const { return cursorShown_; }
|
||||
bool appCursorKeys() const { return appCursor_; } // the arrows are sent another way (vim, less)
|
||||
bool altScreen() const { return alt_; }
|
||||
uint32_t revision() const { return revision_; } // changes whenever the screen may have
|
||||
bool takeBell();
|
||||
|
||||
// Lines that scrolled off the top of the main screen, oldest first; their text only.
|
||||
int historyCount() const { return static_cast<int>(history_.size()); }
|
||||
const std::string& historyLine(int i) const { return history_[static_cast<size_t>(i)]; }
|
||||
|
||||
std::string rowText(int row) const; // without trailing spaces
|
||||
|
||||
private:
|
||||
enum class State { Ground, Esc, Csi, Osc, OscEsc, Charset };
|
||||
|
||||
std::vector<Cell>& grid() { return alt_ ? altGrid_ : mainGrid_; }
|
||||
const std::vector<Cell>& grid() const { return alt_ ? altGrid_ : mainGrid_; }
|
||||
Cell blank() const;
|
||||
void put(uint32_t codePoint);
|
||||
void control(uint8_t c);
|
||||
void escape(uint8_t c);
|
||||
void csi(uint8_t final);
|
||||
void sgr();
|
||||
void mode(bool on);
|
||||
void lineFeed();
|
||||
void reverseIndex();
|
||||
void scrollUp(int top, int bottom, int n, bool toHistory = true);
|
||||
void scrollDown(int top, int bottom, int n);
|
||||
void eraseCells(int row, int from, int to);
|
||||
void moveTo(int row, int col);
|
||||
void reset();
|
||||
int param(size_t i, int fallback) const;
|
||||
|
||||
int cols_, rows_, historyMax_;
|
||||
std::vector<Cell> mainGrid_, altGrid_;
|
||||
std::deque<std::string> history_;
|
||||
bool alt_ = false;
|
||||
int row_ = 0, col_ = 0, top_ = 0, bottom_ = 0;
|
||||
int savedRow_ = 0, savedCol_ = 0;
|
||||
uint8_t savedAttr_ = 0x07;
|
||||
bool wrapPending_ = false, autoWrap_ = true, cursorShown_ = true, appCursor_ = false, lineDrawing_ = false, bell_ = false;
|
||||
uint8_t fg_ = 7, bg_ = 0;
|
||||
bool bold_ = false, inverse_ = false;
|
||||
State state_ = State::Ground;
|
||||
std::vector<int> params_;
|
||||
bool paramStarted_ = false, private_ = false;
|
||||
uint32_t utf8_ = 0;
|
||||
int utf8Left_ = 0;
|
||||
uint32_t revision_ = 0;
|
||||
};
|
||||
|
||||
// What a key sends to the remote program.
|
||||
enum class TermKey { Char, Up, Down, Left, Right, Enter, Backspace, Tab, Escape, PageUp, PageDown };
|
||||
std::string encodeKey(TermKey key, uint32_t ch, bool ctrl, bool alt, bool appCursorKeys);
|
||||
|
||||
// One of the terminal's sixteen colours as red, green and blue.
|
||||
void colourRgb(int index, uint8_t& r, uint8_t& g, uint8_t& b);
|
||||
|
||||
} // namespace roro::term
|
||||
@@ -9,6 +9,9 @@ extra_scripts = pre:scripts/version.py
|
||||
test_framework = unity
|
||||
|
||||
[env:cardputer-adv]
|
||||
extra_scripts =
|
||||
${env.extra_scripts}
|
||||
pre:scripts/libssh_filter.py
|
||||
platform = https://github.com/pioarduino/platform-espressif32/releases/download/55.03.312/platform-espressif32.zip
|
||||
board = m5stack-stamps3
|
||||
framework = arduino
|
||||
@@ -22,6 +25,7 @@ lib_deps =
|
||||
m5stack/M5Cardputer @ 1.1.1
|
||||
jgromes/RadioLib @ 7.8.1
|
||||
esphome/wireguard @ 0.4.8
|
||||
ewpa/LibSSH-ESP32 @ 5.10.0
|
||||
test_ignore = *
|
||||
; 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
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
# libssh ships its own copy of curve25519 (src/external/curve25519_ref.c), whose two functions,
|
||||
# crypto_scalarmult and crypto_scalarmult_base, have the names libsodium's have: and libsodium is
|
||||
# already in the firmware, for WireGuard. Two definitions don't link. libssh's copy is left out
|
||||
# of the build and it uses libsodium's, which is the same function (issue #2, docs/milestones/N1.md).
|
||||
#
|
||||
# A `pre:` script: the libraries are built by the platform's own script, which runs before any `post:` one.
|
||||
Import("env") # noqa: F821 (provided by PlatformIO)
|
||||
|
||||
env.AddBuildMiddleware(lambda env, node: None, "*LibSSH-ESP32*curve25519_ref.c") # noqa: F821
|
||||
@@ -42,6 +42,7 @@ 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
|
||||
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
|
||||
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
|
||||
debug status | debug off [seconds] the Debug Console over Wi-Fi (Settings > Debug Console); with seconds, it comes back
|
||||
debug on | debug token <16 to 64 characters> | debug token new (USB serial only) switch it on, set its token
|
||||
@@ -116,6 +117,7 @@ In **Safe Mode** (see [Crashes and Safe Mode](/dev/debug/crashes/)) only a few r
|
||||
| `ping <host> [count] [size]` / `nslookup <name> [server]` / `port <host> <port>` / `traceroute <host>` / `cancel` | Network troubleshooting (issue #90): does a host answer and how fast; a name's addresses, from which DNS server and in how long; is a TCP port open, refused or silent; the routers on the way. Each runs on a task of its own and prints as it goes, one at a time; `cancel` stops it |
|
||||
| `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 |
|
||||
| `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 |
|
||||
| `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 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 |
|
||||
|
||||
@@ -8,7 +8,7 @@ docs = true
|
||||
source = "docs/milestones/N1.md"
|
||||
tag = "N1"
|
||||
+++
|
||||
**Status:** in progress. The WireGuard tunnel (issue #8) shipped as **v0.19.0**. The network troubleshooting commands (issue #90) shipped in two parts: `ping`, `nslookup`, `port`, `traceroute`, `ifconfig` and `arp` as **v0.20.0**; `tls`, `ntp` and `netstat` as **v0.21.0**. SSH (#2) is not started.
|
||||
**Status:** in progress. The WireGuard tunnel (issue #8) shipped as **v0.19.0**. The network troubleshooting commands (issue #90) shipped in two parts: `ping`, `nslookup`, `port`, `traceroute`, `ifconfig` and `arp` as **v0.20.0**; `tls`, `ntp` and `netstat` as **v0.21.0**. The SSH client (issue #2) shipped as **v0.22.0**.
|
||||
|
||||
**Goal:** reach things from the device that aren't on the Wi-Fi it happens to be on, and keep its traffic private on a network that isn't yours.
|
||||
|
||||
@@ -149,3 +149,82 @@ With a tunnel, fixed addresses and a file server on the device, "is it the netwo
|
||||
| Memory during a `tls` | 44.5 KB free at the lowest, from 104 KB |
|
||||
|
||||
**Not checked:** `tls` with IRC connected (it should refuse for lack of memory); `ntp` against a clock that is wrong; `netstat` while sharing.
|
||||
|
||||
## The SSH client (issue #2)
|
||||
|
||||
A terminal on another machine: one session to a shell, from the SSH App.
|
||||
|
||||
### Measured before deciding (2026-10-08)
|
||||
|
||||
`ewpa/LibSSH-ESP32` 5.10.0 (libssh on mbedTLS) was built into a trial firmware and a session opened against OpenSSH in a container, with a password.
|
||||
|
||||
| | Measured in the trial | As built |
|
||||
|---|---|---|
|
||||
| Flash | 120 KB | **292 KB** |
|
||||
| Static RAM | 1.2 KB | |
|
||||
| The session's task stack | 13 KB used | 13.7 KB used of 20 KB |
|
||||
| Free heap with a session open | | 49 KB of 99 KB: it costs about 50 KB, the stack included |
|
||||
| Lowest free heap during a login | | 30 KB |
|
||||
| Key exchange (curve25519, ed25519 host key) | 227 ms | |
|
||||
|
||||
- **The trial undercounted the flash.** It logged in with a password. Signing with a key of the device's own (Q255) links libssh's table of multiples of the Ed25519 base point: `ge25519.c.o` alone is 109 KB. The rest of the difference is the public-key code, the terminal and three fonts. The firmware is at 71% of its slot.
|
||||
- **libssh carries its own curve25519** (`src/external/curve25519_ref.c`) with the names libsodium uses, and libsodium is already here for WireGuard: two definitions don't link. A build script (`scripts/libssh_filter.py`) leaves libssh's copy out, and it uses libsodium's. It has to be a `pre:` script: libraries are built before any `post:` one runs.
|
||||
- A session and a TLS connection don't fit together: IRC holds 40 KB.
|
||||
|
||||
### Decisions (design round 2026-10-08)
|
||||
|
||||
| # | Decision |
|
||||
|---|---|
|
||||
| Q254 | **LibSSH-ESP32 5.10.0**, with its duplicate curve file left out of the build. |
|
||||
| Q255 | **A password, typed each time and never stored**, or **a key the device makes for itself** (Ed25519, no passphrase, kept in the settings store). Its public half is shown, written to `/ssh/id_ed25519.pub` and printed by `ssh status`. Keys made elsewhere aren't imported. |
|
||||
| Q256 | **A terminal good enough for a shell, `less`, `top`, `nano` and `vim`:** cursor movement, erasing, sixteen colours, scroll regions, the alternate screen, the cursor keys' two modes. `TERM=xterm`. No mouse. |
|
||||
| Q257 | **Five text sizes, changed with Ctrl and + or -** (the user's change to the round: the proposal was a setting). 4x6, 5x8, 6x10, 6x13 and 9x15 pixels: from 60 x 20 to 26 x 8 characters. The far end is told the new size; the choice is kept. |
|
||||
| Q258 | **100 lines of scrollback**, as text, with Alt and up or down, as in the Shell. Not on the alternate screen. |
|
||||
| Q259 | **Keys:** Ctrl with a letter; Tab; the backtick key sends Esc, as it is printed; Alt with it types a backtick; Fn with the arrow keys; Shift with those for Page Up and Down; Ctrl+Alt+q disconnects. Fn with backtick is Home, as everywhere. |
|
||||
| Q260 | **The session outlives the App's time in front.** `SSH` in the Status Bar while one is open. |
|
||||
| Q261 | **Not started under 75 KB free**, with the reason in words. |
|
||||
| Q262 | **Up to eight hosts remembered**, the last used first, once a login has succeeded. Forgetting one forgets its server's fingerprint too, unless another remembered host is the same server. |
|
||||
| Q263 | **Trust on first use,** on the SHA-256 fingerprint; sixteen servers remembered. **A changed key is a warning**, with Cancel selected. |
|
||||
| Q264 | **UTF-8 in, the fonts' Latin-1 out:** what they lack is `?`, box-drawing lines are `+ - \|`. |
|
||||
| Q265 | **`ssh user@host` in the Shell opens the App** and connects there. The password is never asked on a console. |
|
||||
| Q266 | **Not built:** port forwarding, SFTP and scp, jump hosts, agent forwarding, keys with a passphrase, more than one session. |
|
||||
|
||||
### As built
|
||||
|
||||
- **`lib/term`** (host-tested, 12 tests in `test/test_terminal`): `Terminal`, the screen a program's output makes, with its history and the replies a program asks for; `encodeKey`, what a key sends; `SshHosts` and `SshKnownHosts`, the two lists kept in the settings store as lines of text.
|
||||
- **`SshService`** (`src/services/ssh_service`): one session on a task of its own (20 KB of stack). The task and the main loop share the terminal, the bytes to send and the state under one lock. Its questions (is this the right server? the password?) are states it waits in until the App answers; the settings store is only written from the main loop. The password and the private key are overwritten after use.
|
||||
- **`SshApp`** (`src/apps/ssh_app`): the hosts, the entry, the session, the key page. It draws the grid a run of same-coloured cells at a time, at most every 60 ms.
|
||||
- **Keys that aren't characters now say what was held with them** (`lib/input/src/key_mapper.cpp`): the arrows, Enter, Del, Tab and Back carry Shift, Ctrl and Alt. The terminal needs it for Page Up and Alt+backtick. It also makes two documented keys work from the real keyboard, which until now only worked from the Debug Console's `key` command: Ctrl with Fn and up or down in a note, and Shift+Tab in Gemini.
|
||||
- **IRC doesn't try to connect with less than 60 KB free** (`IrcService::kNeedFree`): see below.
|
||||
- Settings: `SshHosts`, `SshKnown`, `SshKey`, `SshPublic`, `SshFont`.
|
||||
|
||||
### Checks on the device (2026-10-08, against OpenSSH 9.7 in a container on the same network)
|
||||
|
||||
| Check | Result |
|
||||
|---|---|
|
||||
| `ssh tester@host:2222` from the Debug Console | The App opens; the fingerprint shown is the one `ssh-keygen -lf` prints on the server |
|
||||
| Trust it, a password | A shell; `stty size` says 15 48, `$TERM` is xterm |
|
||||
| `ls -la`, `top`, `vim` (insert, Esc, `:wq`) | Drawn right: `top`'s reverse-video header, `vim`'s alternate screen and what was there before coming back; the file is on the server |
|
||||
| Ctrl with + and - | `stty size` says 12 40, then 20 60; `top` redraws for it |
|
||||
| `seq 1 60`, Alt with up | The history, in grey, with how far back in the corner |
|
||||
| Fn+backtick, then the App again | The Launcher with `SSH` in the Status Bar; the session as it was |
|
||||
| `sleep 100`, Ctrl+C; Alt+backtick | Interrupted; `` echo `id -u` `` prints 1000 |
|
||||
| Ctrl+Alt+q; `exit` | "Disconnected"; "The session ended" |
|
||||
| This device's key, its public half in `authorized_keys` | "Accepted publickey" in the server's log; no password asked |
|
||||
| The server's host keys replaced | "THE SERVER'S KEY CHANGED" with the new fingerprint, Cancel selected. Cancel: "Not trusted: not connected". Replace: it connects, and doesn't ask again |
|
||||
| A wrong password | "Wrong password", and asked again; Back gives up |
|
||||
| A port nothing listens on | "Nothing listens there: the connection was refused" |
|
||||
| `ssh nobody`, `ssh a@`, a port of 99999 | Refused, each with its reason |
|
||||
| Forgetting a host | Asked, then gone from the list |
|
||||
| `irc start` with a session open | "not enough memory: close the SSH session, retrying in 5 s", and no attempt: the lowest free heap doesn't move |
|
||||
| Memory | 99 KB free before, 49 KB with a session open, 30 KB at the lowest during a login, 99 KB again after |
|
||||
| Stacks | `ssh` 6.8 KB free of 20 KB; `loopTask` 1.9 KB free, as before |
|
||||
|
||||
**Not checked:** the refusal under 75 KB free (it is one comparison, and wasn't provoked). A server on the internet, or through the VPN. Wi-Fi lost in the middle of a session. Servers other than OpenSSH. `nano`, `less`, `htop`, `tmux`. Keyboard-interactive logins (two-factor prompts). A session left open for hours.
|
||||
|
||||
### What went wrong while building it
|
||||
|
||||
- **IRC, started with a session open, took the free heap down to 236 bytes.** A test script's keys went to the Launcher instead of the terminal and opened the IRC App, which connects when opened. Its TLS handshake found no memory, failed, and tried again with its usual back-off, six times; nothing crashed and it never connected, but 236 bytes is no margin at all. IRC now looks at the free heap before each attempt and says "not enough memory: close the SSH session" instead of trying.
|
||||
- **The build script did nothing as a `post:` script:** the libraries were already built when it ran.
|
||||
- **A failed connection was first shown as an empty terminal** with its reason squeezed on the last line, and a host was remembered before anyone had logged in to it. Both changed: the reason has a page, and a host is remembered once a login succeeds.
|
||||
- **The trust question didn't fit its dialog:** the fingerprint is 50 characters. It is now split over two lines, under one line of words.
|
||||
|
||||
+5
-1
@@ -53,7 +53,7 @@ Yes: it is [open source](https://git.twis.la/twisla/roro9stack) (GPL-3.0). The d
|
||||
|
||||
**The firmware contacts the project's server once a day,** to see whether a new release exists: **Settings → Check for updates**, on by default, only when Wi-Fi is up and the clock is set. It installs nothing by itself, and you can switch it off. Besides that, the device talks to what you ask it to: the IRC server and Gemini capsules you open, DNS servers (9.9.9.9 and 1.1.1.1 by default) and time servers (pool.ntp.org and time.cloudflare.com by default), all of which you can change. There is no account, no analytics and no telemetry.
|
||||
|
||||
**It listens on the network only for what you switched on:** the port that receives signed firmware updates, always; the Debug Console, the card shared with a browser and the VPN, only while you have them on.
|
||||
**It listens on the network only for what you switched on:** the port that receives signed firmware updates, always; the Debug Console, the card shared with a browser and the VPN, only while you have them on. The SSH App connects out to the server you name and listens for nothing.
|
||||
|
||||
**This website** sets no cookies and has no analytics, loads nothing from other sites, and its server logs keep only a masked part of visitors' addresses. The Install page asks the project's own server for the latest release.
|
||||
|
||||
@@ -65,6 +65,10 @@ A secure connection takes about 52 KB of the 107 KB the device has, and IRC's ta
|
||||
|
||||
Yes, WireGuard: one tunnel to one server, set up by copying the client's `.conf` to the SD card and importing it in Settings. It can carry everything, or just the VPN's own subnet. See [VPN](/guide/vpn/).
|
||||
|
||||
## 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.
|
||||
|
||||
## How do I copy files to and from my phone?
|
||||
|
||||
In the Storage App, press <kbd>w</kbd>: the device serves a small web page to any browser on the same Wi-Fi. Scan the QR code it shows, type the code, and upload or download. Nothing to install. See [From a phone](/guide/storage/#from-a-phone).
|
||||
|
||||
@@ -12,6 +12,8 @@ This guide says what the firmware does **today** and nothing else. Start with th
|
||||
|
||||
**Three things work on every screen:** <kbd>Fn</kbd> + <kbd>h</kbd> for the keys, <kbd>Fn</kbd> + <kbd>p</kbd> for a screenshot, and <kbd>Fn</kbd> + <kbd>`</kbd> to go back to the Launcher.
|
||||
|
||||
**Two things keep running when you leave their App:** IRC stays connected, and an [SSH](/guide/ssh/) session stays open.
|
||||
|
||||
Looking for a recipe or a quick answer? There are [how-tos](/howto/) and a [FAQ](/faq/).
|
||||
|
||||
Something missing or wrong? Write to the [issue tracker](https://git.twis.la/twisla/roro9stack/issues) or to contact@roro9stack.net.
|
||||
|
||||
@@ -49,6 +49,7 @@ A strip at the top of every screen: the name of the App on the left, and on the
|
||||
| `97%` | The battery (in the warning colour at 15% and under) |
|
||||
| `SD` | A card is in; the warning colour at 80% full |
|
||||
| bars and `W` | Wi-Fi connected, with its signal; `W?` is searching; `MON` is the Wi-Fi radio in its monitoring mode, which pauses IRC |
|
||||
| `SSH` | An [SSH session](/guide/ssh/) is open, whatever App is in front |
|
||||
| `VPN` | The [WireGuard tunnel](/guide/vpn/) is wanted; brighter once the server has answered |
|
||||
| `DBG` | The Debug Console is switched on (Settings → Debug Console); brighter while a PC is connected to it |
|
||||
| `REC` | A GNSS Track is being recorded |
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
+++
|
||||
title = "Every key"
|
||||
description = "The keys of every screen of the firmware, as the help panel lists them on the device: one table for each screen and state."
|
||||
weight = 14
|
||||
weight = 15
|
||||
[extra]
|
||||
tag = "Reference"
|
||||
+++
|
||||
|
||||
@@ -39,7 +39,7 @@ Type a command and press <kbd>Enter</kbd>. `help` lists them all; the [command r
|
||||
|
||||
Type an App's name **with a capital letter** to open it, without going back to the Launcher:
|
||||
|
||||
`Irc` `Wifi` `Gnss` `Gemini` `Lora` `Storage` `Notes` `System` `Settings`
|
||||
`Irc` `Wifi` `Gnss` `Gemini` `Lora` `Storage` `Notes` `Ssh` `System` `Settings`
|
||||
|
||||
The capital is the difference: every command is in small letters, every App starts with a capital. <kbd>Tab</kbd> completes them too.
|
||||
|
||||
@@ -55,6 +55,7 @@ For the day the network doesn't do what it should. Each of the first six takes a
|
||||
| `traceroute 9.9.9.9` | The routers on the way, one a line |
|
||||
| `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 |
|
||||
| `ssh user@host` | Opens the [SSH App](/guide/ssh/) and connects; `ssh status` and `ssh stop` for the session |
|
||||
| `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 |
|
||||
| `netstat` | What the device **listens** on (updates, the Debug Console, sharing) and what is connected to it now |
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
+++
|
||||
title = "SSH"
|
||||
description = "A terminal on another machine: log in to a server with a password or with the device's own key, and run a shell, vim or top on the Cardputer's screen."
|
||||
weight = 13
|
||||
[extra]
|
||||
tag = "SSH"
|
||||
screens = ["ssh.png", "ssh-trust.png", "ssh-terminal.png", "ssh-key.png", "ssh-changed.png"]
|
||||
+++
|
||||
|
||||
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.
|
||||
|
||||
## Connecting
|
||||
|
||||
The App opens on the hosts it has connected to before, the last one first, then **New connection** and **This device's key**.
|
||||
|
||||
1. Choose **New connection** (or press <kbd>n</kbd>) and type **`user@host`**, or `user@host:port` when the port isn't 22. The host is a name or an address.
|
||||
2. **The first time, it shows the server's fingerprint** and asks whether that is the right server. Compare it with what the server's owner gives you (`ssh-keygen -lf /etc/ssh/ssh_host_ed25519_key.pub` on the server prints it), and choose **Trust it**. It is remembered, and not asked again.
|
||||
3. **Type the password** when it asks. It is used for this login and kept nowhere: not in the device, not on the card.
|
||||
|
||||
A host you logged in to is kept in the list: <kbd>Enter</kbd> connects to it again, <kbd>d</kbd> forgets it, and its fingerprint with it. Up to eight are kept.
|
||||
|
||||
From the [Shell](/guide/shell/), `ssh user@host` does the same and opens this App.
|
||||
|
||||
## If the server has changed
|
||||
|
||||
A server that shows **a different key than the one remembered** gets a warning instead of a question: **THE SERVER'S KEY CHANGED**. Either the server was reinstalled, or something between you and it is answering in its place. The safe answer, **Cancel**, is the one selected. Choose **Replace** only when you know why the key changed.
|
||||
|
||||
## Without a password: this device's key
|
||||
|
||||
**This device's key** makes a key pair for the device (Ed25519). The private half stays inside the device and is never shown. The public half is one line of text:
|
||||
|
||||
- it is shown on that page,
|
||||
- written to the card as **`/ssh/id_ed25519.pub`**,
|
||||
- and printed by `ssh status` in the Shell.
|
||||
|
||||
Add that line to `~/.ssh/authorized_keys` on a server, and the device logs in there with no password. See [Log in to a server without a password](/howto/ssh-key/).
|
||||
|
||||
Making a **new key** replaces the old one: servers that knew the old one ask for a password again.
|
||||
|
||||
The key has no passphrase, so **whoever holds the device can log in wherever its key is accepted**. Keys made elsewhere can't be imported.
|
||||
|
||||
## In the terminal
|
||||
|
||||
Every key goes to the other machine, with these differences:
|
||||
|
||||
| Key | Does |
|
||||
|---|---|
|
||||
| <kbd>`</kbd> | **Esc** (the key is printed "esc") |
|
||||
| <kbd>Alt</kbd> + <kbd>`</kbd> | types a backtick |
|
||||
| <kbd>Fn</kbd> + <kbd>;</kbd> <kbd>.</kbd> <kbd>,</kbd> <kbd>/</kbd> | the arrows |
|
||||
| <kbd>Fn</kbd> + <kbd>Shift</kbd> + <kbd>;</kbd> <kbd>.</kbd> | Page Up, Page Down |
|
||||
| <kbd>Ctrl</kbd> + a letter | Ctrl+C, Ctrl+D, Ctrl+Z and the rest |
|
||||
| <kbd>Alt</kbd> + a key | that key with Alt (Esc first) |
|
||||
| <kbd>Alt</kbd> + <kbd>;</kbd> <kbd>.</kbd> | scroll back and forward through the last 100 lines |
|
||||
| <kbd>Ctrl</kbd> + <kbd>+</kbd> <kbd>-</kbd> | larger and smaller text |
|
||||
| <kbd>Ctrl</kbd> + <kbd>Alt</kbd> + <kbd>q</kbd> | disconnect |
|
||||
| <kbd>Fn</kbd> + <kbd>`</kbd> | back to the Launcher, **leaving the session running** |
|
||||
|
||||
**Leaving doesn't disconnect.** Go to the Launcher or to another App and the session goes on; `SSH` shows in the [Status Bar](/guide/basics/#the-status-bar) for as long as it does. Open the SSH App again and you are back in it. `exit` on the other machine, or <kbd>Ctrl</kbd> + <kbd>Alt</kbd> + <kbd>q</kbd> here, ends it.
|
||||
|
||||
### The size of the text
|
||||
|
||||
Five sizes, changed with <kbd>Ctrl</kbd> + <kbd>+</kbd> and <kbd>Ctrl</kbd> + <kbd>-</kbd>; the other machine is told the new size at once, and the choice is kept.
|
||||
|
||||
| Text | Columns × rows |
|
||||
|---|---|
|
||||
| tiny | 60 × 20 |
|
||||
| small (to start with) | 48 × 15 |
|
||||
| medium | 40 × 12 |
|
||||
| normal | 40 × 9 |
|
||||
| large | 26 × 8 |
|
||||
|
||||
A terminal is usually 80 columns wide, and this screen isn't: long lines wrap, and programs that want 80 columns look cramped. `top`, `vim` and `less` adapt.
|
||||
|
||||
### What it shows, and what it doesn't
|
||||
|
||||
- Sixteen colours, bold as a brighter colour, reverse video.
|
||||
- Western European letters (é, ñ, ü). Other characters show as `?`, and box-drawing lines as `+`, `-` and `|`.
|
||||
- No mouse.
|
||||
|
||||
## Memory
|
||||
|
||||
A session takes about **50 KB** of the device's 100 KB while it is open, and gives it back when it ends. That is too much to share with a secure connection:
|
||||
|
||||
- **It doesn't start with less than 75 KB free.** With IRC connected, or a Gemini page open, it says "Not enough memory: close IRC or a Gemini page".
|
||||
- **While a session is open, IRC doesn't connect:** it says so in its buffer and tries again later.
|
||||
|
||||
See [When a connection says "not enough memory"](/howto/not-enough-memory/).
|
||||
|
||||
## From the Shell
|
||||
|
||||
```
|
||||
ssh user@host connect, in the SSH App
|
||||
ssh user@host:2222 on another port
|
||||
ssh status the session, and this device's public key
|
||||
ssh stop end the session
|
||||
```
|
||||
|
||||
## Keys
|
||||
|
||||
What <kbd>Fn</kbd> + <kbd>h</kbd> shows on these screens. These tables are generated from the firmware's own lists, so they are always the current ones.
|
||||
|
||||
{{ keys(scopes=["ssh", "ssh-terminal", "ssh-key"]) }}
|
||||
@@ -1,7 +1,7 @@
|
||||
+++
|
||||
title = "Updates"
|
||||
description = "How the device updates itself from the project's releases, from the SD card or from a PC, and how it protects itself when an update goes wrong."
|
||||
weight = 13
|
||||
weight = 14
|
||||
[extra]
|
||||
tag = "Firmware"
|
||||
screens = ["update.png"]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
+++
|
||||
title = "How-tos"
|
||||
description = "Short recipes for things you will want to do: move files with your phone, set up the VPN, 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, 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"
|
||||
page_template = "guide-page.html"
|
||||
sort_by = "weight"
|
||||
|
||||
@@ -8,7 +8,7 @@ tag = "Memory"
|
||||
|
||||
## What you see
|
||||
|
||||
Opening a Gemini page fails with *not enough memory: stop IRC or retry*. Or an update check or install makes IRC disconnect for a moment.
|
||||
Opening a Gemini page fails with *not enough memory: stop IRC or retry*. Or SSH says *Not enough memory: close IRC or a Gemini page*, or IRC says *not enough memory: close the SSH session*. Or an update check or install makes IRC disconnect for a moment.
|
||||
|
||||
## What to do
|
||||
|
||||
@@ -26,6 +26,7 @@ Small next to a secure connection, but they add up when memory is already short:
|
||||
|
||||
| | While it is in use |
|
||||
|---|---|
|
||||
| An [SSH](/guide/ssh/) session, from login until it ends | 50 KB: IRC waits while it is open, and it doesn't start under 75 KB free |
|
||||
| A note open in the editor, whatever its size | 17 KB |
|
||||
| Sharing the card with a browser | 13 KB |
|
||||
| The Shell | 7 KB |
|
||||
|
||||
@@ -20,6 +20,7 @@ Everything the firmware writes goes in a folder at the top of the card. To get a
|
||||
| Files saved from Gemini that are not text | `/gemini/downloads` | whatever they were |
|
||||
| Update files | `/updates` | `.ota` |
|
||||
| [Screenshots](/howto/screenshot/) (<kbd>Fn</kbd> + <kbd>p</kbd>) | `/screenshots` | `.png`, named by date and time |
|
||||
| The public half of the device's [SSH key](/guide/ssh/#without-a-password-this-device-s-key) | `/ssh/id_ed25519.pub` | one line, for a server's `authorized_keys`; safe to copy anywhere |
|
||||
| A VPN configuration waiting to be imported | `/vpn/wg0.conf` | you put it there; delete it once imported |
|
||||
|
||||
## Rules worth knowing
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
+++
|
||||
title = "Log in to a server without a password"
|
||||
description = "Make the Cardputer's own SSH key, put its public half on a server, and connect with no password to type."
|
||||
weight = 13
|
||||
[extra]
|
||||
tag = "SSH"
|
||||
+++
|
||||
|
||||
Typing a password on a small keyboard, every time, gets old. With a key, the server recognises the device.
|
||||
|
||||
1. On the Cardputer, open **SSH → This device's key** and press <kbd>Enter</kbd>. It makes the key and shows its **public half**: one line starting with `ssh-ed25519`.
|
||||
2. Get that line to the server. It was also written to the card as **`/ssh/id_ed25519.pub`**, so the easy way is the phone: in **Storage** press <kbd>w</kbd>, open the page ([Move files with your phone](/howto/phone-files/)) and download the file. Or log in to the server with your password, from the Cardputer or anything else, and paste it.
|
||||
3. On the server, add the line to the end of **`~/.ssh/authorized_keys`** of the user you log in as:
|
||||
|
||||
```
|
||||
cat id_ed25519.pub >> ~/.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.
|
||||
|
||||
## If it still asks for a password
|
||||
|
||||
| Check | How |
|
||||
|---|---|
|
||||
| The line is whole, on one line | `ssh-ed25519`, a long word, then `roro9stack` |
|
||||
| The file's permissions | `chmod 700 ~/.ssh` and `chmod 600 ~/.ssh/authorized_keys`: OpenSSH ignores a file others can write |
|
||||
| It is the right user's file | the `user` of `user@host` |
|
||||
| The server takes keys | `PubkeyAuthentication yes` in its `sshd_config` (the default) |
|
||||
| You made a new key since | a new key replaces the old one: put the new line on the server |
|
||||
|
||||
## Worth knowing
|
||||
|
||||
The private half never leaves the device and has no passphrase: **whoever holds the Cardputer can log in wherever its key is accepted**. If the device is lost, remove its line from `authorized_keys` on your servers. More in the [SSH](/guide/ssh/) page.
|
||||
@@ -365,6 +365,40 @@ rows = [
|
||||
["; .", "up, down"],
|
||||
]
|
||||
|
||||
[[scope]]
|
||||
id = "ssh"
|
||||
title = "SSH, the hosts"
|
||||
rows = [
|
||||
["Enter", "connect, open"],
|
||||
["; .", "up, down"],
|
||||
["n", "a new connection"],
|
||||
["d", "forget this host"],
|
||||
]
|
||||
|
||||
[[scope]]
|
||||
id = "ssh-terminal"
|
||||
title = "SSH, the terminal"
|
||||
rows = [
|
||||
["`", "Esc"],
|
||||
["Alt `", "a backtick"],
|
||||
["Fn ; . , /", "the arrows"],
|
||||
["Fn Shift ; .", "Page Up, Page Down"],
|
||||
["Ctrl a..z", "Ctrl+C and the rest"],
|
||||
["Alt ; .", "scroll back, forward"],
|
||||
["Ctrl + -", "larger, smaller text"],
|
||||
["Ctrl Alt q", "disconnect"],
|
||||
["Fn `", "leave it running"],
|
||||
]
|
||||
|
||||
[[scope]]
|
||||
id = "ssh-key"
|
||||
title = "SSH, this device's key"
|
||||
rows = [
|
||||
["Enter", "make a key, or a new one"],
|
||||
["w", "write the public half to the card"],
|
||||
["`", "back"],
|
||||
]
|
||||
|
||||
[[scope]]
|
||||
id = "notes"
|
||||
title = "Notes, the list"
|
||||
|
||||
@@ -59,6 +59,31 @@ file = "vpn.png"
|
||||
alt = "Settings, VPN: VPN On, Start with Wi-Fi On, Import /vpn/wg0.conf, Forget it; then It is up, heard 66 s ago, the server's address, and This device 10.9.0.2, through it everything. VPN shows in the Status Bar"
|
||||
caption = "Settings, VPN"
|
||||
|
||||
[[screen]]
|
||||
file = "ssh.png"
|
||||
alt = "The SSH App's list: a saved host, tester at 172.16.42.249 port 2222, then New connection and This device's key"
|
||||
caption = "SSH, the hosts"
|
||||
|
||||
[[screen]]
|
||||
file = "ssh-trust.png"
|
||||
alt = "A dialog over the SSH App: A server not met before, the address 172.16.42.249, and a SHA256 fingerprint on two lines; buttons Cancel, selected, and Trust it"
|
||||
caption = "SSH, a server met for the first time"
|
||||
|
||||
[[screen]]
|
||||
file = "ssh-terminal.png"
|
||||
alt = "The SSH App's terminal showing top on a remote machine: uptime, tasks, memory, then a reverse-video header and five processes. SSH shows in the Status Bar"
|
||||
caption = "SSH, top on another machine"
|
||||
|
||||
[[screen]]
|
||||
file = "ssh-key.png"
|
||||
alt = "This device's key: its public half, for a server's authorized_keys file, a line starting ssh-ed25519; it is also in /ssh/id_ed25519.pub, and the private half never leaves"
|
||||
caption = "SSH, this device's key"
|
||||
|
||||
[[screen]]
|
||||
file = "ssh-changed.png"
|
||||
alt = "A dialog over the SSH App: THE SERVER'S KEY CHANGED. Someone in between? Now it is, and a SHA256 fingerprint; buttons Cancel, selected, and Replace"
|
||||
caption = "SSH, a server whose key changed"
|
||||
|
||||
[[screen]]
|
||||
file = "help.png"
|
||||
alt = "The help panel over the Launcher: Keys: Launcher, up and down, Enter opens the App, then Everywhere: back, home, the arrows, Fn h for these keys and Fn p for a screenshot"
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 4.8 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 4.2 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 5.5 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 4.4 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.3 KiB |
@@ -68,7 +68,7 @@
|
||||
</article>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<p class="cards-note">Plus a <a class="accent-link" href="/guide/shell/">Shell</a> that runs the firmware's commands on the device, a <a class="accent-link" href="/guide/vpn/">WireGuard VPN</a>, a screenshot key that works on every screen, and Settings, with its Wi-Fi and Firmware pages. Every App has a page in the <a class="accent-link" href="/guide/">user guide</a>.</p>
|
||||
<p class="cards-note">Plus a <a class="accent-link" href="/guide/shell/">Shell</a> that runs the firmware's commands on the device, an <a class="accent-link" href="/guide/ssh/">SSH client</a> with a real terminal, a <a class="accent-link" href="/guide/vpn/">WireGuard VPN</a>, a screenshot key that works on every screen, and Settings, with its Wi-Fi and Firmware pages. Every App has a page in the <a class="accent-link" href="/guide/">user guide</a>.</p>
|
||||
</section>
|
||||
|
||||
<section class="wrap" id="screens" aria-labelledby="screens-title">
|
||||
|
||||
@@ -0,0 +1,510 @@
|
||||
#include "apps/ssh_app.h"
|
||||
|
||||
#include <Arduino.h>
|
||||
#include <algorithm>
|
||||
#include <SD.h>
|
||||
|
||||
#include "app_keys.h"
|
||||
#include "text_wrap.h"
|
||||
#include "ui/fonts.h"
|
||||
#include "ui/theme.h"
|
||||
#include "ui/widgets.h"
|
||||
|
||||
namespace roro {
|
||||
|
||||
namespace {
|
||||
constexpr uint32_t kMessageMs = 4000;
|
||||
constexpr uint32_t kFrameMs = 60; // a busy terminal is drawn this often at most
|
||||
|
||||
std::string masked(size_t n) { return std::string(n, '*'); }
|
||||
|
||||
// The terminal keeps characters as the fonts have them, one byte each; drawing wants UTF-8.
|
||||
void appendUtf8(std::string& out, uint8_t ch) {
|
||||
if (ch < 0x80) out += static_cast<char>(ch);
|
||||
else {
|
||||
out += static_cast<char>(0xC0 | (ch >> 6));
|
||||
out += static_cast<char>(0x80 | (ch & 0x3F));
|
||||
}
|
||||
}
|
||||
|
||||
// Alt with ; and . looks back and forward, as in the Shell (Q258): lines to move, or 0.
|
||||
int scrollStep(const KeyEvent& e) {
|
||||
if (!e.alt || e.ctrl) return 0;
|
||||
if (e.key == Key::Up || (e.key == Key::Char && e.ch == ';')) return 4;
|
||||
if (e.key == Key::Down || (e.key == Key::Char && e.ch == '.')) return -4;
|
||||
return 0;
|
||||
}
|
||||
|
||||
uint16_t colour(int index) {
|
||||
uint8_t r, g, b;
|
||||
term::colourRgb(index, r, g, b);
|
||||
return lgfx::color565(r, g, b);
|
||||
}
|
||||
} // namespace
|
||||
|
||||
// Ctrl with + and - step through these (Q257): from 60 columns by 20 rows to 26 by 8.
|
||||
const SshApp::Font SshApp::kFonts[5] = {{&fonts::tiny, 4, 6}, {&fonts::small, 5, 8}, {&fonts::medium, 6, 10}, {&fonts::body, 6, 13}, {&fonts::large, 9, 15}};
|
||||
|
||||
void SshApp::grid(int& cols, int& rows) const {
|
||||
const auto& area = theme::kContent;
|
||||
cols = area.w / kFonts[font_].w;
|
||||
rows = area.h / kFonts[font_].h;
|
||||
}
|
||||
|
||||
void SshApp::say(const std::string& text) {
|
||||
message_ = text;
|
||||
messageMs_ = millis();
|
||||
requestRedraw();
|
||||
}
|
||||
|
||||
void SshApp::onEnter() {
|
||||
font_ = settings_.getInt(Setting::SshFont);
|
||||
hosts_ = term::SshHosts(settings_.getString(Setting::SshHosts)).list();
|
||||
list_.setCount(static_cast<int>(hosts_.size()) + 2);
|
||||
dialog_.reset();
|
||||
ask_ = Ask::None;
|
||||
message_.clear();
|
||||
scroll_ = 0;
|
||||
// A session that was left running is found again.
|
||||
SshService::State s = ssh_.state();
|
||||
view_ = s == SshService::State::Idle || (s == SshService::State::Ended && view_ != View::Session) ? View::Hosts : View::Session;
|
||||
shownState_ = SshService::State::Idle;
|
||||
requestRedraw();
|
||||
}
|
||||
|
||||
void SshApp::connect(const term::SshTarget& target) {
|
||||
int cols, rows;
|
||||
grid(cols, rows);
|
||||
std::string why = ssh_.connect(target, cols, rows);
|
||||
if (!why.empty()) return say(why);
|
||||
password_ = LineEditor(96);
|
||||
scroll_ = 0;
|
||||
view_ = View::Session;
|
||||
shownState_ = SshService::State::Idle;
|
||||
}
|
||||
|
||||
std::string SshApp::connectTo(const std::string& text) {
|
||||
term::SshTarget target;
|
||||
std::string why = term::parseSshTarget(text, target);
|
||||
if (!why.empty()) return why;
|
||||
if (ssh_.state() != SshService::State::Idle && ssh_.state() != SshService::State::Ended) return "a session is open already";
|
||||
font_ = settings_.getInt(Setting::SshFont);
|
||||
message_.clear();
|
||||
connect(target);
|
||||
return view_ == View::Session ? "" : message_;
|
||||
}
|
||||
|
||||
void SshApp::setFont(int index) {
|
||||
index = std::clamp(index, 0, 4);
|
||||
if (index == font_) return;
|
||||
font_ = index;
|
||||
settings_.setInt(Setting::SshFont, font_);
|
||||
int cols, rows;
|
||||
grid(cols, rows);
|
||||
ssh_.resize(cols, rows); // the far end is told, and redraws for the new size
|
||||
scroll_ = 0;
|
||||
say(std::to_string(cols) + " x " + std::to_string(rows));
|
||||
}
|
||||
|
||||
void SshApp::writePublicKey() {
|
||||
std::string line = ssh_.publicKey() + "\n";
|
||||
storage_.runJob([line]() {
|
||||
if (!SD.exists("/ssh")) SD.mkdir("/ssh");
|
||||
File f = SD.open(kPublicKeyPath, FILE_WRITE);
|
||||
if (f) {
|
||||
f.write(reinterpret_cast<const uint8_t*>(line.data()), line.size());
|
||||
f.close();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void SshApp::help(std::vector<KeyHelp>& out) const {
|
||||
if (dialog_) return keys::add(out, keys::kDialog);
|
||||
switch (view_) {
|
||||
case View::Hosts: return keys::add(out, keys::kSsh);
|
||||
case View::Entry: return keys::add(out, keys::kText);
|
||||
case View::Key: return keys::add(out, keys::kSshKey);
|
||||
case View::Session:
|
||||
if (ssh_.state() == SshService::State::AskPassword) return keys::add(out, keys::kText);
|
||||
return keys::add(out, keys::kSshTerminal);
|
||||
}
|
||||
}
|
||||
|
||||
const char* SshApp::helpTitle() const {
|
||||
switch (view_) {
|
||||
case View::Entry: return "SSH: a host";
|
||||
case View::Key: return "SSH: this device's key";
|
||||
case View::Session: return "SSH: the terminal";
|
||||
default: return nullptr;
|
||||
}
|
||||
}
|
||||
|
||||
bool SshApp::sessionKey(const KeyEvent& e) {
|
||||
SshService::State state = ssh_.state();
|
||||
if (state == SshService::State::AskPassword) {
|
||||
switch (e.key) {
|
||||
case Key::Char: password_.insert(e.ch); break;
|
||||
case Key::Delete: password_.backspace(); break;
|
||||
case Key::Back:
|
||||
ssh_.disconnect();
|
||||
break;
|
||||
case Key::Select: {
|
||||
std::string typed = password_.text();
|
||||
password_ = LineEditor(96);
|
||||
ssh_.answerPassword(typed);
|
||||
break;
|
||||
}
|
||||
default: break;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if (state == SshService::State::Connecting) {
|
||||
if (e.key == Key::Back) ssh_.disconnect();
|
||||
return true;
|
||||
}
|
||||
if (state == SshService::State::Ended || state == SshService::State::Idle) {
|
||||
if (int step = scrollStep(e)) { // what it said last can still be read
|
||||
int history = 0;
|
||||
ssh_.withTerminal([&](term::Terminal& t) { history = t.altScreen() ? 0 : t.historyCount(); });
|
||||
scroll_ = std::clamp(scroll_ + step, 0, history);
|
||||
return true;
|
||||
}
|
||||
ssh_.clear(); // read: its memory goes back
|
||||
view_ = View::Hosts;
|
||||
return true;
|
||||
}
|
||||
// Open: every key is the far end's, but these few.
|
||||
if (e.key == Key::Char && e.ctrl && e.alt && (e.ch == 'q' || e.ch == 'Q')) return ssh_.disconnect(), true;
|
||||
if (e.key == Key::Char && e.ctrl && (e.ch == '=' || e.ch == '+')) return setFont(font_ + 1), true;
|
||||
if (e.key == Key::Char && e.ctrl && (e.ch == '-' || e.ch == '_')) return setFont(font_ - 1), true;
|
||||
if (int step = scrollStep(e)) {
|
||||
int history = 0;
|
||||
ssh_.withTerminal([&](term::Terminal& t) { history = t.altScreen() ? 0 : t.historyCount(); });
|
||||
scroll_ = std::clamp(scroll_ + step, 0, history);
|
||||
return true;
|
||||
}
|
||||
bool app = false;
|
||||
ssh_.withTerminal([&](term::Terminal& t) { app = t.appCursorKeys(); });
|
||||
std::string bytes;
|
||||
switch (e.key) {
|
||||
case Key::Char: bytes = term::encodeKey(term::TermKey::Char, e.ch, e.ctrl, e.alt, app); break;
|
||||
case Key::Select: bytes = term::encodeKey(term::TermKey::Enter, 0, false, e.alt, app); break;
|
||||
case Key::Delete: bytes = term::encodeKey(term::TermKey::Backspace, 0, false, e.alt, app); break;
|
||||
case Key::Tab: bytes = term::encodeKey(term::TermKey::Tab, 0, false, false, app); break;
|
||||
// The key is printed "esc", and here that is what it is. With Alt it types the backtick it also carries.
|
||||
case Key::Back: bytes = e.alt ? "`" : term::encodeKey(term::TermKey::Escape, 0, false, false, app); break;
|
||||
case Key::Up: bytes = term::encodeKey(e.shift ? term::TermKey::PageUp : term::TermKey::Up, 0, false, false, app); break;
|
||||
case Key::Down: bytes = term::encodeKey(e.shift ? term::TermKey::PageDown : term::TermKey::Down, 0, false, false, app); break;
|
||||
case Key::Left: bytes = term::encodeKey(term::TermKey::Left, 0, false, false, app); break;
|
||||
case Key::Right: bytes = term::encodeKey(term::TermKey::Right, 0, false, false, app); break;
|
||||
default: break;
|
||||
}
|
||||
if (!bytes.empty()) {
|
||||
scroll_ = 0;
|
||||
ssh_.send(bytes);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool SshApp::onKey(const KeyEvent& e) {
|
||||
requestRedraw();
|
||||
if (dialog_) {
|
||||
dialog_->onKey(e);
|
||||
int result = dialog_->result();
|
||||
if (result == DialogModel::kPending) return true;
|
||||
Ask asked = ask_;
|
||||
ask_ = Ask::None;
|
||||
dialog_.reset();
|
||||
if (asked == Ask::Trust) ssh_.answerTrust(result == 1);
|
||||
else if (asked == Ask::NewKey && result == 1) {
|
||||
std::string why = ssh_.makeKey();
|
||||
if (why.empty()) writePublicKey();
|
||||
say(why.empty() ? std::string("Made, and written to ") + kPublicKeyPath : why);
|
||||
} else if (asked == Ask::Forget && result == 1) {
|
||||
term::SshHosts saved(settings_.getString(Setting::SshHosts));
|
||||
term::SshTarget gone, other;
|
||||
term::parseSshTarget(hosts_[static_cast<size_t>(list_.selected())], gone);
|
||||
saved.remove(static_cast<size_t>(list_.selected()));
|
||||
settings_.setString(Setting::SshHosts, saved.stored());
|
||||
// Its fingerprint goes too, unless another saved host is the same server: met again, it is asked about again.
|
||||
bool shared = false;
|
||||
for (auto& h : saved.list()) shared = shared || (term::parseSshTarget(h, other).empty() && other.hostPort() == gone.hostPort());
|
||||
if (!shared) {
|
||||
term::SshKnownHosts known(settings_.getString(Setting::SshKnown));
|
||||
known.forget(gone.hostPort());
|
||||
settings_.setString(Setting::SshKnown, known.stored());
|
||||
}
|
||||
hosts_ = saved.list();
|
||||
list_.setCount(static_cast<int>(hosts_.size()) + 2);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
switch (view_) {
|
||||
case View::Session: return sessionKey(e);
|
||||
case View::Entry:
|
||||
switch (e.key) {
|
||||
case Key::Char: entry_.insert(e.ch); break;
|
||||
case Key::Delete: entry_.backspace(); break;
|
||||
case Key::Left: entry_.left(); break;
|
||||
case Key::Right: entry_.right(); break;
|
||||
case Key::Back: view_ = View::Hosts; break;
|
||||
case Key::Select: {
|
||||
term::SshTarget target;
|
||||
std::string why = term::parseSshTarget(entry_.text(), target);
|
||||
if (!why.empty()) say(why);
|
||||
else connect(target);
|
||||
break;
|
||||
}
|
||||
default: break;
|
||||
}
|
||||
return true;
|
||||
case View::Key:
|
||||
if (e.key == Key::Back) view_ = View::Hosts;
|
||||
else if (e.key == Key::Select || (e.key == Key::Char && (e.ch == 'n' || e.ch == 'N'))) {
|
||||
if (!ssh_.hasKey()) {
|
||||
std::string why = ssh_.makeKey();
|
||||
if (why.empty()) writePublicKey();
|
||||
say(why.empty() ? std::string("Made, and written to ") + kPublicKeyPath : why);
|
||||
} else {
|
||||
ask_ = Ask::NewKey;
|
||||
dialog_.reset(new DialogModel({"Cancel", "New key"}));
|
||||
}
|
||||
} else if (e.key == Key::Char && (e.ch == 'w' || e.ch == 'W') && ssh_.hasKey()) {
|
||||
writePublicKey();
|
||||
say(std::string("Written to ") + kPublicKeyPath);
|
||||
}
|
||||
return true;
|
||||
case View::Hosts: {
|
||||
int count = static_cast<int>(hosts_.size());
|
||||
switch (e.key) {
|
||||
case Key::Up: list_.up(); break;
|
||||
case Key::Down: list_.down(); break;
|
||||
case Key::Back: return false;
|
||||
case Key::Select: {
|
||||
int i = list_.selected();
|
||||
if (i < count) {
|
||||
term::SshTarget target;
|
||||
if (term::parseSshTarget(hosts_[static_cast<size_t>(i)], target).empty()) connect(target);
|
||||
} else if (i == count) {
|
||||
entry_ = LineEditor(96);
|
||||
view_ = View::Entry;
|
||||
} else {
|
||||
view_ = View::Key;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case Key::Char:
|
||||
if ((e.ch == 'd' || e.ch == 'D') && list_.selected() < count) {
|
||||
ask_ = Ask::Forget;
|
||||
dialog_.reset(new DialogModel({"Cancel", "Forget"}));
|
||||
} else if (e.ch == 'n' || e.ch == 'N') {
|
||||
entry_ = LineEditor(96);
|
||||
view_ = View::Entry;
|
||||
}
|
||||
break;
|
||||
default: break;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
void SshApp::update(uint32_t nowMs) {
|
||||
if (!message_.empty() && nowMs - messageMs_ >= kMessageMs) {
|
||||
message_.clear();
|
||||
requestRedraw();
|
||||
}
|
||||
if (view_ != View::Session) return;
|
||||
SshService::State state = ssh_.state();
|
||||
if (state != shownState_) {
|
||||
shownState_ = state;
|
||||
if (state == SshService::State::AskTrust && !dialog_) {
|
||||
ask_ = Ask::Trust;
|
||||
dialog_.reset(new DialogModel({"Cancel", ssh_.remembered().empty() ? "Trust it" : "Replace"}));
|
||||
} else if (state != SshService::State::AskTrust && ask_ == Ask::Trust) {
|
||||
dialog_.reset();
|
||||
ask_ = Ask::None;
|
||||
}
|
||||
if (state == SshService::State::Open) { // it got in: a host worth remembering (Q262)
|
||||
term::SshHosts saved(settings_.getString(Setting::SshHosts));
|
||||
saved.used(ssh_.target());
|
||||
settings_.setString(Setting::SshHosts, saved.stored());
|
||||
hosts_ = saved.list();
|
||||
list_.setCount(static_cast<int>(hosts_.size()) + 2);
|
||||
list_.select(0);
|
||||
}
|
||||
requestRedraw();
|
||||
}
|
||||
uint32_t revision = ssh_.revision();
|
||||
if (revision != shownRevision_ && nowMs - lastDrawMs_ >= kFrameMs) {
|
||||
shownRevision_ = revision;
|
||||
requestRedraw();
|
||||
}
|
||||
}
|
||||
|
||||
void SshApp::drawHosts(Canvas& c) {
|
||||
const auto& area = theme::kContent;
|
||||
int count = static_cast<int>(hosts_.size());
|
||||
widgets::list(
|
||||
c, list_, {area.x, area.y, area.w, 8 * theme::kLineHeight},
|
||||
[&](int i) -> std::string { return i < count ? hosts_[static_cast<size_t>(i)] : i == count ? "New connection" : "This device's key"; },
|
||||
[&](int i) -> std::string { return i < count ? "" : i == count ? ">" : ssh_.hasKey() ? ">" : "none yet"; });
|
||||
}
|
||||
|
||||
void SshApp::drawKey(Canvas& c) {
|
||||
const auto& area = theme::kContent;
|
||||
c.setFont(&fonts::body);
|
||||
c.setTextColor(theme::kMuted);
|
||||
if (!ssh_.hasKey()) {
|
||||
c.drawString("This device has no key yet.", 4, area.y + 4);
|
||||
c.drawString("With one, servers that know it", 4, area.y + 4 + 2 * theme::kLineHeight);
|
||||
c.drawString("ask for no password.", 4, area.y + 4 + 3 * theme::kLineHeight);
|
||||
c.setTextColor(theme::kText);
|
||||
c.drawString("Enter makes one.", 4, area.y + 4 + 5 * theme::kLineHeight);
|
||||
return;
|
||||
}
|
||||
c.drawString("Its public half, for a server's", 4, area.y + 2);
|
||||
c.drawString("authorized_keys file:", 4, area.y + 2 + theme::kLineHeight);
|
||||
c.setFont(&fonts::small);
|
||||
c.setTextColor(theme::kText);
|
||||
std::string pub = ssh_.publicKey();
|
||||
int y = area.y + 2 + 2 * theme::kLineHeight + 3;
|
||||
for (size_t at = 0; at < pub.size() && y < area.y + area.h - 30; at += 46, y += 9) c.drawString(pub.substr(at, 46).c_str(), 4, y);
|
||||
c.setFont(&fonts::small);
|
||||
c.setTextColor(theme::kMuted);
|
||||
c.drawString((std::string("It is also in ") + kPublicKeyPath + " (w writes it").c_str(), 4, area.y + area.h - 28);
|
||||
c.drawString("again). The private half never leaves.", 4, area.y + area.h - 19);
|
||||
}
|
||||
|
||||
void SshApp::drawTerminal(Canvas& c) {
|
||||
const auto& area = theme::kContent;
|
||||
const Font& f = kFonts[font_];
|
||||
c.setFont(f.font);
|
||||
c.setTextDatum(top_left);
|
||||
ssh_.withTerminal([&](term::Terminal& t) {
|
||||
int rows = std::min(t.rows(), area.h / f.h), cols = std::min(t.cols(), area.w / f.w);
|
||||
int history = t.altScreen() ? 0 : t.historyCount(), back = std::min(scroll_, history);
|
||||
for (int r = 0; r < rows; r++) {
|
||||
int y = area.y + r * f.h, line = r - back; // negative: a line of the history
|
||||
if (line < 0) {
|
||||
const std::string& text = t.historyLine(history + line);
|
||||
std::string shown;
|
||||
for (size_t i = 0; i < text.size() && static_cast<int>(i) < cols; i++) appendUtf8(shown, static_cast<uint8_t>(text[i]));
|
||||
c.setTextColor(theme::kMuted);
|
||||
c.drawString(shown.c_str(), area.x, y);
|
||||
continue;
|
||||
}
|
||||
for (int col = 0; col < cols;) { // one run of the same colours at a time
|
||||
uint8_t attr = t.cell(line, col).attr;
|
||||
std::string run;
|
||||
int from = col;
|
||||
while (col < cols && t.cell(line, col).attr == attr) appendUtf8(run, t.cell(line, col++).ch);
|
||||
if (attr >> 4) c.fillRect(area.x + from * f.w, y, (col - from) * f.w, f.h, colour(attr >> 4));
|
||||
if (run.find_first_not_of(' ') == std::string::npos) continue;
|
||||
c.setTextColor(colour(attr & 15));
|
||||
c.drawString(run.c_str(), area.x + from * f.w, y);
|
||||
}
|
||||
}
|
||||
// The cursor: its cell with the colours swapped.
|
||||
int row = t.cursorRow() + back;
|
||||
if (t.cursorVisible() && row < rows && t.cursorCol() < cols && ssh_.state() == SshService::State::Open) {
|
||||
const term::Cell& cell = t.cell(t.cursorRow(), t.cursorCol());
|
||||
int x = area.x + t.cursorCol() * f.w, y = area.y + row * f.h;
|
||||
c.fillRect(x, y, f.w, f.h, colour(cell.attr & 15));
|
||||
std::string ch;
|
||||
appendUtf8(ch, cell.ch);
|
||||
c.setTextColor(colour(cell.attr >> 4));
|
||||
if (cell.ch != ' ') c.drawString(ch.c_str(), x, y);
|
||||
}
|
||||
if (back) { // looking back: how far
|
||||
std::string where = "-" + std::to_string(back);
|
||||
c.setFont(&fonts::small);
|
||||
c.setTextDatum(top_right);
|
||||
c.fillRect(area.x + area.w - 5 * static_cast<int>(where.size()) - 3, area.y, 5 * static_cast<int>(where.size()) + 3, 9, theme::kAccent);
|
||||
c.setTextColor(0x0000);
|
||||
c.drawString(where.c_str(), area.x + area.w - 1, area.y + 1);
|
||||
c.setTextDatum(top_left);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void SshApp::drawSession(Canvas& c) {
|
||||
const auto& area = theme::kContent;
|
||||
SshService::State state = ssh_.state();
|
||||
if (state == SshService::State::Open || (state == SshService::State::Ended && ssh_.opened())) {
|
||||
drawTerminal(c);
|
||||
if (state == SshService::State::Ended) {
|
||||
std::string why = ssh_.status() + ". Any key.";
|
||||
if (why.size() > 47) why = ssh_.status();
|
||||
c.setFont(&fonts::small);
|
||||
c.fillRect(area.x, area.y + area.h - 11, area.w, 11, theme::kBackground);
|
||||
c.setTextColor(theme::kWarning);
|
||||
c.drawString(why.c_str(), 4, area.y + area.h - 9);
|
||||
}
|
||||
return;
|
||||
}
|
||||
c.setFont(&fonts::body);
|
||||
c.setTextColor(theme::kMuted);
|
||||
c.drawString(ssh_.target().text().c_str(), 4, area.y + 4);
|
||||
if (state == SshService::State::AskPassword) {
|
||||
std::string again = ssh_.status();
|
||||
c.setTextColor(again.empty() ? theme::kText : theme::kWarning);
|
||||
c.drawString(again.empty() ? "Password" : again.c_str(), 4, area.y + 4 + 2 * theme::kLineHeight);
|
||||
LineEditor stars(96);
|
||||
stars.setText(masked(password_.text().size()));
|
||||
widgets::lineEditor(c, stars, {4, area.y + 4 + 3 * theme::kLineHeight + 4, area.w - 8, 0});
|
||||
c.setFont(&fonts::small);
|
||||
c.setTextColor(theme::kMuted);
|
||||
c.drawString("It isn't kept. ` gives up.", 4, area.y + area.h - 10);
|
||||
return;
|
||||
}
|
||||
// Connecting, or it never got as far as a shell: what it is doing, or why not.
|
||||
bool ended = state == SshService::State::Ended;
|
||||
c.setTextColor(ended ? theme::kWarning : theme::kText);
|
||||
auto lines = wrapText(ssh_.status(), area.w - 8, widgets::bodyMeasure(c));
|
||||
for (size_t i = 0; i < lines.size() && i < 4; i++) c.drawString(lines[i].c_str(), 4, area.y + 4 + static_cast<int>(2 + i) * theme::kLineHeight);
|
||||
if (ended) {
|
||||
c.setFont(&fonts::small);
|
||||
c.setTextColor(theme::kMuted);
|
||||
c.drawString("Any key.", 4, area.y + area.h - 10);
|
||||
}
|
||||
}
|
||||
|
||||
void SshApp::draw(Canvas& c) {
|
||||
lastDrawMs_ = millis();
|
||||
const auto& area = theme::kContent;
|
||||
c.setTextDatum(top_left);
|
||||
switch (view_) {
|
||||
case View::Hosts: drawHosts(c); break;
|
||||
case View::Entry:
|
||||
c.setFont(&fonts::body);
|
||||
c.setTextColor(theme::kMuted);
|
||||
c.drawString("Who, and where", 4, area.y + 4);
|
||||
widgets::lineEditor(c, entry_, {4, area.y + 22, area.w - 8, 0});
|
||||
c.drawString("user@host, or user@host:port", 4, area.y + 44);
|
||||
break;
|
||||
case View::Key: drawKey(c); break;
|
||||
case View::Session: drawSession(c); break;
|
||||
}
|
||||
if (!message_.empty() && !dialog_) {
|
||||
c.setFont(&fonts::small);
|
||||
c.fillRect(area.x, area.y + area.h - 11, area.w, 11, theme::kBackground);
|
||||
c.setTextColor(theme::kWarning);
|
||||
c.drawString(message_.c_str(), 4, area.y + area.h - 9);
|
||||
}
|
||||
if (dialog_ && ask_ == Ask::Trust) {
|
||||
std::string was = ssh_.remembered(), now = ssh_.fingerprint();
|
||||
// Three lines: one of words, and the fingerprint in two halves, since it is longer than a line.
|
||||
std::string shown = now.size() > 25 ? now.substr(0, 25) + " " + now.substr(25) : now;
|
||||
std::string host = ssh_.target().host.size() > 30 ? ssh_.target().host.substr(0, 28) + ".." : ssh_.target().host;
|
||||
if (was.empty()) widgets::dialog(c, "A server not met before", host + " " + shown, *dialog_);
|
||||
else widgets::dialog(c, "THE SERVER'S KEY CHANGED", "Someone in between? Now it is " + shown, *dialog_);
|
||||
} else if (dialog_ && ask_ == Ask::NewKey) {
|
||||
widgets::dialog(c, "A new key?", "Servers that know the old one will ask for a password again.", *dialog_);
|
||||
} else if (dialog_ && ask_ == Ask::Forget) {
|
||||
widgets::dialog(c, "Forget this host?", hosts_[static_cast<size_t>(std::min<int>(list_.selected(), static_cast<int>(hosts_.size()) - 1))], *dialog_);
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace roro
|
||||
@@ -0,0 +1,71 @@
|
||||
#pragma once
|
||||
|
||||
#include <memory>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "app.h"
|
||||
#include "dialog_model.h"
|
||||
#include "line_editor.h"
|
||||
#include "list_model.h"
|
||||
#include "services/ssh_service.h"
|
||||
#include "services/storage_service.h"
|
||||
#include "settings.h"
|
||||
#include "ui/canvas.h"
|
||||
|
||||
namespace roro {
|
||||
|
||||
// The SSH App (issue #2, docs/milestones/N1.md): the saved hosts, and a terminal on the one that
|
||||
// is connected. Leaving it with Home doesn't end the session; coming back finds it.
|
||||
class SshApp : public App {
|
||||
public:
|
||||
static constexpr const char* kPublicKeyPath = "/ssh/id_ed25519.pub";
|
||||
|
||||
SshApp(SshService& ssh, Settings& settings, StorageService& storage) : ssh_(ssh), settings_(settings), storage_(storage) {}
|
||||
|
||||
void onEnter() override;
|
||||
bool onKey(const KeyEvent& e) override;
|
||||
bool textEntryActive() const override { return view_ == View::Entry || (view_ == View::Session && !dialog_); }
|
||||
void help(std::vector<KeyHelp>& out) const override;
|
||||
const char* helpTitle() const override;
|
||||
void update(uint32_t nowMs) override;
|
||||
void draw(Canvas& c) override;
|
||||
|
||||
// `ssh user@host` from the Shell or a console: "" or why not.
|
||||
std::string connectTo(const std::string& target);
|
||||
|
||||
private:
|
||||
enum class View { Hosts, Entry, Session, Key };
|
||||
struct Font {
|
||||
const lgfx::IFont* font;
|
||||
int w, h;
|
||||
};
|
||||
static const Font kFonts[5];
|
||||
|
||||
void grid(int& cols, int& rows) const;
|
||||
void connect(const term::SshTarget& target);
|
||||
void setFont(int index);
|
||||
bool sessionKey(const KeyEvent& e);
|
||||
void drawHosts(Canvas& c);
|
||||
void drawSession(Canvas& c);
|
||||
void drawTerminal(Canvas& c);
|
||||
void drawKey(Canvas& c);
|
||||
void say(const std::string& text);
|
||||
void writePublicKey();
|
||||
|
||||
SshService& ssh_;
|
||||
Settings& settings_;
|
||||
StorageService& storage_;
|
||||
View view_ = View::Hosts;
|
||||
ListModel list_{8};
|
||||
std::vector<std::string> hosts_;
|
||||
LineEditor entry_{96}, password_{96};
|
||||
std::unique_ptr<DialogModel> dialog_;
|
||||
enum class Ask { None, Trust, NewKey, Forget } ask_ = Ask::None;
|
||||
SshService::State shownState_ = SshService::State::Idle;
|
||||
std::string message_;
|
||||
uint32_t messageMs_ = 0, shownRevision_ = 0, lastDrawMs_ = 0;
|
||||
int font_ = 1, scroll_ = 0;
|
||||
};
|
||||
|
||||
} // namespace roro
|
||||
@@ -12,7 +12,9 @@
|
||||
#include "apps/demo_app.h"
|
||||
#include "apps/note_editor.h"
|
||||
#include "apps/shell_app.h"
|
||||
#include "apps/ssh_app.h"
|
||||
#include "services/net_tools.h"
|
||||
#include "services/ssh_service.h"
|
||||
#include "services/vpn_service.h"
|
||||
#include "services/web_share.h"
|
||||
#include "apps/gemini_app.h"
|
||||
@@ -91,6 +93,8 @@ static UpdateService* update;
|
||||
static DebugConsole* debugConsole;
|
||||
static VpnService* vpnService; // not in Safe Mode
|
||||
static NetTools netTools; // ping, nslookup and the rest (issue #90)
|
||||
static SshService* sshService; // not in Safe Mode (issue #2)
|
||||
static SshApp* sshApp;
|
||||
static Notifier* notifier;
|
||||
static LauncherApp launcher;
|
||||
static AppManager* apps;
|
||||
@@ -139,6 +143,7 @@ static StatusInfo currentStatus() {
|
||||
s.capturing = loraCapture && loraCapture->capturing();
|
||||
if (vpnService && vpnService->wanted())
|
||||
s.vpn = vpnService->state() == VpnService::State::Up ? StatusInfo::Vpn::Up : StatusInfo::Vpn::Trying;
|
||||
s.ssh = sshService && sshService->active();
|
||||
s.debug = !debugConsole->on() ? StatusInfo::Debug::Off : debugConsole->clientConnected() ? StatusInfo::Debug::Client : StatusInfo::Debug::On;
|
||||
using WifiState = WifiController::State;
|
||||
switch (wifi->state()) {
|
||||
@@ -228,6 +233,9 @@ void setup() {
|
||||
apps->registerApp({"lora", "LoRa Scanner", false, new LoraScannerApp(*radioService, *loraCapture, settings, *clockService)});
|
||||
apps->registerApp({"storage", "Storage", false, new StorageApp(*fileOps, *storageService, *clockService, *update, *power, bus, *new WebShare(*storageService, *fileOps, *wifi))});
|
||||
apps->registerApp({"notes", "Notes", false, new NotesApp(*fileOps, *storageService, *clockService, *power)});
|
||||
sshService = new SshService(settings);
|
||||
sshApp = new SshApp(*sshService, settings, *storageService);
|
||||
apps->registerApp({"ssh", "SSH", false, sshApp});
|
||||
apps->registerApp({"shell", "Shell", false, new ShellApp(shellRun, shellProbe, shellList, shellCount, helpText(), *apps)});
|
||||
// Leaving the foreground App makes it save: a note being typed, when the device is powered off.
|
||||
power->beforePowerOff = []() { apps->home(); };
|
||||
@@ -680,6 +688,7 @@ static const char* const kHelp =
|
||||
"ping <host> [count] [size] | nslookup <name> [server] | port <host> <port> | traceroute <host> | cancel is it there, does its name resolve, is its port open, which way; one at a time\n"
|
||||
"tls <host> [port] | ntp [server] a TLS handshake: who the certificate is for, by whom, until when, and whether this device trusts it; a time server's clock against this one\n"
|
||||
"ifconfig | arp | netstat the interfaces (Wi-Fi and the VPN), their addresses, the default route and the DNS servers; the neighbours heard; what listens and what is connected\n"
|
||||
"ssh user@host[:port] | ssh status | ssh stop a terminal on another machine, in the SSH App; the password is asked there, never here\n"
|
||||
"vpn status | vpn up [seconds] | vpn down | vpn import [path] | vpn forget | vpn auto on|off the WireGuard tunnel (Settings > VPN); import reads /vpn/wg0.conf; with seconds, it goes down by itself\n"
|
||||
"debug status | debug off [seconds] the Debug Console over Wi-Fi (Settings > Debug Console); with seconds, it comes back\n"
|
||||
"debug on | debug token <16 to 64 characters> | debug token new (USB serial only) switch it on, set its token\n"
|
||||
@@ -740,6 +749,28 @@ static void saveScreenshot() {
|
||||
});
|
||||
}
|
||||
|
||||
// `ssh ...` (issue #2, Q265): the session is the SSH App's; this opens it there.
|
||||
static void sshCommand(const String& arg) {
|
||||
if (!sshService) return (void)console.println("ssh: not available in Safe Mode");
|
||||
SshService& s = *sshService;
|
||||
if (arg == "status") {
|
||||
bool live = s.state() != SshService::State::Idle && s.state() != SshService::State::Ended;
|
||||
if (s.state() == SshService::State::Idle) console.println("ssh: no session");
|
||||
else console.printf("ssh: %s%s%s\n", s.target().text().c_str(), live ? ", " : ": ", s.state() == SshService::State::Open ? "open" : s.status().c_str());
|
||||
console.printf("ssh: this device's key: %s\n", s.hasKey() ? s.publicKey().c_str() : "none (the SSH App makes one)");
|
||||
} else if (arg == "stop") {
|
||||
s.disconnect();
|
||||
console.println("ssh: stopped");
|
||||
} else if (arg.indexOf('@') > 0) {
|
||||
std::string why = sshApp->connectTo(arg.c_str());
|
||||
if (!why.empty()) return (void)console.printf("ssh: error %s\n", why.c_str());
|
||||
if (!apps->open("ssh")) return (void)console.println("ssh: connecting, but Setup is running");
|
||||
console.printf("ssh: connecting to %s, in the SSH App\n", s.target().text().c_str());
|
||||
} else {
|
||||
console.println("ssh: user@host[:port] | status | stop");
|
||||
}
|
||||
}
|
||||
|
||||
// `vpn ...` (issue #8). Nothing here prints a key.
|
||||
static void vpnCommand(const String& arg) {
|
||||
if (!vpnService) return (void)console.println("vpn: not available in Safe Mode");
|
||||
@@ -874,6 +905,7 @@ static void runCommand(String line, bool fromSerial = false) {
|
||||
}
|
||||
if (netTools.command(line.c_str())) return;
|
||||
if (line == "cancel") netTools.cancel(); // and a copy or a delete, below
|
||||
if (line == "ssh" || line.startsWith("ssh ")) return sshCommand(line.length() > 4 ? line.substring(4) : String("status"));
|
||||
if (line == "vpn" || line.startsWith("vpn ")) return vpnCommand(line.length() > 4 ? line.substring(4) : String("status"));
|
||||
if (line.startsWith("debug ")) return debugCommand(line.substring(6), fromSerial);
|
||||
if (line == "screenshot" || line.startsWith("screenshot ")) {
|
||||
|
||||
@@ -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
|
||||
@@ -221,4 +221,202 @@ const uint8_t _5x8_tf[1715] =
|
||||
"\3\374\11\64b\23\63\212f\32\375\13D^[\343(\323\210I\1\376\12<^\223\363\212#\345\14\377"
|
||||
"\14<^\23\63\212\62\215\230\24\0\0\0\0\4\377\377\0";
|
||||
|
||||
// For the terminal (issue #2): three more sizes of the same family, from the same file.
|
||||
const uint8_t _4x6_tf[1451] =
|
||||
"\277\0\2\2\3\3\2\4\4\4\6\0\377\5\377\5\377\0\351\1\323\5\216 \5\200\315\0!\6\351\310"
|
||||
"\254\0\42\6\223\313$\25#\12\254\310\244\64T\32*\1$\11\263\307\245\241GJ\0%\10\253\310d"
|
||||
"\324F\1&\11\254\310\305\24\253\230\2'\5\321\313\10(\7\362\307\251f\0)\10\262\307\304T)\0"
|
||||
"*\7\253\310\244j\65+\10\253\310\305\264b\2,\6\222\307)\0-\5\213\312\14.\5\311\310\4/"
|
||||
"\7\253\310Ve\4\60\10\253\310UCU\0\61\7\253\310%Y\15\62\7\253\310\65S\32\63\10\253\310"
|
||||
"\314\224\27\0\64\10\253\310$\65b\1\65\10\253\310\214\250\27\0\66\7\253\310M\325\2\67\10\253\310\314"
|
||||
"TF\0\70\7\253\310\255\326\2\71\7\253\310\265\344\2:\6\341\310\304\0;\7\252\307e\250\0<\7"
|
||||
"\253\310\246\272\0=\6\233\311\354\1>\7\253\310\344\252\4\77\10\253\310\350\224a\2@\6\253\310-["
|
||||
"A\10\253\310UC\251\0B\10\253\310\250\264\322\2C\10\253\310U\62U\0D\10\253\310\250d-\0"
|
||||
"E\10\253\310\214\250\342\0F\10\253\310\214\250b\4G\10\253\310\315\244\222\0H\10\253\310$\65\224\12"
|
||||
"I\7\253\310\254X\15J\7\253\310\226\252\2K\10\253\310$\265\222\12L\7\253\310\304\346\0M\10\253"
|
||||
"\310\244\61\224\12N\10\253\310\252\241$\0O\7\253\310UV\5P\10\253\310\250\264b\4Q\7\263\307"
|
||||
"UV\35R\10\253\310\250\264\222\12S\7\253\310\355\274\0T\7\253\310\254\330\2U\7\253\310$\327\10"
|
||||
"V\10\253\310$k\244\4W\10\253\310$\65\206\12X\10\253\310$\325R\1Y\10\253\310$UV\0"
|
||||
"Z\7\253\310\314T\16[\6\352\310\254J\134\7\253\310\304\134\6]\6\252\310\250j^\5\223\313\65_"
|
||||
"\5\213\307\14`\6\322\313\304\0a\7\243\310-\225\4b\10\253\310D\225\324\2c\6\243\310\315,d"
|
||||
"\10\253\310\246\245\222\0e\6\243\310USf\10\253\310\246\264b\2g\10\253\307\255$\27\0h\10\253"
|
||||
"\310D\225\254\0i\10\253\310e$\323\0j\10\263\307fX.\0k\10\253\310\304\264\222\12l\7\253"
|
||||
"\310\310\326\0m\10\243\310\244\241T\0n\7\243\310\250d\5o\7\243\310U\252\2p\10\253\307\250\264"
|
||||
"b\4q\10\253\307-\225d\0r\10\243\310\244\25#\0s\7\243\310\215\274\0t\10\253\310\245\25s"
|
||||
"\0u\7\243\310$+\11v\7\243\310$\253\2w\10\243\310$\65T\0x\7\243\310\244\62\25y\10"
|
||||
"\253\307$\225\344\2z\7\243\310\314\224\6{\10\263\307\246$\353\0|\6\351\310\14\1}\11\263\307\344"
|
||||
"\250b\212\0~\7\224\313%\225\0\240\5\200\315\0\241\6\351\310\244\1\242\10\253\310\245\21W\2\243\7"
|
||||
"\253\310\246\250\32\244\10\244\310\304$U\14\245\10\253\310\244j\305\4\246\6\351\310(\1\247\10\263\307\215"
|
||||
"T\311\5\250\6\213\314\244\0\251\11\264\307\251\270\226L\12\252\7\253\310\255\244\7\253\10\234\311%\25S"
|
||||
"\0\254\6\223\311\314\0\255\5\213\312\14\256\10\244\311\251\261\222\2\257\5\213\314\14\260\6\233\312u\1\261"
|
||||
"\10\253\310\245\225\321\0\262\6\242\311(\3\263\7\252\310(\251\0\264\6\322\313)\0\265\10\253\307$k"
|
||||
"E\0\266\7\254\310\15\265z\267\5\311\312\4\270\6\322\310)\0\271\6\242\311\255\2\272\7\253\310u\243"
|
||||
"\1\273\10\234\311\244\230T\2\274\10\264\307\344\32U;\275\10\264\307\344J\307,\276\11\264\307\350\230Q"
|
||||
"\262\3\277\10\253\310e\230\262\0\300\10\253\310\344\224\206\12\301\10\253\310\246j\250\0\302\10\253\310\310\224"
|
||||
"\206\12\303\10\253\310\215\224\206\12\304\10\253\310\244\326P\1\305\10\253\310\305\224\206\12\306\11\254\310\215\224"
|
||||
"\206\252\4\307\10\263\307U\62\65\1\310\10\253\310\304\241\342\0\311\7\253\310\216\25\7\312\10\253\310\215\221"
|
||||
"\342\0\313\10\253\310\244\261\342\0\314\10\253\310\304\25\323\0\315\10\253\310\216\24\323\0\316\10\253\310\245\25"
|
||||
"\323\0\317\7\253\310\244\262\32\320\11\254\310\314\264\252\221\0\321\10\254\310%\225\256\12\322\10\253\310\344\224"
|
||||
"T\5\323\10\253\310\246JU\0\324\10\253\310\305\224T\5\325\10\254\310\215\325\31\1\326\10\253\310\244\226"
|
||||
"\252\0\327\6\233\311\244\16\330\7\253\310\35j\1\331\10\253\310\344\224\324\10\332\10\253\310\246J\215\0\333"
|
||||
"\10\253\310e\224\324\10\334\10\253\310\244\234\324\10\335\10\253\310\346T&\0\336\10\253\310D\225V\4\337"
|
||||
"\10\263\307U+\15\11\340\10\253\310\344\270\222\0\341\10\253\310\246\270\222\0\342\10\253\310i\264\222\0\343"
|
||||
"\11\254\310%\25U\251\0\344\10\253\310\244\214V\22\345\10\253\310e\270\222\0\346\10\244\310\215\264\342\0"
|
||||
"\347\10\253\307UZ%\0\350\10\253\310\344\224\246\0\351\7\253\310\246j\12\352\10\253\310\310\224\246\0\353"
|
||||
"\7\253\310\244\326\24\354\7\253\310\344X\15\355\6\253\310\316j\356\7\253\310u\246\1\357\10\253\310\244,"
|
||||
"\323\0\360\10\253\310\244rU\0\361\11\254\310%\225dj\1\362\10\253\310\344\230Z\0\363\10\253\310\246"
|
||||
"\230Z\0\364\10\253\310e\230Z\0\365\7\253\310l\324\5\366\10\253\310\244\214\272\0\367\10\253\310e\264"
|
||||
"Q\2\370\7\243\310-\265\0\371\10\253\310\344\224T\22\372\10\253\310\246J%\1\373\10\253\310e\224T"
|
||||
"\22\374\10\253\310\244\234T\22\375\10\263\307\246j\304\5\376\11\263\307\304\250\322\212\0\377\11\263\307\244\234"
|
||||
"F\134\0\0\0\0\4\377\377\0";
|
||||
|
||||
const uint8_t _6x10_tf[2000] =
|
||||
"\277\0\2\2\3\4\3\5\4\6\12\0\376\7\376\7\0\1B\2\222\7\263 \5\0b\7!\7\71C"
|
||||
"g\250\0\42\7\233R'Y\1#\15=B\257Li\250j\250\62%\0$\13=B\67\257z\247\264"
|
||||
"#\0%\13=B/\252\356\252%\23\0&\14=B/\247\230r\225dT\1'\5\31Sg(\10"
|
||||
"\273B\67\225u\1)\10\273B'\227U\11*\12-F'\247j\250v\0+\12-F\67\243\70d"
|
||||
"F\21,\7\233>O\225\0-\6\15Ng\10.\7\233>/\255\4/\13=B\37e\224\273QF"
|
||||
"\0\60\12=B\67\247\332Nu\4\61\14=B\67\313\224QF\31\305!\62\14=Bo\345\214\242\314"
|
||||
"\31\15\1\63\14=Bgh\224\263\206:-\0\64\14=B\77\313T\246\241\63J\0\65\13=B\347"
|
||||
"F\311\314H\247\5\66\13=BW\346\214\222\251\323\2\67\14=Bgh\224\63\312\65\312\0\70\13="
|
||||
"Boe\235V\326i\1\71\14=Boe\251TF\71J\0:\12\273>/\255\14\323J\0;\11"
|
||||
"\273>/\255\14U\11<\12\274B\77\266QF\31\5=\10\35Jgh\70\4>\13\274B'\243\214"
|
||||
"\62\212m\0\77\12=Bo\345\66\312t\4@\13=Boe\271\222\225\341\2A\13=B\67\247Z"
|
||||
"\217\221u\0B\14=Bg\304*\246Y\305\241\0C\14=Boe\215\62\312(\247\5D\15=B"
|
||||
"g\304*\246\230b\212C\1E\14=B\347F\31\215\224QFCF\15=B\347F\31\215\224QF"
|
||||
"\31\1G\14=Boe\215\62\212;-\0H\11=B'\333cd;I\10\273Bg\305\256\1J"
|
||||
"\14=Bwg\224QFe\224\0K\13=B'\313T\352\24\253\34L\16=B'\243\214\62\312("
|
||||
"\243\214\206\0M\12=B'\353\265\222\266\3N\12=B'\353\251\222\334:O\11=Boe\357\264"
|
||||
"\0P\15=Bg\244\254\207\312(\243\214\0Q\12E>oe\257j\303\0R\13=Bg\244\254\207"
|
||||
"*\253\34S\13=Boe\15\67\324i\1T\16=Bg\310\214\62\312(\243\214\42\0U\10=B"
|
||||
"'\373N\13V\13=B'\333\251L\61\345\10W\12=B'\273\222Jw\0X\12=B'\353T"
|
||||
"W\265\16Y\14=B'\353Tg\224QF\21Z\12=Bgh\224\273\321\20[\10\273Bg\304\316"
|
||||
"\1\134\15=B'\243\14\63\314\60\303\214\2]\10\273Bgv\216\0^\7\35R\67\247:_\6\15"
|
||||
">g\10`\6\22['\6a\12-Bo\303\64t\32\1b\14=B'\243\214\222\251\247R\0c"
|
||||
"\12-Boe\215rZ\0d\13=B\37e\224\314-\225\12e\12-Bo\345\61\62\134\0f\14"
|
||||
"=BWVy\304\214\62\312\0g\14=:oh\235FF:-\0h\13=B'\243\214\222\251\355"
|
||||
"\0i\10\273B/#\331\32j\13\314:\77c]K\231\24\0k\14=B'\243\214\262L\263\312\1"
|
||||
"l\7\273BG\366\32m\12-BG\265TRI\7n\10-B'\231\332\16o\11-Boe;"
|
||||
"-\0p\14=:'\231z*\225QF\0q\13=:\317\334R\251\214\62\12r\13-B'\231\32"
|
||||
"e\224\21\0s\12-Boe\270\341P\0t\15=B/\243<bF\31\305\250\0u\10-B'"
|
||||
";\225\12v\12-B'\353T\246\34\1w\11-B'[Iu\1x\11-B'\247\272\252\3y"
|
||||
"\13=:'\233Je\244\323\2z\10-Bg\350\366\20{\13\274BWe\224\64\212\31\11|\6\71"
|
||||
"C\347\10}\14\274BG\243\230\221\312(I\0~\11\35R/\252$\23\0\240\5\0b\7\241\7\71"
|
||||
"C'\15\1\242\14=>\67\17\25SLy\304\10\243\13=BWVyg\24\225\2\244\12-B'"
|
||||
"\247\231\342\312\1\245\15E>'\353T\307!\63\312(\2\246\6\71Cg\15\247\13E>oe\64;"
|
||||
"\67J\13\250\6\213^'\5\251\14=Boe\225\246J:-\0\252\12\264FoD\245j\64\2\253"
|
||||
"\13.B\267\212)\346\230c\0\254\7\224Jg\344\0\255\6\214Ng\4\256\13=Bo\345\221\346\324"
|
||||
"i\1\257\6\15^g\10\260\6\233R\257\13\261\13\65B\67\243\70dFq\10\262\10\254NO\305\346"
|
||||
"\10\263\12\254Ng\243\244\321H\0\264\6\22[O\1\265\12\65>'\333S\251\214\0\266\16=Bo"
|
||||
"\214\64RR\61\305\24S\0\267\5\11O'\270\6\22;O\1\271\7\253N/\311j\272\12\264FO"
|
||||
"E\231\64\34\1\273\14.B'\346\230c\212)F\0\274\20N>/#\15\63\314hf\244S\34\31"
|
||||
"\6\275\20N>/#\15\63\314heT\303\214\62\32\276\16M>G\303\234a\224Y\246\64\62\12\277"
|
||||
"\12=B\67\323\31\345\326\2\300\14EB/\303\274\262\36#\353\0\301\13EB\277^Y\217\221u\0"
|
||||
"\302\14EB\67\247\270\262\36#\353\0\303\14EB/*\271\262\36#\353\0\304\13EB\257\246V\326"
|
||||
"cd\35\305\14EB\67\247\270\262\36#\353\0\306\13>Bw\244\262\71Fl\16\307\15M:oe"
|
||||
"\215\62\312(\247]\3\310\16EB/\217\215\62\32)\243\214\206\0\311\16EB\77\215\215\62\32)\243"
|
||||
"\214\206\0\312\16EB\67\216\215\62\32)\243\214\206\0\313\16EB\257\32\33e\64RF\31\15\1\314"
|
||||
"\11\303B'\247\25[\3\315\11\303B\67\245\25[\3\316\11\303B\257\32)\266\6\317\11\303B'\345"
|
||||
"\25[\3\320\15=Bg\304*\216T\246\70\24\0\321\13EB\67uO\225\344\326\1\322\13EB/"
|
||||
"\303\274\262;-\0\323\12EB\277^\331\235\26\0\324\13EB\67\247\270\262;-\0\325\12EB\67"
|
||||
"\65Wv\247\5\326\12EB\257\246Vv\247\5\327\11-B'\247\272\252\3\330\13=Bo\305+\315"
|
||||
"\231\26\0\331\12EB/\303\332;-\0\332\11EB\277\314\336i\1\333\13EB\67\247\214\263;-"
|
||||
"\0\334\12EB\257\306\331;-\0\335\14EB\277\314:\325\31e\24\1\336\16=B'\243\221\362P"
|
||||
"\31e\224\21\0\337\13=Boe\231\312*+\5\340\14EB/\303Ln\230\206N#\341\13EB"
|
||||
"\277&\67LC\247\21\342\14EB\67\247Lm\230\206N#\343\14EB\67\265\251\15\323\320i\4\344"
|
||||
"\13=B\257\246\66LC\247\21\345\14EB\67\247\234\67LC\247\21\346\13.BodT\215\231\207"
|
||||
"\0\347\13=:oe\215r\332\65\0\350\14EB/\303L\256<F\206\13\351\13EB\277&W\36"
|
||||
"#\303\5\352\14EB\67\247L\255<F\206\13\353\13=B\257\246V\36#\303\5\354\11\303B'g"
|
||||
"$[\3\355\10\303B\257\206\262\65\356\10\303B\257-[\3\357\10\273B'e\331\32\360\13=BG"
|
||||
"C\271\262\235\26\0\361\12EB\67\265q\62\265\35\362\13EB/\303L\256l\247\5\363\12EB\277"
|
||||
"&W\266\323\2\364\13EB\67\247L\255l\247\5\365\13EB\67\265\251\225\355\264\0\366\12=B\257"
|
||||
"\246V\266\323\2\367\11-F\67SCS\21\370\12-Bo\310\225\346P\0\371\13EB/\303Le"
|
||||
"\247R\1\372\12EB\277\246\262S\251\0\373\13EB\67\247\214\263S\251\0\374\12=B\257\306\331\251"
|
||||
"T\0\375\14M:\277\314\246R\31\351\264\0\376\15E:'\243\221\262=TF\31\1\377\15M:\257"
|
||||
"\306\331T*#\235\26\0\0\0\0\4\377\377\0";
|
||||
|
||||
const uint8_t _9x15_tf[2606] =
|
||||
"\277\0\3\2\4\4\4\5\5\11\17\0\375\12\375\13\377\1\223\3*\12\21 \5\0\310\63!\10\261\14"
|
||||
"\63\16\221\0\42\10\64{\63\42S\0#\16\206\31s\242\226a\211Z\206%j\1$\24\267\371\362\302"
|
||||
"A\211\42)L\322\65\11#\251\62\210\31\0%\21\247\11sB%JJ\255q\32\265\224\22\61\1&"
|
||||
"\22\247\11s\304(\213\262(T\65)\251E\225H\13'\6\61|\63\6(\14\303\373\262\222(\211z"
|
||||
"\213\262\0)\14\303\373\62\262(\213z\211\222\10*\15w\71\363J\225\266-i\252e\0+\13w\31"
|
||||
"\363\342\332\60dq\15,\11R\334\62\206$Q\0-\7\27I\63\16\1.\7\42\14\63\206\0/\14"
|
||||
"\247\11\263\323\70-\247\345\64\6\60\15\247\11\263\266J\352k\222e\23\0\61\15\247\11\363R\61\311\242"
|
||||
"\270\267a\10\62\14\247\11s\6%U\373y\30\2\63\16\247\11\63\16qZ\335\343XM\6\5\64\21"
|
||||
"\247\11sS\61\311\242Z\22&\303\220\306\25\0\65\17\247\11\63\16reH\304\270\254&\203\2\66\20"
|
||||
"\247\11\263\206(\215+C\42\252\326dP\0\67\16\247\11\63\16q\32\247q\32\247q\10\70\21\247\11"
|
||||
"\263\266J\232d\331VI\325$\313&\0\71\17\247\11s\6%uT\206$\256FC\4:\10r\14"
|
||||
"\63\206x\10;\12\242\334\62\206xH\22\5<\11\245\12\63\263\216i\7=\12G)\63\16\71>\14"
|
||||
"\1>\12\245\12\63\322\216YG\0\77\16\247\11s\6%U\343\264\71\207\63\0@\21\247\11s\6%"
|
||||
"\65\15J\246D\223\42\347\203\2A\15\247\11\363\322$\253\244\326\341j\15B\22\247\11\63\6)LR"
|
||||
"\61\31\244\60I\215\311 \1C\15\247\11s\6%\225\373\232\14\12\0D\15\247\11\63\6)LR\77"
|
||||
"&\203\4E\15\247\11\63\16ry\220\342\346a\10F\14\247\11\63\16ry\220\342\316\0G\17\247\11"
|
||||
"s\6%\225\333\206\324\232\14\12\0H\12\247\11\63R\327\341\352\65I\12\245\12\63\6)\354\247AJ"
|
||||
"\15\250\11\363\6\65\357S\230\15\31\0K\21\247\11\63R\61\311\242\332\230\204QV\12\223\64L\12\247"
|
||||
"\11\63\342\376<\14\1M\20\247\11\63Ru[*JE\212\244H\265\6N\17\247\11\63RuT\62"
|
||||
")\322\22q\265\6O\14\247\11s\6%\365\327dP\0P\15\247\11\63.\251u\30\222\270\63\0Q"
|
||||
"\16\307\351r\6%\365K&U\6\65\27R\20\247\11\63.\251u\30\222(+\205I\252\6S\16\247"
|
||||
"\11s\6%\265\357V\65\31\24\0T\12\247\11\63\16Y\334\337\0U\13\247\11\63R\377\232\14\12\0"
|
||||
"V\21\247\11\63Rk\222EY\224U\302$L\322\14W\20\247\11\63RO\221\24I\221\24)\335\22"
|
||||
"\0X\20\247\11\63R\65\311*i\234&Y%U\3Y\15\247\11\63R\65\311*i\334\33\0Z\14"
|
||||
"\247\11\63\16q\332\347x\30\2[\12\304\373\62\6\255\177\33\2\134\13\247\11\63\362\70/\347\345<]"
|
||||
"\12\304\372\62\206\254\177\33\4^\12Gi\363\322$\253\244\1_\7\30\370\62\16\2`\7\63\213\63\262"
|
||||
"\2a\16w\11s\6=N\206!\25\225!\11b\17\247\11\63\342\226!\21U\353\250\14\11\0c\14"
|
||||
"w\11s\6%\225[\223A\1d\15\247\11\263[\206D\134\35\225!\11e\15w\11s\6%U\207"
|
||||
"s>(\0f\16\247\11\363\266R\26\305\341 \306\215\0g\23\247\331r\206DL\302$\214\206(\37"
|
||||
"\224TM\6\5h\14\247\11\63\342\226!\21U\257\1i\12\245\12stt\354i\20j\15\326\331\62"
|
||||
"u\312\332U\64&C\2k\17\247\11\63\342VMI\64\65\321\62%\15l\11\245\12\63\306\376\64\10"
|
||||
"m\20w\11\63\26%\212\244H\212\244H\212\324\0n\13w\11\63\222!\21U\257\1o\14w\11s"
|
||||
"\6%\365\232\14\12\0p\17\247\331\62\222!\21U\353\250\14I\134\6q\15\247\331r\206D\134\35\225"
|
||||
"!\211\33r\14w\11\63\242IK\302$n\5s\15w\11s\6%\325\7]M\6\5t\14\227\11"
|
||||
"\263\342p\330\342n\331\2u\20w\11\63\302$L\302$L\302$\214\206$v\16w\11\63R\65\311"
|
||||
"\242\254\22&i\6w\16w\11\63RS$ER\244tK\0x\15w\11\63\322$\253\244\225\254\222"
|
||||
"\6y\15\246\331\62B\337\224%\25\223!\1z\12w\11\63\16i\257\303\20{\15\305\373\262\226\260\32"
|
||||
"ij\26V\7|\6\301\374\62>}\16\305\371\62\326\260\226jR\32V&\0~\12\67ys\64)"
|
||||
"\322\24\0\240\5\0\310\63\241\10\261\14\63\244a\10\242\21\206\11\63\243!\211\22\251\222%Q\62D!"
|
||||
"\0\243\21\247\11\363\266R\34\16b\234\212I\226$\13\0\244\16g\71\63\322d\220\262(\213\6%\15"
|
||||
"\245\20\247\11\63R\65\311*\331 \206\203\30\327\0\246\10\261\374\62\6e\20\247\16\264\372r\224HT"
|
||||
"\42S\42J\211\2\250\7%\232\63\62-\251\25\230\30\263\206,L\42K\224(\241\22%\222\224\204\331"
|
||||
"\20\1\252\14u\71s\244\322\22EC:\10\253\15\207\31\363\242~\213\302(\214\302(\254\7F)\63"
|
||||
"\256\15\255\7\25J\63\6\1\256\25\230\30\263\206,L\222I\211\22eRJJ\224\24\263!\2\257\6"
|
||||
"\26\231\63\16\260\12Dks\224HJ\24\0\261\16\227\31\363\342\332\60dq\35\33\206\0\262\13dI"
|
||||
"s\224(K\224l\10\263\13dIs\224\250(%\12\0\264\10\63\213\263\222\22\0\265\14\227\351\62R"
|
||||
"\257\333\262\310\61\0\266\26\247\11s\206!K\264DK\222!\11\223\60\11\223\60\11\223\0\267\7\42L"
|
||||
"\63\206\0\270\10\64\332\262\246D\1\271\10cIs\22\251e\272\12eIs\226LK\346A\273\16\207"
|
||||
"\31\63\242\60\12\243\60\312\242~\3\274\17\247\11sR\271q\210\304$\213\62%\25\275\16\247\11sR"
|
||||
"\271I\31\242\70\24\343!\276\21\247\11s\304(\315\263\250\42\211I\26eJ*\277\15\247\11\363r\270"
|
||||
"\332\234\252\311\240\0\300\16\307\11s\362:\272URu\270Z\3\301\15\307\11s\333\321\255\222\252\303\325"
|
||||
"\32\302\17\307\11\363\322$\253c[%U\207\253\65\303\17\267\11s\64i\307\266J\252\16Wk\0\304"
|
||||
"\17\267\11s\262(\313\261\255\222\252\303\325\32\305\17\267\11\263\266\332\230d\225T\35\256\326\0\306\25\247"
|
||||
"\11s\224!\312\242,\312\242lX\242,\312\242,\32\2\307\20\327\331r\6%\225\373\232\14\242\26\205"
|
||||
"\32\0\310\21\307\11s\362:\66\14I\34\17Y\134\35\206\0\311\20\307\11s\333\261aH\342x\310\342"
|
||||
"\352\60\4\312\22\307\11\363\322$\253#\303\220\304\361\220\305\325a\10\313\22\267\11s\262(\313\221aH"
|
||||
"\342x\310\342\352\60\4\314\14\305\12\63\322\372 \205=\15\2\315\14\305\12\63\263\372 \205=\15\2\316"
|
||||
"\15\305\12\263\262\244\226\16R\330\323 \317\14\265\12\63\62-\35\244\260\247A\320\25\250\10s\6-\214"
|
||||
"\322$\35\302$M\322$M\302h\220\0\321\22\267\11s\64iG\322Q\311\244H\212\264D\134\3\322"
|
||||
"\16\307\11s\362:\70(\251\257\311\240\0\323\15\307\11s\333\301AI}M\6\5\324\20\307\11\363\322"
|
||||
"$\253C\203\222\372\232\14\12\0\325\17\267\11s\64i\207\6%\365\65\31\24\0\326\17\267\11s\262("
|
||||
"\313\241AI}M\6\5\327\15w\31\63\322$\253\244\225\254\222\6\330\26\307\371\262\223A\11\267DK"
|
||||
"\244H\212\224L\311\306dPb\0\331\15\307\11s\362:\226\372\65\31\24\0\332\14\307\11s\333\261\324"
|
||||
"\257\311\240\0\333\16\307\11\363\322$\253#\251_\223A\1\334\16\267\11s\262(\313\221\324\257\311\240\0"
|
||||
"\335\16\307\11s\333\261TM\262J\32\267\1\336\16\247\11\63\342xXR\353\60$q\31\337\24\246\11"
|
||||
"\263\246,\311\222(Q\262\250\226dI\226$\12\0\340\21\267\11\263\362:\66\350q\62\14\251\250\14I"
|
||||
"\0\341\20\267\11s\333\301A\217\223aHEeH\2\342\22\267\11\363\322$\253C\203\36'\303\220\212"
|
||||
"\312\220\4\343\22\247\11\263\244$\322\241A\217\223aHEeH\2\344\22\247\11s\262(\313\241A\217"
|
||||
"\223aHEeH\2\345\22\267\11\363\304(\324\261A\217\223aHEeH\2\346\17w\11s,Q"
|
||||
"-J\6%\312\242\212\62\347\17\247\331r\6%\225[\223A\324\242P\3\350\17\267\11s\362:\70("
|
||||
"\251:\234\363A\1\351\17\267\11s\333\301AI\325\341\234\17\12\0\352\21\267\11\363\322$\253C\203\222"
|
||||
"\252\303\71\37\24\0\353\21\247\11s\262(\313\241AI\325\341\234\17\12\0\354\12\265\12\63\322\372\330\323"
|
||||
" \355\12\265\12\363\332\221\261\247A\356\15\266\11\263\302$\312rd\355m\20\357\14\245\12\63\242$\212"
|
||||
"\307\236\6\1\360\20\267\11s\242PL\362lPR\257\311\240\0\361\16\247\11s\64iG\222!\21U"
|
||||
"\257\1\362\16\267\11s\362:\70(\251\327dP\0\363\15\267\11s\333\301AI\275&\203\2\364\17\267"
|
||||
"\11\363\322$\253C\203\222zM\6\5\365\17\247\11s\64i\207\6%\365\232\14\12\0\366\17\247\11s"
|
||||
"\262(\313\241AI\275&\203\2\367\16\227\11\363\322\65\307\206!\307\322\65\3\370\23\227\371\262\223A\311"
|
||||
"\22-\221\42%S\262dPb\0\371\23\267\11s\362:\26&a\22&a\22&a\64$\1\372\22"
|
||||
"\267\11s\333\261\60\11\223\60\11\223\60\11\243!\11\373\24\267\11\363\322$\253#a\22&a\22&a"
|
||||
"\22FC\22\374\24\247\11s\242,\312\241\60\11\223\60\11\223\60\11\243!\11\375\17\346\331\62\333\241\320"
|
||||
"\67eI\305dH\0\376\20\307\331\62\342\226!\21UuT\206$.\3\377\17\326\331r\242\366\320\67"
|
||||
"eI\305dH\0\0\0\0\4\377\377\0";
|
||||
|
||||
} // namespace roro::fontdata
|
||||
|
||||
@@ -8,6 +8,9 @@ namespace fontdata {
|
||||
extern const uint8_t _6x13_tf[];
|
||||
extern const uint8_t _6x13B_tf[];
|
||||
extern const uint8_t _5x8_tf[];
|
||||
extern const uint8_t _4x6_tf[];
|
||||
extern const uint8_t _6x10_tf[];
|
||||
extern const uint8_t _9x15_tf[];
|
||||
} // namespace fontdata
|
||||
|
||||
// Latin-1 bitmap fonts (accented letters included).
|
||||
@@ -15,6 +18,9 @@ namespace fonts {
|
||||
inline const lgfx::U8g2font body(fontdata::_6x13_tf); // 6x13
|
||||
inline const lgfx::U8g2font bold(fontdata::_6x13B_tf); // 6x13 bold
|
||||
inline const lgfx::U8g2font small(fontdata::_5x8_tf); // 5x8, Status Bar
|
||||
inline const lgfx::U8g2font tiny(fontdata::_4x6_tf); // 4x6: the terminal's smallest (issue #2)
|
||||
inline const lgfx::U8g2font medium(fontdata::_6x10_tf); // 6x10
|
||||
inline const lgfx::U8g2font large(fontdata::_9x15_tf); // 9x15
|
||||
} // namespace fonts
|
||||
|
||||
} // namespace roro
|
||||
|
||||
@@ -53,6 +53,7 @@ void statusBar(Canvas& c, const StatusInfo& info) {
|
||||
case StatusInfo::Vpn::Up: right("VPN", kAccent); break;
|
||||
case StatusInfo::Vpn::Off: break;
|
||||
}
|
||||
if (info.ssh) right("SSH", kAccent); // Q260: a session goes on, whatever App is in front
|
||||
switch (info.debug) { // Q190: there while the console listens, bright with someone connected
|
||||
case StatusInfo::Debug::On: right("DBG", kMuted); break;
|
||||
case StatusInfo::Debug::Client: right("DBG", kAccent); break;
|
||||
|
||||
+2
-1
@@ -32,12 +32,13 @@ struct StatusInfo {
|
||||
bool capturing = false; // a LoRa Capture is recording (Q97)
|
||||
enum class Debug { Off, On, Client } debug = Debug::Off; // the Debug Console listens (ADR 0010, Q190)
|
||||
enum class Vpn { Off, Trying, Up } vpn = Vpn::Off; // the WireGuard tunnel (issue #8, Q252)
|
||||
bool ssh = false; // an SSH session is open (issue #2, Q260)
|
||||
|
||||
bool operator==(const StatusInfo& o) const {
|
||||
return title == o.title && batteryPercent == o.batteryPercent && clock == o.clock &&
|
||||
sdPresent == o.sdPresent && sdLevel == o.sdLevel && compose == o.compose && wifi == o.wifi &&
|
||||
wifiBars == o.wifiBars && unread == o.unread && gnss == o.gnss && gnssSatellites == o.gnssSatellites &&
|
||||
tracking == o.tracking && radio == o.radio && capturing == o.capturing && debug == o.debug && vpn == o.vpn;
|
||||
tracking == o.tracking && radio == o.radio && capturing == o.capturing && debug == o.debug && vpn == o.vpn && ssh == o.ssh;
|
||||
}
|
||||
bool operator!=(const StatusInfo& o) const { return !(*this == o); }
|
||||
};
|
||||
|
||||
@@ -231,6 +231,35 @@ void test_fn_h_is_help_in_both_modes() {
|
||||
}
|
||||
}
|
||||
|
||||
// Issue #2: a key that isn't a character says what was held with it. A terminal sends Page Up for
|
||||
// Shift with the up arrow, and a note jumps to its start for Ctrl with it.
|
||||
void test_arrows_enter_and_tab_carry_what_was_held() {
|
||||
KeyMapper m;
|
||||
m.setTextEntry(true);
|
||||
RawKeys r = withFn({';'});
|
||||
r.shift = true;
|
||||
auto ev = press(m, r);
|
||||
TEST_ASSERT_EQUAL(static_cast<int>(Key::Up), static_cast<int>(ev[0].key));
|
||||
TEST_ASSERT_TRUE(ev[0].shift);
|
||||
TEST_ASSERT_FALSE(ev[0].ctrl);
|
||||
|
||||
KeyMapper m2;
|
||||
RawKeys tab;
|
||||
tab.tab = true;
|
||||
tab.shift = true;
|
||||
ev = press(m2, tab);
|
||||
TEST_ASSERT_EQUAL(static_cast<int>(Key::Tab), static_cast<int>(ev[0].key));
|
||||
TEST_ASSERT_TRUE(ev[0].shift);
|
||||
|
||||
KeyMapper m3;
|
||||
m3.setTextEntry(true);
|
||||
RawKeys back = chars({'`'});
|
||||
back.alt = true;
|
||||
ev = press(m3, back);
|
||||
TEST_ASSERT_EQUAL(static_cast<int>(Key::Back), static_cast<int>(ev[0].key));
|
||||
TEST_ASSERT_TRUE(ev[0].alt);
|
||||
}
|
||||
|
||||
// Issue #83: Fn+p everywhere.
|
||||
void test_fn_p_is_a_screenshot_in_both_modes() {
|
||||
for (bool typing : {true, false}) {
|
||||
@@ -294,6 +323,7 @@ int main() {
|
||||
RUN_TEST(test_other_characters_still_type_when_not_typing);
|
||||
RUN_TEST(test_shifted_arrow_keys_type_their_symbols_when_not_typing);
|
||||
RUN_TEST(test_fn_h_is_help_in_both_modes);
|
||||
RUN_TEST(test_arrows_enter_and_tab_carry_what_was_held);
|
||||
RUN_TEST(test_fn_p_is_a_screenshot_in_both_modes);
|
||||
RUN_TEST(test_plain_h_still_types);
|
||||
RUN_TEST(test_question_mark_is_help_only_when_not_typing);
|
||||
|
||||
@@ -0,0 +1,291 @@
|
||||
#include <unity.h>
|
||||
|
||||
#include <string>
|
||||
|
||||
#include "ssh_hosts.h"
|
||||
#include "terminal.h"
|
||||
|
||||
using namespace roro::term;
|
||||
|
||||
void setUp() {}
|
||||
void tearDown() {}
|
||||
|
||||
namespace {
|
||||
void feed(Terminal& t, const std::string& s) { t.feed(reinterpret_cast<const uint8_t*>(s.data()), s.size()); }
|
||||
#define ROW(t, r, text) TEST_ASSERT_EQUAL_STRING(text, (t).rowText(r).c_str())
|
||||
} // namespace
|
||||
|
||||
void test_text_lines_and_the_cursor() {
|
||||
Terminal t(20, 5, 10);
|
||||
feed(t, "hello\r\nworld");
|
||||
ROW(t, 0, "hello");
|
||||
ROW(t, 1, "world");
|
||||
TEST_ASSERT_EQUAL_INT(1, t.cursorRow());
|
||||
TEST_ASSERT_EQUAL_INT(5, t.cursorCol());
|
||||
feed(t, "\b\b!"); // backspace moves, it doesn't erase
|
||||
ROW(t, 1, "wor!d");
|
||||
feed(t, "\rW\tx"); // a tab goes to the next eighth column
|
||||
ROW(t, 1, "Wor!d x");
|
||||
feed(t, "\a");
|
||||
TEST_ASSERT_TRUE(t.takeBell());
|
||||
TEST_ASSERT_FALSE(t.takeBell());
|
||||
}
|
||||
|
||||
void test_wrapping_and_scrolling_into_history() {
|
||||
Terminal t(10, 3, 5);
|
||||
feed(t, "0123456789"); // exactly a line: the cursor waits at the edge
|
||||
TEST_ASSERT_EQUAL_INT(0, t.cursorRow());
|
||||
TEST_ASSERT_EQUAL_INT(9, t.cursorCol());
|
||||
feed(t, "ab");
|
||||
ROW(t, 0, "0123456789");
|
||||
ROW(t, 1, "ab");
|
||||
feed(t, "\r\nc\r\nd\r\ne"); // five lines through three rows
|
||||
ROW(t, 0, "c");
|
||||
ROW(t, 2, "e");
|
||||
TEST_ASSERT_EQUAL_INT(2, t.historyCount());
|
||||
TEST_ASSERT_EQUAL_STRING("0123456789", t.historyLine(0).c_str());
|
||||
TEST_ASSERT_EQUAL_STRING("ab", t.historyLine(1).c_str());
|
||||
for (int i = 0; i < 20; i++) feed(t, "\r\nx");
|
||||
TEST_ASSERT_EQUAL_INT(5, t.historyCount()); // no more than it was given room for
|
||||
}
|
||||
|
||||
void test_moving_and_erasing() {
|
||||
Terminal t(20, 5, 0);
|
||||
feed(t, "aaaaaaaaaa\r\nbbbbbbbbbb\r\ncccccccccc");
|
||||
feed(t, "\x1b[2;4H"); // row 2, column 4
|
||||
TEST_ASSERT_EQUAL_INT(1, t.cursorRow());
|
||||
TEST_ASSERT_EQUAL_INT(3, t.cursorCol());
|
||||
feed(t, "\x1b[K"); // to the end of the line
|
||||
ROW(t, 1, "bbb");
|
||||
feed(t, "\x1b[A\x1b[1K"); // up one, erase to the start (the cursor's place included)
|
||||
ROW(t, 0, " aaaaaa");
|
||||
feed(t, "\x1b[3;1H\x1b[2K");
|
||||
ROW(t, 2, "");
|
||||
feed(t, "\x1b[1;1Hxyz\x1b[J"); // from the cursor to the end of the screen
|
||||
ROW(t, 0, "xyz");
|
||||
ROW(t, 1, "");
|
||||
feed(t, "\x1b[5;18Hend\x1b[99;99H"); // far outside: the last cell
|
||||
TEST_ASSERT_EQUAL_INT(4, t.cursorRow());
|
||||
TEST_ASSERT_EQUAL_INT(19, t.cursorCol());
|
||||
feed(t, "\x1b[2J");
|
||||
for (int r = 0; r < 5; r++) ROW(t, r, "");
|
||||
feed(t, "\x1b[Habcdef\x1b[1;3H\x1b[2P"); // two characters deleted in the middle
|
||||
ROW(t, 0, "abef");
|
||||
feed(t, "\x1b[2@"); // and two blanks put in
|
||||
ROW(t, 0, "ab ef");
|
||||
feed(t, "\x1b[1;1H\x1b[3X");
|
||||
ROW(t, 0, " ef");
|
||||
feed(t, "\x1b[4G!\x1b[2d?"); // column 4, then row 2 in the column after
|
||||
ROW(t, 0, " !ef");
|
||||
ROW(t, 1, " ?");
|
||||
}
|
||||
|
||||
void test_colours() {
|
||||
Terminal t(20, 3, 0);
|
||||
feed(t, "a\x1b[31mb\x1b[1mc\x1b[0;44md\x1b[7me\x1b[0m\x1b[38;5;196mf\x1b[48;2;0;0;255mg\x1b[92mh");
|
||||
TEST_ASSERT_EQUAL_HEX8(0x07, t.cell(0, 0).attr);
|
||||
TEST_ASSERT_EQUAL_HEX8(0x01, t.cell(0, 1).attr); // red
|
||||
TEST_ASSERT_EQUAL_HEX8(0x09, t.cell(0, 2).attr); // bold red is bright red
|
||||
TEST_ASSERT_EQUAL_HEX8(0x47, t.cell(0, 3).attr); // on blue
|
||||
TEST_ASSERT_EQUAL_HEX8(0x74, t.cell(0, 4).attr); // inverse: the two swapped
|
||||
TEST_ASSERT_EQUAL_HEX8(0x09, t.cell(0, 5).attr); // one of 256, as the nearest of sixteen
|
||||
TEST_ASSERT_EQUAL_HEX8(0xC9, t.cell(0, 6).attr); // a colour by its red, green and blue
|
||||
TEST_ASSERT_EQUAL_HEX8(0xCA, t.cell(0, 7).attr); // bright green, written directly
|
||||
feed(t, "\x1b[0m\x1b[42m\x1b[2;1H\x1b[K"); // erasing paints with the colour behind
|
||||
TEST_ASSERT_EQUAL_HEX8(0x27, t.cell(1, 5).attr);
|
||||
uint8_t r, g, b;
|
||||
colourRgb(15, r, g, b);
|
||||
TEST_ASSERT_EQUAL_UINT8(255, r);
|
||||
}
|
||||
|
||||
void test_scroll_regions_as_less_and_vim_use_them() {
|
||||
Terminal t(10, 5, 5);
|
||||
feed(t, "1\r\n2\r\n3\r\n4\r\n5");
|
||||
feed(t, "\x1b[2;4r"); // rows 2 to 4 scroll; the cursor goes home
|
||||
TEST_ASSERT_EQUAL_INT(0, t.cursorRow());
|
||||
feed(t, "\x1b[4;1H\n"); // a line feed at the bottom of the region
|
||||
ROW(t, 0, "1");
|
||||
ROW(t, 1, "3");
|
||||
ROW(t, 2, "4");
|
||||
ROW(t, 3, "");
|
||||
ROW(t, 4, "5");
|
||||
TEST_ASSERT_EQUAL_INT(0, t.historyCount()); // a region that isn't the whole screen keeps nothing
|
||||
feed(t, "\x1b[2;1H\x1bM"); // reverse index at its top: it scrolls down
|
||||
ROW(t, 1, "");
|
||||
ROW(t, 2, "3");
|
||||
ROW(t, 3, "4");
|
||||
feed(t, "\x1b[3;1H\x1b[L"); // a line put in
|
||||
ROW(t, 2, "");
|
||||
ROW(t, 3, "3");
|
||||
feed(t, "\x1b[M"); // and taken out
|
||||
ROW(t, 2, "3");
|
||||
feed(t, "\x1b[r\x1b[1;1H\x1b[M"); // the whole screen again; a deleted first line isn't history
|
||||
ROW(t, 0, "");
|
||||
TEST_ASSERT_EQUAL_INT(0, t.historyCount());
|
||||
}
|
||||
|
||||
void test_the_alternate_screen() {
|
||||
Terminal t(10, 3, 5);
|
||||
feed(t, "shell$ vi");
|
||||
feed(t, "\x1b[?1049h");
|
||||
TEST_ASSERT_TRUE(t.altScreen());
|
||||
ROW(t, 0, "");
|
||||
feed(t, "~\r\n~\r\n~\r\n~"); // scrolling here is nobody's history
|
||||
TEST_ASSERT_EQUAL_INT(0, t.historyCount());
|
||||
feed(t, "\x1b[?1049l");
|
||||
TEST_ASSERT_FALSE(t.altScreen());
|
||||
ROW(t, 0, "shell$ vi"); // as it was left
|
||||
TEST_ASSERT_EQUAL_INT(9, t.cursorCol());
|
||||
}
|
||||
|
||||
void test_modes_and_what_is_asked_back() {
|
||||
Terminal t(10, 3, 0);
|
||||
std::string said;
|
||||
t.reply = [&](const std::string& s) { said += s; };
|
||||
feed(t, "\x1b[?25l");
|
||||
TEST_ASSERT_FALSE(t.cursorVisible());
|
||||
feed(t, "\x1b[?25h\x1b[?1h");
|
||||
TEST_ASSERT_TRUE(t.cursorVisible());
|
||||
TEST_ASSERT_TRUE(t.appCursorKeys());
|
||||
feed(t, "\x1b[2;5H\x1b[6n");
|
||||
TEST_ASSERT_EQUAL_STRING("\x1b[2;5R", said.c_str());
|
||||
said.clear();
|
||||
feed(t, "\x1b[c");
|
||||
TEST_ASSERT_EQUAL_STRING("\x1b[?6c", said.c_str());
|
||||
feed(t, "\x1b[?7l\x1b[1;1H0123456789abc"); // no wrapping: the last column is overwritten
|
||||
ROW(t, 0, "012345678c");
|
||||
ROW(t, 1, "");
|
||||
feed(t, "\x1b" "c"); // a full reset
|
||||
ROW(t, 0, "");
|
||||
TEST_ASSERT_FALSE(t.appCursorKeys());
|
||||
}
|
||||
|
||||
void test_what_is_skipped_and_what_is_replaced() {
|
||||
Terminal t(30, 3, 0);
|
||||
feed(t, "\x1b]0;a window title\x07" "A"); // a title: not shown
|
||||
feed(t, "\x1b]2;another\x1b\\" "B"); // ended the other way
|
||||
feed(t, "\x1b[?2004h\x1b[>4;2m\x1b=" "C"); // modes this has no use for
|
||||
ROW(t, 0, "ABC");
|
||||
feed(t, "\r\n\xC3\xA9t\xC3\xA9 \xE2\x82\xAC \xE2\x94\x8C\xE2\x94\x80\xE2\x94\x90 \xE2\x94\x82 \xF0\x9F\x98\x80"); // été € ┌─┐ │ 😀
|
||||
ROW(t, 1, "\xE9t\xE9 ? +-+ | ?");
|
||||
feed(t, "\r\n\x1b(0lqk\x1b(B lqk"); // the old way of drawing lines, then letters again
|
||||
ROW(t, 2, "+-+ lqk");
|
||||
feed(t, "\x1b[2J\x1b[H\xC3("); // a character cut short doesn't swallow what follows
|
||||
ROW(t, 0, "(");
|
||||
// Nonsense must not get it stuck or out of its grid.
|
||||
std::string noise;
|
||||
for (int i = 0; i < 4000; i++) noise += static_cast<char>((i * 73 + i / 7) & 0xFF);
|
||||
feed(t, noise);
|
||||
feed(t, "\x1b" "c" "ok");
|
||||
ROW(t, 0, "ok");
|
||||
}
|
||||
|
||||
void test_a_new_size() {
|
||||
Terminal t(10, 4, 10);
|
||||
feed(t, "one\r\ntwo\r\nthree\r\nfour");
|
||||
t.resize(20, 2); // fewer rows: the cursor's line stays, the top goes to the history
|
||||
ROW(t, 0, "three");
|
||||
ROW(t, 1, "four");
|
||||
TEST_ASSERT_EQUAL_INT(1, t.cursorRow());
|
||||
TEST_ASSERT_EQUAL_INT(2, t.historyCount());
|
||||
TEST_ASSERT_EQUAL_STRING("two", t.historyLine(1).c_str());
|
||||
t.resize(4, 6); // narrower: what doesn't fit is cut
|
||||
ROW(t, 0, "thre");
|
||||
feed(t, "\r\n123456");
|
||||
ROW(t, 2, "1234");
|
||||
ROW(t, 3, "56");
|
||||
}
|
||||
|
||||
void test_keys() {
|
||||
TEST_ASSERT_EQUAL_STRING("a", encodeKey(TermKey::Char, 'a', false, false, false).c_str());
|
||||
TEST_ASSERT_EQUAL_STRING("\x03", encodeKey(TermKey::Char, 'c', true, false, false).c_str());
|
||||
TEST_ASSERT_EQUAL_STRING("\x03", encodeKey(TermKey::Char, 'C', true, false, false).c_str());
|
||||
TEST_ASSERT_EQUAL_STRING("\x1b", encodeKey(TermKey::Char, '[', true, false, false).c_str());
|
||||
TEST_ASSERT_EQUAL_size_t(1, encodeKey(TermKey::Char, ' ', true, false, false).size()); // Ctrl+Space is a zero
|
||||
TEST_ASSERT_EQUAL_STRING("\x1b" "b", encodeKey(TermKey::Char, 'b', false, true, false).c_str()); // Alt is Esc first
|
||||
TEST_ASSERT_EQUAL_STRING("\xC3\xA9", encodeKey(TermKey::Char, 0xE9, false, false, false).c_str());
|
||||
TEST_ASSERT_EQUAL_STRING("\x1b[A", encodeKey(TermKey::Up, 0, false, false, false).c_str());
|
||||
TEST_ASSERT_EQUAL_STRING("\x1bOA", encodeKey(TermKey::Up, 0, false, false, true).c_str()); // as vim asks for them
|
||||
TEST_ASSERT_EQUAL_STRING("\x1b[D", encodeKey(TermKey::Left, 0, false, false, false).c_str());
|
||||
TEST_ASSERT_EQUAL_STRING("\r", encodeKey(TermKey::Enter, 0, false, false, false).c_str());
|
||||
TEST_ASSERT_EQUAL_STRING("\x7f", encodeKey(TermKey::Backspace, 0, false, false, false).c_str());
|
||||
TEST_ASSERT_EQUAL_STRING("\x1b", encodeKey(TermKey::Escape, 0, false, false, false).c_str());
|
||||
TEST_ASSERT_EQUAL_STRING("\x1b[5~", encodeKey(TermKey::PageUp, 0, false, false, false).c_str());
|
||||
}
|
||||
|
||||
void test_who_to_connect_to() {
|
||||
SshTarget t;
|
||||
TEST_ASSERT_EQUAL_STRING("", parseSshTarget("pi@raspberrypi.local", t).c_str());
|
||||
TEST_ASSERT_EQUAL_STRING("pi", t.user.c_str());
|
||||
TEST_ASSERT_EQUAL_STRING("raspberrypi.local", t.host.c_str());
|
||||
TEST_ASSERT_EQUAL_UINT16(22, t.port);
|
||||
TEST_ASSERT_EQUAL_STRING("pi@raspberrypi.local", t.text().c_str());
|
||||
TEST_ASSERT_EQUAL_STRING("raspberrypi.local:22", t.hostPort().c_str());
|
||||
TEST_ASSERT_EQUAL_STRING("", parseSshTarget("root@10.9.0.1:2222", t).c_str());
|
||||
TEST_ASSERT_EQUAL_UINT16(2222, t.port);
|
||||
TEST_ASSERT_EQUAL_STRING("root@10.9.0.1:2222", t.text().c_str());
|
||||
TEST_ASSERT_EQUAL_STRING("user@host, please", parseSshTarget("raspberrypi.local", t).c_str());
|
||||
TEST_ASSERT_EQUAL_STRING("user@host, please", parseSshTarget("@host", t).c_str());
|
||||
TEST_ASSERT_EQUAL_STRING("a port from 1 to 65535", parseSshTarget("a@b:0", t).c_str());
|
||||
TEST_ASSERT_EQUAL_STRING("a port from 1 to 65535", parseSshTarget("a@b:99999", t).c_str());
|
||||
TEST_ASSERT_EQUAL_STRING("that isn't a host", parseSshTarget("a@b c", t).c_str());
|
||||
TEST_ASSERT_EQUAL_STRING("that isn't a host", parseSshTarget("a@", t).c_str());
|
||||
TEST_ASSERT_EQUAL_UINT16(2222, t.port); // a refused line changes nothing
|
||||
}
|
||||
|
||||
void test_saved_hosts_and_the_servers_met() {
|
||||
SshHosts hosts;
|
||||
SshTarget a, b;
|
||||
parseSshTarget("pi@one", a);
|
||||
parseSshTarget("root@two:2222", b);
|
||||
hosts.used(a);
|
||||
hosts.used(b);
|
||||
hosts.used(a); // again: to the front, not twice
|
||||
TEST_ASSERT_EQUAL_size_t(2, hosts.list().size());
|
||||
TEST_ASSERT_EQUAL_STRING("pi@one", hosts.list()[0].c_str());
|
||||
SshHosts again(hosts.stored());
|
||||
TEST_ASSERT_EQUAL_STRING("root@two:2222", again.list()[1].c_str());
|
||||
for (int i = 0; i < 12; i++) {
|
||||
SshTarget t;
|
||||
parseSshTarget("u@h" + std::to_string(i), t);
|
||||
again.used(t);
|
||||
}
|
||||
TEST_ASSERT_EQUAL_size_t(8, again.list().size());
|
||||
TEST_ASSERT_EQUAL_STRING("u@h11", again.list()[0].c_str());
|
||||
again.remove(0);
|
||||
TEST_ASSERT_EQUAL_STRING("u@h10", again.list()[0].c_str());
|
||||
TEST_ASSERT_EQUAL_size_t(0, SshHosts("garbage\n\n@@\n").list().size());
|
||||
|
||||
SshKnownHosts known;
|
||||
TEST_ASSERT_EQUAL_STRING("", known.fingerprintOf("one:22").c_str());
|
||||
known.remember("one:22", "SHA256:aaaa");
|
||||
known.remember("two:2222", "SHA256:bbbb");
|
||||
known.remember("one:22", "SHA256:cccc"); // it changed, and the user said yes
|
||||
SshKnownHosts stored(known.stored());
|
||||
TEST_ASSERT_EQUAL_STRING("SHA256:cccc", stored.fingerprintOf("one:22").c_str());
|
||||
TEST_ASSERT_EQUAL_STRING("SHA256:bbbb", stored.fingerprintOf("two:2222").c_str());
|
||||
TEST_ASSERT_EQUAL_STRING("", stored.fingerprintOf("one:2222").c_str()); // another port is another server
|
||||
for (int i = 0; i < 20; i++) stored.remember("h" + std::to_string(i) + ":22", "SHA256:x");
|
||||
TEST_ASSERT_EQUAL_STRING("", stored.fingerprintOf("one:22").c_str()); // sixteen kept, the oldest gone
|
||||
TEST_ASSERT_EQUAL_STRING("SHA256:x", stored.fingerprintOf("h19:22").c_str());
|
||||
stored.forget("h19:22"); // forgotten with its host: met again, it is asked about again
|
||||
TEST_ASSERT_EQUAL_STRING("", stored.fingerprintOf("h19:22").c_str());
|
||||
TEST_ASSERT_EQUAL_STRING("SHA256:x", stored.fingerprintOf("h18:22").c_str());
|
||||
}
|
||||
|
||||
int main() {
|
||||
UNITY_BEGIN();
|
||||
RUN_TEST(test_text_lines_and_the_cursor);
|
||||
RUN_TEST(test_wrapping_and_scrolling_into_history);
|
||||
RUN_TEST(test_moving_and_erasing);
|
||||
RUN_TEST(test_colours);
|
||||
RUN_TEST(test_scroll_regions_as_less_and_vim_use_them);
|
||||
RUN_TEST(test_the_alternate_screen);
|
||||
RUN_TEST(test_modes_and_what_is_asked_back);
|
||||
RUN_TEST(test_what_is_skipped_and_what_is_replaced);
|
||||
RUN_TEST(test_a_new_size);
|
||||
RUN_TEST(test_keys);
|
||||
RUN_TEST(test_who_to_connect_to);
|
||||
RUN_TEST(test_saved_hosts_and_the_servers_met);
|
||||
return UNITY_END();
|
||||
}
|
||||
Reference in New Issue
Block a user