From 7eaf6a76c2bc3522cf924c9e1bb428b426606994 Mon Sep 17 00:00:00 2001 From: R0ck Date: Sat, 24 Oct 2026 23:51:39 +0100 Subject: [PATCH] companion: run a command on another node over the air companion.cli sends TXT_TYPE_CLI_DATA from a connected companion to a node it is logged in to; the node runs it and answers in kind. Replies are kept as CLI messages and listed by companion.state as cli_replies. The command line gains meshcore-cli's cmd. Closes #253. --- CHANGELOG.md | 7 ++ docs/scripting-api.md | 2 +- docs/scripting-verbs.md | 11 +- docs/verb-reference.md | 31 +++++- docs/verbs.json | 52 ++++++++- internal/app/session/companion.go | 40 +++++++ internal/app/session/companion.verbs.json | 52 ++++++++- .../app/session/companioncli_live_test.go | 103 ++++++++++++++++++ internal/app/session/companionsession.go | 8 +- internal/app/session/inputqueue.go | 1 + internal/app/session/meshclidirect.go | 23 ++++ internal/mesh/proto/decode.go | 3 +- internal/mesh/proto/proto.go | 25 ++++- internal/mesh/proto/proto_test.go | 33 ++++++ tools/verbdoc/facade.json | 1 + 15 files changed, 376 insertions(+), 16 deletions(-) create mode 100644 internal/app/session/companioncli_live_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index a38e02b4..dc027d2c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,13 @@ had changed in them - which is the gap this file exists to close. ### Added +- **Run a command on another node over the air.** `companion.cli` sends a + command from a connected companion to a node it is logged in to, as + MeshCore's remote admin does (`TXT_TYPE_CLI_DATA`). The node's own firmware + runs it and answers the same way, and `companion.state` lists the answers as + `cli_replies`. The companion command line has it as `cmd + `. A study can now provision a mesh the way an operator does, + rather than over every node's serial port. - **Script input at an instant of simulated time.** `console.type`, `console.cli` and the `companion.*` verbs that write to a node take `at_ms`: the call is held and runs when the run reaches that instant, so a script diff --git a/docs/scripting-api.md b/docs/scripting-api.md index df0e4f90..67a5dd77 100644 --- a/docs/scripting-api.md +++ b/docs/scripting-api.md @@ -39,7 +39,7 @@ and a verb added tomorrow is usable today. ### Why two layers rather than one -**260** addressable methods hand-written across two +**261** addressable methods hand-written across two languages is twice that many things to keep in step. Keeping them in step is what the verb manifest (`docs/verbs.json`) is for: a hand-written surface that names a verb the tree no longer has is exactly the drift it catches, where once a tool could call a deleted verb and nothing diff --git a/docs/scripting-verbs.md b/docs/scripting-verbs.md index 29e6554d..85749fbd 100644 --- a/docs/scripting-verbs.md +++ b/docs/scripting-verbs.md @@ -1,8 +1,8 @@ # Every verb, and the call that covers it The wire underneath [scripting-api.md](scripting-api.md). The store registers -**222** public verbs a script can call and -**38** internal callbacks it cannot, **260** +**223** public verbs a script can call and +**38** internal callbacks it cannot, **261** in total, plus the two the socket answers itself, with what each reads, what each returns, and which façade call reaches it. @@ -77,8 +77,8 @@ Two verbs are **not** in this table: ## What this table shows about the surface -- **222** public verbs, plus **38** - the workbench keeps to itself, **260** registered in +- **223** public verbs, plus **38** + the workbench keeps to itself, **261** registered in total. The façade covers the public verbs, over roughly 60 calls once objects and properties absorb them. - **The naming is not regular.** `node.*` and `nodes.*` are both node verbs and @@ -240,6 +240,7 @@ Two verbs are **not** in this table: |---|---|---|---| | `companion.add_channel` | *a bare string*, `node` string, `index` number, `at_ms` number | `asked_for_channel`, `queued`, `at_ms`, `now_ms` | `node.companion.add_channel(index)` | | `companion.advert` | *a bare string*, `node` string, `flood` bool, `at_ms` number | `advert`, `flood`, `queued`, `at_ms`, `now_ms` | `node.companion.advert(flood=False)` | +| `companion.cli` | *a bare string*, `node` string, `to` string, `command` string, `at_ms` number | `node`, `to`, `command`, `note`, `queued`, `at_ms`, `now_ms` | `node.companion.remote(to, command)` | | `companion.configure` | *a bare string*, `node` string, `name` string, `lat` number, `lon` number, `tx_dbm` number, `freq_khz` number, `bw_khz` number, `sf` number, `cr` number, `path_hash` number, `at_ms` number | `set`, `queued`, `at_ms`, `now_ms` | `node.companion.configure(...)` | | `companion.connect` | *a bare string*, `node` string, `at_ms` number | `connected`, `took_port`, `note`, `queued`, `at_ms`, `now_ms` | `node.companion.connect()` | | `companion.disconnect` | *a bare string*, `node` string | `disconnected` | `node.companion.disconnect()` | @@ -248,7 +249,7 @@ Two verbs are **not** in this table: | `companion.refresh` | *a bare string*, `node` string, `at_ms` number | `node`, `queued`, `at_ms`, `now_ms` | `node.companion.refresh()` | | `companion.scope` | *a bare string*, `node` string, `scope` string, `at_ms` number | `node`, `scope`, `queued`, `at_ms`, `now_ms` | `node.companion.scope = name` | | `companion.send` | *a bare string*, `node` string, `text` string, `channel` number, `path_hash` number, `at_ms` number | `sent`, `channel`, `queued`, `at_ms`, `now_ms` | `node.companion.send(text, channel=, path_hash=)` | -| `companion.state` | *a bare string*, `node` string | `node`, `contacts`, `messages`, `channels`, `recent`, `name`, `freq_khz` | `node.companion (properties)` | +| `companion.state` | *a bare string*, `node` string | `node`, `contacts`, `messages`, `channels`, `recent`, `name`, `freq_khz`, `cli_replies` | `node.companion (properties)` | ### Serving to real clients diff --git a/docs/verb-reference.md b/docs/verb-reference.md index 36e84e35..9ece23e5 100644 --- a/docs/verb-reference.md +++ b/docs/verb-reference.md @@ -3,9 +3,9 @@ Generated. Run `tools/verbdoc/verbdoc.py` to rewrite it and `tools/verbdoc/verbdoc.py --check` to fail when it is stale. -The store registers 260 verbs: 222 a script may call and +The store registers 261 verbs: 223 a script may call and 38 the workbench calls on itself, which the socket refuses. Of those, -260 say what they are for and 0 do not yet; the ones that +261 say what they are for and 0 do not yet; the ones that do not are marked, and what is printed for them is read out of the handler rather than said by it. @@ -2222,6 +2222,31 @@ Not made by the test suite: this call needs more than the two-node headless sess **Client** `node.companion.advert(flood=False)` +### `companion.cli` + +Run a command on another node over the air, as an operator's phone administers a repeater it cannot plug into: the text goes as a remote-admin message (TXT_TYPE_CLI_DATA), the recipient runs it on its own command line, and its answer comes back the same way. + +**Takes** + +| parameter | type | | what | +|---|---|---|---| +| `node` | string | required, primary | the connected companion to send from; refused when nothing is connected to it, or when it has not answered anything since it was connected | +| `to` | string | required | the node to run it, by contact name or by public key in hex as the companion holds it; refused when the companion holds no such contact and the key is too short to address one | +| `command` | string | required | the command line to run there, as it would be typed at that node's serial console; refused when absent or blank | +| `at_ms` | number | optional | an instant of simulated time to run this at instead of now: the call is held and runs when the engine reaches it, so it lands at the same instant on any machine. Refused unless it is after now. Without it, a call made while the clock is playing lands wherever the clock happens to be, and sim.state then says the run cannot be reproduced | + +**Answers** `node`, `to`, `command`, `note`, `queued`, `at_ms`, `now_ms`. The recipient runs the command only for a sender logged in to it as admin, so log in first (`console.cli` with `login `). Nothing is acked: the answer is the reply itself, which arrives later and is read from `companion.state` as `cli_replies`. A command from a sender that is not logged in gets no reply at all. + +**Example** - ask a repeater over the air what radio settings it holds + +```json +{"id":1,"method":"companion.cli","params":{"command":"get radio","node":"Phone","to":"West Lomond"}} +``` + +Not made by the test suite: this call needs more than the two-node headless session the runnable examples go to. + +**Client** `node.companion.remote(to, command)` + ### `companion.configure` Change a node's own settings the way a phone would, sending only the fields that were asked for, and reporting which ones went. @@ -2429,7 +2454,7 @@ Read what a client attached to this node would be showing, which is what the nod |---|---|---|---| | `node` | string | required, primary | the connected node; refused when nothing is connected to it | -**Answers** `node`, `contacts`, `messages`, `channels`, `recent`, `name`, `freq_khz`. `contacts`, `messages` and `channels` are counts rather than the things themselves, and `recent` is the session's own note lines. `name` and `freq_khz` appear only once the node has answered a device query, so their absence means it has not. +**Answers** `node`, `contacts`, `messages`, `channels`, `recent`, `name`, `freq_khz`, `cli_replies`. `contacts`, `messages` and `channels` are counts rather than the things themselves, and `recent` is the session's own note lines. `name` and `freq_khz` appear only once the node has answered a device query, so their absence means it has not. `cli_replies` lists the answers to remote commands sent with `companion.cli`, oldest first, each with the key prefix it came `from` and its `text`. **Example** - ask what the client sees diff --git a/docs/verbs.json b/docs/verbs.json index bc16e7ab..32f065b8 100644 --- a/docs/verbs.json +++ b/docs/verbs.json @@ -679,6 +679,53 @@ "what": "announce to the neighbours only" } }, + "companion.cli": { + "what": "run a command on another node over the air, as an operator's phone administers a repeater it cannot plug into: the text goes as a remote-admin message (TXT_TYPE_CLI_DATA), the recipient runs it on its own command line, and its answer comes back the same way", + "params": [ + { + "name": "node", + "type": "string", + "required": true, + "primary": true, + "what": "the connected companion to send from; refused when nothing is connected to it, or when it has not answered anything since it was connected" + }, + { + "name": "to", + "type": "string", + "required": true, + "what": "the node to run it, by contact name or by public key in hex as the companion holds it; refused when the companion holds no such contact and the key is too short to address one" + }, + { + "name": "command", + "type": "string", + "required": true, + "what": "the command line to run there, as it would be typed at that node's serial console; refused when absent or blank" + }, + { + "name": "at_ms", + "type": "number", + "what": "an instant of simulated time to run this at instead of now: the call is held and runs when the engine reaches it, so it lands at the same instant on any machine. Refused unless it is after now. Without it, a call made while the clock is playing lands wherever the clock happens to be, and sim.state then says the run cannot be reproduced" + } + ], + "returns": [ + "node", + "to", + "command", + "note", + "queued", + "at_ms", + "now_ms" + ], + "answers": "The recipient runs the command only for a sender logged in to it as admin, so log in first (`console.cli` with `login \u003ccontact\u003e \u003cpassword\u003e`). Nothing is acked: the answer is the reply itself, which arrives later and is read from `companion.state` as `cli_replies`. A command from a sender that is not logged in gets no reply at all.", + "example": { + "params": { + "command": "get radio", + "node": "Phone", + "to": "West Lomond" + }, + "what": "ask a repeater over the air what radio settings it holds" + } + }, "companion.configure": { "what": "change a node's own settings the way a phone would, sending only the fields that were asked for, and reporting which ones went", "params": [ @@ -1009,9 +1056,10 @@ "channels", "recent", "name", - "freq_khz" + "freq_khz", + "cli_replies" ], - "answers": "`contacts`, `messages` and `channels` are counts rather than the things themselves, and `recent` is the session's own note lines. `name` and `freq_khz` appear only once the node has answered a device query, so their absence means it has not.", + "answers": "`contacts`, `messages` and `channels` are counts rather than the things themselves, and `recent` is the session's own note lines. `name` and `freq_khz` appear only once the node has answered a device query, so their absence means it has not. `cli_replies` lists the answers to remote commands sent with `companion.cli`, oldest first, each with the key prefix it came `from` and its `text`.", "example": { "params": { "node": "West Lomond" diff --git a/internal/app/session/companion.go b/internal/app/session/companion.go index 68f3c305..8e26ca2b 100644 --- a/internal/app/session/companion.go +++ b/internal/app/session/companion.go @@ -13,6 +13,7 @@ package session import ( + "encoding/hex" "fmt" "strings" "time" @@ -141,9 +142,48 @@ func registerCompanion(st *state.Store, s *Sim) { out["name"] = c.self.Name out["freq_khz"] = c.self.FreqKHz } + // The answers to remote commands, oldest first: the only place a + // script can read what companion.cli got back. + replies := []map[string]any{} + for _, m := range c.messages { + if m.CLI { + replies = append(replies, map[string]any{ + "from": hex.EncodeToString(m.SenderKey), "text": m.Text, + }) + } + } + out["cli_replies"] = replies return out, nil }) + // A command for another node to run, over the air. The firmware has done + // this end to end all along - a repeater takes TXT_TYPE_CLI_DATA from a + // logged-in admin and answers in kind - and this only originates it, so + // a study can provision a mesh the way an operator does rather than over + // every node's serial port. + s.handleInput(st, "companion.cli", func(w *state.World, p any) (any, error) { + node, _ := stringField(p, "node") + to, _ := namedField(p, "to") + command, _ := namedField(p, "command") + if strings.TrimSpace(to) == "" || strings.TrimSpace(command) == "" { + return nil, fmt.Errorf("companion.cli needs to and command") + } + c, _, err := s.companionFor(node) + if err != nil { + return nil, err + } + if err := silentCompanion(c, node); err != nil { + return nil, err + } + said, err := s.remoteCommand(node, to, command) + if err != nil { + return nil, err + } + c.note("cmd " + to + ": " + command) + s.publishCompanions(w) + return map[string]any{"node": node, "to": to, "command": command, "note": said}, nil + }) + s.handleInput(st, "companion.send", func(w *state.World, p any) (any, error) { node, _ := stringField(p, "node") text, _ := namedField(p, "text") diff --git a/internal/app/session/companion.verbs.json b/internal/app/session/companion.verbs.json index 96432a6b..29aeec4e 100644 --- a/internal/app/session/companion.verbs.json +++ b/internal/app/session/companion.verbs.json @@ -71,6 +71,53 @@ "what": "announce to the neighbours only" } }, + "companion.cli": { + "what": "run a command on another node over the air, as an operator's phone administers a repeater it cannot plug into: the text goes as a remote-admin message (TXT_TYPE_CLI_DATA), the recipient runs it on its own command line, and its answer comes back the same way", + "params": [ + { + "name": "node", + "type": "string", + "required": true, + "primary": true, + "what": "the connected companion to send from; refused when nothing is connected to it, or when it has not answered anything since it was connected" + }, + { + "name": "to", + "type": "string", + "required": true, + "what": "the node to run it, by contact name or by public key in hex as the companion holds it; refused when the companion holds no such contact and the key is too short to address one" + }, + { + "name": "command", + "type": "string", + "required": true, + "what": "the command line to run there, as it would be typed at that node's serial console; refused when absent or blank" + }, + { + "name": "at_ms", + "type": "number", + "what": "an instant of simulated time to run this at instead of now: the call is held and runs when the engine reaches it, so it lands at the same instant on any machine. Refused unless it is after now. Without it, a call made while the clock is playing lands wherever the clock happens to be, and sim.state then says the run cannot be reproduced" + } + ], + "returns": [ + "node", + "to", + "command", + "note", + "queued", + "at_ms", + "now_ms" + ], + "answers": "The recipient runs the command only for a sender logged in to it as admin, so log in first (`console.cli` with `login `). Nothing is acked: the answer is the reply itself, which arrives later and is read from `companion.state` as `cli_replies`. A command from a sender that is not logged in gets no reply at all.", + "example": { + "params": { + "node": "Phone", + "to": "West Lomond", + "command": "get radio" + }, + "what": "ask a repeater over the air what radio settings it holds" + } + }, "companion.connect": { "what": "claim a node's serial port for the companion protocol and make the same opening a phone makes, so a client's view of the node can be shown or driven", "params": [ @@ -284,9 +331,10 @@ "channels", "recent", "name", - "freq_khz" + "freq_khz", + "cli_replies" ], - "answers": "`contacts`, `messages` and `channels` are counts rather than the things themselves, and `recent` is the session's own note lines. `name` and `freq_khz` appear only once the node has answered a device query, so their absence means it has not.", + "answers": "`contacts`, `messages` and `channels` are counts rather than the things themselves, and `recent` is the session's own note lines. `name` and `freq_khz` appear only once the node has answered a device query, so their absence means it has not. `cli_replies` lists the answers to remote commands sent with `companion.cli`, oldest first, each with the key prefix it came `from` and its `text`.", "example": { "params": { "node": "West Lomond" diff --git a/internal/app/session/companioncli_live_test.go b/internal/app/session/companioncli_live_test.go new file mode 100644 index 00000000..0f551322 --- /dev/null +++ b/internal/app/session/companioncli_live_test.go @@ -0,0 +1,103 @@ +package session + +import ( + "context" + "os" + "strings" + "testing" + "time" + + "github.com/MeshBench/meshbench/internal/app/state" + "github.com/MeshBench/meshbench/internal/rf/antenna" + "github.com/MeshBench/meshbench/internal/world/scenario" +) + +// A companion logged in to a repeater runs a command on it over the air and +// reads the answer back (#253). Nothing here is modelled: the repeater's own +// firmware takes TXT_TYPE_CLI_DATA from an admin and answers in kind, and the +// verb only originates it. +func TestLiveACompanionRunsACommandOnARepeater(t *testing.T) { + if os.Getenv("MESHBENCH_LIVE") == "" { + t.Skip("set MESHBENCH_LIVE=1") + } + ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second) + defer cancel() + + radio := scenario.RadioConfig{CentreHz: 869.618e6, BandwidthHz: 62500, SpreadFactor: 8, CodingRate: 4} + mk := func(name string, kind scenario.Kind, lon float64) scenario.Node { + return scenario.Node{ + Name: name, Kind: kind, + Position: scenario.LatLon{Lat: 56.7, Lon: lon}, HeightAGLm: 2, + Antenna: antenna.Mounted{Pattern: antenna.Dipole{}, Polarisation: "vertical"}, + TxPowerDBm: 14, + NoiseFigureDB: 6, + Radio: radio, + } + } + + st := state.New(10) + s := &Sim{gpuAsked: true} + Register(st, s) + go st.Run(ctx) + s.build([]scenario.Node{mk("Phone", scenario.Companion, -3.900), mk("Rep", scenario.SimpleRepeater, -3.901)}, 869.618) + if err := s.eng.AttachNative(ctx, 4417); err != nil { + t.Fatal(err) + } + defer s.Close() + + // Run takes an instant, not a duration; this runs for one, collecting + // whatever the companion says is waiting as it goes. + run := func(ms uint32) { + t.Helper() + end := s.eng.NowMs() + ms + for s.eng.NowMs() < end { + s.collectWaiting() + if err := s.eng.Run(ctx, min(end, s.eng.NowMs()+2000)); err != nil { + t.Fatal(err) + } + } + } + do := func(verb string, p map[string]any) any { + t.Helper() + v, err := st.Do(ctx, verb, p) + if err != nil { + t.Fatalf("%s: %v", verb, err) + } + return v + } + + do("companion.connect", map[string]any{"node": "Phone"}) + run(2000) + // The repeater has to be a contact before anything can be addressed to it. + do("console.type", map[string]any{"node": "Rep", "command": "advert"}) + run(8000) + do("console.cli", map[string]any{"node": "Phone", "command": "contacts"}) + run(2000) + // Unprovisioned, the repeater adverts under its firmware's own name, so + // it is addressed as the contact the phone actually holds. + phone := s.comps["Phone"] + phone.mu.Lock() + if len(phone.contacts) == 0 { + phone.mu.Unlock() + t.Fatal("the phone never heard the repeater's advert") + } + rep := phone.contacts[0].Name + phone.mu.Unlock() + do("console.cli", map[string]any{"node": "Phone", "command": "login " + rep + " password"}) + run(10000) + got := do("companion.cli", map[string]any{"node": "Phone", "to": rep, "command": "get name"}).(map[string]any) + if got["to"] != rep { + t.Fatalf("companion.cli answered %v", got) + } + run(14000) + + state := do("companion.state", map[string]any{"node": "Phone"}).(map[string]any) + replies, _ := state["cli_replies"].([]map[string]any) + for _, r := range replies { + if strings.Contains(r["text"].(string), rep) { + t.Logf("%s answered: %q", rep, r["text"]) + return + } + } + t.Fatalf("no answer to get name came back: replies %v; recent %v", replies, state["recent"]) +} diff --git a/internal/app/session/companionsession.go b/internal/app/session/companionsession.go index 8c158b0a..ca27195d 100644 --- a/internal/app/session/companionsession.go +++ b/internal/app/session/companionsession.go @@ -10,6 +10,7 @@ package session import ( "encoding/binary" + "encoding/hex" "fmt" "sync" @@ -163,7 +164,12 @@ func (c *compSession) apply(f proto.Frame) { } c.unread[f.Message.ChannelIdx]++ } - c.note(fmt.Sprintf("[%.3f s] message: %s", float64(c.simMs())/1000, f.Message.Text)) + if f.Message.CLI { + c.note(fmt.Sprintf("[%.3f s] reply from %s: %s", float64(c.simMs())/1000, + hex.EncodeToString(f.Message.SenderKey), f.Message.Text)) + } else { + c.note(fmt.Sprintf("[%.3f s] message: %s", float64(c.simMs())/1000, f.Message.Text)) + } case f.Scope != nil: c.scope, c.scopeKnown = f.Scope.Name, true if f.Scope.Name == "" { diff --git a/internal/app/session/inputqueue.go b/internal/app/session/inputqueue.go index ebe022c7..bbfbc51a 100644 --- a/internal/app/session/inputqueue.go +++ b/internal/app/session/inputqueue.go @@ -33,6 +33,7 @@ var inputVerbs = map[string]bool{ "companion.refresh": true, "companion.raw": true, "companion.configure": true, + "companion.cli": true, } // queuedInput is one held call. diff --git a/internal/app/session/meshclidirect.go b/internal/app/session/meshclidirect.go index 836de8c7..6c0575a3 100644 --- a/internal/app/session/meshclidirect.go +++ b/internal/app/session/meshclidirect.go @@ -55,6 +55,12 @@ var directCommands = []cliCommand{ } return s.request(node, "req_status", to, "", proto.StatusRequest(key)) }}, + {"cmd", "c", " ", "run a command on a node you are logged in to", func(s *Sim, node string, a []string) (string, error) { + if len(a) < 2 { + return "", fmt.Errorf("cmd ") + } + return s.remoteCommand(node, a[0], strings.Join(a[1:], " ")) + }}, {"scope", "", "", "the region floods go out under; * for none", func(s *Sim, node string, a []string) (string, error) { if len(a) != 1 { return "", fmt.Errorf("scope , or scope * to send unscoped") @@ -81,6 +87,23 @@ func init() { } } +// remoteCommand sends a command for another node to run, as an operator's +// phone administers a repeater it cannot plug into (TXT_TYPE_CLI_DATA). The +// recipient runs it only for a sender logged in as admin, and its answer comes +// back as a CLI message, which the transcript prints and companion.state +// lists. +func (s *Sim) remoteCommand(node, who, command string) (string, error) { + to, key, err := s.contactKey(node, who, 6) + if err != nil { + return "", err + } + frame, err := proto.SendCommandData(key, time.Now(), command) + if err != nil { + return "", err + } + return s.request(node, "cmd", to, command, frame) +} + // request sends one frame that the firmware answers with RESP_CODE_SENT, and // remembers it so the answer can be matched. func (s *Sim) request(node, kind, to, text string, frame []byte) (string, error) { diff --git a/internal/mesh/proto/decode.go b/internal/mesh/proto/decode.go index 81c40c6f..27e7fb9d 100644 --- a/internal/mesh/proto/decode.go +++ b/internal/mesh/proto/decode.go @@ -251,7 +251,8 @@ func decodeMessage(channel, v3 bool, b []byte) (*Message, error) { i++ } if len(b) > i { - i++ // txt_type + m.CLI = b[i] == txtTypeCLIData + i++ } if len(b) >= i+4 { m.At = time.Unix(int64(binary.LittleEndian.Uint32(b[i:])), 0).UTC() diff --git a/internal/mesh/proto/proto.go b/internal/mesh/proto/proto.go index 51b7495a..0f03cdfa 100644 --- a/internal/mesh/proto/proto.go +++ b/internal/mesh/proto/proto.go @@ -90,6 +90,13 @@ const ( // send path; anything else answers ERR_CODE_UNSUPPORTED_CMD. const txtTypePlain = 0 +// txtTypeCLIData marks a direct message whose text is a command for the +// recipient to run (TXT_TYPE_CLI_DATA): MeshCore's remote administration, the +// same command line a repeater's serial port takes, carried over the air to a +// node the sender is logged in to. The answer comes back as a direct message +// of the same type. +const txtTypeCLIData = 1 + // AppStart is the handshake. The firmware answers with SelfInfo, and until // it has, nothing else is worth sending. func AppStart(appName string) []byte { @@ -114,11 +121,23 @@ func SendChannelText(channelIdx uint8, at time.Time, text string) []byte { // SendContactText sends to one contact, addressed by the first six bytes of // its public key - which is what the firmware matches on. func SendContactText(pubKeyPrefix []byte, at time.Time, attempt uint8, text string) ([]byte, error) { + return sendTxt(txtTypePlain, pubKeyPrefix, at, attempt, text) +} + +// SendCommandData sends a command for a contact to run, as remote admin does. +// The recipient runs it only for a sender logged in to it as admin, and +// answers with a CLI message; the firmware expects no ack and stamps it with +// its own clock rather than this one, so replay protection holds. +func SendCommandData(pubKeyPrefix []byte, at time.Time, text string) ([]byte, error) { + return sendTxt(txtTypeCLIData, pubKeyPrefix, at, 0, text) +} + +func sendTxt(txtType uint8, pubKeyPrefix []byte, at time.Time, attempt uint8, text string) ([]byte, error) { if len(pubKeyPrefix) < 6 { return nil, fmt.Errorf("proto: contact key prefix is %d bytes, need 6", len(pubKeyPrefix)) } b := make([]byte, 0, 13+len(text)) - b = append(b, byte(CmdSendTxtMsg), txtTypePlain, attempt) + b = append(b, byte(CmdSendTxtMsg), txtType, attempt) b = binary.LittleEndian.AppendUint32(b, uint32(at.Unix())) b = append(b, pubKeyPrefix[:6]...) return append(b, text...), nil @@ -218,6 +237,10 @@ type Message struct { SNRdB float64 PathLen int + // CLI marks a reply to a remote command (TXT_TYPE_CLI_DATA) rather than + // something somebody wrote. + CLI bool + // Mine marks a message this client sent, rather than one the node // received. It is still the firmware's doing - the frame went to the node // and the node transmitted it - but nothing comes back to echo it, so diff --git a/internal/mesh/proto/proto_test.go b/internal/mesh/proto/proto_test.go index 520808b2..802183dd 100644 --- a/internal/mesh/proto/proto_test.go +++ b/internal/mesh/proto/proto_test.go @@ -280,3 +280,36 @@ func TestDecodeMessageTooShort(t *testing.T) { t.Fatalf("a 1-byte message body decoded as %+v, want an error", f.Message) } } + +// A remote command is a direct message marked TXT_TYPE_CLI_DATA, attempt 0, +// and its reply decodes as a CLI message rather than a written one (#253). +func TestARemoteCommandIsMarkedAsOne(t *testing.T) { + key := []byte{1, 2, 3, 4, 5, 6, 7, 8} + f, err := proto.SendCommandData(key, time.Unix(0x01020304, 0), "get name") + if err != nil { + t.Fatal(err) + } + want := append([]byte{byte(proto.CmdSendTxtMsg), 1, 0, 4, 3, 2, 1, 1, 2, 3, 4, 5, 6}, "get name"...) + if string(f) != string(want) { + t.Errorf("frame % x, want % x", f, want) + } + if _, err := proto.SendCommandData(key[:5], time.Now(), "x"); err == nil { + t.Error("a five-byte key was accepted") + } + + // A v3 contact message: snr, two reserved, six-byte sender, path length, + // txt_type, timestamp, text. + reply := append([]byte{byte(proto.RespContactMsgRecvV3), 40, 0, 0, 1, 2, 3, 4, 5, 6, 0xFF, 1, 0, 0, 0, 0}, + " -> Rep"...) + fr, err := proto.Decode(reply) + if err != nil || fr.Message == nil { + t.Fatalf("decode: %v %+v", err, fr) + } + if !fr.Message.CLI || fr.Message.Text != " -> Rep" { + t.Errorf("a CLI reply decoded as %+v", fr.Message) + } + reply[11] = 0 + if fr, _ := proto.Decode(reply); fr.Message == nil || fr.Message.CLI { + t.Errorf("a plain message decoded as CLI: %+v", fr.Message) + } +} diff --git a/tools/verbdoc/facade.json b/tools/verbdoc/facade.json index 9cfcc057..b83c93b5 100644 --- a/tools/verbdoc/facade.json +++ b/tools/verbdoc/facade.json @@ -117,6 +117,7 @@ "companion.configure": "node.companion.configure(...)", "companion.add_channel": "node.companion.add_channel(index)", "companion.raw": "node.companion.raw(bytes)", + "companion.cli": "node.companion.remote(to, command)", "bench.serve": "node.serve(kind='tcp'|'serial')", "bench.drop": "node.unserve()", "bench.refresh": "wb.endpoints",