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