Skip to content

variants/linux: control-socket console and meshcorectl - #5

Closed
mmmorks wants to merge 1 commit into
pr/04-event-loop-cadfrom
pr/05-control-socket-console
Closed

variants/linux: control-socket console and meshcorectl#5
mmmorks wants to merge 1 commit into
pr/04-event-loop-cadfrom
pr/05-control-socket-console

Conversation

@mmmorks

@mmmorks mmmorks commented Sep 7, 2026

Copy link
Copy Markdown
Owner

Staged on the fork for review. Final destination: l5yth/meshcore-linux linux (competes with upstream l5yth#25; may be reworked). Base here is pr/04-event-loop-cad so the diff shows only this change.

Summary

On Linux the Arduino Serial object is an output-only stub, so the firmware's serial CLI printed but could never receive a command: a packaged meshcored was configurable only over the mesh or through meshcored.ini. This adds LinuxConsole, a Stream the CLI reads from and writes to, backed by a Unix-domain control socket (or stdin when run in a foreground terminal), and meshcorectl, a dependency-free Python client for it.

Relationship to l5yth#25. @rgrizzell's l5yth#25 solves the same problem with a pseudo-terminal, and the two are alternative designs rather than complementary ones — both are Linux-only, both hook the same point in main.cpp. The trade-offs, as neutrally as I can put them:

  • variants/linux: local CLI console over a PTY l5yth/meshcore-linux#25's PTY is usable by existing terminal tools with no client of its own: meshcore-cli -r -s, minicom, screen. That matters on hosts too small for Python.
  • This one is a socket with a client. That buys a REPL with line editing, history and Tab completion of the real command set, one-shot and piped modes with meaningful exit codes (0 only if every command was actually run by the daemon — including reboot, whose only reply is its echo), a single-client rule with an explicit ERR: control socket busy refusal instead of silently queueing a second client's command, a liveness probe so a second daemon cannot steal a live socket, close-on-exec descriptors so a reboot re-exec does not inherit the listener, and the socket path resolution (RuntimeDirectory$XDG_RUNTIME_DIR/tmp) with the permissions story written down. Raw tools still work (socat - UNIX-CONNECT:...).

Happy for the maintainer to pick one. If l5yth#25 is preferred, the parts of this PR worth keeping regardless are PeekableStream (also used by the GPS stream PR) and the descriptor registration with the event loop; if this one is preferred, exposing a PTY alongside the socket for meshcore-cli is a contained addition to LinuxConsole.

What changed

  • variants/linux/LinuxConsole.{h,cpp}: the console. A live socket client takes priority over stdin. The socket is created at mode 0600 under umask(0177) and widened to 0660 afterwards (no window at umask-derived permissions). try_bind() probes each candidate path with connect() first and only reclaims one that answers ECONNREFUSED; it refuses to unlink anything that is not a socket (the path is operator input via MESHCORED_CONTROL_SOCKET). A client in a persistent read-error state is dropped rather than retried at 1 kHz; a second concurrent client is answered and closed. registerPollFds() tells the event loop exactly which descriptors rawReadByte() will drain next, so the daemon wakes on console traffic and on nothing else.
  • variants/linux/PeekableStream.h: the one-byte-lookahead Stream idiom (available()/peek()/read() over a non-blocking descriptor), so subclasses supply only rawReadByte().
  • variants/linux/meshcorectl: the client. Completion tables checked against the dispatch literals in CommonCLI.cpp and MyMesh.cpp (get and set have separate key pools; region subcommands listed). Its docstring records the firmware behaviours it cannot fix (clock sync needs a sender timestamp the socket path passes as 0; region load is multi-line).
  • examples/simple_repeater/main.cpp: the CLI reads from Console on Linux (MC_CLI, Serial elsewhere); Console.begin() in setup().
  • variants/linux/LinuxBoard.{h,cpp}: reboot() tears the console down before the re-exec; idleUntilEvent() registers the console's descriptors.
  • variants/linux/meshcored.service: RuntimeDirectory=meshcored (mode 0750), so /run/meshcored/meshcored.sock exists and is reachable from outside PrivateTmp.
  • test/test_linux_console: lifecycle over real sockets in a temp dir — accept and echo, hangup releasing the listener, persistent read error, the busy refusal, the non-socket refusal, both path-length reports, close-on-exec, and PeekableStream's contract. LinuxConsole.cpp joins the native build, which is why MSG_NOSIGNAL is guarded (macOS uses SO_NOSIGPIPE).
  • README: meshcorectl installed alongside meshcored; a ## The control CLI section (socket path order, permissions, the /tmp fallback's caveat, the three ways to drive the client, the one-client rule, stdin when run in a terminal).

How it was tested

  • Native suite passes with the new console tests; each fix in the hardening rounds was confirmed to fail its test when reverted.
  • Clean linux_repeater build for arm64 in the container; two daemons run concurrently in the container to confirm the first keeps its socket (same inode) and the second declines it, and that a stale socket is reclaimed once its owner dies.
  • meshcorectl exercised against a stand-in socket for each failure mode (live, busy, silent, dying mid-script, missing) and end to end against the real console in one-shot, piped and REPL form on a Pi.

Dependencies

Stacked on pr/04-event-loop-cad (the console registers its descriptors with the event loop, and reboot()'s teardown assumes that PR's LinuxBoard.cpp). Review the last commit only until that merges. pr/06-gps adds an identical PeekableStream.h; git merges identical additions cleanly.

Shared code touched

  • examples/simple_repeater/main.cpp (a #define selecting Console on Linux and Serial everywhere else; no change for other targets)
  • platformio.ini (native test env only)

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EXSCjgNEbJfHwLjD2WSHW4
@mmmorks

mmmorks commented Sep 7, 2026

Copy link
Copy Markdown
Owner Author

Superseded by #10, which keeps upstream l5yth#25's PTY console and layers this PR's hardening, stdin input and meshcorectl on top of it. Branch pr/05-control-socket-console stays for reference.

@mmmorks mmmorks closed this Sep 7, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant