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