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