From b6620be8dfcab39ed8c059d20fa2ace4b2928e6e Mon Sep 17 00:00:00 2001 From: mmmorks Date: Mon, 7 Sep 2026 15:44:40 -0700 Subject: [PATCH] variants/linux: harden the PTY console and add stdin input Builds on #25's PTY console (PtyConsole/LinuxConsole) and keeps its shape: a pseudo-terminal published as a stable symlink, so meshcore-cli -r -s and any serial tool attach directly, and the CLI's traffic mirrored to stdout so journald records what was run. Four things change underneath it. The daemon holds a descriptor of its own on the slave. Without one the master reports POLLHUP and read() fails with EIO from the moment the last client closes until the next one opens -- a level condition the poll() loop introduced in the previous commit could only throttle, not clear, so one detached client cost a wake-up per millisecond for the rest of the run. With it a detached console is idle: poll() sleeps, read() says "nothing yet", and the next client attaches as if the first had never left. The master and holder are registered with the event loop as permanent descriptors, so the daemon wakes on console input. Every descriptor is close-on-exec and reboot() unpublishes the console before re-exec. An inherited master would keep the old /dev/pts/N alive with nobody reading it; a symlink left behind would point at a number the exec has just freed, which the next image can only guess about. begin() now tries candidates in order -- console_path, /run/meshcored/console when the unit's RuntimeDirectory is present (so the packaged service needs no INI edit), $XDG_RUNTIME_DIR/meshcore/console, /tmp/meshcore-/console -- and declines one that a live console holds (its symlink resolves to an existing device) or that is not a symlink at all, since the path is operator input and unlink() does not care what it removes. A dangling symlink from a crashed daemon is reclaimed. stdin is a second door onto the same CLI when meshcored runs in a terminal: raw, non-blocking, no kernel echo (the CLI echoes), restored on end(). Replies follow the command to its source -- always to stdout, and to the PTY only when the command arrived there -- so a foreground session's output does not queue in the PTY to greet the next client. The PTY engine joins the native test build under PIO_UNIT_TESTING, which is what #25 wanted and reverted: the "SIGHUP" its author saw is PlatformIO's runner mapping a plain exit status 1 (a failed assertion) to signal 1. The suite covers the symlink and its mode, both directions and the newline map, the holder (a detached client leaves poll() sleeping and the event loop timing out cleanly), close-on-exec for every descriptor begin() opens, the live/stale/non-symlink candidate rules, the XDG default, stdin routing with a real terminal on fd 0, and end()/begin() across a simulated reboot. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01EXSCjgNEbJfHwLjD2WSHW4 --- examples/simple_repeater/main.cpp | 25 +- platformio.ini | 2 + src/helpers/LinuxConsole.cpp | 98 ++++ src/helpers/LinuxConsole.h | 99 ++-- src/helpers/PtyConsole.cpp | 141 +++-- src/helpers/PtyConsole.h | 42 +- .../test_linux_console/test_linux_console.cpp | 485 ++++++++++++++++++ variants/linux/LinuxBoard.cpp | 32 +- variants/linux/LinuxBoard.h | 8 +- variants/linux/README.md | 55 +- variants/linux/meshcored.ini | 11 +- variants/linux/meshcored.service | 14 +- 12 files changed, 899 insertions(+), 113 deletions(-) create mode 100644 src/helpers/LinuxConsole.cpp create mode 100644 test/test_linux_console/test_linux_console.cpp diff --git a/examples/simple_repeater/main.cpp b/examples/simple_repeater/main.cpp index ace899bd89..f45c3a9bb9 100644 --- a/examples/simple_repeater/main.cpp +++ b/examples/simple_repeater/main.cpp @@ -18,12 +18,11 @@ SimpleMeshTables tables; MyMesh the_mesh(board, radio_driver, *new ArduinoMillis(), fast_rng, rtc_clock, tables); -// CLI console stream. On Linux the interactive CLI runs over a Unix-domain -// socket (so the socket carries only the CLI while Serial keeps the logs); on -// MCU targets, and as the Linux fallback, it is just Serial. +// CLI console stream. On Linux the interactive CLI runs over a pseudo-terminal +// (so the PTY carries only the CLI while Serial keeps the logs) and over stdin +// when meshcored runs in a terminal; on MCU targets it is just Serial. #if defined(ARDULINUX_PLATFORM) || defined(LINUX_PLATFORM) #include - static LinuxConsole linux_console; #endif static Stream* console = &Serial; @@ -75,15 +74,17 @@ void setup() { #endif #if defined(ARDULINUX_PLATFORM) || defined(LINUX_PLATFORM) - // Bring up the local CLI console (config path, or a per-user default). Log - // which console is active so a failure (e.g. an unwritable configured path) is - // diagnosable rather than a silent no-CLI daemon; on failure the CLI stays on - // Serial (stdin/stdout). - if (linux_console.begin(board.config.console_path)) { - console = &linux_console; - Serial.print("CLI console on "); Serial.println(linux_console.path()); + // Bring up the local CLI console: the PTY at console_path (or the default + // search order), plus stdin when that is a terminal. Log which so a failure + // (an unwritable configured path, say) is diagnosable rather than a silent + // no-CLI daemon. Serial's read() is a stub on Linux, so there is no fallback + // to it: without a PTY the CLI is reachable only from a terminal's stdin. + Console.begin(board.config.console_path); + console = &Console; + if (Console.hasPty()) { + Serial.print("CLI console on "); Serial.println(Console.path()); } else { - Serial.println("CLI console: unavailable (see stderr), using stdio"); + Serial.println("CLI console: no PTY (see stderr); stdin only, if this is a terminal"); } #endif diff --git a/platformio.ini b/platformio.ini index 0878e438c8..465356d13b 100644 --- a/platformio.ini +++ b/platformio.ini @@ -228,6 +228,8 @@ build_src_filter = +<../src/Packet.cpp> +<../src/helpers/ConfigSerializer.cpp> +<../src/helpers/DynamicConfigSerializer.cpp> + +<../src/helpers/PtyConsole.cpp> + +<../src/helpers/LinuxConsole.cpp> +<../variants/linux/LinuxEventLoop.cpp> +<../variants/linux/LinuxRadioWait.cpp> lib_deps = diff --git a/src/helpers/LinuxConsole.cpp b/src/helpers/LinuxConsole.cpp new file mode 100644 index 0000000000..ed2d3c760f --- /dev/null +++ b/src/helpers/LinuxConsole.cpp @@ -0,0 +1,98 @@ +#include "LinuxConsole.h" + +#if defined(ARDULINUX_PLATFORM) || defined(LINUX_PLATFORM) || defined(PIO_UNIT_TESTING) + +#include +#include +#include + +LinuxConsole Console; + +LinuxConsole::~LinuxConsole() { end(); } + +bool LinuxConsole::begin(const char* link) { + bool pty_ok = _pty.begin(link); + + // Foreground use: read keystrokes one at a time, no kernel echo (we echo + // ourselves, exactly as the PTY path does), Enter delivered as the '\r' the + // CLI terminates on, and never block the mesh loop waiting for input. ISIG + // stays on so Ctrl-C still stops the daemon. + _stdin_tty = isatty(STDIN_FILENO); + if (_stdin_tty && !_raw_active && tcgetattr(STDIN_FILENO, &_orig_tty) == 0) { + struct termios raw = _orig_tty; + raw.c_lflag &= ~(ICANON | ECHO); + raw.c_iflag &= ~(ICRNL | INLCR); + raw.c_cc[VMIN] = 0; + raw.c_cc[VTIME] = 0; + _raw_active = tcsetattr(STDIN_FILENO, TCSANOW, &raw) == 0; + } + if (_stdin_tty) { + int fl = fcntl(STDIN_FILENO, F_GETFL, 0); + if (fl != -1) fcntl(STDIN_FILENO, F_SETFL, fl | O_NONBLOCK); + } + return pty_ok || _stdin_tty; +} + +void LinuxConsole::end() { + _pty.end(); + if (_raw_active) { + tcsetattr(STDIN_FILENO, TCSANOW, &_orig_tty); + _raw_active = false; + } + _stdin_tty = false; + _peeked = -1; +} + +int LinuxConsole::stdinFd() const { + return _stdin_tty ? STDIN_FILENO : -1; +} + +// The next byte from either source, or -1. The PTY is asked first, but nothing +// waits on it: whichever has a byte delivers it, so neither can starve the +// other or leave itself readable and unread. +int LinuxConsole::fetch() { + int c = _pty.read(); // already maps '\n' -> '\r' + if (c >= 0) { _src = FROM_PTY; return c; } + if (_stdin_tty) { + unsigned char b; + if (::read(STDIN_FILENO, &b, 1) == 1) { + _src = FROM_STDIN; + return (b == '\n') ? '\r' : b; // same mapping, same reason + } + } + return -1; +} + +int LinuxConsole::available() { + if (_peeked < 0) _peeked = fetch(); + return _peeked >= 0 ? 1 : 0; +} + +int LinuxConsole::peek() { + if (_peeked < 0) _peeked = fetch(); + return _peeked; +} + +int LinuxConsole::read() { + int c = peek(); + _peeked = -1; + return c; +} + +size_t LinuxConsole::write(uint8_t c) { + putchar(c); // journald's copy, or the terminal + if (_src == FROM_PTY) _pty.write(c); // and the attached console client + // stdout is line-buffered (ardulinux sets that up for Serial). A command + // being typed at the terminal has no newline yet, and its echo would sit + // in the buffer until Enter -- the user types blind. Flush per byte only + // there: nobody is watching journald arrive character by character. + if (_stdin_tty) fflush(stdout); + return 1; +} + +void LinuxConsole::flush() { + fflush(stdout); + _pty.flush(); +} + +#endif // ARDULINUX_PLATFORM || LINUX_PLATFORM || PIO_UNIT_TESTING diff --git a/src/helpers/LinuxConsole.h b/src/helpers/LinuxConsole.h index e2977818bd..c04153b7e4 100644 --- a/src/helpers/LinuxConsole.h +++ b/src/helpers/LinuxConsole.h @@ -1,45 +1,86 @@ #pragma once -#if defined(ARDULINUX_PLATFORM) || defined(LINUX_PLATFORM) +#if defined(ARDULINUX_PLATFORM) || defined(LINUX_PLATFORM) || defined(PIO_UNIT_TESTING) #include -#include +#include #include "PtyConsole.h" -// Arduino Stream adapter over PtyConsole. +// Arduino Stream adapter over PtyConsole, plus stdin when that is a terminal. // // The repeater's text CLI reads from / writes to this instead of Serial, so the -// PTY carries only the CLI while Serial (stdout) keeps the debug logs — the -// clean log/CLI split. write() also mirrors each byte to stdout, so admin -// commands and their replies are still recorded in the log (journald) alongside -// the debug output, while the console stays free of log noise. +// PTY carries only the CLI while Serial (stdout) keeps the debug logs -- the +// clean log/CLI split. On Linux Serial's read() is a stub that never returns a +// byte, so this is the only way a command reaches the daemon at all. // -// A client attaches to the published PTY symlink, e.g.: -// meshcore-cli -r -s /run/meshcored/console +// Two doors onto one CLI: // -// All the PTY mechanics live in PtyConsole (host-unit-tested); this adapter is a -// thin delegating shim. +// * the PTY, published at a stable path (see PtyConsole) for +// meshcore-cli -r -s or any serial tool; and +// * stdin, when meshcored runs in a foreground terminal, so a developer can +// type at it without attaching anything. +// +// Both are drained on every read, so both can be watched by the event loop +// permanently. Output follows the input: every byte is written to stdout (when +// the command came over the PTY that is the copy journald keeps, alongside the +// debug log), and to the PTY only when the current command arrived there -- a +// foreground session's echo must not sit in the PTY's buffer waiting to greet +// the next client. class LinuxConsole : public Stream { - PtyConsole _pty; + enum Source { FROM_PTY, FROM_STDIN }; + + PtyConsole _pty; + bool _stdin_tty = false; + bool _raw_active = false; // stdin termios changed; restore on end() + struct termios _orig_tty; + int _peeked = -1; // one-byte lookahead over both sources + Source _src = FROM_PTY; // where the byte last read came from + + int fetch(); public: - // Open the PTY and publish the symlink (see PtyConsole::begin). Returns true - // on success; on failure the caller should fall back to Serial. - bool begin(const char *link) { return _pty.begin(link); } - void end() { _pty.end(); } - bool isOpen() const { return _pty.isOpen(); } - const char *path() const { return _pty.path(); } - - int available() override { return _pty.available(); } - int read() override { return _pty.read(); } - int peek() override { return _pty.peek(); } - size_t write(uint8_t c) override { - putchar(c); // mirror to stdout so commands/replies reach journald - _pty.write(c); // and to the attached console client - return 1; - } - void flush() override { fflush(stdout); _pty.flush(); } + LinuxConsole() = default; + ~LinuxConsole(); + + // The descriptors below are owned, not shared, and stdin's terminal state is + // process-wide. There is exactly one console. + LinuxConsole(const LinuxConsole&) = delete; + LinuxConsole& operator=(const LinuxConsole&) = delete; + + // Publish the PTY console (see PtyConsole::begin for how `link` and the + // default search order are used) and, when stdin is a terminal, put it in + // raw non-blocking mode for keystrokes. Never blocks. Returns true if at + // least one input is usable; a failure is reported on stderr either way. + bool begin(const char* link); + + // Unpublish and close the PTY and restore the terminal. Idempotent. + // + // Exists for LinuxBoard::reboot(), which re-execs this process image: the + // descriptors are close-on-exec, but the symlink is on disk, and a symlink + // left pointing at a /dev/pts/N that the exec has just freed would name + // whatever terminal next reuses that number. Removing it here first makes + // the new image's begin() find nothing to reclaim rather than something to + // decline. + void end(); + + bool hasPty() const { return _pty.isOpen(); } + const char* path() const { return _pty.path(); } + + // Descriptors to watch for readability: the PTY master (-1 when there is + // none) and stdin (-1 unless it is a terminal). Every byte either delivers + // is consumed by read(), so registering both permanently cannot leave a + // level-triggered POLLIN that nothing drains. + int ptyFd() const { return _pty.fd(); } + int stdinFd() const; + + int available() override; + int peek() override; + int read() override; + size_t write(uint8_t c) override; + void flush() override; using Print::write; // pull in write(str) / write(buf, size) }; -#endif // ARDULINUX_PLATFORM || LINUX_PLATFORM +extern LinuxConsole Console; + +#endif // ARDULINUX_PLATFORM || LINUX_PLATFORM || PIO_UNIT_TESTING diff --git a/src/helpers/PtyConsole.cpp b/src/helpers/PtyConsole.cpp index 15217347ed..7cd6a0bdbb 100644 --- a/src/helpers/PtyConsole.cpp +++ b/src/helpers/PtyConsole.cpp @@ -4,7 +4,7 @@ #include "PtyConsole.h" -#if defined(ARDULINUX_PLATFORM) || defined(LINUX_PLATFORM) +#if defined(ARDULINUX_PLATFORM) || defined(LINUX_PLATFORM) || defined(PIO_UNIT_TESTING) #include #include @@ -18,25 +18,44 @@ namespace { -// Resolve the symlink path to publish for the PTY slave. Non-empty `link` is -// used verbatim; otherwise $XDG_RUNTIME_DIR/meshcore/console, else -// /tmp/meshcore-/console, with the parent directory created mode 0700. -std::string resolveLinkPath(const char *link) { - if (link && *link) return std::string(link); - - const char *xdg = getenv("XDG_RUNTIME_DIR"); - std::string dir = (xdg && *xdg) - ? std::string(xdg) + "/meshcore" - : std::string("/tmp/meshcore-") + std::to_string((unsigned)getuid()); - mkdir(dir.c_str(), 0700); // best effort - return dir + "/console"; +// The systemd unit's RuntimeDirectory. It exists only while the unit runs and +// is owned by the service user, so "present and writable" identifies the +// packaged-service case without any configuration. +const char* const RUNTIME_DIR = "/run/meshcored"; + +// fcntl() rather than O_CLOEXEC at open time: posix_openpt() takes only O_RDWR +// and O_NOCTTY portably, and this file also compiles for the host test build on +// macOS. The window between creating a descriptor and marking it does not +// matter here -- the only exec is this process's own reboot(), which cannot run +// part-way through begin(). +void set_cloexec(int fd) { + int fl = fcntl(fd, F_GETFD, 0); + if (fl != -1) fcntl(fd, F_SETFD, fl | FD_CLOEXEC); +} + +void set_nonblock(int fd) { + int fl = fcntl(fd, F_GETFL, 0); + if (fl != -1) fcntl(fd, F_SETFL, fl | O_NONBLOCK); +} + +// True if `link` is a symlink whose target still exists as a character device: +// a console that some other process is holding open right now. A symlink left +// by a daemon that has since exited points at a /dev/pts/N that no longer +// exists (the kernel removes the node when the master closes), so it is stale +// and safe to replace. The one thing this cannot tell apart is a stale symlink +// whose pts number an unrelated terminal has since reused; that case declines +// a path it could have taken, and says so, which is the cheap direction to be +// wrong in. +bool held_by_live_console(const char* link) { + struct stat st; + return stat(link, &st) == 0 && S_ISCHR(st.st_mode); } } // namespace PtyConsole::~PtyConsole() { end(); } -bool PtyConsole::begin(const char *link) { +bool PtyConsole::begin(const char* link) { if (master_fd != -1) return true; // already open int fd = posix_openpt(O_RDWR | O_NOCTTY); @@ -44,7 +63,8 @@ bool PtyConsole::begin(const char *link) { fprintf(stderr, "meshcore: console posix_openpt() failed: %s\n", strerror(errno)); return false; } - fcntl(fd, F_SETFL, fcntl(fd, F_GETFL, 0) | O_NONBLOCK); + set_cloexec(fd); + set_nonblock(fd); if (grantpt(fd) != 0 || unlockpt(fd) != 0) { fprintf(stderr, "meshcore: console grantpt/unlockpt failed: %s\n", strerror(errno)); @@ -71,24 +91,84 @@ bool PtyConsole::begin(const char *link) { // Owner-only: attaching to the console grants the privileged local CLI. chmod(pts_path.c_str(), 0600); - // Publish a stable symlink so clients have a fixed path across restarts - // (the /dev/pts/N number varies). If the symlink can't be made, clients can - // still use the raw pts path (path() falls back to it). - std::string lp = resolveLinkPath(link); - unlink(lp.c_str()); - if (symlink(pts_path.c_str(), lp.c_str()) == 0) { - link_path = lp; - } else { - fprintf(stderr, "meshcore: console symlink(%s) failed: %s; use %s\n", - lp.c_str(), strerror(errno), pts_path.c_str()); + // Our own descriptor on the slave, so a detached console reads as idle + // rather than hung up (see the class comment). O_NOCTTY: this must not + // become the daemon's controlling terminal. + int holder = open(pts_path.c_str(), O_RDWR | O_NOCTTY | O_NONBLOCK); + if (holder < 0) { + fprintf(stderr, "meshcore: console open(%s) failed: %s\n", pts_path.c_str(), strerror(errno)); + close(fd); + pts_path.clear(); + return false; } + set_cloexec(holder); master_fd = fd; + holder_fd = holder; + peeked = -1; + + // Publish a stable symlink so clients have a fixed path across restarts + // (the /dev/pts/N number varies). Every candidate can fail -- held by + // another instance, occupied by something that is not ours to remove, or + // simply unwritable -- and each failure is reported, because a console that + // silently came up somewhere else is the least diagnosable outcome. If none + // can be made, clients can still use the raw pts path (path() falls back to + // it). + const char* xdg = getenv("XDG_RUNTIME_DIR"); + std::string candidates[4]; + int n = 0; + if (link && *link) candidates[n++] = link; + if (access(RUNTIME_DIR, W_OK) == 0) candidates[n++] = std::string(RUNTIME_DIR) + "/console"; + if (xdg && *xdg) candidates[n++] = std::string(xdg) + "/meshcore/console"; + candidates[n++] = std::string("/tmp/meshcore-") + std::to_string((unsigned)getuid()) + "/console"; + + for (int i = 0; i < n && link_path.empty(); i++) publish(candidates[i].c_str()); + if (link_path.empty()) + fprintf(stderr, "meshcore: no console symlink could be published; use %s\n", pts_path.c_str()); + return true; +} + +// Point `link` at the slave device. Returns false, with the reason on stderr, +// if that cannot be done safely. +bool PtyConsole::publish(const char* link) { + if (held_by_live_console(link)) { + fprintf(stderr, "meshcore: console %s is in use by another instance; not taking it over\n", link); + return false; + } + + // The parent directory is created only for the per-user defaults; the + // runtime directory belongs to systemd and a configured path to the + // operator. mkdir() on an existing directory is harmless. + std::string dir(link); + size_t slash = dir.rfind('/'); + if (slash != std::string::npos && slash > 0) { + dir.erase(slash); + mkdir(dir.c_str(), 0700); // best effort; symlink() reports the real failure + } + + // Replace only what we could have created. The path is operator input, and + // unlink() does not care what it removes. lstat(), not stat(): the symlink + // itself is the question, not what it points at. + struct stat st; + if (lstat(link, &st) == 0) { + if (!S_ISLNK(st.st_mode)) { + fprintf(stderr, "meshcore: console path %s exists and is not a symlink; refusing to remove it\n", link); + return false; + } + unlink(link); + } + + if (symlink(pts_path.c_str(), link) != 0) { + fprintf(stderr, "meshcore: console symlink(%s) failed: %s\n", link, strerror(errno)); + return false; + } + link_path = link; return true; } void PtyConsole::end() { if (!link_path.empty()) { unlink(link_path.c_str()); link_path.clear(); } + if (holder_fd != -1) { close(holder_fd); holder_fd = -1; } if (master_fd != -1) { close(master_fd); master_fd = -1; } pts_path.clear(); peeked = -1; @@ -117,20 +197,19 @@ int PtyConsole::read() { // with tools that send '\n'. Kept 1:1 so available()/read() stay consistent // (the repeater's read() is unchecked). if (n == 1) return (b == '\n') ? '\r' : b; - // n == 0 (no slave open) or n < 0 (EAGAIN, or EIO after the client closed): - // no data right now. The master persists; a client can reattach. + // EAGAIN: no data right now. (EIO cannot happen while holder_fd is open.) return -1; } size_t PtyConsole::write(uint8_t c) { if (master_fd != -1) { - // A PTY master write with no reader just buffers (or EAGAIN/EIO under - // O_NONBLOCK) — no SIGPIPE — so unwritten console output is simply - // dropped, never fatal. + // A PTY master write with no reader just buffers (or EAGAIN under + // O_NONBLOCK once the slave's input queue is full) -- no SIGPIPE -- so + // unwritten console output is simply dropped, never fatal. ssize_t r = ::write(master_fd, &c, 1); (void)r; } return 1; } -#endif // ARDULINUX_PLATFORM || LINUX_PLATFORM +#endif // ARDULINUX_PLATFORM || LINUX_PLATFORM || PIO_UNIT_TESTING diff --git a/src/helpers/PtyConsole.h b/src/helpers/PtyConsole.h index 0a560d3f53..e24c99fe86 100644 --- a/src/helpers/PtyConsole.h +++ b/src/helpers/PtyConsole.h @@ -1,6 +1,8 @@ #pragma once -#if defined(ARDULINUX_PLATFORM) || defined(LINUX_PLATFORM) +// Also built for the host test env (PlatformIO defines PIO_UNIT_TESTING there), +// where the PTY mechanics are exercised end to end against real /dev/pts nodes. +#if defined(ARDULINUX_PLATFORM) || defined(LINUX_PLATFORM) || defined(PIO_UNIT_TESTING) #include #include @@ -15,26 +17,47 @@ // (meshcore-cli's repeater mode drives a raw-text serial CLI via pyserial, which // needs a tty — hence a PTY rather than a socket). // -// Pure POSIX (no Arduino dependency) so the accept-free read/write/newline logic -// is unit-testable on the host; LinuxConsole wraps it in an Arduino Stream. +// Pure POSIX (no Arduino dependency) so the read/write/newline logic is +// unit-testable on the host; LinuxConsole wraps it in an Arduino Stream. // // The PTY master persists for the daemon's life; a client just opens/closes the // slave, so there is no accept/reap. The slave device is chmod'd 0600 (the // unauthenticated local CLI's access gate). +// +// The daemon also keeps one descriptor of its own on the slave open. Without it +// the master reports POLLHUP and read() fails with EIO from the moment the last +// client closes until the next one opens -- a level condition that would wake a +// poll()-based main loop continuously. With it, a detached console is simply +// idle: poll() sleeps, read() says "nothing yet", and the next client attaches +// as if the previous one had never left. +// +// Every descriptor is close-on-exec. LinuxBoard::reboot() re-execs this process +// image, and a master that reached the new image would keep the old /dev/pts/N +// alive with nobody reading it. class PtyConsole { int master_fd = -1; // PTY master; -1 when closed + int holder_fd = -1; // our own descriptor on the slave (see above) int peeked = -1; // one-byte pushback for peek(); -1 when empty std::string link_path; // published symlink to the slave (unlinked on end()) std::string pts_path; // the slave device path (/dev/pts/N) + bool publish(const char* link); + public: PtyConsole() = default; ~PtyConsole(); - // Open the PTY and publish a symlink at `link` (empty => a per-user default: - // $XDG_RUNTIME_DIR/meshcore/console, else /tmp/meshcore-/console). - // Returns true on success; on failure logs to stderr and returns false so - // the caller can fall back to the stdout console. + PtyConsole(const PtyConsole&) = delete; + PtyConsole& operator=(const PtyConsole&) = delete; + + // Open the PTY and publish a symlink to it. `link` non-empty is tried first; + // then, in order, /run/meshcored/console (when that directory exists and is + // writable -- the systemd unit's RuntimeDirectory), $XDG_RUNTIME_DIR/ + // meshcore/console, and /tmp/meshcore-/console. A candidate is skipped, + // with a line on stderr, if it is held by another live console or is + // something other than a symlink. Returns true on success; on failure logs + // to stderr and returns false. The PTY itself is always created even when + // no symlink could be published; path() then names the raw pts device. bool begin(const char* link); void end(); @@ -45,10 +68,13 @@ class PtyConsole { void flush() {} bool isOpen() const { return master_fd != -1; } + // The master descriptor, for poll(): readable exactly when a client has sent + // bytes that read() has not yet consumed. -1 when closed. + int fd() const { return master_fd; } // Path a client should open: the published symlink, else the raw pts device. const char* path() const { return link_path.empty() ? pts_path.c_str() : link_path.c_str(); } }; -#endif // ARDULINUX_PLATFORM || LINUX_PLATFORM +#endif // ARDULINUX_PLATFORM || LINUX_PLATFORM || PIO_UNIT_TESTING diff --git a/test/test_linux_console/test_linux_console.cpp b/test/test_linux_console/test_linux_console.cpp new file mode 100644 index 0000000000..e61c914492 --- /dev/null +++ b/test/test_linux_console/test_linux_console.cpp @@ -0,0 +1,485 @@ +#ifndef _GNU_SOURCE +#define _GNU_SOURCE // ptsname_r() on glibc +#endif + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include + +#include "helpers/LinuxConsole.h" +#include "LinuxEventLoop.h" + +namespace { + +// Open the console's published path the way a serial client does. Non-blocking +// so a test that expects silence can prove it rather than hang. +int open_client(const char* path) { + int fd = ::open(path, O_RDWR | O_NOCTTY | O_NONBLOCK); + if (fd >= 0) { + struct termios t; + if (tcgetattr(fd, &t) == 0) { cfmakeraw(&t); tcsetattr(fd, TCSANOW, &t); } + } + return fd; +} + +// Everything the far end has to say, up to `idle_ms` of silence. +std::string drain(int fd, int idle_ms = 250) { + std::string out; + for (;;) { + struct pollfd p = { fd, POLLIN, 0 }; + if (poll(&p, 1, idle_ms) <= 0) break; + char buf[256]; + ssize_t n = ::read(fd, buf, sizeof buf); + if (n <= 0) break; + out.append(buf, (size_t)n); + } + return out; +} + +// The kernel moves bytes from slave to master asynchronously, so a byte just +// written by a client is not necessarily readable on the very next call. +template int read_within(C& c, int ms = 500) { + for (int i = 0; i < ms; i++) { + int b = c.read(); + if (b >= 0) return b; + usleep(1000); + } + return -1; +} + +template int peek_within(C& c, int ms = 500) { + for (int i = 0; i < ms; i++) { + int b = c.peek(); + if (b >= 0) return b; + usleep(1000); + } + return -1; +} + +std::set open_fds() { + std::set fds; + DIR* d = opendir("/dev/fd"); + if (d == nullptr) return fds; + for (struct dirent* e = readdir(d); e != nullptr; e = readdir(d)) { + if (e->d_name[0] == '.') continue; + int fd = atoi(e->d_name); + if (fd != dirfd(d)) fds.insert(fd); + } + closedir(d); + return fds; +} + +void remove_tree(const std::string& dir) { + DIR* d = opendir(dir.c_str()); + if (d == nullptr) return; + for (struct dirent* e = readdir(d); e != nullptr; e = readdir(d)) { + if (strcmp(e->d_name, ".") == 0 || strcmp(e->d_name, "..") == 0) continue; + std::string p = dir + "/" + e->d_name; + struct stat st; + if (lstat(p.c_str(), &st) == 0 && S_ISDIR(st.st_mode)) remove_tree(p); + else unlink(p.c_str()); + } + closedir(d); + rmdir(dir.c_str()); +} + +class ConsoleTest : public ::testing::Test { +protected: + void SetUp() override { + // stdin must not be a TTY unless a test says so: begin() would otherwise + // put the developer's terminal into raw mode and read their keystrokes. + _saved_stdin = dup(STDIN_FILENO); + int devnull = open("/dev/null", O_RDONLY); + ASSERT_GE(devnull, 0); + ASSERT_GE(dup2(devnull, STDIN_FILENO), 0); + close(devnull); + + char tmpl[] = "/tmp/mccon-XXXXXX"; + ASSERT_NE(nullptr, mkdtemp(tmpl)); + _dir = tmpl; + _link = _dir + "/console"; + + // begin() narrates to stderr. Capturing it keeps the suite's output clean + // and makes the messages themselves assertable. + _saved_stderr = dup(STDERR_FILENO); + _err_path = _dir + "/stderr"; + int errfd = open(_err_path.c_str(), O_RDWR | O_CREAT | O_TRUNC, 0600); + ASSERT_GE(errfd, 0); + ASSERT_GE(dup2(errfd, STDERR_FILENO), 0); + close(errfd); + + // The candidate after a declined explicit path is /run/meshcored (absent, + // or not writable, on a development host) and then XDG_RUNTIME_DIR. + // Pointing XDG at the temp dir keeps a declined path from landing on the + // shared /tmp last resort. + setenv("XDG_RUNTIME_DIR", _dir.c_str(), 1); + _xdg_default = _dir + "/meshcore/console"; + } + + void TearDown() override { + fflush(stderr); + dup2(_saved_stderr, STDERR_FILENO); + close(_saved_stderr); + dup2(_saved_stdin, STDIN_FILENO); + close(_saved_stdin); + unsetenv("XDG_RUNTIME_DIR"); + remove_tree(_dir); + } + + std::string captured_stderr() { + fflush(stderr); + std::string out; + int fd = open(_err_path.c_str(), O_RDONLY); + if (fd < 0) return out; + char buf[512]; + ssize_t n; + while ((n = ::read(fd, buf, sizeof buf)) > 0) out.append(buf, (size_t)n); + close(fd); + return out; + } + + std::string _dir, _link, _xdg_default, _err_path; + int _saved_stdin = -1; + int _saved_stderr = -1; +}; + +// --- PtyConsole: the PTY itself ---------------------------------------------- + +TEST_F(ConsoleTest, PublishesASymlinkToAnOwnerOnlySlave) { + PtyConsole c; + ASSERT_TRUE(c.begin(_link.c_str())); + EXPECT_STREQ(_link.c_str(), c.path()); + EXPECT_GE(c.fd(), 0); + + struct stat st; + ASSERT_EQ(0, lstat(_link.c_str(), &st)); + EXPECT_TRUE(S_ISLNK(st.st_mode)); + ASSERT_EQ(0, stat(_link.c_str(), &st)); // through the link, to /dev/pts/N + EXPECT_TRUE(S_ISCHR(st.st_mode)); + EXPECT_EQ(0600u, st.st_mode & 0777) << "the mode is the access gate"; + + c.end(); + EXPECT_NE(0, lstat(_link.c_str(), &st)) << "end() must unpublish"; + EXPECT_EQ(-1, c.fd()); + EXPECT_FALSE(c.isOpen()); +} + +TEST_F(ConsoleTest, CarriesBothDirectionsAndMapsNewline) { + PtyConsole c; + ASSERT_TRUE(c.begin(_link.c_str())); + int client = open_client(c.path()); + ASSERT_GE(client, 0); + + ASSERT_EQ(4, ::write(client, "ver\n", 4)); + EXPECT_EQ('v', read_within(c)); + EXPECT_EQ('e', read_within(c)); + EXPECT_EQ('r', read_within(c)); + EXPECT_EQ('\r', read_within(c)) << "tools send '\\n'; the CLI ends a line on '\\r'"; + EXPECT_EQ(-1, c.read()); + + c.write('O'); + c.write('K'); + EXPECT_EQ("OK", drain(client)); + + close(client); +} + +TEST_F(ConsoleTest, PeekDoesNotConsume) { + PtyConsole c; + ASSERT_TRUE(c.begin(_link.c_str())); + int client = open_client(c.path()); + ASSERT_GE(client, 0); + + ASSERT_EQ(1, ::write(client, "Z", 1)); + EXPECT_EQ('Z', peek_within(c)); + EXPECT_EQ('Z', c.peek()); + EXPECT_EQ(1, c.available()); + EXPECT_EQ('Z', c.read()); + EXPECT_EQ(-1, c.read()); + EXPECT_EQ(0, c.available()); + + close(client); +} + +// The reason the console holds a descriptor on its own slave. Without one, the +// master reports POLLHUP and read() fails with EIO from the moment the last +// client closes until the next one opens -- a level condition the event loop +// can only throttle, not clear. +TEST_F(ConsoleTest, ADetachedClientLeavesTheConsoleIdleNotHungUp) { + PtyConsole c; + ASSERT_TRUE(c.begin(_link.c_str())); + + int first = open_client(c.path()); + ASSERT_GE(first, 0); + ASSERT_EQ(1, ::write(first, "a", 1)); + EXPECT_EQ('a', read_within(c)); + close(first); + usleep(20 * 1000); + + struct pollfd p = { c.fd(), POLLIN, 0 }; + EXPECT_EQ(0, poll(&p, 1, 50)) << "revents=" << p.revents; + EXPECT_EQ(-1, c.read()); + for (int i = 0; i < 100; i++) c.write('x'); // nobody reading: dropped, not fatal + + // And through the event loop: a clean timeout, not the 1 ms error backoff. + LinuxEventLoop loop; + loop.reset(); + loop.registerFd(c.fd()); + auto t0 = std::chrono::steady_clock::now(); + EXPECT_EQ(0, loop.wait(40)); + auto ms = std::chrono::duration_cast( + std::chrono::steady_clock::now() - t0).count(); + EXPECT_GE(ms, 35) << "wait() returned early: the master is reporting a condition"; + + int second = open_client(c.path()); + ASSERT_GE(second, 0); + ASSERT_EQ(1, ::write(second, "b", 1)); + EXPECT_EQ('b', read_within(c)) << "the next client attaches as if the first had never left"; + close(second); +} + +// LinuxBoard::reboot() re-execs this process image, and execv() keeps every +// descriptor that is not close-on-exec. A master that reached the new image +// would keep the old /dev/pts/N alive with nobody reading it. +TEST_F(ConsoleTest, EveryDescriptorItOpensIsCloseOnExec) { + std::set before = open_fds(); + PtyConsole c; + ASSERT_TRUE(c.begin(_link.c_str())); + + int opened = 0; + for (int fd : open_fds()) { + if (before.count(fd)) continue; + opened++; + EXPECT_TRUE(fcntl(fd, F_GETFD) & FD_CLOEXEC) << "descriptor " << fd; + } + EXPECT_EQ(2, opened) << "the master and the console's own slave descriptor"; + + c.end(); + EXPECT_EQ(before, open_fds()) << "end() must close everything begin() opened"; +} + +TEST_F(ConsoleTest, DeclinesAPathAnotherLiveConsoleHolds) { + PtyConsole first; + ASSERT_TRUE(first.begin(_link.c_str())); + + PtyConsole second; + ASSERT_TRUE(second.begin(_link.c_str())); // same path + EXPECT_STREQ(_xdg_default.c_str(), second.path()) << "fell through to the next candidate"; + EXPECT_NE(std::string::npos, captured_stderr().find("in use")); + + // The first instance kept the path, and it still works. + int client = open_client(_link.c_str()); + ASSERT_GE(client, 0); + ASSERT_EQ(1, ::write(client, "m", 1)); + EXPECT_EQ('m', read_within(first)); + EXPECT_EQ(-1, second.read()); + close(client); +} + +// What a crashed daemon leaves behind: a symlink to a /dev/pts/N that no longer +// exists. That is nobody's console, so it is reclaimed. +TEST_F(ConsoleTest, ReclaimsAStaleSymlink) { + ASSERT_EQ(0, symlink("/dev/pts/no-such-console", _link.c_str())); + + PtyConsole c; + ASSERT_TRUE(c.begin(_link.c_str())); + EXPECT_STREQ(_link.c_str(), c.path()); + + char target[128] = {0}; + ASSERT_GT(readlink(_link.c_str(), target, sizeof target - 1), 0); + EXPECT_STRNE("/dev/pts/no-such-console", target); + struct stat st; + EXPECT_EQ(0, stat(_link.c_str(), &st)) << "the link now resolves to a live device"; +} + +TEST_F(ConsoleTest, RefusesToReplaceARegularFile) { + const char* content = "not a console\n"; + int f = open(_link.c_str(), O_WRONLY | O_CREAT | O_TRUNC, 0600); + ASSERT_GE(f, 0); + ASSERT_EQ((ssize_t)strlen(content), ::write(f, content, strlen(content))); + close(f); + + PtyConsole c; + ASSERT_TRUE(c.begin(_link.c_str())); + EXPECT_STREQ(_xdg_default.c_str(), c.path()); + EXPECT_NE(std::string::npos, captured_stderr().find("is not a symlink")); + + // An operator who points console_path at the wrong file gets a refusal, not + // a deletion. + struct stat st; + ASSERT_EQ(0, lstat(_link.c_str(), &st)); + EXPECT_TRUE(S_ISREG(st.st_mode)); + EXPECT_EQ((off_t)strlen(content), st.st_size); +} + +TEST_F(ConsoleTest, DefaultsToAPrivateDirectoryUnderXdgRuntimeDir) { + PtyConsole c; + ASSERT_TRUE(c.begin("")); + EXPECT_STREQ(_xdg_default.c_str(), c.path()); + + struct stat st; + ASSERT_EQ(0, stat((_dir + "/meshcore").c_str(), &st)); + EXPECT_TRUE(S_ISDIR(st.st_mode)); + EXPECT_EQ(0700u, st.st_mode & 0777); +} + +// --- LinuxConsole: the Stream the CLI sees ----------------------------------- + +TEST_F(ConsoleTest, NormalisesNewlineIdenticallyForPeekAndRead) { + LinuxConsole console; + ASSERT_TRUE(console.begin(_link.c_str())); + int client = open_client(console.path()); + ASSERT_GE(client, 0); + + ASSERT_EQ(2, ::write(client, "a\n", 2)); + EXPECT_EQ('a', peek_within(console)); + EXPECT_EQ('a', console.read()); + // The CLI ends a command on '\r'; peek() reporting '\n' would tell a caller + // the line is unfinished when read() is about to say it is finished. + EXPECT_EQ('\r', peek_within(console)); + EXPECT_EQ('\r', console.read()); + EXPECT_EQ(0, console.available()); + + close(client); +} + +TEST_F(ConsoleTest, WatchesStdinOnlyWhenItIsATerminal) { + LinuxConsole console; + ASSERT_TRUE(console.begin(_link.c_str())); + EXPECT_GE(console.ptyFd(), 0); + EXPECT_EQ(-1, console.stdinFd()) << "stdin is /dev/null here"; +} + +// One CLI, two doors. Output goes to stdout always (journald's copy) and to the +// PTY only for a command that arrived there. +TEST_F(ConsoleTest, RepliesFollowTheCommandToItsSource) { + // A terminal on stdin: the slave of a second PTY pair, driven from its master. + int term = posix_openpt(O_RDWR | O_NOCTTY); + ASSERT_GE(term, 0); + ASSERT_EQ(0, grantpt(term)); + ASSERT_EQ(0, unlockpt(term)); + char term_slave[128]; + ASSERT_EQ(0, ptsname_r(term, term_slave, sizeof term_slave)); + int keyboard = open(term_slave, O_RDWR | O_NOCTTY); + ASSERT_GE(keyboard, 0); + ASSERT_GE(dup2(keyboard, STDIN_FILENO), 0); + close(keyboard); + struct termios before; + ASSERT_EQ(0, tcgetattr(STDIN_FILENO, &before)); + + // Capture stdout. gtest prints nothing during a test body, so the redirect + // is invisible to it as long as it is undone before the body ends. + std::string out_path = _dir + "/stdout"; + fflush(stdout); + int saved_stdout = dup(STDOUT_FILENO); + int out_fd = open(out_path.c_str(), O_RDWR | O_CREAT | O_TRUNC, 0600); + ASSERT_GE(out_fd, 0); + ASSERT_GE(dup2(out_fd, STDOUT_FILENO), 0); + close(out_fd); + + LinuxConsole console; + bool began = console.begin(_link.c_str()); + int stdin_fd = console.stdinFd(); + struct termios raw; + tcgetattr(STDIN_FILENO, &raw); + + // Over the PTY: the reply reaches the client (and stdout). + int client = open_client(console.path()); + int a1 = -1, a2 = -1, b1 = -1, b2 = -1; + std::string a_reply, b_leak, term_echo; + if (client >= 0 && ::write(client, "a\r", 2) == 2) { + a1 = read_within(console); + a2 = read_within(console); + console.print(" -> A\n"); + a_reply = drain(client); + } + + // From the terminal: the reply reaches stdout and stays out of the PTY. + if (::write(term, "b\r", 2) == 2) { + b1 = read_within(console); + b2 = read_within(console); + console.print(" -> B\n"); + b_leak = drain(client, 100); + term_echo = drain(term, 50); + } + + console.end(); + struct termios after; + tcgetattr(STDIN_FILENO, &after); + + // Everything is asserted only once stdout is back: a failure message printed + // into the capture file would otherwise be invisible. + fflush(stdout); + dup2(saved_stdout, STDOUT_FILENO); + close(saved_stdout); + if (client >= 0) close(client); + close(term); + + ASSERT_TRUE(began); + EXPECT_EQ(STDIN_FILENO, stdin_fd); + EXPECT_EQ(0u, raw.c_lflag & (ICANON | ECHO)) << "keystrokes, not lines, and no double echo"; + EXPECT_NE(0u, raw.c_lflag & ISIG) << "Ctrl-C must still stop the daemon"; + ASSERT_GE(client, 0); + EXPECT_EQ('a', a1); + EXPECT_EQ('\r', a2); + EXPECT_EQ(" -> A\n", a_reply); + EXPECT_EQ('b', b1); + EXPECT_EQ('\r', b2); + EXPECT_EQ("", b_leak) << "a foreground session's output must not queue for the next client"; + EXPECT_EQ("", term_echo) << "the terminal echoes nothing on its own"; + // Only the bits begin() changed are compared: the kernel keeps transient + // state flags (PENDIN) in c_lflag as well. + EXPECT_EQ(before.c_lflag & (ICANON | ECHO), after.c_lflag & (ICANON | ECHO)) << "end() restores the terminal"; + EXPECT_EQ(before.c_iflag & (ICRNL | INLCR), after.c_iflag & (ICRNL | INLCR)); + + std::string out; + int fd = open(out_path.c_str(), O_RDONLY); + ASSERT_GE(fd, 0); + char buf[256]; + ssize_t n; + while ((n = ::read(fd, buf, sizeof buf)) > 0) out.append(buf, (size_t)n); + close(fd); + EXPECT_EQ(" -> A\n -> B\n", out) << "stdout carries both, in order"; +} + +// What LinuxBoard::reboot() does, and what the re-exec'd image must then find. +TEST_F(ConsoleTest, EndThenBeginRestartsCleanly) { + std::set baseline = open_fds(); + LinuxConsole console; + ASSERT_TRUE(console.begin(_link.c_str())); + console.end(); + + struct stat st; + EXPECT_NE(0, lstat(_link.c_str(), &st)) << "nothing left for the next image to decline"; + EXPECT_EQ(baseline, open_fds()); + EXPECT_FALSE(console.hasPty()); + EXPECT_EQ(-1, console.ptyFd()); + EXPECT_EQ(-1, console.read()); + + ASSERT_TRUE(console.begin(_link.c_str())); + EXPECT_STREQ(_link.c_str(), console.path()); + int client = open_client(console.path()); + ASSERT_GE(client, 0); + ASSERT_EQ(1, ::write(client, "r", 1)); + EXPECT_EQ('r', read_within(console)); + close(client); +} + +} // namespace + +int main(int argc, char** argv) { + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} diff --git a/variants/linux/LinuxBoard.cpp b/variants/linux/LinuxBoard.cpp index 513c19baf5..27d333600a 100644 --- a/variants/linux/LinuxBoard.cpp +++ b/variants/linux/LinuxBoard.cpp @@ -14,6 +14,7 @@ #endif #include "LinuxBoard.h" #include "LinuxEventLoop.h" +#include #include "LinuxRadioWait.h" #include "AppInfo.h" @@ -193,7 +194,17 @@ void LinuxBoard::begin() { // (CommonCLI::handleCommand), so that would take an unattended repeater // off-air. Qualified as ::reboot() to resolve to the global one and not recurse // into this member of the same name. +// +// The console comes down first. Its descriptors are close-on-exec, so the new +// image inherits none of them, but its symlink is on disk: left behind, it +// points at a /dev/pts/N the exec has just freed, and the new image's begin() +// would either find it dangling and reclaim it (fine) or find that number +// already reused by an unrelated terminal and decline its own path (not fine, +// and only the log would say where the CLI went). Unpublishing here makes the +// outcome depend on nothing but this call. powerOff() needs none of this: +// exit(0) runs the destructor. void LinuxBoard::reboot() { + Console.end(); ::reboot(); } @@ -213,12 +224,21 @@ void LinuxBoard::idleUntilEvent(uint32_t max_wait_ms) { EventLoop.reset(); EventLoop.setEventSource(src); - // Only descriptors that loop() will actually drain this iteration may be - // registered here. POLLIN is level-triggered, so a registered descriptor - // that nothing reads stays readable forever and turns this wait back into - // the busy loop it exists to remove. A byte source that is only drained - // conditionally (a GPS stream while GPS is switched off, say) is better - // served off the poll timeout than registered. + // The console's inputs: the PTY master and, in a foreground terminal, stdin. + // Both are safe to watch permanently because loop() drains whichever has a + // byte on every iteration, and the PTY master never reports a hangup (the + // console holds the slave open itself), so a detached console is idle rather + // than a level condition. Either accessor is -1 when it does not apply, which + // registerFd() ignores. + EventLoop.registerFd(Console.ptyFd()); + EventLoop.registerFd(Console.stdinFd()); + + // Nothing else belongs here unless loop() will drain it every iteration. + // POLLIN is level-triggered, so a registered descriptor that nothing reads + // stays readable forever and turns this wait back into the busy loop it + // exists to remove. A byte source that is only drained conditionally (a GPS + // stream while GPS is switched off, say) is better served off the poll + // timeout than registered. // Refresh the cached IRQ level immediately before blocking. Packet // correctness does not come from the edge-event descriptor above; it comes diff --git a/variants/linux/LinuxBoard.h b/variants/linux/LinuxBoard.h index 83f347bbd4..398a8629b5 100644 --- a/variants/linux/LinuxBoard.h +++ b/variants/linux/LinuxBoard.h @@ -45,8 +45,9 @@ class LinuxConfig { const char* spidev = "/dev/spidev0.0"; const char* lora_gpiochip = "gpiochip0"; - // Local CLI console path. Empty => a per-user default - // ($XDG_RUNTIME_DIR/meshcore/console, else /tmp/meshcore-/console). + // Local CLI console path. Empty => the default search order: the systemd + // unit's /run/meshcored/console when that directory exists, else + // $XDG_RUNTIME_DIR/meshcore/console, else /tmp/meshcore-/console. // Connect with `meshcore-cli -r -s `. const char* console_path = ""; @@ -108,7 +109,8 @@ class LinuxBoard : public mesh::MainBoard { } } - // Re-exec this process image rather than exit. Defined in LinuxBoard.cpp. + // Unpublish the console and re-exec this process image rather than exit. + // Defined in LinuxBoard.cpp. void reboot() override; // Block on the LoRa IRQ edge descriptor (plus any other descriptor that will diff --git a/variants/linux/README.md b/variants/linux/README.md index 2318421b64..7d854a6523 100644 --- a/variants/linux/README.md +++ b/variants/linux/README.md @@ -104,6 +104,7 @@ Key settings: |-----|---------|-------| | `spidev` | `/dev/spidev0.0` | SPI device node | | `lora_gpiochip` | `gpiochip0` | Name of the `/dev/gpiochip*` device (or kernel label). `gpiochip0` is correct for Pi 3/4/Zero 2W; Pi 5 may need `gpiochip4` or `pinctrl-rp1` depending on kernel | +| `console_path` | (auto) | Where to publish the CLI console. Unset, the daemon picks `/run/meshcored/console` under the systemd unit, else a per-user path; see [The control CLI](#the-control-cli) | | `lora_irq_pin` | (none) | GPIO line number for IRQ | | `lora_reset_pin` | (none) | GPIO line number for RESET | | `lora_nss_pin` | (none) | GPIO line number for NSS/CS (if not handled by the SPI driver) | @@ -240,19 +241,16 @@ sudo journalctl -u meshcored -f > If you smoke-tested by running directly first, clear any stale state so the > service first-boots with the INI defaults: `sudo rm -rf /var/lib/meshcore/*` -> For the local CLI console under systemd, uncomment -> `console_path = /run/meshcored/console` in `/etc/meshcored/meshcored.ini` -> (the unit's `RuntimeDirectory` provides `/run/meshcored`). See +> Under the unit the CLI console appears at `/run/meshcored/console` with no +> INI change: the daemon finds the unit's `RuntimeDirectory` on its own. See > [§5](#5-reconfiguring-after-first-run). ### 5. Reconfiguring after first run -`meshcored` exposes a local CLI at the path set by `console_path` in -`meshcored.ini`, kept separate from the logs (which go to stdout / journald). -Under the systemd unit, uncomment `console_path = /run/meshcored/console` — the -unit's `RuntimeDirectory` creates that directory, owned by the `meshcore` user. - -Connect with [`meshcore-cli`](https://github.com/fdlamotte/meshcore-cli): +`meshcored` exposes a local CLI console, kept separate from the logs (which go +to stdout / journald). Under the systemd unit it is `/run/meshcored/console`. +Connect with [`meshcore-cli`](https://github.com/fdlamotte/meshcore-cli), or any +serial terminal (see [The control CLI](#the-control-cli)): ```sh sudo meshcore-cli -r -s /run/meshcored/console @@ -275,9 +273,8 @@ set sf 7 > **Security:** connecting to the console grants the privileged, *unauthenticated* > local CLI — it can change the radio, read the private key (`get prv.key`), erase > state, etc. The console is owner-only (mode `0600`), so keep the daemon's user -> (`root`/`meshcore`) trusted. When `console_path` is unset (e.g. running -> `meshcored` **directly**, not under systemd) it defaults to -> `$XDG_RUNTIME_DIR/meshcore/console` (else `/tmp/meshcore-/console`). +> (`root`/`meshcore`) trusted. Where the console is published is described in +> [The control CLI](#the-control-cli). Logs stream to journald (`sudo journalctl -u meshcored -f`); the daemon line-buffers stdout itself, so no `stdbuf` wrapper is needed. @@ -303,6 +300,40 @@ sudo systemctl start meshcored > **Note:** LoRa radio parameters (`lora_freq`, `lora_bw`, `lora_sf`, `lora_cr`, `lora_tx_power`) are also first-run defaults. After first boot they are saved in `com_prefs` and the INI values are no longer read for those fields. To apply a changed radio parameter on a running node, use the console CLI (`set freq`, `set sf`, etc.; see [§5](#5-reconfiguring-after-first-run)) or reset prefs as above. +## The control CLI + +Everything the MeshCore docs describe as the "serial CLI" — `set`, `get`, +`advert`, `neighbors`, and the rest — is reached here through the console. + +The Arduino `Serial` object is output-only on Linux, so `meshcored` prints its +logs to stdout and serves the CLI on a **pseudo-terminal**, published as a +symlink at a stable path. `console_path` in `meshcored.ini` sets it; unset, the +first of these that can be published wins, and startup logs which one it was: + +| Order | Path | When | +|-------|------|------| +| 1 | `console_path`, if set | | +| 2 | `/run/meshcored/console` | the directory exists and is writable: the unit's `RuntimeDirectory=` | +| 3 | `$XDG_RUNTIME_DIR/meshcore/console` | running directly as a logged-in user | +| 4 | `/tmp/meshcore-/console` | neither of the above | + +The device behind the symlink is mode `0600` and owned by the user running the +daemon, so reaching it means being that user or root. A path is skipped, with a +line on stderr, if another live `meshcored` already holds it or if something +that is not a symlink sits there; a symlink left by a crashed daemon is +reclaimed. `reboot` unpublishes the console before re-executing, so the new +process starts from a clean path. + +Because the console is a terminal device, any serial tool can attach: +`meshcore-cli -r -s `, `screen`, `minicom`, `picocom`. There is no +single-client rule: two tools attached at once share one CLI and see each +other's traffic. + +Running `meshcored` in a foreground terminal, you can also type commands +straight into its stdin. Replies are printed to stdout either way; a command +typed at the terminal is not copied to the console, and a command sent over the +console is answered there. + ## Operation ### Idle CPU usage diff --git a/variants/linux/meshcored.ini b/variants/linux/meshcored.ini index a4c94db8dd..c2cfd46c1d 100644 --- a/variants/linux/meshcored.ini +++ b/variants/linux/meshcored.ini @@ -3,11 +3,12 @@ admin_password = password lat = 0.0 lon = 0.0 -# Local CLI console. Connect with `meshcore-cli -r -s `. Leave unset for a -# per-user default ($XDG_RUNTIME_DIR/meshcore/console, else -# /tmp/meshcore-/console). Under the systemd unit, uncomment the /run path -# below (its RuntimeDirectory provides and cleans up that directory). The console -# is owner-only (mode 0600) and grants the privileged, unauthenticated CLI. +# Local CLI console, for `meshcore-cli -r -s ` or any serial terminal. +# Leave unset for the default: /run/meshcored/console under the systemd unit (its +# RuntimeDirectory provides and cleans up that directory), else +# $XDG_RUNTIME_DIR/meshcore/console, else /tmp/meshcore-/console. The +# console is owner-only (mode 0600) and grants the privileged, unauthenticated +# CLI. #console_path = /run/meshcored/console # Waveshare LoRa hat diff --git a/variants/linux/meshcored.service b/variants/linux/meshcored.service index f3cdead632..27ba5ccbf1 100644 --- a/variants/linux/meshcored.service +++ b/variants/linux/meshcored.service @@ -8,13 +8,13 @@ Wants=network.target Type=simple User=meshcore Group=meshcore -# Local CLI console. Set its path in meshcored.ini -# (console_path = /run/meshcored/console); RuntimeDirectory creates /run/meshcored -# owned by meshcore, mode 0700, and removes it on stop. /run (not /tmp) is used -# because PrivateTmp=yes gives the service a private /tmp a client outside the -# unit could not reach. The console is owner-only (mode 0600) and grants the -# privileged, unauthenticated CLI. Connect with: -# meshcore-cli -r -s /run/meshcored/console +# Local CLI console. RuntimeDirectory creates /run/meshcored owned by meshcore, +# mode 0700, and removes it on stop; meshcored publishes the console there as +# /run/meshcored/console whenever the directory exists (console_path in +# meshcored.ini overrides). /run (not /tmp) because PrivateTmp=yes gives the +# service a private /tmp a client outside the unit could not reach. The console +# is owner-only (mode 0600) and grants the privileged, unauthenticated CLI. +# Connect with `meshcore-cli -r -s /run/meshcored/console`, or any serial terminal. RuntimeDirectory=meshcored RuntimeDirectoryMode=0700 ExecStart=/usr/bin/meshcored --fsdir /var/lib/meshcore