Module: Meshtastic::Admin::Backup

Defined in:
lib/meshtastic/admin/backup.rb

Overview

Host-side snapshots, not firmware filesystem backup commands.

Constant Summary collapse

FORMAT =
'meshtastic-admin-backup'
WARNING =
'Contains secrets returned by firmware. Firmware may redact secrets; absent/default values are not proof of completeness. Owner identity and hardware metadata are excluded. ACKs do not prove persistence.'
CONFIG_TYPES =
%i[DEVICE_CONFIG POSITION_CONFIG POWER_CONFIG NETWORK_CONFIG DISPLAY_CONFIG LORA_CONFIG BLUETOOTH_CONFIG SECURITY_CONFIG].freeze
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
OWNER_FIELDS =
%w[long_name short_name is_licensed].freeze

Class Method Summary collapse

Class Method Details

.authorsObject



515
516
517
# File 'lib/meshtastic/admin/backup.rb', line 515

public_class_method def self.authors
  "AUTHOR(S):\n        0day Inc. <[email protected]>\n"
end

.export(opts = {}) ⇒ Object



31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
# File 'lib/meshtastic/admin/backup.rb', line 31

public_class_method def self.export(opts = {})
  validate_options(options: opts, operation: :export)
  connection = connection_options(opts)
  return export_profile(opts.merge(connection: connection)) if opts.fetch(:format, :json) == :device_profile

  records = []
  selection(opts).each do |section, slot|
    value = Admin.request(connection.merge(read_command(section: section, slot: slot)))[:value]
    value = Meshtastic::User.new(long_name: value.long_name, short_name: value.short_name, is_licensed: value.is_licensed) if section == 'owner'
    json = JSON.parse(value.class.encode_json(value, emit_defaults: true, preserve_proto_fieldnames: true))
    json.select! { |key, _| OWNER_FIELDS.include?(key) } if section == 'owner'
    records << { 'section' => section, 'slot' => slot, 'value' => json }
  end
  document = { 'format' => FORMAT, 'version' => 1, 'warning' => WARNING, 'records' => records }
  validate_document(document: document)
  if opts[:path]
    File.open(opts[:path], File::WRONLY | File::CREAT | File::EXCL | File::NOFOLLOW, 0o600) do |file|
      file.chmod(0o600)
      file.write(JSON.pretty_generate(document))
      file.flush
      file.fsync
    end
  end
  { status: :exported, count: records.length, backup: document, warnings: [WARNING] }
end

.helpObject



519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
# File 'lib/meshtastic/admin/backup.rb', line 519

public_class_method def self.help
  puts "USAGE:
    # Export fresh selected configuration sections.
    #{self}.export(
      transport_obj: 'required - connected Serial, Bluetooth or TCP handle',
      format: 'optional - :json (default version-1 Hash) or :device_profile (binary String in result[:backup]); result also has status, count and warnings',
      path: 'optional - new secret JSON or binary .cfg file matching format; never overwritten; mode 0600',
      to: 'optional - explicit unicast target; defaults to local node',
      timeout: 'optional - positive finite per-request seconds; default 10',
      config_types: 'optional - ConfigType symbols; default eight writable core sections',
      module_config_types: 'optional - ModuleConfigType symbols; default empty',
      channel_indexes: 'optional - unique zero-based indexes 0 through 7; default all eight; binary requires contiguous prefix from 0 with PRIMARY then SECONDARY roles, no disabled slots',
      include_owner: 'optional - portable owner names and license flag; binary also preserves present is_unmessagable; default true',
      include_ui: 'optional - dedicated UI configuration; default false; true is rejected for binary before requests',
      fixed_position: 'optional - binary only: explicit Position protobuf or field Hash; no getter exists; omitted by default',
      include_ringtone: 'optional - binary only: fresh ringtone including empty string; boolean default false',
      include_canned_messages: 'optional - binary only: fresh canned messages including empty string; boolean default false',
      channel: 'optional - mesh transport channel index; default Admin behavior',
      hop_limit: 'optional - mesh transport hop limit; default Admin behavior'
    )
    # Restore with bounded ACKs or exact timeout readback; never replay writes.
    #{self}.import(
      transport_obj: 'required - connected Serial, Bluetooth or TCP handle; no MQTT',
      path: 'optional - existing JSON or binary DeviceProfile .cfg file; mutually exclusive with backup',
      backup: 'optional - versioned JSON Hash, JSON String or binary DeviceProfile String; mutually exclusive with path',
      format: 'optional - :auto (default), :json or :device_profile; cfg paths select binary, other content is validated',
      dry_run: 'optional - validate and plan without any radio requests; default false',
      edit_transaction: 'optional - begin/commit on firmware known to support edits; default false',
      verify: 'optional - additional fresh comparison after completed writes/commit; timeout recovery reads occur regardless; not durability proof; default false',
      timeout: 'optional - positive finite seconds per request; default 10',
      to: 'optional - explicit unicast target; defaults to connected local node',
      channel: 'optional - mesh transport channel index; default Admin behavior',
      hop_limit: 'optional - mesh transport hop limit; default Admin behavior'
    )
    # Contains secrets; firmware may redact values. Never logs document contents.
    # Display the module authors.
    #{self}.authors
  "
end

.import(opts = {}) ⇒ Object



115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
# File 'lib/meshtastic/admin/backup.rb', line 115

public_class_method def self.import(opts = {})
  validate_options(options: opts, operation: :import)
  connection = connection_options(opts)
  raise ArgumentError, 'supply exactly one of path or backup' unless opts.key?(:path) ^ opts.key?(:backup)

  document = opts[:backup]
  if opts.key?(:path)
    document = File.open(opts[:path], File::RDONLY | File::NOFOLLOW) do |file|
      raise ArgumentError, 'backup path must be a regular file' unless file.stat.file?

      file.binmode
      file.read
    end
  end
  entries = import_plan(document: document, format: opts.fetch(:format, :auto), path: opts[:path])
  plan = entries.each_with_index.sort_by do |entry, index|
    priority = if entry[:section] == 'config'
                 { 'LORA_CONFIG' => 20, 'BLUETOOTH_CONFIG' => 21, 'NETWORK_CONFIG' => 22, 'SECURITY_CONFIG' => 23 }.fetch(entry[:slot], 0)
               else
                 entry[:section] == 'channel' ? 10 : 0
               end
    [priority, index]
  end.map(&:first)
  report = { status: :dry_run, planned: plan.length, attempted: 0, acknowledged: 0, readback_confirmed: 0, persistence_verified: false,
             transaction: :not_requested, records: [], plan: plan.map { |entry| entry.slice(:section, :slot) }, warnings: [WARNING] }
  return report if opts.fetch(:dry_run, false)

  failure = nil
  if opts.fetch(:edit_transaction, false) && !plan.empty?
    failure = { operation: :begin_edit_settings }
    report[:transaction] = :begin_uncertain
    Admin.request(connection.merge(begin_edit_settings: true))
    report[:transaction] = :open
  end
  plan.each do |entry|
    field = { 'owner' => :set_owner, 'config' => :set_config, 'module_config' => :set_module_config,
              'channel' => :set_channel, 'ui' => :store_ui_config, 'fixed_position' => :set_fixed_position,
              'ringtone' => :set_ringtone_message, 'canned_messages' => :set_canned_message_module_messages }.fetch(entry[:section])
    failure = entry.slice(:section, :slot).merge(operation: field)
    report[:attempted] += 1
    status = apply_entry(connection: connection, field: field, entry: entry, failure: failure)
    report[status] += 1
    report[:records] << entry.slice(:section, :slot).merge(status: status)
  end
  if report[:transaction] == :open
    failure = { operation: :commit_edit_settings }
    report[:transaction] = :commit_uncertain
    Admin.request(connection.merge(commit_edit_settings: true))
    report[:transaction] = :commit_acknowledged
  end
  report[:status] = report[:readback_confirmed].positive? ? :applied : :acknowledged
  verify_readback(connection: connection, plan: plan, report: report) if opts.fetch(:verify, false)
  report
rescue StandardError => e
  raise unless failure

  report[:status] = :partial_failure
  report[:failure] = failure.merge(error: e.class.name)
  report[:failure][:reason] = e.reason if e.is_a?(Admin::RoutingError)
  report
end