meshtastic 0.0.187 → 0.0.188
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/documentation/README.md +2 -1
- data/documentation/admin-backup.md +82 -3
- data/documentation/admin-config.md +28 -1
- data/documentation/admin-firmware-migration.md +171 -0
- data/documentation/admin-firmware.md +7 -3
- data/documentation/admin.md +16 -0
- data/documentation/mesh-interface.md +46 -18
- data/documentation/mqtt.md +63 -71
- data/documentation/waypoint.md +54 -0
- data/lib/meshtastic/admin/backup.rb +44 -5
- data/lib/meshtastic/admin/channel.rb +3 -8
- data/lib/meshtastic/admin/config.rb +63 -1
- data/lib/meshtastic/admin/firmware.rb +43 -10
- data/lib/meshtastic/admin/validation.rb +78 -0
- data/lib/meshtastic/admin.rb +28 -22
- data/lib/meshtastic/mesh_interface.rb +123 -43
- data/lib/meshtastic/module_config.rb +2 -0
- data/lib/meshtastic/mqtt.rb +21 -12
- data/lib/meshtastic/version.rb +1 -1
- data/lib/meshtastic/waypoint.rb +85 -0
- data/lib/meshtastic.rb +1 -0
- data/spec/lib/meshtastic/admin/backup_spec.rb +153 -9
- data/spec/lib/meshtastic/admin/config_spec.rb +70 -1
- data/spec/lib/meshtastic/admin/firmware_migration_spec.rb +121 -0
- data/spec/lib/meshtastic/admin/firmware_spec.rb +1 -1
- data/spec/lib/meshtastic/admin/firmware_target_selection_spec.rb +44 -0
- data/spec/lib/meshtastic/admin/validation_spec.rb +89 -0
- data/spec/lib/meshtastic/admin_spec.rb +77 -0
- data/spec/lib/meshtastic/bluetooth_spec.rb +26 -1
- data/spec/lib/meshtastic/mesh_interface_spec.rb +54 -0
- data/spec/lib/meshtastic/module_config_spec.rb +1 -1
- data/spec/lib/meshtastic/mqtt_spec.rb +165 -0
- data/spec/lib/meshtastic/serial_spec.rb +26 -0
- data/spec/lib/meshtastic/tcp_spec.rb +29 -0
- data/spec/lib/meshtastic/waypoint_spec.rb +55 -0
- data/spec/support/channel_crypto_reference.rb +29 -0
- data/spec/support/firmware_release_artifact_audit.rb +39 -0
- metadata +11 -1
data/documentation/mqtt.md
CHANGED
|
@@ -1,101 +1,93 @@
|
|
|
1
1
|
# Meshtastic::MQTT
|
|
2
2
|
|
|
3
|
-
Broker client
|
|
3
|
+
Broker client using protobuf `ServiceEnvelope`. The host performs channel PSK encryption/decryption; MQTT does **not** provide public-key direct messaging. A broker publish is submission, not RF delivery or a verified ACK.
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
## Methods
|
|
8
|
-
|
|
9
|
-
- `connect` — returns an `MQTT::Client`
|
|
10
|
-
- `subscribe` — blocking loop; yields a hash per envelope, or pretty-prints without a block. Disconnects on exit.
|
|
11
|
-
- `send_text` — publishes a `ServiceEnvelope` (raises if text exceeds `Meshtastic::Constants::DATA_PAYLOAD_LEN`)
|
|
12
|
-
- `disconnect`
|
|
13
|
-
- `help` / `authors`
|
|
14
|
-
|
|
15
|
-
### `connect`
|
|
5
|
+
## Connect and subscribe
|
|
16
6
|
|
|
17
7
|
```ruby
|
|
8
|
+
require 'meshtastic'
|
|
9
|
+
|
|
18
10
|
mqtt_obj = Meshtastic::MQTT.connect(
|
|
19
|
-
host: 'mqtt.meshtastic.org',
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
username: 'meshdev',
|
|
23
|
-
password: 'large4cats',
|
|
24
|
-
client_id: nil, # default: random 4-byte hex
|
|
25
|
-
keep_alive: 15,
|
|
26
|
-
ack_timeout: 30
|
|
11
|
+
host: 'mqtt.meshtastic.org', port: 1883, tls: false,
|
|
12
|
+
username: 'meshdev', password: 'large4cats',
|
|
13
|
+
client_id: 'aabbccdd', keep_alive: 15, ack_timeout: 30
|
|
27
14
|
)
|
|
28
|
-
```
|
|
29
15
|
|
|
30
|
-
|
|
16
|
+
# Use your own Base64 16- or 32-byte channel key, not this public alias.
|
|
17
|
+
keys = {
|
|
18
|
+
LongFast: 'AQ==', # backward-compatible AES-CTR
|
|
19
|
+
Private: { psk: 'AQ==', use_aead: true } # firmware AES-CCM, public example key
|
|
20
|
+
}
|
|
31
21
|
|
|
32
|
-
```ruby
|
|
33
22
|
Meshtastic::MQTT.subscribe(
|
|
34
|
-
mqtt_obj: mqtt_obj,
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
qos: 0,
|
|
40
|
-
exclude: nil, # comma-delimited substrings to hide
|
|
41
|
-
include: nil, # all listed substrings must appear
|
|
42
|
-
gps_metadata: false,
|
|
43
|
-
include_raw: false
|
|
44
|
-
) do |message|
|
|
45
|
-
puts message.inspect
|
|
23
|
+
mqtt_obj: mqtt_obj, root_topic: 'msh', region: 'US',
|
|
24
|
+
topic: '2/e/Private/#', psks: keys, qos: 0,
|
|
25
|
+
include: nil, exclude: nil, gps_metadata: false, include_raw: false
|
|
26
|
+
) do |envelope|
|
|
27
|
+
puts envelope[:packet].inspect
|
|
46
28
|
end
|
|
47
29
|
```
|
|
48
30
|
|
|
49
|
-
`include
|
|
31
|
+
`subscribe` is blocking and disconnects on exit. `include`/`exclude` are comma-delimited substrings. `json: true` accepts JSON envelopes but does not make their security claims trustworthy. Binary protobuf is the normal interoperable path.
|
|
50
32
|
|
|
51
|
-
|
|
33
|
+
The exact `ServiceEnvelope.channel_id` selects the key. If absent, the penultimate MQTT topic segment is used. Unknown names never fall back to LongFast or the first key. Encrypted packets must have the firmware hash matching the configured name, expanded key and mode. Already-decoded envelopes bypass host decryption and are not authenticated by it.
|
|
52
34
|
|
|
53
|
-
|
|
54
|
-
require 'meshtastic'
|
|
35
|
+
## Send text or application Data
|
|
55
36
|
|
|
56
|
-
|
|
57
|
-
client_id = "!#{mqtt_obj.client_id}"
|
|
37
|
+
```ruby
|
|
58
38
|
Meshtastic::MQTT.send_text(
|
|
59
|
-
mqtt_obj: mqtt_obj,
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
39
|
+
mqtt_obj: mqtt_obj, from: '!aabbccdd', to: '!ffffffff',
|
|
40
|
+
channel_id: 'Private', psks: keys,
|
|
41
|
+
text: 'Hello', want_ack: false, hop_limit: 3
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
Meshtastic::MQTT.send_data(
|
|
45
|
+
mqtt_obj: mqtt_obj, from: '!aabbccdd',
|
|
46
|
+
channel_id: 'Private', psks: keys,
|
|
47
|
+
data: Meshtastic::Data.new(portnum: :TEXT_MESSAGE_APP, payload: 'Hello')
|
|
68
48
|
)
|
|
69
49
|
```
|
|
70
50
|
|
|
71
|
-
|
|
51
|
+
- `channel_id:` selects the exact channel name. It may be omitted only for a single configured key; omitted `psks` retains the public LongFast CTR default. Multiple keys without a selector, duplicate string/symbol names, unknown selectors, and conflicting publication topics raise `ArgumentError` before publish.
|
|
52
|
+
- `psks:` maps names to Base64 strings (legacy CTR), or `{ psk: base64_string, use_aead: true/false }`. `use_aead` belongs **inside each key record**, not at the top level. Structured records require exactly `psk` and a boolean `use_aead`; omitted/misspelled modes and top-level `use_aead` are rejected rather than silently selecting CTR. No mode probing or AEAD-to-CTR retry occurs.
|
|
53
|
+
- Keys must decode to 16 or 32 bytes, or a nonzero one-byte firmware PSK alias. Aliases 1–255 expand using the firmware default key and last-byte adjustment on **any** channel name. Empty/zero aliases, malformed keys and other lengths are rejected; unlike firmware, arbitrary short keys are not silently zero-padded. Resolve secondary-channel inherited keys before supplying them here. Public aliases do not provide confidentiality.
|
|
54
|
+
- The packet channel hash is computed, not guessed: XOR all UTF-8 channel-name bytes and expanded key bytes, then XOR `0xAE` for AEAD. Legacy numeric `channel:` remains accepted but does not override this computed MQTT hash. On radio transports, `channel:` is still a device slot.
|
|
55
|
+
- The default publish path is `msh/US/2/e/<selected channel>/<from>`. Supply `root_topic`, `region`, or `topic: '2/e/Private/#'` to customize. A topic must identify the selected channel.
|
|
56
|
+
- `send_text` and text `send_data` reject nonbroadcast destinations, even without `pki_encrypted`. Explicit `pki_encrypted: true` or recipient `public_key:` also raise. Use Serial/Bluetooth/TCP with device-owned PKI for direct messages. Non-text channel-addressed application Data remains supported, but is not PKI.
|
|
57
|
+
- `psks: nil` and `{}` cannot publish plaintext. `send_text` enforces the payload byte limit. Large encoded Data plus AEAD overhead still must fit the receiving firmware's RF limits; publish success does not prove airtime acceptance.
|
|
58
|
+
- Do not reuse a packet ID with the same sender/key. Normal sends generate IDs; `last_packet_id:` exists for controlled sequencing/testing, not replay protection. The host does not maintain a durable nonce/replay database.
|
|
59
|
+
|
|
60
|
+
`disconnect(mqtt_obj:)`, `help`, and `authors` are also available.
|
|
61
|
+
|
|
62
|
+
## Security provenance
|
|
63
|
+
|
|
64
|
+
Every enriched packet has host-created `:security_provenance`:
|
|
72
65
|
|
|
73
66
|
```ruby
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
psks: { LongFast: 'AQ==' },
|
|
81
|
-
include: '!c0ffee00'
|
|
82
|
-
) do |message|
|
|
83
|
-
puts message.inspect
|
|
84
|
-
end
|
|
67
|
+
{
|
|
68
|
+
source: :mqtt,
|
|
69
|
+
verification: :untrusted,
|
|
70
|
+
reported: { xeddsa_signed: true, ack_proof_status: :ACK_PROOF_VALID },
|
|
71
|
+
channel_authentication: :aead # only after a successful local CCM check
|
|
72
|
+
}
|
|
85
73
|
```
|
|
86
74
|
|
|
87
|
-
|
|
75
|
+
Broker `xeddsa_signed` and `ack_proof_status` are removed from the top-level packet hash and retained only under `reported`. They are untrusted broker assertions, including on already-decoded packets, encrypted packets and failed-enrichment paths. A broker-supplied `security_provenance` is overwritten. `include_raw` is wire evidence, not a trusted verification result.
|
|
88
76
|
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
77
|
+
Successful CCM proves possession of the shared channel key, **not** sender identity, XEdDSA signature validity, ACK proof validity, freshness or radio delivery. CTR decoding sets `channel_authentication: :none`; protobuf parsing is not authentication. Failures preserve ciphertext and add `decryption_error` without publishing partial plaintext. PKI ciphertext is never attempted with PSK crypto.
|
|
78
|
+
|
|
79
|
+
## Pinned firmware interoperability
|
|
80
|
+
|
|
81
|
+
Implemented against release **v2.8.1.8e6a88d**:
|
|
82
|
+
|
|
83
|
+
- Firmware [`8e6a88d06f44cad26f1e8d7cd402939ccacb9b4c`](https://github.com/meshtastic/firmware/tree/8e6a88d06f44cad26f1e8d7cd402939ccacb9b4c): `src/mesh/Channels.cpp`, `CryptoEngine.cpp/.h`, `aes-ccm.cpp`, and `test/test_crypto/test_main.cpp`.
|
|
84
|
+
- Protobuf [`8f97d66a63ce10cfb12f94203e361691647f5ad3`](https://github.com/meshtastic/protobufs/tree/8f97d66a63ce10cfb12f94203e361691647f5ad3): existing `ChannelSettings.use_aead`, `MeshPacket.xeddsa_signed` and `ack_proof_status` schemas.
|
|
92
85
|
|
|
93
|
-
|
|
86
|
+
CTR uses AES-128/256 with the 16-byte nonce `packet_id64le || from32le || zero32`. Channel AEAD uses AES-128/256-CCM, **13-byte nonce** `packet_id64le || from32le || 00`, **12-byte tag**, and AAD `from32le || to32le`. Wire bytes are `ciphertext || tag`; there is no PKI extra nonce. Relayed hop fields are not AAD. Packet IDs in MeshPacket are 32-bit and occupy the low four bytes of the 64-bit nonce field.
|
|
94
87
|
|
|
95
|
-
`
|
|
88
|
+
Ruby OpenSSL must provide AES-CCM, `iv_len=`, `auth_tag_len=`, `ccm_data_len=`, and authenticated decrypt. Unsupported crypto fails, never downgrades. No new gem/native extension dependency was added. Tests use fixed independently calculated CCM vectors, upstream RFC3610 validation, real ServiceEnvelope/FromRadio codecs, fake broker/GATT, PTY UART and UNIXSocket TCP; no hardware or live broker was used.
|
|
96
89
|
|
|
97
90
|
## Related
|
|
98
91
|
|
|
99
|
-
- [
|
|
100
|
-
- [
|
|
101
|
-
- Protobuf: `ServiceEnvelope`, `MapReport` in [protobufs.md](protobufs.md)
|
|
92
|
+
- [MeshInterface](mesh-interface.md)
|
|
93
|
+
- [Protobuf catalog](protobufs.md)
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# Meshtastic::Waypoint
|
|
2
|
+
|
|
3
|
+
`Waypoint` remains the generated protobuf **class**. `build(fields:)` and `transmit` add validated waypoint/geofence helpers without replacing its constructor, descriptor, encoder, or decoder. All methods accept one options Hash; `help` and `authors` expose usage and attribution.
|
|
4
|
+
|
|
5
|
+
## Circle and box examples
|
|
6
|
+
|
|
7
|
+
```ruby
|
|
8
|
+
circle = Meshtastic::Waypoint.build(fields: {
|
|
9
|
+
id: 7, name: 'Meeting point',
|
|
10
|
+
latitude_i: 450_000_000, longitude_i: -730_000_000,
|
|
11
|
+
geofence_radius: 100,
|
|
12
|
+
notify_on_enter: true, notify_on_exit: true,
|
|
13
|
+
notify_favorites_only: true
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
box = Meshtastic::Waypoint.build(fields: {
|
|
17
|
+
id: 8, name: 'Search area',
|
|
18
|
+
bounding_box: {
|
|
19
|
+
longitude_west_i: -731_000_000, latitude_south_i: 449_000_000,
|
|
20
|
+
longitude_east_i: -729_000_000, latitude_north_i: 451_000_000
|
|
21
|
+
},
|
|
22
|
+
notify_on_enter: true
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
Meshtastic::Waypoint.transmit(serial_obj: connection, waypoint: circle, want_ack: true)
|
|
26
|
+
# Alternative: pass fields: directly, not both waypoint and fields.
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Coordinates are **integer decimal degrees × 10,000,000**, not radians, floating-point degrees, or map pixels. Latitude must be within ±900,000,000 and longitude within ±1,800,000,000. Supply waypoint latitude and longitude together, including explicit zero for equator/prime-meridian locations. A positive radius requires that center. `geofence_radius` is uint32 meters; zero means no circular fence.
|
|
30
|
+
|
|
31
|
+
`bounding_box` uses exactly `longitude_west_i`, `latitude_south_i`, `longitude_east_i`, `latitude_north_i` (west/south/east/north), with south ≤ north and west ≤ east. Antimeridian-spanning boxes must be split; they are not silently reordered. An explicitly empty box is valid proto3 and denotes all-zero bounds. Circle and box may coexist, as allowed by the pinned schema.
|
|
32
|
+
|
|
33
|
+
Notification flags are booleans: `notify_on_enter`, `notify_on_exit`, `notify_favorites_only`. Favorite status is resolved **locally on each receiving device**. These are firmware/client notification instructions, not a host tracking service or a guarantee that every device will alert.
|
|
34
|
+
|
|
35
|
+
## Other fields and validation
|
|
36
|
+
|
|
37
|
+
Only exact generated `Waypoint` fields are accepted under `fields:`. In addition to the geometry/notification fields, these are `id`, `expire` (Unix seconds), `locked_to` (editing node number, zero for unrestricted), `name`, `description`, and `icon` (Unicode designator integer). There is no invented `shape`, `radius`, `receive_only`, or `geofence` wire field. The helper rejects unknown fields, invalid types, out-of-range numbers, nil values, geographic bounds violations, incomplete centers, and reversed boxes before transmission.
|
|
38
|
+
|
|
39
|
+
Pinned nanopb storage allows **29 UTF-8 bytes for name and 99 for description**, leaving the terminating byte inside the schema's 30/100-byte storage buffers. Overlength text, embedded NUL, and invalid UTF-8 are rejected, never truncated. `build` without fields and a decoded empty proto3 message are valid default waypoints; absence of payload bytes does not mean a malformed known protobuf payload.
|
|
40
|
+
|
|
41
|
+
## Transmission and limits
|
|
42
|
+
|
|
43
|
+
`transmit(waypoint:)` revalidates even an object made directly with `Waypoint.new`, then submits `Data(portnum: :WAYPOINT_APP)` through `Meshtastic.deliver_data`. Use the existing non-Admin transport keywords: `serial_obj`, `bluetooth_obj`, `tcp_obj`, or `mqtt_obj`. Shared transport options such as destination, channel, hop limit, packet ID, `want_ack`, `pki_encrypted`, and `public_key` pass through unchanged. For MQTT use `channel_id` and `psks` per the [MQTT](mqtt.md) documentation; MQTT PKI remains unsupported. Do not supply multiple transport handles.
|
|
44
|
+
|
|
45
|
+
Submission is not an RF receipt or notification confirmation. Signing reports, ACK proofs, and broker claims are different evidence; see [Admin delivery results](admin.md#delivery-evidence-and-trust-boundary-v2818e6a88d). Geofence handling and UI notifications depend on the receiver's firmware/build. No live radio verification was performed.
|
|
46
|
+
|
|
47
|
+
## Pinned contract
|
|
48
|
+
|
|
49
|
+
Firmware **v2.8.1.8e6a88d**, commit `8e6a88d06f44cad26f1e8d7cd402939ccacb9b4c`, pins protobuf commit `8f97d66a63ce10cfb12f94203e361691647f5ad3`:
|
|
50
|
+
|
|
51
|
+
- [Waypoint and BoundingBox schema](https://github.com/meshtastic/protobufs/blob/8f97d66a63ce10cfb12f94203e361691647f5ad3/meshtastic/mesh.proto)
|
|
52
|
+
- [Firmware string capacities](https://github.com/meshtastic/protobufs/blob/8f97d66a63ce10cfb12f94203e361691647f5ad3/meshtastic/mesh.options)
|
|
53
|
+
|
|
54
|
+
Related: [Position](position.md), [Admin::Config](admin-config.md).
|
|
@@ -13,7 +13,7 @@ module Meshtastic
|
|
|
13
13
|
# Host-side snapshots, not firmware filesystem backup commands.
|
|
14
14
|
module Backup
|
|
15
15
|
FORMAT = 'meshtastic-admin-backup'
|
|
16
|
-
WARNING = 'Contains secrets returned by firmware. Firmware may redact secrets; absent/default values are not proof of completeness. Owner
|
|
16
|
+
WARNING = 'Contains secrets returned by firmware. Firmware may redact secrets; absent/default values are not proof of completeness. Owner id, MAC and hardware metadata are excluded, NOT security private/public identity keys. ACKs do not prove persistence.'
|
|
17
17
|
CONFIG_TYPES = %i[DEVICE_CONFIG POSITION_CONFIG POWER_CONFIG NETWORK_CONFIG DISPLAY_CONFIG LORA_CONFIG BLUETOOTH_CONFIG SECURITY_CONFIG].freeze
|
|
18
18
|
MODULE_TYPES = %i[MQTT_CONFIG SERIAL_CONFIG EXTNOTIF_CONFIG STOREFORWARD_CONFIG RANGETEST_CONFIG TELEMETRY_CONFIG CANNEDMSG_CONFIG AUDIO_CONFIG REMOTEHARDWARE_CONFIG NEIGHBORINFO_CONFIG AMBIENTLIGHTING_CONFIG DETECTIONSENSOR_CONFIG PAXCOUNTER_CONFIG STATUSMESSAGE_CONFIG TRAFFICMANAGEMENT_CONFIG TAK_CONFIG MESHBEACON_CONFIG].freeze
|
|
19
19
|
OWNER_FIELDS = %w[long_name short_name is_licensed].freeze
|
|
@@ -127,6 +127,20 @@ module Meshtastic
|
|
|
127
127
|
end
|
|
128
128
|
end
|
|
129
129
|
entries = import_plan(document: document, format: opts.fetch(:format, :auto), path: opts[:path])
|
|
130
|
+
identity = entries.any? do |entry|
|
|
131
|
+
security = entry[:message].security if entry[:section] == 'config'
|
|
132
|
+
security && (!security.private_key.empty? || !security.public_key.empty?)
|
|
133
|
+
end
|
|
134
|
+
raise ArgumentError, 'backup contains security identity keys; explicitly select identity_keys: :restore or remove the entire security record offline' if identity && opts.fetch(:identity_keys, :reject) != :restore
|
|
135
|
+
|
|
136
|
+
reconnect = identity || entries.any? { |entry| entry[:section] == 'owner' || (entry[:section] == 'config' && %w[LORA_CONFIG SECURITY_CONFIG NETWORK_CONFIG BLUETOOTH_CONFIG].include?(entry[:slot])) }
|
|
137
|
+
privacy = entries.any? { |entry| privacy_sensitive?(entry) }
|
|
138
|
+
raise ArgumentError, 'backup changes location, sharing or telemetry settings; explicitly select allow_privacy_changes: true' if privacy && !opts.fetch(:allow_privacy_changes, false)
|
|
139
|
+
|
|
140
|
+
warnings = [WARNING]
|
|
141
|
+
warnings << 'Privacy: restored location, channel precision, MQTT sharing or enabled telemetry may disclose location, traffic or device, environment, air quality, power and health metrics. Firmware may clamp public-channel precision; values are never silently stripped by this client.' if privacy
|
|
142
|
+
warnings << 'Identity or connection settings may change the node number or connection. Reconnect, rediscover the target, and compare fresh configuration before claiming persistence; this API does not reconnect or retarget automatically.' if reconnect
|
|
143
|
+
warnings << 'Security public keys may be derived from private keys by firmware; absent private keys preserve an existing identity, not a request to reset it.' if entries.any? { |entry| entry[:section] == 'config' && entry[:slot] == 'SECURITY_CONFIG' }
|
|
130
144
|
plan = entries.each_with_index.sort_by do |entry, index|
|
|
131
145
|
priority = if entry[:section] == 'config'
|
|
132
146
|
{ 'LORA_CONFIG' => 20, 'BLUETOOTH_CONFIG' => 21, 'NETWORK_CONFIG' => 22, 'SECURITY_CONFIG' => 23 }.fetch(entry[:slot], 0)
|
|
@@ -136,7 +150,7 @@ module Meshtastic
|
|
|
136
150
|
[priority, index]
|
|
137
151
|
end.map(&:first)
|
|
138
152
|
report = { status: :dry_run, planned: plan.length, attempted: 0, acknowledged: 0, readback_confirmed: 0, persistence_verified: false,
|
|
139
|
-
transaction: :not_requested, records: [], plan: plan.map { |entry| entry.slice(:section, :slot) }, warnings:
|
|
153
|
+
transaction: :not_requested, records: [], plan: plan.map { |entry| entry.slice(:section, :slot) }, warnings: warnings, reconnect_required: reconnect }
|
|
140
154
|
return report if opts.fetch(:dry_run, false)
|
|
141
155
|
|
|
142
156
|
failure = nil
|
|
@@ -174,6 +188,23 @@ module Meshtastic
|
|
|
174
188
|
report
|
|
175
189
|
end
|
|
176
190
|
|
|
191
|
+
private_class_method def self.privacy_sensitive?(opts = {})
|
|
192
|
+
message = opts[:message]
|
|
193
|
+
case opts[:section]
|
|
194
|
+
when 'fixed_position' then true
|
|
195
|
+
when 'config' then opts[:slot] == 'POSITION_CONFIG'
|
|
196
|
+
when 'channel'
|
|
197
|
+
settings = message.settings
|
|
198
|
+
settings && (settings.uplink_enabled || settings.module_settings&.position_precision.to_i.positive?)
|
|
199
|
+
when 'module_config'
|
|
200
|
+
return message.mqtt.enabled || message.mqtt.map_reporting_enabled if message.mqtt
|
|
201
|
+
|
|
202
|
+
telemetry = message.telemetry
|
|
203
|
+
telemetry && %w[device_telemetry_enabled environment_measurement_enabled air_quality_enabled power_measurement_enabled health_measurement_enabled].any? { |field| telemetry[field] }
|
|
204
|
+
else false
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
|
|
177
208
|
# A timed-out mutation is never resent; only a fresh getter can resolve it.
|
|
178
209
|
private_class_method def self.apply_entry(opts = {})
|
|
179
210
|
entry = opts[:entry]
|
|
@@ -345,6 +376,7 @@ module Meshtastic
|
|
|
345
376
|
end
|
|
346
377
|
raise ArgumentError, 'DeviceProfile contains no restorable fields' if entries.empty?
|
|
347
378
|
|
|
379
|
+
entries.each { |entry| Validation.validate(message: entry[:message]) if entry[:message].respond_to?(:to_proto) }
|
|
348
380
|
entries
|
|
349
381
|
rescue Google::Protobuf::ParseError, TypeError, RangeError, URI::InvalidURIError
|
|
350
382
|
raise ArgumentError, 'invalid DeviceProfile protobuf or channel URL', cause: nil
|
|
@@ -422,6 +454,7 @@ module Meshtastic
|
|
|
422
454
|
end
|
|
423
455
|
validate_proto_json(value: record['value'], descriptor: klass.descriptor)
|
|
424
456
|
message = klass.decode_json(JSON.generate(record['value']), ignore_unknown_fields: false)
|
|
457
|
+
Validation.validate(message: message)
|
|
425
458
|
if %w[config module_config].include?(section)
|
|
426
459
|
types = section == 'config' ? CONFIG_TYPES : MODULE_TYPES
|
|
427
460
|
fields = klass.descriptor.map(&:name)
|
|
@@ -461,14 +494,16 @@ module Meshtastic
|
|
|
461
494
|
private_class_method def self.validate_options(opts = {})
|
|
462
495
|
options = opts[:options]
|
|
463
496
|
allowed = %i[transport_obj to timeout channel hop_limit path]
|
|
464
|
-
allowed += opts[:operation] == :export ? %i[format config_types module_config_types channel_indexes include_owner include_ui] : %i[backup format dry_run edit_transaction verify]
|
|
497
|
+
allowed += opts[:operation] == :export ? %i[format config_types module_config_types channel_indexes include_owner include_ui] : %i[backup format dry_run edit_transaction verify identity_keys allow_privacy_changes]
|
|
498
|
+
raise ArgumentError, 'identity_keys must be :reject (default) or :restore' if options.key?(:identity_keys) && !%i[reject restore].include?(options[:identity_keys])
|
|
499
|
+
|
|
465
500
|
allowed += %i[fixed_position include_ringtone include_canned_messages] if opts[:operation] == :export && options[:format] == :device_profile
|
|
466
501
|
raise ArgumentError, 'unknown backup options' unless (options.keys - allowed).empty?
|
|
467
502
|
|
|
468
503
|
formats = opts[:operation] == :export ? %i[json device_profile] : %i[auto json device_profile]
|
|
469
504
|
raise ArgumentError, "format must be one of #{formats.join(', ')}" if options.key?(:format) && !formats.include?(options[:format])
|
|
470
505
|
|
|
471
|
-
%i[include_owner include_ui include_ringtone include_canned_messages dry_run edit_transaction verify].each do |key|
|
|
506
|
+
%i[include_owner include_ui include_ringtone include_canned_messages dry_run edit_transaction verify allow_privacy_changes].each do |key|
|
|
472
507
|
raise ArgumentError, 'backup flags must be boolean' if options.key?(key) && ![true, false].include?(options[key])
|
|
473
508
|
end
|
|
474
509
|
timeout = options.fetch(:timeout, 10)
|
|
@@ -542,7 +577,9 @@ module Meshtastic
|
|
|
542
577
|
path: 'optional - existing JSON or binary DeviceProfile .cfg file; mutually exclusive with backup',
|
|
543
578
|
backup: 'optional - versioned JSON Hash, JSON String or binary DeviceProfile String; mutually exclusive with path',
|
|
544
579
|
format: 'optional - :auto (default), :json or :device_profile; cfg paths select binary, other content is validated',
|
|
545
|
-
|
|
580
|
+
identity_keys: 'optional - :reject (default) rejects any nonempty security private/public identity key; :restore sends unchanged; never strips keys, including in dry runs',
|
|
581
|
+
allow_privacy_changes: 'optional - boolean default false; explicitly consent to position/fixed-position, positive channel precision/uplink, enabled MQTT/map reporting or telemetry (device, environment, air quality, power, health) restoration',
|
|
582
|
+
dry_run: 'optional - validate and plan without any radio requests; default false; consent flags still required',
|
|
546
583
|
edit_transaction: 'optional - begin/commit on firmware known to support edits; default false',
|
|
547
584
|
verify: 'optional - additional fresh comparison after completed writes/commit; timeout recovery reads occur regardless; not durability proof; default false',
|
|
548
585
|
timeout: 'optional - positive finite seconds per request; default 10',
|
|
@@ -551,6 +588,8 @@ module Meshtastic
|
|
|
551
588
|
hop_limit: 'optional - mesh transport hop limit; default Admin behavior'
|
|
552
589
|
)
|
|
553
590
|
# Contains secrets; firmware may redact values. Never logs document contents.
|
|
591
|
+
# Firmware v2.8.1.8e6a88d semantics; owner metadata exclusion does not exclude security identity keys.
|
|
592
|
+
# Reports reconnect_required when identity/connection settings may change. No automatic reconnect or target discovery; persistence_verified remains false.
|
|
554
593
|
# Display the module authors.
|
|
555
594
|
#{self}.authors
|
|
556
595
|
"
|
|
@@ -25,10 +25,7 @@ module Meshtastic
|
|
|
25
25
|
value = field.subtype.msgclass.new(value) if value.is_a?(Hash) && field.subtype
|
|
26
26
|
settings[field.name] = value
|
|
27
27
|
end
|
|
28
|
-
|
|
29
|
-
raise ArgumentError, 'channel name must be fewer than 12 bytes' unless settings.name.bytesize < 12
|
|
30
|
-
|
|
31
|
-
settings
|
|
28
|
+
Validation.validate(message: settings)
|
|
32
29
|
end
|
|
33
30
|
|
|
34
31
|
public_class_method def self.build(opts = {})
|
|
@@ -38,10 +35,8 @@ module Meshtastic
|
|
|
38
35
|
|
|
39
36
|
channel.index = index
|
|
40
37
|
channel.role = opts[:role] if opts[:role]
|
|
41
|
-
raise ArgumentError, 'role must be PRIMARY, SECONDARY, or DISABLED' unless %i[PRIMARY SECONDARY DISABLED].include?(channel.role)
|
|
42
|
-
|
|
43
38
|
channel.settings = build_settings(opts.merge(settings: opts.fetch(:settings, channel.settings)))
|
|
44
|
-
channel
|
|
39
|
+
Validation.validate(message: channel)
|
|
45
40
|
end
|
|
46
41
|
|
|
47
42
|
public_class_method def self.get(opts = {})
|
|
@@ -82,7 +77,7 @@ module Meshtastic
|
|
|
82
77
|
channel_set = Meshtastic::ChannelSet.decode(Base64.urlsafe_decode64(uri.fragment))
|
|
83
78
|
raise ArgumentError, 'invalid channel URL' unless (1..8).cover?(channel_set.settings.length)
|
|
84
79
|
|
|
85
|
-
channel_set
|
|
80
|
+
Validation.validate(message: channel_set)
|
|
86
81
|
rescue URI::InvalidURIError, Google::Protobuf::ParseError, ArgumentError, TypeError
|
|
87
82
|
raise ArgumentError, 'invalid channel URL'
|
|
88
83
|
end
|
|
@@ -100,6 +100,49 @@ module Meshtastic
|
|
|
100
100
|
set(opts.merge(config: Meshtastic::Config.new(device_ui: opts.fetch(:device_ui))))
|
|
101
101
|
end
|
|
102
102
|
|
|
103
|
+
public_class_method def self.set_packet_signature_policy(opts = {})
|
|
104
|
+
policy = opts.fetch(:policy)
|
|
105
|
+
enum = Meshtastic::Config::SecurityConfig::PacketSignaturePolicy
|
|
106
|
+
valid = policy.is_a?(Symbol) ? enum.resolve(policy) : (policy.is_a?(Integer) && enum.lookup(policy))
|
|
107
|
+
raise ArgumentError, 'policy must be a known PacketSignaturePolicy symbol or integer' unless valid
|
|
108
|
+
|
|
109
|
+
section = Meshtastic::Config::SecurityConfig.new(packet_signature_policy: policy)
|
|
110
|
+
Admin::Validation.validate(message: Meshtastic::AdminMessage.new(set_config: Meshtastic::Config.new(security: section)))
|
|
111
|
+
update_field(opts.except(:policy).merge(section: :security, config_type: :SECURITY_CONFIG, field: :packet_signature_policy,
|
|
112
|
+
value: section.packet_signature_policy))
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
public_class_method def self.set_receive_only(opts = {})
|
|
116
|
+
enabled = opts.fetch(:enabled)
|
|
117
|
+
raise ArgumentError, 'enabled must be true or false' unless [true, false].include?(enabled)
|
|
118
|
+
raise ArgumentError, 'receive-only requires a local radio connection' if Admin.transport_type(opts) == :mqtt
|
|
119
|
+
|
|
120
|
+
connection = opts.fetch(:transport_obj)
|
|
121
|
+
target = opts.fetch(:to, connection[:my_node_num])
|
|
122
|
+
target = target.delete_prefix('!').to_i(16) if target.is_a?(String) && target.match?(/\A![0-9a-fA-F]{8}\z/)
|
|
123
|
+
raise ArgumentError, 'receive-only requires the local node for reliable readback and recovery' unless target && target == connection[:my_node_num]
|
|
124
|
+
|
|
125
|
+
update_field(opts.except(:enabled).merge(section: :lora, config_type: :LORA_CONFIG, field: :tx_enabled, value: !enabled))
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
private_class_method def self.update_field(opts = {})
|
|
129
|
+
section = opts.fetch(:section)
|
|
130
|
+
field = opts.fetch(:field)
|
|
131
|
+
value = opts.fetch(:value)
|
|
132
|
+
options = opts.except(:section, :field, :value, :config_type, :wait, :request_id).merge(wait: true)
|
|
133
|
+
query = options.merge(get_config_request: opts.fetch(:config_type))
|
|
134
|
+
before = Admin.request(query).fetch(:value)
|
|
135
|
+
raise IOError, "expected #{section} configuration readback" unless before.is_a?(Meshtastic::Config) && before.payload_variant == section
|
|
136
|
+
|
|
137
|
+
intended = Meshtastic::Config.decode(before.to_proto)
|
|
138
|
+
intended.public_send(section).public_send("#{field}=", value)
|
|
139
|
+
acknowledgment = Admin.request(options.merge(set_config: intended))
|
|
140
|
+
readback = Admin.request(query)
|
|
141
|
+
actual = readback.fetch(:value)
|
|
142
|
+
matched = actual.is_a?(Meshtastic::Config) && actual.payload_variant == section && actual.public_send(section).public_send(field) == value
|
|
143
|
+
{ acknowledgment: acknowledgment, readback: readback, matched: matched, reboot_requested: false }
|
|
144
|
+
end
|
|
145
|
+
|
|
103
146
|
public_class_method def self.authors
|
|
104
147
|
"AUTHOR(S):\n 0day Inc. <support@0dayinc.com>\n "
|
|
105
148
|
end
|
|
@@ -115,7 +158,7 @@ module Meshtastic
|
|
|
115
158
|
# Write a Config protobuf to the node.
|
|
116
159
|
#{self}.set(
|
|
117
160
|
transport_obj: 'required - connected Serial, Bluetooth, TCP handle or MQTT client',
|
|
118
|
-
config: 'required -
|
|
161
|
+
config: 'required - Config protobuf with exactly one selected section; empty outer messages and unknown enum values are rejected; device_ui uses dedicated store operation'
|
|
119
162
|
)
|
|
120
163
|
|
|
121
164
|
# Request DEVICE_CONFIG from the node.
|
|
@@ -204,6 +247,25 @@ module Meshtastic
|
|
|
204
247
|
# Write device UI configuration to node.
|
|
205
248
|
#{self}.set_device_ui(device_ui: 'required - DeviceUIConfig protobuf or field hash')
|
|
206
249
|
|
|
250
|
+
# Change signature policy preserving other security fields with fresh readback.
|
|
251
|
+
#{self}.set_packet_signature_policy(
|
|
252
|
+
transport_obj: 'required - connected radio handle with receive queue; MQTT unsupported',
|
|
253
|
+
policy: 'required - PACKET_SIGNATURE_POLICY_COMPATIBLE, BALANCED or STRICT enum symbol with full prefix, or known integer',
|
|
254
|
+
to: 'optional - target node, defaults to connected radio; accepts shared Admin request options',
|
|
255
|
+
timeout: 'optional - per-request positive deadline seconds; default ten'
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
# Set local LoRa transmit enablement with fresh readback.
|
|
259
|
+
#{self}.set_receive_only(
|
|
260
|
+
transport_obj: 'required - local connected radio with receive queue; remote targets and MQTT rejected',
|
|
261
|
+
enabled: 'required - true disables LoRa transmission, false enables it; preserves other LoRa fields',
|
|
262
|
+
to: 'optional - must equal the connected local node number',
|
|
263
|
+
timeout: 'optional - per-request positive deadline seconds; default ten'
|
|
264
|
+
)
|
|
265
|
+
# Helpers return acknowledgment, readback, matched and reboot_requested false.
|
|
266
|
+
# They do not request reboot; firmware may itself reboot/disconnect after settings changes.
|
|
267
|
+
# Timeout propagates without replay; reconnect and read back before retrying mutations.
|
|
268
|
+
|
|
207
269
|
# Print the AUTHOR(S) string for this module.
|
|
208
270
|
#{self}.authors
|
|
209
271
|
"
|
|
@@ -125,8 +125,11 @@ module Meshtastic
|
|
|
125
125
|
public_class_method def self.verify_reboot(opts = {})
|
|
126
126
|
validate_verification(opts.merge({}))
|
|
127
127
|
handle = nil
|
|
128
|
-
|
|
129
|
-
|
|
128
|
+
handle_type = nil
|
|
129
|
+
transports = { tcp: Meshtastic::TCP, bluetooth: Meshtastic::Bluetooth, serial: Meshtastic::Serial }
|
|
130
|
+
keys = { tcp: :tcp_obj, bluetooth: :bluetooth_obj, serial: :serial_obj }
|
|
131
|
+
transport = transports.fetch(opts.fetch(:transport))
|
|
132
|
+
key = keys.fetch(opts.fetch(:transport))
|
|
130
133
|
Timeout.timeout(opts.fetch(:timeout, 60)) do
|
|
131
134
|
sleep opts.fetch(:reboot_delay, 3)
|
|
132
135
|
begin
|
|
@@ -135,6 +138,9 @@ module Meshtastic
|
|
|
135
138
|
else
|
|
136
139
|
transport.connect(opts.fetch(:connection).merge(want_config: true))
|
|
137
140
|
end
|
|
141
|
+
handle_type = Admin.transport_type(transport_obj: handle)
|
|
142
|
+
raise ArgumentError, 'Reconnect transport does not match selected transport' unless handle_type == opts.fetch(:transport)
|
|
143
|
+
|
|
138
144
|
transport.wait_for_config(key => handle, timeout: opts.fetch(:timeout, 60))
|
|
139
145
|
rescue IOError, SystemCallError
|
|
140
146
|
transport.disconnect(key => handle) if handle
|
|
@@ -142,25 +148,50 @@ module Meshtastic
|
|
|
142
148
|
sleep 0.25
|
|
143
149
|
retry
|
|
144
150
|
end
|
|
145
|
-
|
|
151
|
+
node = handle[:my_node_num]
|
|
152
|
+
identity = verify_identity(opts.merge(handle: handle, node: node))
|
|
153
|
+
reply = Admin.request(transport_obj: handle, to: node, get_device_metadata_request: true, timeout: opts.fetch(:timeout, 60))
|
|
146
154
|
metadata = reply.fetch(:value).to_h
|
|
147
|
-
raise IOError,
|
|
148
|
-
raise IOError, 'Post-reboot node
|
|
155
|
+
raise IOError, 'Firmware version mismatch' unless metadata[:firmware_version] == opts.fetch(:expected_version)
|
|
156
|
+
raise IOError, 'Post-reboot node changed during verification' unless handle[:my_node_num] == node
|
|
149
157
|
|
|
150
|
-
{ status: :boot_verified, firmware_version: metadata[:firmware_version], node_num:
|
|
158
|
+
{ status: :boot_verified, firmware_version: metadata[:firmware_version], node_num: node, node_id: format('!%08x', node), metadata: metadata }.merge(identity)
|
|
151
159
|
end
|
|
152
160
|
ensure
|
|
153
|
-
|
|
161
|
+
transports.fetch(handle_type).disconnect(keys.fetch(handle_type) => handle) if handle && transports.key?(handle_type)
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
private_class_method def self.verify_identity(opts = {})
|
|
165
|
+
node = opts[:node]
|
|
166
|
+
raise IOError, 'Missing valid local node identity' unless node.is_a?(Integer) && (1...0xffffffff).cover?(node)
|
|
167
|
+
raise IOError, 'Post-reboot node identity mismatch' if opts[:expected_node] && node != opts[:expected_node]
|
|
168
|
+
return {} unless opts[:expected_public_key]
|
|
169
|
+
|
|
170
|
+
reply = Admin.request(transport_obj: opts[:handle], to: node, get_owner_request: true, timeout: opts.fetch(:timeout, 60))
|
|
171
|
+
raise IOError, 'Post-reboot public-key identity mismatch' unless reply.fetch(:value).public_key == opts[:expected_public_key]
|
|
172
|
+
|
|
173
|
+
result = { identity: :public_key_continuity }
|
|
174
|
+
return result unless opts[:previous_node]
|
|
175
|
+
|
|
176
|
+
result.merge(previous_node_num: opts[:previous_node], previous_node_id: format('!%08x', opts[:previous_node]), node_changed: node != opts[:previous_node])
|
|
154
177
|
end
|
|
155
178
|
|
|
156
179
|
private_class_method def self.validate_verification(opts = {})
|
|
157
180
|
raise ArgumentError, 'verify must be a Hash of verify_reboot options' unless opts.is_a?(Hash)
|
|
158
181
|
|
|
159
|
-
allowed = %i[transport connection expected_version expected_node reconnect timeout reboot_delay]
|
|
182
|
+
allowed = %i[transport connection expected_version expected_node expected_public_key previous_node reconnect timeout reboot_delay]
|
|
160
183
|
raise ArgumentError, 'Unknown reboot verification option' unless (opts.keys - allowed).empty?
|
|
161
184
|
raise ArgumentError, 'transport must be :tcp, :bluetooth or :serial' unless %i[tcp bluetooth serial].include?(opts[:transport])
|
|
162
185
|
raise ArgumentError, 'expected_version must be a nonempty firmware version String' unless opts[:expected_version].is_a?(String) && !opts[:expected_version].empty?
|
|
163
|
-
|
|
186
|
+
|
|
187
|
+
%i[expected_node previous_node].each do |name|
|
|
188
|
+
raise ArgumentError, "#{name} must be a unicast numeric node ID" if opts.key?(name) && !(opts[name].is_a?(Integer) && (1...0xffffffff).cover?(opts[name]))
|
|
189
|
+
end
|
|
190
|
+
if opts.key?(:expected_public_key)
|
|
191
|
+
key = opts[:expected_public_key]
|
|
192
|
+
raise ArgumentError, 'expected_public_key must be 32 nonzero raw bytes' unless key.is_a?(String) && key.bytesize == 32 && key.bytes.any?(&:positive?)
|
|
193
|
+
end
|
|
194
|
+
raise ArgumentError, 'previous_node requires expected_public_key continuity' if opts.key?(:previous_node) && !opts[:expected_public_key]
|
|
164
195
|
|
|
165
196
|
timeout = opts.fetch(:timeout, 60)
|
|
166
197
|
delay = opts.fetch(:reboot_delay, 3)
|
|
@@ -300,7 +331,9 @@ module Meshtastic
|
|
|
300
331
|
transport: 'required - :tcp, :bluetooth or :serial for the restarted application',
|
|
301
332
|
connection: 'optional - connect options Hash with explicit host/address/block_dev; required without reconnect',
|
|
302
333
|
expected_version: 'required - exact firmware version reported by the intended application',
|
|
303
|
-
expected_node: 'optional - numeric node identity
|
|
334
|
+
expected_node: 'optional - strict numeric post-restart node identity; never relaxed by key continuity',
|
|
335
|
+
expected_public_key: 'optional - trusted pre-upgrade public identity, 32 raw bytes; compared with fresh local owner reply',
|
|
336
|
+
previous_node: 'optional - pre-upgrade numeric node for changed mapping output; requires expected_public_key',
|
|
304
337
|
reconnect: 'optional - callable accepting options Hash and returning a newly connected transport handle',
|
|
305
338
|
timeout: 'optional - entire reboot/reconnect/config/metadata deadline seconds, default 60',
|
|
306
339
|
reboot_delay: 'optional - initial wait for loader restart, default 3 seconds'
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Meshtastic
|
|
4
|
+
module Admin
|
|
5
|
+
# Firmware v2.8.1.8e6a88d: reject lossy writes rather than emulate clamping.
|
|
6
|
+
module Validation
|
|
7
|
+
public_class_method def self.validate(opts = {})
|
|
8
|
+
message = opts.fetch(:message)
|
|
9
|
+
message.class.descriptor.each do |field|
|
|
10
|
+
value = message[field.name]
|
|
11
|
+
values = field.label == :repeated ? value : [value]
|
|
12
|
+
values.each do |item|
|
|
13
|
+
if field.type == :enum
|
|
14
|
+
known = item.is_a?(Symbol) ? field.subtype.lookup_name(item) : field.subtype.lookup_value(item)
|
|
15
|
+
raise ArgumentError, "unknown enum for #{field.name}" if known.nil?
|
|
16
|
+
elsif field.type == :message && item
|
|
17
|
+
validate(message: item)
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
if message.is_a?(Meshtastic::Config::SecurityConfig)
|
|
22
|
+
keys = [message.private_key, message.public_key] + message.admin_key.to_a
|
|
23
|
+
raise ArgumentError, 'security keys must contain zero or 32 bytes; at most three admin keys' unless keys.all? { |key| [0, 32].include?(key.bytesize) } && message.admin_key.length <= 3
|
|
24
|
+
end
|
|
25
|
+
if message.is_a?(Meshtastic::Channel)
|
|
26
|
+
raise ArgumentError, 'channel index must be from 0 through 7' unless message.index.between?(0, 7)
|
|
27
|
+
elsif message.is_a?(Meshtastic::ChannelSettings)
|
|
28
|
+
raise ArgumentError, 'PSK must contain 0, 1, 16, or 32 raw bytes' unless [0, 1, 16, 32].include?(message.psk.bytesize)
|
|
29
|
+
raise ArgumentError, 'channel name must be fewer than 12 UTF-8 bytes without NUL' unless message.name.valid_encoding? && message.name.bytesize < 12 && !message.name.include?("\0")
|
|
30
|
+
end
|
|
31
|
+
if message.is_a?(Meshtastic::Config) || message.is_a?(Meshtastic::ModuleConfig)
|
|
32
|
+
raise ArgumentError, 'exactly one config section is required' unless message.payload_variant
|
|
33
|
+
raise ArgumentError, 'sessionkey is request-only; device_ui requires store_ui_config' if %i[sessionkey device_ui].include?(message.payload_variant)
|
|
34
|
+
end
|
|
35
|
+
owner(message: message) if message.is_a?(Meshtastic::User)
|
|
36
|
+
ham(message: message.set_ham_mode) if message.is_a?(Meshtastic::AdminMessage) && (message.payload_variant == :set_ham_mode)
|
|
37
|
+
message
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private_class_method def self.owner(opts = {})
|
|
41
|
+
message = opts[:message]
|
|
42
|
+
name(value: message.long_name, label: 'owner long_name', limit: 24)
|
|
43
|
+
name(value: message.short_name, label: 'owner short_name', limit: 4)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private_class_method def self.ham(opts = {})
|
|
47
|
+
message = opts[:message]
|
|
48
|
+
name(value: message.call_sign, label: 'ham call_sign', limit: 7)
|
|
49
|
+
raise ArgumentError, 'ham call_sign is required' if message.call_sign.empty?
|
|
50
|
+
|
|
51
|
+
name(value: message.long_name, label: 'ham long_name', limit: 14)
|
|
52
|
+
name(value: message.short_name, label: 'ham short_name', limit: 4)
|
|
53
|
+
composed = message.long_name.empty? ? message.call_sign : "#{message.call_sign}//#{message.long_name}"
|
|
54
|
+
name(value: composed, label: 'ham composed owner long_name', limit: 24)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
private_class_method def self.name(opts = {})
|
|
58
|
+
value = opts[:value]
|
|
59
|
+
valid = value.encoding == Encoding::UTF_8 && value.valid_encoding? && !value.include?("\0") && value.bytesize <= opts[:limit]
|
|
60
|
+
valid &&= value.empty? || !value.match?(/\A[ \t\r\n\v\f]*\z/)
|
|
61
|
+
raise ArgumentError, "#{opts[:label]} must be nonblank UTF-8 without NUL, at most #{opts[:limit]} bytes (empty means omitted)" unless valid
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
public_class_method def self.authors
|
|
65
|
+
"AUTHOR(S):\n 0day Inc. <support@0dayinc.com>\n"
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
public_class_method def self.help
|
|
69
|
+
puts "USAGE:
|
|
70
|
+
# Validate semantics without changing data or contacting hardware.
|
|
71
|
+
#{self}.validate(message: 'required - protobuf message to validate against firmware v2.8.1.8e6a88d')
|
|
72
|
+
# Display the module authors.
|
|
73
|
+
#{self}.authors
|
|
74
|
+
"
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|