From 0b581659420bd6ffa82c5b3083327829bef56c07 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. What changes is 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 is registered with the event loop as a permanent descriptor, 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 and writable (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. A candidate is declined too unless its parent directory is a directory, owned by our own uid, with no group or other permissions. mkdir() is best effort and its result discarded, so pre-creating that directory is otherwise enough to take the console over: `ln -s /etc /tmp/meshcore-0` aims a root daemon's symlink() and unlink() wherever the owner of the link likes, and a 0777 directory lets them substitute their own PTY for the console and read whatever the operator types at it -- `get prv.key` included. For the same reason the slave device is chmod'd 0600 before unlockpt() rather than after: open() on a locked pts fails, so there is no longer a window in which the 0620 root:tty node devpts creates is open to everyone in group tty. stdin is a second door onto the same CLI when meshcored runs in the foreground of a terminal: raw, no kernel echo (the CLI echoes), and VMIN=0/VTIME=0 so a read never blocks the mesh loop. The foreground only -- isatty() is just as true for `meshcored &`, and tcsetattr() from a process in a background process group of its controlling terminal raises SIGTTOU at that whole group, whose default action is to stop it, so the daemon would report "Stopped" from inside setup() and never boot; a read() would do the same via SIGTTIN. Both signals are ignored as well, so a session backgrounded after startup gets an error return rather than a stop. 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. A terminal that hangs up (nohup ... & over an ssh session that then drops) is unregistered, instead of staying POLLHUP forever and reinstating the busy loop the previous commit removed. The terminal is handed back on the way out. end() restores it, and so does a SIGINT/SIGTERM/SIGHUP handler installed alongside the raw mode: loop() never returns and nothing calls exit(), so those signals -- not the destructor -- are how this process really ends, and Ctrl-C used to leave the operator at a shell with ECHO and ICANON off, typing blind until they thought to run `reset`. The handler does nothing that is not async-signal-safe: restore, then re-raise with the default disposition so the exit status still reports the signal. fd 0 keeps the flags it came with, too -- O_NONBLOCK belongs to the open file description, which fd 0 shares with the invoking shell's terminal, so setting it there outlived this process ("bash: read error: 0: Resource temporarily unavailable"), and with VMIN=0 it was never needed. Nothing was line-buffering stdout. The C library gives stdout line buffering only when it is a terminal; under StandardOutput=journal it is a socket and fully buffered, so a reply or a log line waited for 4 KB to pile up behind it before journald saw any of it. setup() now asks for _IOLBF before the first write, which is what makes the README's claim that no stdbuf wrapper is needed true, and the console's per-byte fflush() is unconditional instead of skipped in exactly the case that needs it. The PTY engine joins the native test build under PIO_UNIT_TESTING, which is what #25 wanted, and the suite runs on Linux and macOS. It 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 candidate rules (live, stale, not a symlink, parent directory open to others), the XDG default, stdin routing with a real terminal on fd 0, a background job left neither stopped nor in raw mode, a hung-up stdin dropped rather than polled forever, Ctrl-C putting the terminal back, and end()/begin() across a simulated reboot. --- examples/simple_repeater/main.cpp | 38 +- platformio.ini | 2 + src/helpers/LinuxConsole.cpp | 198 +++++ src/helpers/LinuxConsole.h | 110 ++- src/helpers/PtyConsole.cpp | 190 ++++- src/helpers/PtyConsole.h | 50 +- .../test_linux_console/test_linux_console.cpp | 706 ++++++++++++++++++ variants/linux/LinuxBoard.cpp | 32 +- variants/linux/LinuxBoard.h | 8 +- variants/linux/README.md | 63 +- variants/linux/meshcored.ini | 11 +- variants/linux/meshcored.service | 14 +- 12 files changed, 1304 insertions(+), 118 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..23ee000e7e 100644 --- a/examples/simple_repeater/main.cpp +++ b/examples/simple_repeater/main.cpp @@ -18,12 +18,13 @@ 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 the foreground of a terminal; on MCU targets it is +// just Serial. #if defined(ARDULINUX_PLATFORM) || defined(LINUX_PLATFORM) + #include #include - static LinuxConsole linux_console; #endif static Stream* console = &Serial; @@ -65,6 +66,16 @@ static unsigned long userBtnDownAt = 0; #endif void setup() { +#if defined(ARDULINUX_PLATFORM) || defined(LINUX_PLATFORM) + // Line-buffer the debug log. Serial is stdout here, and the C library gives + // stdout line buffering only when it is a terminal; under the systemd unit + // (StandardOutput=journal) it is a socket and defaults to a 4 KB block + // buffer, so `journalctl -u meshcored -f` would show nothing until 4 KB of + // log had piled up. Before the first write, which is why it is here and not + // in Console.begin(): board.begin() below already logs. + setvbuf(stdout, nullptr, _IOLBF, 0); +#endif + Serial.begin(115200); delay(1000); @@ -75,15 +86,18 @@ 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 this is running in the foreground of 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 foreground terminal"); } #endif diff --git a/platformio.ini b/platformio.ini index 99ff227e4c..13ae6d14ee 100644 --- a/platformio.ini +++ b/platformio.ini @@ -233,6 +233,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..a45f887a48 --- /dev/null +++ b/src/helpers/LinuxConsole.cpp @@ -0,0 +1,198 @@ +#include "LinuxConsole.h" + +#if defined(ARDULINUX_PLATFORM) || defined(LINUX_PLATFORM) || defined(PIO_UNIT_TESTING) + +#include +#include +#include +#include +#include +#include + +LinuxConsole Console; + +namespace { + +// stdin's terminal mode as we found it. This lives here rather than in the +// object because the signal handler below has to reach it and a handler is +// given no context of its own; there is exactly one console (see the header), +// so there is exactly one terminal to remember. +struct termios g_orig_tty; +volatile sig_atomic_t g_raw_active = 0; // g_orig_tty holds something to restore + +// Async-signal-safe: tcsetattr() is on POSIX's list and nothing else here does +// anything. Shared by end() and by the handler. +void restore_stdin() { + if (g_raw_active) { + g_raw_active = 0; + tcsetattr(STDIN_FILENO, TCSANOW, &g_orig_tty); + } +} + +// loop() never returns and nothing calls exit(), so the destructor is not how +// this process ends -- SIGINT (Ctrl-C) and SIGTERM (systemctl stop) are. Left +// to their default action they kill the daemon with the terminal still in raw +// mode, and the operator is left typing blind at a shell with no echo until +// they think to run `reset`. So: put the terminal back, then die exactly as we +// would have -- same signal, default disposition, so the exit status still +// reports it, and a shell still sees "terminated by SIGINT". +void restore_tty_and_reraise(int sig) { + restore_stdin(); + signal(sig, SIG_DFL); + raise(sig); +} + +void install_restore_handler(int sig) { + struct sigaction old; + // Only take a signal nothing else is handling: replacing another handler + // would silently disable whatever it was for. + if (sigaction(sig, nullptr, &old) != 0 || old.sa_handler != SIG_DFL) return; + struct sigaction sa; + sa.sa_handler = restore_tty_and_reraise; + sigemptyset(&sa.sa_mask); + sa.sa_flags = 0; // no SA_RESTART: the main loop's poll() should see EINTR + sigaction(sig, &sa, nullptr); +} + +// True if putting stdin in raw mode and reading it cannot stop this process. +// +// isatty() alone is not enough, because it is just as true for `meshcored &`: +// tcsetattr() from a process in a background process group of its *controlling* +// terminal sends SIGTTOU to that whole group, and read() sends SIGTTIN, both of +// which stop it by default. begin() runs inside setup(), so a backgrounded +// daemon would report "Stopped" and never boot. tcgetpgrp() answers the +// question directly. Failing with ENOTTY answers it too: the terminal on stdin +// is not this process's controlling terminal, and job control does not apply to +// it at all (stdin redirected from an unrelated tty, and the test suite's +// pseudo-terminal, both land here). +bool stdin_is_drivable() { + if (!isatty(STDIN_FILENO)) return false; + pid_t fg = tcgetpgrp(STDIN_FILENO); + return fg < 0 ? errno == ENOTTY : fg == getpgrp(); +} + +// A terminal in the VMIN=0 mode begin() sets returns 0 from read() both when +// nothing has been typed and when it has gone away, so poll() is what tells the +// two apart. POLLIN has to be asked for even though POLLHUP is reported +// regardless: with an empty event mask macOS reports nothing at all. +bool stdin_hung_up() { + struct pollfd p = { STDIN_FILENO, POLLIN, 0 }; + return poll(&p, 1, 0) > 0 && (p.revents & (POLLHUP | POLLERR | POLLNVAL)) != 0; +} + +} // namespace + +LinuxConsole::~LinuxConsole() { end(); } + +bool LinuxConsole::begin(const char* link) { + bool pty_ok = _pty.begin(link); + + // Job control must never stop the daemon. Ignoring these does not replace + // the check below -- with SIGTTOU ignored tcsetattr() is *allowed* to + // proceed, which is exactly the wrong outcome for a background job -- but + // it does mean that a session backgrounded after startup (Ctrl-Z, bg), or + // one writing to a terminal under `stty tostop`, gets an error return + // instead of a stopped process. + signal(SIGTTIN, SIG_IGN); + signal(SIGTTOU, SIG_IGN); + + // 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 -- + // VMIN=0/VTIME=0 returns 0 from read() the moment there is nothing to read, + // which is why the descriptor is left alone: O_NONBLOCK belongs to the open + // file description, which fd 0 shares with the shell that started us, and + // it would still be set there after this process exits. ISIG stays on so + // Ctrl-C still stops the daemon. + bool drivable = stdin_is_drivable(); + if (drivable && !g_raw_active && tcgetattr(STDIN_FILENO, &g_orig_tty) == 0) { + struct termios raw = g_orig_tty; + raw.c_lflag &= ~(ICANON | ECHO); + raw.c_iflag &= ~(ICRNL | INLCR); + raw.c_cc[VMIN] = 0; + raw.c_cc[VTIME] = 0; + if (tcsetattr(STDIN_FILENO, TCSANOW, &raw) == 0) { + g_raw_active = 1; + install_restore_handler(SIGINT); + install_restore_handler(SIGTERM); + install_restore_handler(SIGHUP); + } + } + // Only a terminal that is actually in that mode may be read: a canonical + // one would block the entire daemon until somebody pressed Enter. + _stdin_tty = drivable && g_raw_active; + return pty_ok || _stdin_tty; +} + +void LinuxConsole::end() { + _pty.end(); + restore_stdin(); + _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; + ssize_t n = ::read(STDIN_FILENO, &b, 1); + if (n == 1) { + _src = FROM_STDIN; + return (b == '\n') ? '\r' : b; // same mapping, same reason + } + // Stop watching a terminal that has gone away: `nohup meshcored &` over + // an ssh session that then drops (nohup does not redirect stdin). fd 0 + // stays open and stays hung up forever, and a level condition is the + // one thing the event loop can only throttle, not clear -- the busy + // loop this console exists to remove, at a thousand wake-ups a second. + // idleUntilEvent() re-registers from stdinFd() on every iteration, so + // clearing the flag is what unregisters it. + if (n == 0 ? stdin_hung_up() : (errno != EINTR && errno != EAGAIN)) _stdin_tty = false; + } + 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 + // Flushed per byte, unconditionally. At a terminal, a command being typed + // has no newline yet and its echo would sit in the buffer until Enter -- + // the user types blind. Under systemd there is no line buffering to rely on + // at all: stdout is a socket, which the C library block-buffers (it + // line-buffers only a terminal), so a reply would wait behind 4 KB of log + // before journald saw it. One write() per CLI byte, and only on CLI traffic + // -- the debug log takes the buffered path set up in setup(). + 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..571ac8f196 100644 --- a/src/helpers/LinuxConsole.h +++ b/src/helpers/LinuxConsole.h @@ -1,45 +1,99 @@ #pragma once -#if defined(ARDULINUX_PLATFORM) || defined(LINUX_PLATFORM) +#if defined(ARDULINUX_PLATFORM) || defined(LINUX_PLATFORM) || defined(PIO_UNIT_TESTING) #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. Only in the foreground: driving +// the terminal of a backgrounded `meshcored &` would stop the job (see +// begin()), so there stdin is left alone and the PTY is the only door. +// +// 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 { + enum Source { FROM_PTY, FROM_STDIN }; + PtyConsole _pty; + bool _stdin_tty = false; // stdin is a terminal, in raw mode, ours to read + 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 this process + // may drive, put it in raw mode for keystrokes. Never blocks. Returns true + // if at least one input is usable; a failure is reported on stderr either + // way. + // + // "may drive" is the foreground check: tcsetattr() and read() from a + // background process group of the controlling terminal raise SIGTTOU / + // SIGTTIN, which by default *stop* the process -- `meshcored &` would print + // "Stopped" in setup() and never boot. Both signals are also set to SIG_IGN + // here so that a session backgrounded later (Ctrl-Z, bg) degrades to a read + // error rather than a stopped daemon. + // + // Also arranges for the terminal to be handed back on the way out: a + // SIGINT / SIGTERM / SIGHUP handler restores it and re-raises. loop() never + // returns and nothing calls exit(), so those signals -- not the destructor + // -- are how this process actually ends, and a raw terminal survives it. + bool begin(const char* link); + + // Unpublish and close the PTY and restore stdin's terminal mode. 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 we are driving, and again + // once that terminal hangs up). 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..33e1547ad8 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,10 +63,11 @@ 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)); + if (grantpt(fd) != 0) { + fprintf(stderr, "meshcore: console grantpt() failed: %s\n", strerror(errno)); close(fd); return false; } @@ -60,6 +80,29 @@ bool PtyConsole::begin(const char *link) { } pts_path = buf; + // Owner-only: attaching to the console grants the privileged local CLI, so + // the mode is the whole access gate. Set before unlockpt(), not after: Linux + // devpts creates the slave node 0620 root:tty and grantpt() keeps that, so + // every instant between the two is one in which anybody in group tty could + // open the console. open() on a pts that is still locked fails, so doing it + // in this order leaves no window at all -- and ptsname_r() and chmod() both + // work on a locked pts (verified on Linux and macOS). A mode we could not + // set is fatal for the same reason it is the gate. + if (chmod(pts_path.c_str(), 0600) != 0) { + fprintf(stderr, "meshcore: console chmod(%s, 0600) failed: %s\n", pts_path.c_str(), + strerror(errno)); + close(fd); + pts_path.clear(); + return false; + } + + if (unlockpt(fd) != 0) { + fprintf(stderr, "meshcore: console unlockpt() failed: %s\n", strerror(errno)); + close(fd); + pts_path.clear(); + return false; + } + // Raw line discipline: no echo / canonical / CR-NL translation, so bytes // pass through unchanged (our read() does the '\n'->'\r' mapping itself). struct termios t; @@ -68,27 +111,105 @@ bool PtyConsole::begin(const char *link) { tcsetattr(fd, TCSANOW, &t); } - // Owner-only: attaching to the console grants the privileged local CLI. - chmod(pts_path.c_str(), 0600); + // 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). 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()); + // (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; } - master_fd = fd; + // 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 -- and is not the + // check, because a directory that already exists is the interesting case: + // whoever owns it owns the console. On a shared machine an unprivileged + // user can pre-create /tmp/meshcore-0 as a symlink to /etc and aim a root + // daemon's symlink() and unlink() at it, or leave it 0777 and swap our + // console symlink for a PTY of their own, at which point the operator's + // next `get prv.key` is typed into their terminal. So: take a directory + // only if it is really a directory, is ours, and is closed to everyone + // else. + std::string dir(link); + size_t slash = dir.rfind('/'); + if (slash != std::string::npos && slash > 0) { + dir.erase(slash); + mkdir(dir.c_str(), 0700); + struct stat dst; + if (lstat(dir.c_str(), &dst) != 0) { + fprintf(stderr, "meshcore: console directory %s is unusable: %s\n", dir.c_str(), + strerror(errno)); + return false; + } + if (!S_ISDIR(dst.st_mode) || dst.st_uid != geteuid() || (dst.st_mode & 0077) != 0) { + fprintf(stderr, + "meshcore: console directory %s is not a private directory owned by uid %u; " + "not using it\n", + dir.c_str(), (unsigned)geteuid()); + return false; + } + } + + // 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 +238,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..4a85eb2d66 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,51 @@ // (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). +// slave, so there is no accept/reap. The slave device is chmod'd 0600 -- the +// unauthenticated local CLI's access gate -- while the pts is still locked, so +// there is no instant at which it is open to group tty. +// +// 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, is something + // other than a symlink, or sits in a directory that is not ours and private + // (whoever can write that directory can substitute their own PTY for the + // console, which is the unauthenticated admin CLI). 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 +72,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..2a11dae11a --- /dev/null +++ b/test/test_linux_console/test_linux_console.cpp @@ -0,0 +1,706 @@ +#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 +#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; +} + +// A pseudo-terminal to stand in for the one meshcored is started from. Returns +// the master; `slave` receives the device path of the far end. +int make_terminal(char* slave, size_t slave_len) { + int m = posix_openpt(O_RDWR | O_NOCTTY); + if (m < 0) return -1; + if (grantpt(m) != 0 || unlockpt(m) != 0 || ptsname_r(m, slave, slave_len) != 0) { + close(m); + return -1; + } + return m; +} + +// What a body run by run_with_controlling_tty() did, and what it left behind. +struct JobResult { + int status = -1; // raw wait status: exit code, or the signal that killed it + struct termios tty = {}; // the terminal, read back before the session ended +}; + +// Exit codes the harness itself reports; a body's own are below 90. +enum JobFailure { JOB_NO_SESSION = 90, JOB_NO_TERMINAL = 91, JOB_NO_FORK = 92, JOB_STOPPED = 93 }; + +// Run `body` in a process whose stdin is `slave` -- its *controlling* terminal +// -- either in that terminal's foreground process group or, exactly as +// `meshcored &` leaves it, in a background one. That needs a session of its +// own, so it happens in a child; the result travels back over a pipe, along +// with the terminal's state as the body left it (read before this session ends, +// so nothing about the teardown can affect it). +// +// waitpid() uses WUNTRACED deliberately: SIGTTIN/SIGTTOU stop a process rather +// than killing it, so without it the failure being guarded against would hang +// the suite instead of failing it. +bool run_with_controlling_tty(const char* slave, bool background, int (*body)(), JobResult* out) { + int pipefd[2]; + if (pipe(pipefd) != 0) return false; + + pid_t leader = fork(); + if (leader < 0) { + close(pipefd[0]); + close(pipefd[1]); + return false; + } + if (leader == 0) { + close(pipefd[0]); + JobResult r; + if (setsid() < 0) { + r.status = JOB_NO_SESSION << 8; + } else { + int t = open(slave, O_RDWR); // no O_NOCTTY: this is to become the ctty + if (t < 0) { + r.status = JOB_NO_TERMINAL << 8; + } else { + ioctl(t, TIOCSCTTY, 0); + dup2(t, STDIN_FILENO); + if (t != STDIN_FILENO) close(t); + pid_t job = fork(); + if (job == 0) { + if (background) setpgid(0, 0); // ... and now it is a background job + _exit(body()); + } + if (job < 0) { + r.status = JOB_NO_FORK << 8; + } else if (waitpid(job, &r.status, WUNTRACED) != job) { + r.status = -1; + } else if (WIFSTOPPED(r.status)) { + kill(job, SIGKILL); + waitpid(job, nullptr, 0); + r.status = JOB_STOPPED << 8; + } + tcgetattr(STDIN_FILENO, &r.tty); + } + } + ssize_t w = ::write(pipefd[1], &r, sizeof r); + (void)w; + _exit(0); + } + + close(pipefd[1]); + bool ok = ::read(pipefd[0], out, sizeof *out) == (ssize_t)sizeof *out; + close(pipefd[0]); + waitpid(leader, nullptr, 0); + return ok; +} + +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); +} + +// Whoever can write the directory owns the console: they can replace the +// symlink with a PTY of their own and read whatever the operator types at it, +// including `get prv.key`. Pre-creating the directory is not a privilege, so +// the check cannot be "did mkdir() succeed". +TEST_F(ConsoleTest, DeclinesAParentDirectoryOthersCanWrite) { + std::string open_dir = _dir + "/open"; + ASSERT_EQ(0, mkdir(open_dir.c_str(), 0700)); + ASSERT_EQ(0, chmod(open_dir.c_str(), 0777)); // mkdir()'s mode goes through umask + std::string link = open_dir + "/console"; + + PtyConsole c; + ASSERT_TRUE(c.begin(link.c_str())); + EXPECT_STREQ(_xdg_default.c_str(), c.path()) << "fell through to the next candidate"; + EXPECT_NE(std::string::npos, captured_stderr().find("not a private directory")); + + struct stat st; + EXPECT_NE(0, lstat(link.c_str(), &st)) << "nothing published where anyone can rewrite it"; +} + +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"; +} + +// The body of the test below, in the background job itself. +int begin_in_background() { + if (tcgetpgrp(STDIN_FILENO) < 0) return 80; // harness: stdin is not the ctty + if (tcgetpgrp(STDIN_FILENO) == getpgrp()) return 81; // harness: this is the foreground + struct termios before; + if (tcgetattr(STDIN_FILENO, &before) != 0) return 82; + + LinuxConsole console; + console.begin(""); // XDG_RUNTIME_DIR points at the test's temp dir + int fd = console.stdinFd(); + + struct termios after; + if (tcgetattr(STDIN_FILENO, &after) != 0) return 83; + bool changed = (before.c_lflag & (ICANON | ECHO)) != (after.c_lflag & (ICANON | ECHO)); + console.end(); + if (fd != -1) return 1; + if (changed) return 2; + return 0; +} + +// `meshcored &`. isatty() is just as true for a background job, but tcsetattr() +// from one sends SIGTTOU to its whole process group -- unconditionally, not +// gated on TOSTOP -- and the default action is to *stop* it. begin() runs +// inside setup(), so the daemon would print "Stopped" and never boot; reading +// stdin would do the same via SIGTTIN. So a background job's terminal is left +// entirely alone and the PTY is the only door. +TEST_F(ConsoleTest, LeavesTheTerminalAloneWhenStartedInTheBackground) { + char term_slave[128]; + int term = make_terminal(term_slave, sizeof term_slave); + ASSERT_GE(term, 0); + + JobResult r; + ASSERT_TRUE(run_with_controlling_tty(term_slave, true, begin_in_background, &r)); + close(term); + + ASSERT_TRUE(WIFEXITED(r.status)) << "raw wait status " << r.status; + ASSERT_NE(JOB_STOPPED, WEXITSTATUS(r.status)) << "it was stopped: `meshcored &` never boots"; + EXPECT_EQ(0, WEXITSTATUS(r.status)) << "1: stdin taken over; 2: the terminal was changed; " + ">= 80: the harness itself (see begin_in_background)"; + EXPECT_NE(0u, r.tty.c_lflag & (ICANON | ECHO)) << "the shell's terminal must be untouched"; +} + +// 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. + char term_slave[128]; + int term = make_terminal(term_slave, sizeof term_slave); + ASSERT_GE(term, 0); + 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"; +} + +// `nohup ./meshcored &` over an ssh session that then drops (nohup redirects +// stdout, not stdin): fd 0 stays open and stays hung up for the rest of the +// run. Nothing can drain a hung-up descriptor, and a level condition is the one +// thing the event loop can only throttle -- a thousand wake-ups a second, the +// busy loop this console exists to remove. So stdin is dropped instead. +TEST_F(ConsoleTest, StopsWatchingStdinOnceTheTerminalHangsUp) { + char term_slave[128]; + int term = make_terminal(term_slave, sizeof term_slave); + ASSERT_GE(term, 0); + int keyboard = open(term_slave, O_RDWR | O_NOCTTY); + ASSERT_GE(keyboard, 0); + ASSERT_GE(dup2(keyboard, STDIN_FILENO), 0); + close(keyboard); + + LinuxConsole console; + ASSERT_TRUE(console.begin(_link.c_str())); + ASSERT_EQ(STDIN_FILENO, console.stdinFd()); + + // Nothing typed yet. In the VMIN=0 mode begin() sets, that is read() == 0 -- + // the same value a hangup gives -- and it must not be mistaken for one. + EXPECT_EQ(-1, console.read()); + EXPECT_EQ(STDIN_FILENO, console.stdinFd()) << "an idle terminal is not a gone one"; + + close(term); // the far end goes away + struct pollfd p = { STDIN_FILENO, POLLIN, 0 }; + ASSERT_GT(poll(&p, 1, 1000), 0) << "the slave should be reporting the hangup by now"; + + EXPECT_EQ(-1, console.read()); + EXPECT_EQ(-1, console.stdinFd()) << "idleUntilEvent() must stop registering fd 0"; +} + +// The body of the test below, in a foreground job of its own terminal. +int begin_then_interrupt() { + if (tcgetpgrp(STDIN_FILENO) != getpgrp()) return 80; // harness: not the foreground + // Whatever started the suite may have left SIGINT ignored (a shell does that + // for its background jobs); the daemon's case is a plain Ctrl-C. + signal(SIGINT, SIG_DFL); + + LinuxConsole console; + if (!console.begin("")) return 81; + if (console.stdinFd() != STDIN_FILENO) return 82; + struct termios raw; + if (tcgetattr(STDIN_FILENO, &raw) != 0) return 83; + if ((raw.c_lflag & (ICANON | ECHO)) != 0) return 84; // must be raw before we test the undo + + raise(SIGINT); + return 85; // the handler must re-raise, not return +} + +// Ctrl-C is how a foreground meshcored actually ends: loop() never returns and +// nothing calls exit(), so the destructor never runs. Without a handler the +// operator gets their shell back with ECHO and ICANON off, typing blind until +// they think to run `reset`. +TEST_F(ConsoleTest, RestoresTheTerminalWhenInterrupted) { + char term_slave[128]; + int term = make_terminal(term_slave, sizeof term_slave); + ASSERT_GE(term, 0); + int probe = open(term_slave, O_RDWR | O_NOCTTY); + ASSERT_GE(probe, 0); + struct termios before; + ASSERT_EQ(0, tcgetattr(probe, &before)); + close(probe); + + JobResult r; + ASSERT_TRUE(run_with_controlling_tty(term_slave, false, begin_then_interrupt, &r)); + close(term); + + ASSERT_FALSE(WIFEXITED(r.status)) << "exited " << WEXITSTATUS(r.status) + << " instead of dying on SIGINT (see begin_then_interrupt)"; + ASSERT_TRUE(WIFSIGNALED(r.status)) << "raw wait status " << r.status; + EXPECT_EQ(SIGINT, WTERMSIG(r.status)) << "the handler must re-raise, so the exit status still " + "says what killed it"; + EXPECT_EQ(before.c_lflag & (ICANON | ECHO), r.tty.c_lflag & (ICANON | ECHO)) + << "Ctrl-C left the terminal in raw mode"; + EXPECT_EQ(before.c_iflag & (ICRNL | INLCR), r.tty.c_iflag & (ICRNL | INLCR)); +} + +// 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 cee6fd68a7..c22e95fd68 100644 --- a/variants/linux/LinuxBoard.cpp +++ b/variants/linux/LinuxBoard.cpp @@ -12,6 +12,7 @@ #endif #include "LinuxBoard.h" #include "LinuxEventLoop.h" +#include #include "LinuxRadioWait.h" #include "AppInfo.h" @@ -191,7 +192,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(); } @@ -211,12 +222,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 41a414e75f..224da96b80 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 instead of spinning. Defined in diff --git a/variants/linux/README.md b/variants/linux/README.md index 6b7356f3cb..9ab22cc2f8 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) | @@ -249,19 +250,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 @@ -284,9 +282,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. @@ -312,6 +309,48 @@ 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 `prefs.json` 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, if something that +is not a symlink sits there, or if its parent directory is not owned by the +daemon's own user with no group or other permissions — anyone who can write that +directory can point `console` at a terminal of their own and read what the +operator types at it. (The unit's `RuntimeDirectoryMode=0700` satisfies that; +so do the `0700` directories the daemon creates for the per-user paths.) 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. The terminal is put in raw mode for this and handed +back as it was, on `Ctrl-C` and `SIGTERM` as well as on a clean exit. Started in +the background (`meshcored &`, or under systemd) it leaves stdin alone +altogether — reading a terminal from a background job would only stop the job — +so there the console is the way in. + ## 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