From 501387dc748961053344ba8d9037bf203f65ed1d Mon Sep 17 00:00:00 2001 From: mmmorks Date: Mon, 7 Sep 2026 14:23:07 -0700 Subject: [PATCH] variants/linux: control-socket console and meshcorectl On Linux the Arduino Serial object is an output-only stub, so the firmware CLI printed but never received a command: a packaged meshcored could be configured only over the mesh or through meshcored.ini. LinuxConsole is a Stream the CLI reads from and writes to, sourcing bytes from a Unix-domain control socket -- /run/meshcored/meshcored.sock under the unit's new RuntimeDirectory, else $XDG_RUNTIME_DIR, else /tmp -- or from stdin when the daemon runs in a foreground terminal. meshcorectl is a dependency-free Python client: a readline REPL with history and Tab completion of the real command set, a one-shot form, and a piped form, exiting 0 only if every command was actually run by the daemon (the echo is the proof; text without it is the daemon explaining why nothing ran). The socket carries the unauthenticated admin CLI, so the details are the point. It is created at 0600 under umask(0177) and widened to 0660 afterwards, in that order, so there is no window at umask-derived permissions. try_bind() probes each candidate with connect() and only reclaims a path that answers ECONNREFUSED -- a socket file gives no other way to tell stale from live, and unlinking first let a second meshcored silently take a running first one's path -- and it refuses to unlink anything that is not a socket, since the path is operator input. Only one client is served: a second connection is answered with "ERR: control socket busy" and closed, rather than parked in the listen backlog to have its command run minutes later on behalf of a process that has exited. A client in a persistent read-error state is dropped instead of retried at 1 kHz. Every descriptor is close-on-exec and reboot() tears the console down before re-exec, because a listener that reached the new image answered its own liveness probe and made the daemon decline its own socket. The console registers exactly the descriptors it will drain next with the event loop, so the daemon wakes on console traffic and on nothing else; a registered descriptor nothing reads stays POLLIN forever and reinstates the busy loop. That precedence lives in LinuxConsole rather than the board, next to rawReadByte(), so the two cannot drift apart. PeekableStream hoists the one-byte-lookahead Stream idiom so the console (and the GPS stream that follows) supply only rawReadByte(). Verified: native tests over real sockets in a temp dir (accept/echo, hangup, persistent read error, busy refusal, non-socket refusal, both path-length reports, close-on-exec), each fix confirmed to fail its test when reverted; two daemons run concurrently in the arm64 container -- the first keeps its socket on the same inode, the second declines it, a stale socket is reclaimed once its owner dies; meshcorectl end to end on a Pi in one-shot, piped and REPL form. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01EXSCjgNEbJfHwLjD2WSHW4 --- examples/simple_repeater/main.cpp | 23 +- platformio.ini | 1 + .../test_linux_console/test_linux_console.cpp | 480 ++++++++++++++++++ variants/linux/LinuxBoard.cpp | 29 +- variants/linux/LinuxBoard.h | 3 +- variants/linux/LinuxConsole.cpp | 395 ++++++++++++++ variants/linux/LinuxConsole.h | 93 ++++ variants/linux/PeekableStream.h | 40 ++ variants/linux/README.md | 69 ++- variants/linux/meshcorectl | 282 ++++++++++ variants/linux/meshcored.service | 6 + 11 files changed, 1407 insertions(+), 14 deletions(-) create mode 100644 test/test_linux_console/test_linux_console.cpp create mode 100644 variants/linux/LinuxConsole.cpp create mode 100644 variants/linux/LinuxConsole.h create mode 100644 variants/linux/PeekableStream.h create mode 100755 variants/linux/meshcorectl diff --git a/examples/simple_repeater/main.cpp b/examples/simple_repeater/main.cpp index b44f6a6217..bdf0816dbf 100644 --- a/examples/simple_repeater/main.cpp +++ b/examples/simple_repeater/main.cpp @@ -13,6 +13,15 @@ #include #endif +// On Linux, Serial is output-only, so the interactive CLI reads from a control +// socket / stdin console instead. Other platforms keep using hardware Serial. +#ifdef ARDULINUX_PLATFORM + #include + #define MC_CLI Console +#else + #define MC_CLI Serial +#endif + StdRNG fast_rng; SimpleMeshTables tables; @@ -59,6 +68,10 @@ void setup() { Serial.begin(115200); delay(1000); +#ifdef ARDULINUX_PLATFORM + Console.begin(); // open the control socket / prepare stdin for the CLI +#endif + board.begin(); #ifdef HAS_EXTERNAL_WATCHDOG @@ -151,12 +164,12 @@ void setup() { void loop() { // Handle Serial CLI int len = strlen(command); - while (Serial.available() && len < sizeof(command)-1) { - char c = Serial.read(); + while (MC_CLI.available() && len < sizeof(command)-1) { + char c = MC_CLI.read(); if (c != '\n') { command[len++] = c; command[len] = 0; - Serial.print(c); + MC_CLI.print(c); } if (c == '\r') break; } @@ -165,7 +178,7 @@ void loop() { } if (len > 0 && command[len - 1] == '\r') { // received complete line - Serial.print('\n'); + MC_CLI.print('\n'); command[len - 1] = 0; // replace newline with C string null terminator char reply[160]; reply[0] = 0; @@ -177,7 +190,7 @@ void loop() { the_mesh.handleCommand(0, command, reply); // NOTE: there is no sender_timestamp via serial! #endif if (reply[0]) { - Serial.print(" -> "); Serial.println(reply); + MC_CLI.print(" -> "); MC_CLI.println(reply); } command[0] = 0; // reset command buffer diff --git a/platformio.ini b/platformio.ini index 0878e438c8..767edb04e1 100644 --- a/platformio.ini +++ b/platformio.ini @@ -228,6 +228,7 @@ build_src_filter = +<../src/Packet.cpp> +<../src/helpers/ConfigSerializer.cpp> +<../src/helpers/DynamicConfigSerializer.cpp> + +<../variants/linux/LinuxConsole.cpp> +<../variants/linux/LinuxEventLoop.cpp> +<../variants/linux/LinuxRadioWait.cpp> lib_deps = 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..e59d9f7731 --- /dev/null +++ b/test/test_linux_console/test_linux_console.cpp @@ -0,0 +1,480 @@ +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include "LinuxConsole.h" +#include "LinuxEventLoop.h" + +namespace { + +// The console's client descriptor normally arrives from accept(). Attaching one +// directly is the only way to reach states accept() cannot produce -- chiefly a +// descriptor whose every read() fails, which is what a POLLERR client looks +// like from rawReadByte()'s side. +class TestConsole : public LinuxConsole { +public: + using LinuxConsole::attachClient; + using LinuxConsole::clientFd; + using LinuxConsole::serverFd; +}; + +// Minimal PeekableStream over a fixed script, for the lookahead contract tests. +class ScriptedStream : public PeekableStream { +public: + explicit ScriptedStream(const char* script) : _script(script) { } + + using PeekableStream::clearPeek; + size_t write(uint8_t) override { return 1; } + int raw_calls = 0; + +protected: + int rawReadByte() override { + raw_calls++; + return *_script ? (uint8_t)*_script++ : -1; + } + +private: + const char* _script; +}; + +int connect_client(const char* path) { + struct sockaddr_un addr; + memset(&addr, 0, sizeof addr); + addr.sun_family = AF_UNIX; + strncpy(addr.sun_path, path, sizeof(addr.sun_path) - 1); + int fd = socket(AF_UNIX, SOCK_STREAM, 0); + if (fd < 0) return -1; + if (connect(fd, (struct sockaddr*)&addr, sizeof addr) != 0) { close(fd); return -1; } + return fd; +} + +// Everything the client end has to say, up to `idle_ms` of silence or a hangup. +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 = ::recv(fd, buf, sizeof buf, 0); + if (n <= 0) break; + out.append(buf, (size_t)n); + } + return out; +} + +// True once the peer has closed: recv() reports end of file rather than EAGAIN. +bool peer_hung_up(int fd, int idle_ms = 250) { + struct pollfd p = { fd, POLLIN, 0 }; + if (poll(&p, 1, idle_ms) <= 0) return false; + char b; + return ::recv(fd, &b, 1, 0) == 0; +} + +void remove_tree(const char* dir) { + DIR* d = opendir(dir); + 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; + unlink((std::string(dir) + "/" + e->d_name).c_str()); + } + closedir(d); + rmdir(dir); +} + +class LinuxConsoleTest : public ::testing::Test { +protected: + void SetUp() override { + // stdin must not be a TTY for these tests: begin() would otherwise put the + // developer's terminal into raw mode, and rawReadByte() would fall back to + // reading keystrokes. /dev/null makes the answer the same either way. + _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); + + strcpy(_dir, "/tmp/mccon-XXXXXX"); + ASSERT_NE(nullptr, mkdtemp(_dir)); + snprintf(_sock, sizeof _sock, "%s/ctl.sock", _dir); + snprintf(_err_path, sizeof _err_path, "%s/stderr", _dir); + + // begin() narrates to stderr. Capturing it keeps the suite's output clean + // and makes the messages themselves assertable. + _saved_stderr = dup(STDERR_FILENO); + int errfd = open(_err_path, O_RDWR | O_CREAT | O_TRUNC, 0600); + ASSERT_GE(errfd, 0); + ASSERT_GE(dup2(errfd, STDERR_FILENO), 0); + close(errfd); + + setenv("MESHCORED_CONTROL_SOCKET", _sock, 1); + // The candidates after the override are /run/meshcored (absent here) and + // XDG_RUNTIME_DIR, then /tmp/meshcored.sock. Pointing XDG at the temp dir + // keeps a refused override from landing on that shared last resort. + setenv("XDG_RUNTIME_DIR", _dir, 1); + } + + void TearDown() override { + fflush(stderr); + dup2(_saved_stderr, STDERR_FILENO); + close(_saved_stderr); + dup2(_saved_stdin, STDIN_FILENO); + close(_saved_stdin); + unsetenv("MESHCORED_CONTROL_SOCKET"); + unsetenv("XDG_RUNTIME_DIR"); + remove_tree(_dir); + } + + std::string captured_stderr() { + fflush(stderr); + std::string out; + int fd = open(_err_path, 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 path_in_dir(const char* name) const { + return std::string(_dir) + "/" + name; + } + + // rawReadByte() runs once per available() call; that is how the daemon's + // loop drives the accept/refuse/read state machine. + static void pump(LinuxConsole& c) { c.available(); } + + char _dir[64]; + char _sock[128]; + char _err_path[128]; + int _saved_stdin = -1; + int _saved_stderr = -1; +}; + +TEST_F(LinuxConsoleTest, AcceptsAClientAndCarriesBothDirections) { + LinuxConsole console; + console.begin(); + + int client = connect_client(_sock); + ASSERT_GE(client, 0); + ASSERT_EQ(3, ::write(client, "ver", 3)); + + EXPECT_EQ('v', console.read()); + EXPECT_EQ('e', console.read()); + EXPECT_EQ('r', console.read()); + EXPECT_EQ(-1, console.read()); // nothing more pending + + console.print(" -> OK\n"); + EXPECT_EQ(" -> OK\n", drain(client)); + + close(client); +} + +TEST_F(LinuxConsoleTest, NormalisesNewlineIdenticallyForPeekAndRead) { + LinuxConsole console; + console.begin(); + + int client = connect_client(_sock); + ASSERT_GE(client, 0); + ASSERT_EQ(2, ::write(client, "a\n", 2)); + + EXPECT_EQ('a', console.peek()); + 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', console.peek()); + EXPECT_EQ('\r', console.read()); + + close(client); +} + +TEST_F(LinuxConsoleTest, ClientHangupFreesTheConsoleForTheNextClient) { + LinuxConsole console; + LinuxEventLoop loop; + console.begin(); + + int first = connect_client(_sock); + ASSERT_GE(first, 0); + ASSERT_EQ(1, ::write(first, "a", 1)); + EXPECT_EQ('a', console.read()); + close(first); + + EXPECT_EQ(-1, console.read()); // sees EOF and drops the client + + // Back to the idle descriptor set: the listener only (stdin is not a TTY). + loop.reset(); + console.registerPollFds(loop); + EXPECT_EQ(1, loop.registeredCount()); + + int second = connect_client(_sock); + ASSERT_GE(second, 0); + ASSERT_EQ(1, ::write(second, "b", 1)); + EXPECT_EQ('b', console.read()); + + close(second); +} + +TEST_F(LinuxConsoleTest, PersistentReadErrorTearsTheClientDown) { + TestConsole console; + LinuxEventLoop loop; + console.begin(); + + // A write-only descriptor fails every read() with EBADF: the persistent error + // state of a client that reports POLLERR instead of POLLIN. Treating it as + // transient wedges the console -- it is the only descriptor read while a + // client is attached, so stdin and every later client stay locked out. + int wronly = open(path_in_dir("sink").c_str(), O_WRONLY | O_CREAT, 0600); + ASSERT_GE(wronly, 0); + console.attachClient(wronly); + ASSERT_EQ(wronly, console.clientFd()); + + EXPECT_EQ(-1, console.read()); + EXPECT_LT(console.clientFd(), 0); + + loop.reset(); + console.registerPollFds(loop); + EXPECT_EQ(1, loop.registeredCount()); + + // And the console is genuinely usable again, not merely reset. + int client = connect_client(_sock); + ASSERT_GE(client, 0); + ASSERT_EQ(1, ::write(client, "z", 1)); + EXPECT_EQ('z', console.read()); + close(client); +} + +TEST_F(LinuxConsoleTest, TransientReadErrorKeepsTheClient) { + // The counterweight to the test above: EAGAIN is what an idle non-blocking + // client returns on every loop iteration, and dropping the session for that + // would make the console unusable rather than merely wedged. + LinuxConsole console; + console.begin(); + + int client = connect_client(_sock); + ASSERT_GE(client, 0); + pump(console); // accepts, then reads EAGAIN + + ASSERT_EQ(1, ::write(client, "q", 1)); + EXPECT_EQ('q', console.read()); // same session, still attached + + close(client); +} + +TEST_F(LinuxConsoleTest, SecondClientIsRefusedWhileOneIsAttached) { + LinuxConsole console; + LinuxEventLoop loop; + console.begin(); + + int first = connect_client(_sock); + ASSERT_GE(first, 0); + ASSERT_EQ(1, ::write(first, "a", 1)); + EXPECT_EQ('a', console.read()); + + // The listener stays in the poll set alongside the client, so the daemon + // wakes for the second connection instead of leaving it in the backlog. + loop.reset(); + console.registerPollFds(loop); + EXPECT_EQ(2, loop.registeredCount()); + + int second = connect_client(_sock); + ASSERT_GE(second, 0); + ASSERT_EQ(4, ::write(second, "ver\r", 4)); + + pump(console); // one iteration of the daemon loop + + std::string refusal = drain(second); + EXPECT_NE(std::string::npos, refusal.find("busy")) + << "second client got: [" << refusal << "]"; + EXPECT_TRUE(peer_hung_up(second)) << "a refused client must be closed, not parked"; + + // Its command must not reach the CLI -- not now, and not later when the + // first client detaches. + EXPECT_EQ(-1, console.read()); + close(first); + EXPECT_EQ(-1, console.read()); // notices the hangup + EXPECT_EQ(-1, console.read()); // and nothing is waiting behind it +} + +TEST_F(LinuxConsoleTest, RefusesToUnlinkANonSocketPath) { + const char* content = "not a socket\n"; + int f = open(_sock, O_WRONLY | O_CREAT | O_TRUNC, 0600); + ASSERT_GE(f, 0); + ASSERT_EQ((ssize_t)strlen(content), ::write(f, content, strlen(content))); + close(f); + + LinuxConsole console; + console.begin(); + + // An operator who points MESHCORED_CONTROL_SOCKET at the wrong file gets a + // refusal, not a deletion. + struct stat st; + ASSERT_EQ(0, lstat(_sock, &st)); + EXPECT_TRUE(S_ISREG(st.st_mode)); + EXPECT_EQ((off_t)strlen(content), st.st_size); + EXPECT_NE(std::string::npos, captured_stderr().find("is not a socket")); + + // ...and the daemon still comes up, on the next usable candidate. + int client = connect_client(path_in_dir("meshcored.sock").c_str()); + ASSERT_GE(client, 0); + ASSERT_EQ(1, ::write(client, "k", 1)); + EXPECT_EQ('k', console.read()); + close(client); +} + +TEST_F(LinuxConsoleTest, ReportsAnOverlongSocketPath) { + std::string too_long = std::string(_dir) + "/" + std::string(200, 'x'); + setenv("MESHCORED_CONTROL_SOCKET", too_long.c_str(), 1); + + LinuxConsole console; + console.begin(); + + EXPECT_NE(std::string::npos, + captured_stderr().find("control socket path is too long")); + + int client = connect_client(path_in_dir("meshcored.sock").c_str()); + ASSERT_GE(client, 0); // fell through to XDG_RUNTIME_DIR + close(client); +} + +TEST_F(LinuxConsoleTest, ReportsAnOverlongXdgRuntimeDir) { + setenv("XDG_RUNTIME_DIR", std::string(200, 'y').c_str(), 1); + + LinuxConsole console; + console.begin(); + + // Truncating the path silently would bind the socket at a name nobody is + // looking for. + EXPECT_NE(std::string::npos, + captured_stderr().find("XDG_RUNTIME_DIR is too long")); + + int client = connect_client(_sock); + ASSERT_GE(client, 0); // the override still worked + close(client); +} + +TEST_F(LinuxConsoleTest, DoesNotStealASocketAnotherInstanceIsUsing) { + LinuxConsole first; + first.begin(); + + LinuxConsole second; + second.begin(); // same MESHCORED_CONTROL_SOCKET + + EXPECT_NE(std::string::npos, captured_stderr().find("already in use")); + + // second.begin()'s liveness probe connected and hung up; one loop iteration + // retires that connection. (It has to happen before the next connect(): the + // backlog is one deep, and a blocking connect() to a full one waits.) + EXPECT_EQ(-1, first.read()); + + int client = connect_client(_sock); + ASSERT_GE(client, 0); + ASSERT_EQ(1, ::write(client, "m", 1)); + EXPECT_EQ('m', first.read()); // the first instance kept the path + close(client); +} + +// --- close-on-exec across LinuxBoard::reboot() ------------------------------- +// +// reboot() re-execs this process image, and execv() keeps every descriptor that +// is not close-on-exec. An inherited listener is the damaging one: the new +// image's begin() decides whether another instance owns the control-socket path +// by connecting to it, and the leftover listener answers -- so the daemon +// declines its own path, falls through to /tmp, and after a second reboot has no +// control socket at all. + +TEST_F(LinuxConsoleTest, ControlSocketDescriptorsAreCloseOnExec) { + TestConsole console; + console.begin(); + + ASSERT_GE(console.serverFd(), 0); + EXPECT_TRUE(fcntl(console.serverFd(), F_GETFD) & FD_CLOEXEC) + << "an inherited listener answers the next image's liveness probe"; + + int client = connect_client(_sock); + ASSERT_GE(client, 0); + ASSERT_EQ(1, ::write(client, "a", 1)); + EXPECT_EQ('a', console.read()); // one call accepts and reads + ASSERT_GE(console.clientFd(), 0); + + // accept() does not carry the listener's descriptor flags across, so this is + // a separate guarantee rather than a consequence of the one above. + EXPECT_TRUE(fcntl(console.clientFd(), F_GETFD) & FD_CLOEXEC) + << "an inherited client fd holds a dead session open at the far end"; + + close(client); +} + +// What the re-exec'd image must find: the socket file still on disk, but nothing +// listening on it, so try_bind()'s probe is refused and the stale-socket path +// unlinks and rebinds the same candidate. +TEST_F(LinuxConsoleTest, AReleasedListenerLeavesOnlyAStaleSocketToRebind) { + { + LinuxConsole console; + console.begin(); + console.end(); // what LinuxBoard::reboot() does before execv() + + struct sockaddr_un addr; + memset(&addr, 0, sizeof addr); + addr.sun_family = AF_UNIX; + strncpy(addr.sun_path, _sock, sizeof(addr.sun_path) - 1); + int probe = socket(AF_UNIX, SOCK_STREAM, 0); + ASSERT_GE(probe, 0); + EXPECT_NE(0, connect(probe, (struct sockaddr*)&addr, sizeof addr)); + EXPECT_EQ(ECONNREFUSED, errno) << "something is still listening after end()"; + close(probe); + + struct stat st; + ASSERT_EQ(0, lstat(_sock, &st)); // the file outlives the descriptor + EXPECT_TRUE(S_ISSOCK(st.st_mode)); + } // and end() runs again from the destructor, harmlessly + + LinuxConsole restarted; + restarted.begin(); + EXPECT_EQ(std::string::npos, captured_stderr().find("already in use")) + << "the restarted daemon refused the path it had just released"; + + int client = connect_client(_sock); + ASSERT_GE(client, 0); + ASSERT_EQ(1, ::write(client, "r", 1)); + EXPECT_EQ('r', restarted.read()); + close(client); +} + +// --- PeekableStream contract ------------------------------------------------- + +TEST(PeekableStreamContract, PeekDoesNotConsume) { + ScriptedStream s("ab"); + EXPECT_EQ(1, s.available()); + EXPECT_EQ('a', s.peek()); + EXPECT_EQ('a', s.peek()); + EXPECT_EQ(1, s.raw_calls) << "the lookahead must be filled once, not per peek"; + EXPECT_EQ('a', s.read()); + EXPECT_EQ('b', s.read()); + EXPECT_EQ(-1, s.read()); + EXPECT_EQ(0, s.available()); +} + +TEST(PeekableStreamContract, ClearPeekDropsTheLookahead) { + ScriptedStream s("ab"); + EXPECT_EQ('a', s.peek()); + s.clearPeek(); // source closed or replaced + EXPECT_EQ('b', s.read()) << "a byte from the old source must not survive it"; +} + +} // 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 d8e49a6bbf..e3353506cc 100644 --- a/variants/linux/LinuxBoard.cpp +++ b/variants/linux/LinuxBoard.cpp @@ -13,6 +13,7 @@ #include "EventGPIOPin.h" #endif #include "LinuxBoard.h" +#include "LinuxConsole.h" #include "LinuxEventLoop.h" #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. +// +// execv() keeps every descriptor that is not close-on-exec, and the console's +// listener is the one that must not reach the new image: begin() there decides +// whether a control socket already belongs to another instance by connecting to +// it, and an inherited listener answers, so the daemon would decline its own +// path. LinuxConsole marks its descriptors close-on-exec; closing them here as +// well means a descriptor that ever escaped that -- a new one added there, or +// an exec that reaches this process by another route -- still cannot resurrect +// the listener. powerOff() needs none of this: exit(0) closes everything. void LinuxBoard::reboot() { + Console.end(); ::reboot(); } @@ -213,12 +224,18 @@ 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. + // Exactly the descriptor(s) LinuxConsole::rawReadByte() will actually consume + // this iteration. The console owns that choice because it owns the precedence + // -- registering one it will not drain leaves it permanently POLLIN and + // reinstates the busy loop for as little as one concurrent `meshcorectl`. + Console.registerPollFds(EventLoop); + + // 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 15b71e051d..104a7c9c21 100644 --- a/variants/linux/LinuxBoard.h +++ b/variants/linux/LinuxBoard.h @@ -103,7 +103,8 @@ class LinuxBoard : public mesh::MainBoard { } } - // Re-exec this process image rather than exit. Defined in LinuxBoard.cpp. + // Tear the control socket down and re-exec this process image. Defined in + // LinuxBoard.cpp, which is where LinuxConsole is visible. void reboot() override; // Block on the LoRa IRQ edge descriptor (plus any other descriptor that will diff --git a/variants/linux/LinuxConsole.cpp b/variants/linux/LinuxConsole.cpp new file mode 100644 index 0000000000..65a0a8a738 --- /dev/null +++ b/variants/linux/LinuxConsole.cpp @@ -0,0 +1,395 @@ +#include "LinuxConsole.h" + +#include "LinuxEventLoop.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +LinuxConsole Console; + +// Refusal sent to a second client; see refuseExtraClients(). +static const char BUSY_MSG[] = "ERR: control socket busy, another client is connected\r\n"; + +// SIGPIPE from a client that vanished mid-reply would kill the daemon. Linux +// suppresses it per send() with MSG_NOSIGNAL; macOS -- which the native test +// build compiles for -- has no such flag and offers a socket option instead. +// Defining the flag away there leaves the Linux code path exactly as written. +#ifndef MSG_NOSIGNAL + #define MSG_NOSIGNAL 0 +#endif + +static void set_nosigpipe(int fd) { +#ifdef SO_NOSIGPIPE + int on = 1; + setsockopt(fd, SOL_SOCKET, SO_NOSIGPIPE, &on, sizeof on); +#else + (void)fd; +#endif +} + +// --- stdin raw-mode handling (foreground/interactive use only) --------------- +static struct termios s_orig_tty; +static bool s_raw_active = false; + +static void restore_tty() { + if (s_raw_active) { + tcsetattr(STDIN_FILENO, TCSANOW, &s_orig_tty); + s_raw_active = false; + } +} + +static void set_nonblock(int fd) { + int fl = fcntl(fd, F_GETFL, 0); + if (fl != -1) fcntl(fd, F_SETFL, fl | O_NONBLOCK); +} + +// LinuxBoard::reboot() replaces this process image with execv(), which keeps +// every descriptor that is not close-on-exec. A surviving listener is the +// damaging one: the new image's begin() decides whether a control socket is +// already owned by probing it with connect(), and the leftover listener answers +// -- so the daemon declines its own path, falls through to /tmp, and after a +// second reboot has no control socket at all. Everything created here is marked +// instead of only the listener, so no site has to be reasoned about separately. +// +// fcntl() rather than SOCK_CLOEXEC/accept4(): this file also compiles for the +// native test build on macOS, which has neither. 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 these calls. +static void set_cloexec(int fd) { + int fl = fcntl(fd, F_GETFD, 0); + if (fl != -1) fcntl(fd, F_SETFD, fl | FD_CLOEXEC); +} + +// Fill `addr` with the AF_UNIX address for `path`. False if it will not fit. +static bool fill_sun_path(struct sockaddr_un* addr, const char* path) { + if (!path || !*path) return false; + if (strlen(path) >= sizeof(addr->sun_path)) return false; + + memset(addr, 0, sizeof *addr); + addr->sun_family = AF_UNIX; + strncpy(addr->sun_path, path, sizeof(addr->sun_path) - 1); + return true; +} + +// True if something is already accepting connections at `path`. +// +// try_bind() has to unlink whatever it finds before it can bind, and a socket +// file gives no way to tell a stale one -- left behind by a crashed run -- from +// a live one owned by another meshcored. Unlinking a live one is silently +// destructive in both directions: the other daemon keeps listening on an +// inode with no name, so it becomes unreachable without noticing, and this one +// takes over the path as if nothing happened. Probing with connect() is the +// only way to distinguish them, so do that first and decline the path if it +// answers. +// +// Non-blocking because connect() to a listening AF_UNIX socket whose backlog is +// full otherwise *blocks* until a slot frees -- which would hang startup behind +// the other daemon's client. Non-blocking turns that case into EAGAIN, which is +// itself proof of a live listener. +static bool socket_is_live(const char* path) { + struct sockaddr_un addr; + if (!fill_sun_path(&addr, path)) return false; + + int fd = socket(AF_UNIX, SOCK_STREAM, 0); + if (fd < 0) return false; // cannot probe; treat as stale rather than refuse to start + set_cloexec(fd); + set_nonblock(fd); + + // "Refused" is the only answer that proves nobody is home. Anything else -- + // connected, backlog full, still connecting -- means a listener, and a false + // positive is cheap: begin() simply moves on to the next candidate path, so + // the daemon still comes up, just on a different socket and having said so. + bool live = connect(fd, (struct sockaddr*)&addr, sizeof addr) == 0 + || errno == EAGAIN || errno == EWOULDBLOCK || errno == EINPROGRESS; + close(fd); + return live; +} + +// Create, bind and listen on a non-blocking AF_UNIX socket at `path`. +// Returns the fd, or -1 on any failure (including "someone else is there"). +static int try_bind(const char* path) { + struct sockaddr_un addr; + if (!fill_sun_path(&addr, path)) { + // Silently skipping this would leave an operator who set + // MESHCORED_CONTROL_SOCKET to a long path wondering why the daemon is + // listening somewhere else entirely. + fprintf(stderr, "[meshcored] control socket path is too long " + "(limit %zu characters): %s\n", + sizeof(addr.sun_path) - 1, path); + return -1; + } + + if (socket_is_live(path)) { + fprintf(stderr, "[meshcored] control socket %s is already in use " + "by another instance; not taking it over\n", path); + return -1; + } + + // Nobody answered, so whatever sits at `path` is either a socket left by a + // crashed run or something that has no business being there. bind() needs the + // name free and unlink() is the only way to free it -- but unlink() does not + // care what it removes, and this path is operator input. Delete only what we + // could have created. lstat(), not stat(): a symlink is not ours to follow. + struct stat st; + if (lstat(path, &st) == 0) { + if (!S_ISSOCK(st.st_mode)) { + fprintf(stderr, "[meshcored] %s exists and is not a socket; " + "refusing to remove it\n", path); + return -1; + } + if (unlink(path) != 0) { + fprintf(stderr, "[meshcored] cannot remove stale control socket %s: %s\n", + path, strerror(errno)); + return -1; + } + } + + int fd = socket(AF_UNIX, SOCK_STREAM, 0); + if (fd < 0) return -1; + set_cloexec(fd); + + // Anyone who reaches this socket gets the unauthenticated admin CLI, so it + // must never exist -- even momentarily -- at wider permissions than intended. + // bind() applies the process umask, which is inherited and typically 022, so + // without this the socket appears world-connectable until the chmod() below. + // Creating it at 0600 and *widening* to 0660 afterwards leaves no window; the + // other order leaves one. (It is small either way in /run/meshcored, whose + // 0750 mode covers it, but not in the /tmp fallback.) + mode_t old_umask = umask(0177); + int bind_rv = bind(fd, (struct sockaddr*)&addr, sizeof addr); + umask(old_umask); + if (bind_rv != 0) { close(fd); return -1; } + + if (listen(fd, 1) != 0) { close(fd); unlink(path); return -1; } + + set_nonblock(fd); + chmod(path, 0660); // owner + service group may connect + return fd; +} + +void LinuxConsole::begin() { + // Pick a control-socket path: explicit override, then the systemd runtime + // dir, then the per-user runtime dir, then /tmp. + const char* candidates[4]; + char xdg_buf[108]; + int n = 0; + + const char* override_path = getenv("MESHCORED_CONTROL_SOCKET"); + if (override_path && *override_path) candidates[n++] = override_path; + candidates[n++] = "/run/meshcored/meshcored.sock"; + const char* xrd = getenv("XDG_RUNTIME_DIR"); + if (xrd && *xrd) { + int len = snprintf(xdg_buf, sizeof xdg_buf, "%s/meshcored.sock", xrd); + // A truncated path is a *different* path, and binding it would put the + // socket somewhere nobody is looking. Say so and fall through to /tmp. + if (len < 0 || (size_t)len >= sizeof xdg_buf) + fprintf(stderr, "[meshcored] XDG_RUNTIME_DIR is too long for a control " + "socket path; skipping %s/meshcored.sock\n", xrd); + else + candidates[n++] = xdg_buf; + } + candidates[n++] = "/tmp/meshcored.sock"; + + for (int i = 0; i < n; i++) { + int fd = try_bind(candidates[i]); + if (fd >= 0) { + _server_fd = fd; + strncpy(_sock_path, candidates[i], sizeof(_sock_path) - 1); + break; + } + } + + if (_server_fd >= 0) + fprintf(stderr, "[meshcored] control socket listening at %s\n", _sock_path); + else + fprintf(stderr, "[meshcored] WARNING: no control socket; " + "serial CLI is available on stdin only\n"); + + // Foreground use: read keystrokes one at a time, no kernel echo (we echo + // ourselves), and never block the mesh loop waiting for input. + _stdin_tty = isatty(STDIN_FILENO); + if (_stdin_tty) { + // Raw mode below clears ECHO, which makes writeByte()'s putchar() the only + // thing putting typed characters on screen -- and stdout to a terminal is + // line buffered, so every character of a command would sit in the buffer + // until Enter flushed the lot. The user types blind. Unbuffering is what + // makes the echo appear per keystroke. + // + // Only in the TTY case: under systemd stdout is the journal, and one write + // syscall per character of debug output is a real cost with nobody watching + // it arrive. (The unit sets `stdbuf -oL` for that path instead.) + setvbuf(stdout, NULL, _IONBF, 0); + } + if (_stdin_tty && tcgetattr(STDIN_FILENO, &s_orig_tty) == 0) { + struct termios raw = s_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) { + s_raw_active = true; + atexit(restore_tty); + } + } + if (_stdin_tty) set_nonblock(STDIN_FILENO); +} + +int LinuxConsole::stdinFd() const { + return _stdin_tty ? STDIN_FILENO : -1; +} + +void LinuxConsole::attachClient(int fd) { + // One client at a time, and it starts with a clean slate: any lookahead byte + // belongs to the previous input, and letting it surface here would prepend a + // fragment of someone else's command to this client's first line. + closeClient(); + // accept() does not carry the listener's descriptor flags across, so this is + // a fresh site, not a repeat of the one in try_bind(). + set_cloexec(fd); + set_nonblock(fd); + set_nosigpipe(fd); + _client_fd = fd; + clearPeek(); +} + +void LinuxConsole::closeClient() { + if (_client_fd < 0) return; + close(_client_fd); + _client_fd = -1; + clearPeek(); +} + +bool LinuxConsole::tryAccept() { + if (_server_fd < 0) return false; + int fd = accept(_server_fd, nullptr, nullptr); + if (fd < 0) return false; + attachClient(fd); + return true; +} + +void LinuxConsole::refuseExtraClients() { + if (_server_fd < 0) return; + // The console serves one client, so a second one has to be told no -- and + // told now. Leaving it in the backlog is the damaging option: connect() + // succeeds, so the tool sends its command and exits believing it ran, and + // the bytes sit in the kernel until the first client detaches, at which + // point the command executes on behalf of a process that is long gone. + // + // Draining the listener here is also what lets registerPollFds() keep it in + // the poll set while a client is attached; an accepted-and-closed connection + // clears its POLLIN, an ignored one would not. + int fd; + while ((fd = accept(_server_fd, nullptr, nullptr)) >= 0) { + set_cloexec(fd); + set_nosigpipe(fd); + ssize_t n = send(fd, BUSY_MSG, sizeof BUSY_MSG - 1, MSG_NOSIGNAL); + (void)n; // best effort: the message is a courtesy, the close is the answer + close(fd); + } +} + +int LinuxConsole::rawReadByte() { + // Prefer a live control-socket client; otherwise accept a pending one. + if (_client_fd < 0) + tryAccept(); + else + refuseExtraClients(); + + if (_client_fd >= 0) { + uint8_t b; + ssize_t r = ::read(_client_fd, &b, 1); + if (r == 1) return b; + + // r == 0 is a clean hangup. A negative r is retryable for exactly the three + // errnos below; any other one describes a descriptor that will fail the + // same way forever. It cannot be ignored: such an error raises POLLERR + // rather than POLLIN, and while a client is attached this is the only + // descriptor being read, so keeping it would lock stdin and every future + // client out for the lifetime of the process. + if (r == 0 || (errno != EAGAIN && errno != EWOULDBLOCK && errno != EINTR)) + closeClient(); + return -1; // retry on the next loop + } + if (_stdin_tty) { + uint8_t b; + if (::read(STDIN_FILENO, &b, 1) == 1) return b; + } + return -1; +} + +// Normalise Enter so the CLI's '\r' check fires. peek() has to agree with +// read(): they answer the same question about the same byte, and a caller that +// switched between them would otherwise see the line terminator change. +int LinuxConsole::read() { + int c = PeekableStream::read(); + return c == '\n' ? '\r' : c; +} + +int LinuxConsole::peek() { + int c = PeekableStream::peek(); + return c == '\n' ? '\r' : c; +} + +void LinuxConsole::registerPollFds(LinuxEventLoop& loop) const { + // The listener is always watched: a second client that connects while the + // console is busy must be refused promptly, and refuseExtraClients() drains + // it on the same call rawReadByte() would have used to read the client. + // + // stdin is not, once a client is attached, because rawReadByte() stops + // reading it then -- and a registered descriptor that nothing drains stays + // POLLIN forever, which is the busy loop LinuxEventLoop exists to remove. + loop.registerFd(_server_fd); + if (_client_fd < 0) + loop.registerFd(stdinFd()); + else + loop.registerFd(_client_fd); +} + +void LinuxConsole::writeByte(uint8_t c) { + if (_client_fd >= 0) { + ssize_t w; + do { + // MSG_NOSIGNAL: a disconnected client must not raise SIGPIPE and kill us. + w = send(_client_fd, &c, 1, MSG_NOSIGNAL); + } while (w < 0 && errno == EINTR); // nothing was sent; retrying costs nothing + + if (w > 0) return; + + // A full send buffer -- a client that has stopped reading -- says nothing + // about whether the client is still there. Dropping the byte costs one + // character of a reply; tearing the session down costs the rest of it, and + // dumps the remainder onto stdout where nobody asked for it. Only a real + // error means the client is gone. + if (w < 0 && (errno == EAGAIN || errno == EWOULDBLOCK)) return; + + closeClient(); // fall through and echo to stdout instead + } + putchar(c); + if (c == '\n') fflush(stdout); +} + +size_t LinuxConsole::write(uint8_t c) { + writeByte(c); + return 1; +} + +void LinuxConsole::end() { + closeClient(); + if (_server_fd >= 0) { close(_server_fd); _server_fd = -1; } + // The socket file is deliberately left in place: begin() decides whether an + // existing one is stale by probing it, and /run/meshcored is a systemd + // RuntimeDirectory that goes away with the unit anyway. +} + +LinuxConsole::~LinuxConsole() { + end(); +} diff --git a/variants/linux/LinuxConsole.h b/variants/linux/LinuxConsole.h new file mode 100644 index 0000000000..24a10c1ec7 --- /dev/null +++ b/variants/linux/LinuxConsole.h @@ -0,0 +1,93 @@ +#pragma once +#include "PeekableStream.h" + +class LinuxEventLoop; + +// Control console for the ArduLinux (Linux) build. +// +// On Linux the Arduino `Serial` object is an output-only stub (its read() always +// returns -1), so the firmware's serial CLI can print but never receives typed +// commands. LinuxConsole fills that gap: it is a Stream the CLI reads from and +// writes to, sourcing bytes from either +// +// * a Unix-domain control socket -> for the systemd daemon case, or +// * stdin -> when meshcored runs in a foreground TTY. +// +// Command echo and replies are written back to the connected socket client, or +// to stdout when driven from stdin. A socket client always takes priority over +// stdin. This class is Linux-only; other platforms keep using hardware Serial. +class LinuxConsole : public PeekableStream { +public: + LinuxConsole() = default; + ~LinuxConsole(); + + // The descriptors below are owned, not shared: a copy would close them from + // two places and, worse, two consoles would each believe they own the single + // control client. There is exactly one console. + LinuxConsole(const LinuxConsole&) = delete; + LinuxConsole& operator=(const LinuxConsole&) = delete; + + // Open the control socket and, if stdin is a TTY, put it in raw non-blocking + // mode. Call once from setup(). Never blocks. + void begin(); + + // Close the listener and any attached client. Idempotent. + // + // Exists for LinuxBoard::reboot(), which re-execs this process image: the + // descriptors are close-on-exec, but a listener that reached the new image + // anyway would answer begin()'s liveness probe and make the daemon refuse + // its own control-socket path. Closing here first makes that outcome depend + // on nothing but this call. The socket file is left in place -- see the + // stale-socket handling in try_bind(). + void end(); + + int read() override; // PeekableStream::read() plus newline normalisation + int peek() override; // same normalisation, so the two cannot disagree + size_t write(uint8_t c) override; + using Print::write; + + // Register the descriptors rawReadByte() will actually consume on its next + // call, so a waiting daemon wakes on console traffic and on nothing else. + // + // This lives here rather than in the caller because it has to track + // rawReadByte()'s precedence exactly: a registered descriptor that nothing + // drains stays POLLIN forever, which turns the blocking wait back into the + // busy loop LinuxEventLoop exists to remove. Keeping both in one class makes + // that agreement structural instead of a comment in another file. + void registerPollFds(LinuxEventLoop& loop) const; + +protected: + // One raw byte from the active input, or -1. Prefers a live control-socket + // client, else accepts a pending one, else stdin -- the precedence + // registerPollFds() mirrors. + int rawReadByte() override; + + // Adopt an already-connected client descriptor: at most one at a time, + // non-blocking, SIGPIPE-proof, and with no lookahead inherited from whatever + // was being read before. tryAccept() is the only caller in the daemon; it is + // a separate entry point so tests can reach client states accept() cannot + // produce, such as a descriptor that fails every read(). + void attachClient(int fd); + + // The descriptor currently serving the console, or -1 when none is attached. + int clientFd() const { return _client_fd; } + + // The listening control socket, or -1 when none was bound. Protected for the + // same reason as clientFd(): tests need to inspect the descriptor (that it is + // close-on-exec), and nothing outside this class may act on it. + int serverFd() const { return _server_fd; } + +private: + bool tryAccept(); + void refuseExtraClients(); + void closeClient(); + void writeByte(uint8_t c); + int stdinFd() const; // STDIN_FILENO if a TTY, else -1 + + int _server_fd = -1; // listening Unix socket + int _client_fd = -1; // currently connected control client, or -1 + bool _stdin_tty = false; + char _sock_path[108] = {0}; // sockaddr_un.sun_path capacity +}; + +extern LinuxConsole Console; diff --git a/variants/linux/PeekableStream.h b/variants/linux/PeekableStream.h new file mode 100644 index 0000000000..206f2d3265 --- /dev/null +++ b/variants/linux/PeekableStream.h @@ -0,0 +1,40 @@ +#pragma once +#include + +// Arduino Stream over a non-blocking byte source. +// +// available(), peek() and read() all have to answer "is there a byte?" without +// consuming it, but a non-blocking descriptor only answers that by reading. One +// byte of lookahead bridges the two, and it is the same bridge for every such +// source -- so subclasses supply only rawReadByte() and keep the buffering, +// which is easy to get subtly inconsistent, in one place. +class PeekableStream : public Stream { +public: + int available() override { return fill() >= 0 ? 1 : 0; } + int peek() override { return fill(); } + + int read() override { + int c = fill(); + _peek = -1; + return c; + } + + using Print::write; + +protected: + // The next byte from the underlying source, or -1 if none is available right + // now. Must not block. + virtual int rawReadByte() = 0; + + // Discard any buffered lookahead. Call when the source is closed or replaced, + // so a byte read from the old one cannot surface from the new. + void clearPeek() { _peek = -1; } + +private: + int fill() { + if (_peek < 0) _peek = rawReadByte(); + return _peek; + } + + int _peek = -1; // one-byte lookahead, or -1 +}; diff --git a/variants/linux/README.md b/variants/linux/README.md index 950ad9997b..6accef2590 100644 --- a/variants/linux/README.md +++ b/variants/linux/README.md @@ -72,12 +72,16 @@ about a minute. ## Setup -### 1. Install the binary +### 1. Install the binaries ```sh sudo install -m 755 .pio/build/linux_repeater/meshcored /usr/bin/meshcored +sudo install -m 755 variants/linux/meshcorectl /usr/bin/meshcorectl ``` +Install `meshcorectl` now — once `meshcored` runs as a service it is the only way +to reach the CLI (see [The control CLI](#the-control-cli-meshcorectl)). + ### 2. Create the config file Two ready-made templates are provided in `variants/linux/`: @@ -242,7 +246,8 @@ sudo journalctl -u meshcored -f ### 5. Reconfiguring after first run -Node name, password, and location can be changed via the serial CLI after first boot: +Node name, password, and location can be changed via the CLI after first boot +(over `meshcorectl`, see [The control CLI](#the-control-cli-meshcorectl)): ``` set name @@ -272,6 +277,66 @@ 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, use the CLI (`set freq`, `set sf`, etc.) or reset prefs as above. +## The control CLI (`meshcorectl`) + +Everything the MeshCore docs describe as the "serial CLI" — `set`, `get`, +`advert`, `neighbors`, and the rest — is reached here through `meshcorectl`. + +The Arduino `Serial` object is output-only on Linux, so `meshcored` prints to +stdout and listens for commands on a **Unix-domain control socket**. The first +writable path wins, and startup logs which one it picked: + +| Order | Path | +|-------|------| +| 1 | `$MESHCORED_CONTROL_SOCKET`, if set | +| 2 | `/run/meshcored/meshcored.sock` (created by the unit's `RuntimeDirectory=`) | +| 3 | `$XDG_RUNTIME_DIR/meshcored.sock` | +| 4 | `/tmp/meshcored.sock` | + +The socket is mode `0660` and owned by the user running the daemon, so reaching +it means being that user, being in its group, or being root. + +**The `/tmp` fallback is a predictable, shared path.** Unlike `/run/meshcored` +(a systemd `RuntimeDirectory`, mode `0750`) or `$XDG_RUNTIME_DIR` (per-user, +mode `0700`), `/tmp` is world-writable and its name never changes, so on a +multi-user host another local user can pre-create or race for +`/tmp/meshcored.sock` before the daemon starts. `meshcored` only takes over a +path it can prove is not already answering connections (see `try_bind()` in +`variants/linux/LinuxConsole.cpp`), so this is not an admin-socket takeover — +but it is still a path only a single-user development box should rely on. +Under systemd or with `$XDG_RUNTIME_DIR` set, this fallback is never reached. + +Three ways to drive it: + +```sh +sudo meshcorectl # REPL: line editing, history, Tab completion +meshcorectl set name my-repeater # one-shot: send, print reply, exit +printf 'ver\nneighbors\n' | meshcorectl # piped: one command per line +``` + +The REPL needs no `socat` or `rlwrap`: arrow-key editing, Ctrl-R search, Tab +completion of known commands, and history in `~/.meshcorectl_history`. +`MESHCORED_CONTROL_SOCKET` overrides the path for the client exactly as it does +for the daemon, which is how you reach a node that is not the packaged service. + +Only **one client at a time** is served; a second connection is refused +immediately with `ERR: control socket busy, another client is connected` and +closed. Raw tools work too: `sudo socat - UNIX-CONNECT:/run/meshcored/meshcored.sock` +(also subject to the same one-client rule). + +`meshcorectl` exits `0` only if every command it sent was actually run by the +daemon — that includes `reboot`, `clkreboot` and `poweroff`, whose only "reply" +is their own echo before the daemon goes away. It exits `1` if the socket is +missing or unusable, if the connection is refused as busy, if a command draws no +reply at all, or if a *later* command in a piped script finds the daemon already +gone (the case after one of those three ran earlier in the same script). A piped +script stops at the first such failure rather than sending the remaining lines +into a dead or busy connection. + +Running `meshcored` in a foreground terminal with no service behind it, you can +skip the socket and type commands straight into its stdin. A connected socket +client takes priority over stdin while it is attached. + ## Operation ### Idle CPU usage diff --git a/variants/linux/meshcorectl b/variants/linux/meshcorectl new file mode 100755 index 0000000000..e2ac150a29 --- /dev/null +++ b/variants/linux/meshcorectl @@ -0,0 +1,282 @@ +#!/usr/bin/env python3 +"""meshcorectl - talk to a running meshcored over its control socket. + + meshcorectl interactive REPL (line editing, history, tab-complete) + meshcorectl set name foo one-shot: send one command, print the reply, exit + echo -e "advert\\nneighbors" | meshcorectl pipe: run each line, print replies + +The interactive mode is a self-contained readline client (equivalent to wrapping +a raw socket in rlwrap): arrow-key editing, Ctrl-R reverse search, persistent +history in ~/.meshcorectl_history, and Tab completion of known commands. No socat +or rlwrap needed. + +Socket path: $MESHCORED_CONTROL_SOCKET, else /run/meshcored/meshcored.sock. +Access: run as root or a member of the `meshcore` group. + +Exit status is 0 only if every command was actually run by the daemon. It is 1 +if the socket is missing or unusable, if meshcored refuses the connection +because another client already holds the console, if a command draws no reply +at all, or if the daemon closes the socket mid-script (which is what `reboot` +and friends do). Remaining lines of a piped script are not attempted after any +of those. +""" +import os +import sys +import time +import socket +import select +import atexit + +SOCK = os.environ.get("MESHCORED_CONTROL_SOCKET", "/run/meshcored/meshcored.sock") +HISTFILE = os.path.expanduser("~/.meshcorectl_history") + +# Top-level commands, in the order the firmware dispatches them: +# examples/simple_repeater/MyMesh.cpp handleCommand() takes the first three, +# then falls through to src/helpers/CommonCLI.cpp handleCommand() for the rest. +# Anything not matched there comes back as "Unknown command". +# +# Entries ending in a bare word still take arguments -- "neighbor.remove", +# "password", "sensor get/set", "setperm", "tempradio" and "time" all match on a +# trailing space in the firmware, so "time" alone is an unknown command. Use +# "time " to set the clock and "clock" to read it back. +# +# "region load" is listed for completeness but is not usable from here: it puts +# the firmware into a multi-line mode (CommonCLI.cpp region_load_active) that +# reads indented region names and ends on a blank line, and both loops below +# strip indentation and skip blank lines. Use "region put"/"region def" instead. +# +# "clock sync" is listed because it exists, but it only works over the mesh: it +# needs the sender's timestamp, and the control socket always passes 0 (see the +# handleCommand(0, ...) calls in examples/simple_repeater/main.cpp), so it +# replies "ERR: clock cannot go backwards" every time. Use "time" instead. +TOP = [ + "advert", "advert.zerohop", "board", "clear stats", "clkreboot", "clock", + "clock sync", "discover.neighbors", "erase", "get", "gps", "gps advert", + "gps interval", "gps off", "gps on", "gps setloc", "gps sync", + "log", "log erase", "log start", "log stop", + "neighbors", "neighbor.remove", "password", "poweroff", "powersaving", + "powersaving off", "powersaving on", "reboot", + "region", "region allowf", "region def", "region default", "region denyf", + "region get", "region home", "region list allowed", "region list denied", + "region load", "region put", "region remove", "region save", + "sensor get", "sensor list", "sensor set", "set", "setperm", "shutdown", + "start ota", "stats-core", "stats-packets", "stats-radio", "tempradio", + "time", "ver", +] + +# Keys usable after "set " (CommonCLI.cpp handleSetCmd). +# +# The bridge.* setters are absent on purpose: every one of them sits behind +# WITH_BRIDGE / WITH_RS232_BRIDGE / WITH_ESPNOW_BRIDGE (CommonCLI.cpp:720-768), +# and no linux env defines any of the three, so the firmware this talks to +# answers "unknown config: bridge.enabled ...". ("bridge.type" is a getter and +# is not guarded, so it stays in GETONLY below.) +SETKEYS = [ + "adc.multiplier", "advert.interval", "af", "agc.reset.interval", + "allow.read.only", "cad", + "direct.txdelay", "dutycycle", "flood.advert.interval", "flood.max", + "flood.max.advert", "flood.max.unscoped", "freq", "guest.password", + "int.thresh", "lat", "lon", "loop.detect", "multi.acks", "name", + "owner.info", "path.hash.mode", "prv.key", "radio", "radio.fem.rxgain", + "radio.rxgain", "repeat", "rxdelay", "tx", "txdelay", +] + +# "get" accepts everything "set" does, plus these read-only keys +# (CommonCLI.cpp handleGetCmd, and "get acl" from MyMesh.cpp). +GETONLY = [ + "acl", "bootloader.ver", "bridge.type", "public.key", "pwrmgt.bootmv", + "pwrmgt.bootreason", "pwrmgt.source", "pwrmgt.support", "role", +] +GETKEYS = sorted(SETKEYS + GETONLY) + + +def connect(): + if not os.path.exists(SOCK): + sys.exit("meshcorectl: control socket not found at %s\n" + " is meshcored running? override with MESHCORED_CONTROL_SOCKET=/path" + % SOCK) + s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + try: + s.connect(SOCK) + except OSError as e: + sys.exit("meshcorectl: cannot connect to %s: %s" % (SOCK, e)) + return s + + +class Refused(Exception): + """A command the daemon did not run. The message says how we know.""" + + +def drain(sock, wait=0.2): + """Whatever the daemon has already put in the socket, as text. + + Only ever called on a connection that is already known to be broken, so an + error here means there is nothing left to recover -- never a traceback. + """ + buf = b"" + try: + while select.select([sock], [], [], wait)[0]: + chunk = sock.recv(4096) + if not chunk: + break + buf += chunk + except OSError: + pass + return buf.decode(errors="replace").replace("\r", "").strip() + + +def send_command(sock, line, idle=0.4, first=2.0, total=6.0): + """Send one command and return the daemon's reply, or raise Refused. + + The daemon echoes a command character by character as it consumes it and + only then prints the reply (MC_CLI.print(c) in the repeater's loop()), so + the echo doubles as proof that the command was actually read. Whatever the + daemon says without that echo in front of it -- the refusal a second client + gets -- describes why nothing ran, and is not a reply to anything. + """ + try: + sock.sendall((line + "\r").encode()) + except (BrokenPipeError, ConnectionResetError): + # `reboot`, `poweroff` and `shutdown` take the daemon down without + # replying, so a script that continues past one finds the socket gone; + # a refusal also closes the socket, and leaves its reason behind in the + # receive buffer. Read that before guessing. + raise Refused(drain(sock) or + "meshcored closed the control socket before '%s' could be " + "sent\n an earlier command most likely rebooted or shut " + "it down" % line) + + buf = b"" + deadline = time.monotonic() + total + while True: + # The first byte is worth waiting longer for than the rest: the reply + # follows its echo back to back, but the echo itself waits on the + # daemon's next loop iteration, and an empty answer is an error now + # rather than a blank line. + r, _, _ = select.select([sock], [], [], idle if buf else first) + if not r: # idle gap -> reply is complete + break + chunk = sock.recv(4096) + if not chunk: # daemon closed the connection + break + buf += chunk + if time.monotonic() > deadline: + break + + text = buf.decode(errors="replace").replace("\r", "") + echo = line + "\n" + if not text.startswith(echo): + raise Refused(text.strip("\n") or + "no reply from meshcored at %s for '%s'\n the connection " + "was accepted but the command was never read; check that " + "meshcored is still running" % (SOCK, line)) + return text[len(echo):].strip("\n") + + +def make_completer(): + """Complete against the whole line, not just the last word. + + interactive() clears readline's delimiter set, so `text` is everything typed + so far. Matching on the full line is what makes the multi-word entries usable + -- "region li" only reaches "region list allowed" if the completer can see + both words. + """ + def completer(text, state): + stripped = text.lstrip() + indent = text[:len(text) - len(stripped)] + for prefix, pool in (("set ", SETKEYS), ("get ", GETKEYS)): + if stripped.startswith(prefix): + arg = stripped[len(prefix):] + matches = [prefix + k for k in pool if k.startswith(arg)] + break + else: + matches = [c for c in TOP if c.startswith(stripped)] + matches = sorted(set(matches)) + return indent + matches[state] if state < len(matches) else None + return completer + + +def report(refusal): + """Print why a command did not run, and give the exit status.""" + sys.stderr.write("meshcorectl: %s\n" % refusal) + return 1 + + +def interactive(): + import readline + try: + readline.read_history_file(HISTFILE) + except OSError: + pass + atexit.register(lambda: _save_history(readline)) + readline.set_history_length(1000) + readline.set_completer_delims("") + readline.set_completer(make_completer()) + readline.parse_and_bind("tab: complete") + + sock = connect() + print("meshcorectl -> %s (Tab completes, Ctrl-D quits)" % SOCK) + status = 0 + while True: + try: + line = input("meshcore> ").strip() + except EOFError: + print() + break + except KeyboardInterrupt: + print() + continue + if not line: + continue + if line in ("quit", "exit"): + break + try: + out = send_command(sock, line) + except Refused as refusal: + status = report(refusal) + break + if out: + print(out) + sock.close() + return status + + +def _save_history(readline): + try: + readline.write_history_file(HISTFILE) + except OSError: + pass + + +def run_lines(lines): + """Run each line over one connection. Returns the process exit status.""" + sock = connect() + status = 0 + for raw in lines: + cmd = raw.strip() + if not cmd: + continue + try: + out = send_command(sock, cmd) + except Refused as refusal: + # Nothing ran, and every cause of that also leaves the connection + # useless for the rest of the script. + status = report(refusal) + break + if out: + print(out) + sock.close() + return status + + +def main(): + args = sys.argv[1:] + if args: + return run_lines([" ".join(args)]) # one-shot + if not sys.stdin.isatty(): + return run_lines(sys.stdin) # piped script + return interactive() + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/variants/linux/meshcored.service b/variants/linux/meshcored.service index 4809fd0fd0..ecfb1977eb 100644 --- a/variants/linux/meshcored.service +++ b/variants/linux/meshcored.service @@ -18,6 +18,12 @@ ProtectHome=yes PrivateTmp=yes NoNewPrivileges=yes StateDirectory=meshcore +# Creates /run/meshcored (owned by meshcore:meshcore) for the CLI control +# socket. meshcored listens on /run/meshcored/meshcored.sock by default; talk to +# it with `meshcorectl` (or socat). RuntimeDirectory is exempt from PrivateTmp, +# so an admin client outside the unit can still reach the socket. +RuntimeDirectory=meshcored +RuntimeDirectoryMode=0750 [Install] WantedBy=multi-user.target