Class: Msf::Sessions::Modem::Quectel::Driver

Inherits:
Object
  • Object
show all
Defined in:
lib/msf/base/sessions/modem/quectel.rb

Overview

Encapsulates serial I/O and URC parsing for the Quectel module.

Initialization only opens the port, configures termios, and starts the background reader thread. The caller is responsible for the startup sequence (AT probe, ATE0, leftover-socket cleanup, health watchdog) so that it can interleave its own status output between steps.

Constant Summary collapse

TCGETS =

Linux termios constants for in-process serial port configuration. Source: asm-generic/ioctls.h and asm-generic/termbits.h (NCCS=19). Using ioctl avoids shelling out to stty.


0x5401
TCSETSW =

get termios struct

0x5403
TERMIOS_SIZE =

drain then apply termios struct

36
BAUD_CONSTANTS =

Baud-rate values encoded in c_cflag (B* constants)

{
   9_600 => 0x0000_000D,
  19_200 => 0x0000_000E,
  38_400 => 0x0000_000F,
  57_600 => 0x0000_1001,
 115_200 => 0x0000_1002,
 230_400 => 0x0000_1003,
 460_800 => 0x0000_1004,
 921_600 => 0x0000_1007,
}.freeze
CBAUD =

baud-rate mask in c_cflag

0x0000_100F
CSIZE =

character-size mask

0x0000_0030
CS8 =

8-bit characters

0x0000_0030
CSTOPB =

2 stop bits (clear = 1 stop bit)

0x0000_0040
CREAD =

enable receiver

0x0000_0080
PARENB =

parity enable

0x0000_0100
CRTSCTS =

RTS/CTS hardware flow control

0x8000_0000
IFLAG_RAW_CLEAR =

c_iflag bits cleared by cfmakeraw

0x0001 |  # IGNBRK
0x0002 |  # BRKINT
0x0008 |  # PARMRK
0x0020 |  # ISTRIP
0x0040 |  # INLCR
0x0080 |  # IGNCR
0x0100 |  # ICRNL
0x0400 |  # IXON
0x1000
OPOST =

IXOFF

0x0000_0001
LFLAG_RAW_CLEAR =

c_lflag bits cleared by cfmakeraw

0x0001 |  # ISIG  - no signal generation (no Ctrl-C etc.)
0x0002 |  # ICANON - line-by-line processing off
0x0008 |  # ECHO
0x0010 |  # ECHOE
0x0020 |  # ECHOK
0x0040 |  # ECHONL
0x8000
VTIME_IDX =

IEXTEN

5
VMIN_IDX =

c_cc index: read timeout (tenths of a second)

6

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(port, baud, framework, cfg) ⇒ Driver

c_cc index: minimum bytes before read returns



263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
# File 'lib/msf/base/sessions/modem/quectel.rb', line 263

def initialize(port, baud, framework, cfg)
  @framework = framework
  @serial = ::File.open(port, 'r+b')
  @serial.sync = true

  configure_serial_port(@serial, baud)

  @cfg = cfg

  @mutex        = Mutex.new    # serialize direct writes/reads as needed
  @cmd_mutex    = Mutex.new
  @at_lock      = Mutex.new    # serialize all AT commands (prevents interleaving)
  @pending_cmds = []           # FIFO of CmdWaiter

  @line_buf    = ''.b
  @reader_stop = false
  @rdy_event   = Concurrent::Event.new

  # Serialize QIOPEN operations (one open in-flight at a time)
  @open_lock        = Mutex.new
  @pending_send_sid = nil

  @id_mutex = Mutex.new
  @free_ids = (0...@cfg[:modem_sockets]).to_a
  @conns    = {}              # sid -> Connection

  @modem_ready       = true
  @ready_mutex       = Mutex.new
  @health_fail_count = 0
  @health_stop       = false
  @closed = false

  # Spawn dedicated reader thread
  @reader_thread = @framework.threads.spawn('cellular_modem_reader', false) do
    reader_loop
  end

rescue ::Exception
  # Stop background threads that may have been spawned before the failure.
  close
  raise
end

Instance Attribute Details

#at_lockObject (readonly)

Returns the value of attribute at_lock.



202
203
204
# File 'lib/msf/base/sessions/modem/quectel.rb', line 202

def at_lock
  @at_lock
end

#cfgObject (readonly)

Returns the value of attribute cfg.



200
201
202
# File 'lib/msf/base/sessions/modem/quectel.rb', line 200

def cfg
  @cfg
end

#mutexObject (readonly)

Returns the value of attribute mutex.



200
201
202
# File 'lib/msf/base/sessions/modem/quectel.rb', line 200

def mutex
  @mutex
end

#open_lockObject (readonly)

Returns the value of attribute open_lock.



203
204
205
# File 'lib/msf/base/sessions/modem/quectel.rb', line 203

def open_lock
  @open_lock
end

#pending_send_sidObject

Returns the value of attribute pending_send_sid.



201
202
203
# File 'lib/msf/base/sessions/modem/quectel.rb', line 201

def pending_send_sid
  @pending_send_sid
end

#serialObject (readonly)

Returns the value of attribute serial.



200
201
202
# File 'lib/msf/base/sessions/modem/quectel.rb', line 200

def serial
  @serial
end

Class Method Details

.supported_platform?Boolean

Serial port configuration uses Linux-specific termios ioctls (TCGETS/TCSETSW).

Returns:

  • (Boolean)


206
207
208
# File 'lib/msf/base/sessions/modem/quectel.rb', line 206

def self.supported_platform?
  RUBY_PLATFORM.include?('linux')
end

Instance Method Details

#allocate_idObject

— SID pool management —



504
505
506
507
508
509
510
511
# File 'lib/msf/base/sessions/modem/quectel.rb', line 504

def allocate_id
  @id_mutex.synchronize do
    sid = @free_ids.shift
    raise ::RuntimeError, 'No socket IDs available' unless sid

    sid
  end
end

#closeObject



420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
# File 'lib/msf/base/sessions/modem/quectel.rb', line 420

def close
  return if @closed

  @closed = true
  @health_stop = true
  @reader_stop = true
  conns = []
  if @id_mutex && @conns
    @id_mutex.synchronize do
      conns = @conns.values
      @conns.clear
      @free_ids = (0...@cfg[:modem_sockets]).to_a if @free_ids && @cfg
    end
  else
    conns = @conns&.values || []
    @conns&.clear
  end
  conns.each(&:mark_closed)

  begin
    @serial.close if @serial
  rescue ::IOError
  end

  stop_thread(@health_thread)
  stop_thread(@reader_thread)
end

#closed?Boolean

readiness/health helpers =====================================

Returns:

  • (Boolean)


308
309
310
# File 'lib/msf/base/sessions/modem/quectel.rb', line 308

def closed?
  @closed
end

#connection_for_id(sid) ⇒ Object



528
529
530
531
532
# File 'lib/msf/base/sessions/modem/quectel.rb', line 528

def connection_for_id(sid)
  @id_mutex.synchronize do
    @conns[sid]
  end
end

#handle_line(line) ⇒ Object



654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
# File 'lib/msf/base/sessions/modem/quectel.rb', line 654

def handle_line(line)
  # Avoid spewing raw binary to the console (can happen if echo is re-enabled after reboot)
  if line.bytes.any? { |b| b < 0x09 || (b > 0x0D && b < 0x20) || b == 0x7F }
    hex = line.bytes.first(32).map { |b| format('%02X', b) }.join(' ')
    log_debug("URC: [binary #{line.bytesize} bytes] #{hex}#{line.bytesize > 32 ? ' ...' : ''}")
  else
    log_debug("URC: #{line}")
  end

  # Boot ready
  if line == 'RDY'
    dlog('[URC] RDY', 'cellular_modem')
    @rdy_event.set
    return
  end

  if line =~ /(POWERED DOWN|POWER DOWN|NORMAL POWER DOWN)/i
    wlog("[URC] #{line}", 'cellular_modem')
    set_modem_ready(false, reason: line)
    return
  end

  # First, feed pending command waiter if any
  @cmd_mutex.synchronize do
    if (waiter = @pending_cmds.first)
      case line
      when 'OK'
        waiter.buf << 'OK'
        waiter.ok  = true
        waiter.event.set
        @pending_cmds.shift
      when /^ERROR/, 'SEND FAIL'
        waiter.buf << line
        waiter.ok  = false
        waiter.event.set
        @pending_cmds.shift
      else
        waiter.buf << line
      end
    end
  end

  # +QIOPEN: sid,err
  if line.start_with?('+QIOPEN:')
    begin
      rest = line.split(':', 2)[1].strip
      parts = rest.split(',').map(&:strip)
      sid  = parts[0].to_i
      err  = parts[1].to_i
      if (conn = connection_for_id(sid))
        conn.open_ok  = (err == 0)
        conn.open_err = err
        conn.open_event.set
      end
    rescue ::StandardError
    end
    return
  end

  # +QIURC: "recv",sid,len
  if line.start_with?('+QIURC: "recv"')
    begin
      parts = line.split(',')
      sid   = parts[1].to_i
      len   = parts[2].to_i
    rescue ::StandardError
      return
    end

    payload = ''.b
    while payload.bytesize < len
      begin
        chunk = @serial.read(len - payload.bytesize)
      rescue ::EOFError, ::IOError
        break
      end
      next unless chunk
      payload << chunk
    end

    if (conn = connection_for_id(sid))
      conn.push_payload(payload)
    end
    return
  end

  # +QIURC: "closed",sid
  if line.start_with?('+QIURC: "closed"')
    begin
      sid = line.split(',')[1].to_i
    rescue ::StandardError
      return
    end
    if (conn = connection_for_id(sid))
      conn.mark_closed
    end
    return
  end

  # QISEND results / early rejects mapped via pending_send_sid
  if ['SEND OK', 'SEND FAIL', 'ERROR'].include?(line)
    sid = @pending_send_sid
    if sid && (conn = connection_for_id(sid))
      ok = (line == 'SEND OK')
      conn.ack_ok = ok
      conn.ack_event.set

      # If the modem rejects QISEND before issuing a '>' prompt, wake the sender
      # that's blocked waiting on prompt_event.
      if !ok && line == 'ERROR'
        conn.prompt_ok = false
        conn.prompt_event.set
      end
    end
    return
  end
end

#log_debug(msg) ⇒ Object



448
449
450
# File 'lib/msf/base/sessions/modem/quectel.rb', line 448

def log_debug(msg)
  dlog(msg, 'cellular_modem')
end

#modem_ready?Boolean

Returns:

  • (Boolean)


312
313
314
# File 'lib/msf/base/sessions/modem/quectel.rb', line 312

def modem_ready?
  @ready_mutex.synchronize { @modem_ready }
end

#open_tcp_client_socket(host, port) ⇒ Connection?

Open an outbound TCP socket through the Quectel modem.

Parameters:

  • host (String)

    remote IP address or hostname

  • port (Integer)

    remote TCP port

Returns:



540
541
542
# File 'lib/msf/base/sessions/modem/quectel.rb', line 540

def open_tcp_client_socket(host, port)
  open_socket('TCP', host, port, 'open_tcp_connection')
end

#open_udp_socket(host, port) ⇒ Connection?

Open an outbound UDP socket through the Quectel modem.

Parameters:

  • host (String)

    remote IP address or hostname

  • port (Integer)

    remote UDP port

Returns:



550
551
552
# File 'lib/msf/base/sessions/modem/quectel.rb', line 550

def open_udp_socket(host, port)
  open_socket('UDP', host, port, 'open_udp_connection')
end

#reader_loopObject

— Reader loop and line handler —



622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
# File 'lib/msf/base/sessions/modem/quectel.rb', line 622

def reader_loop
  loop do
    break if @reader_stop
    ch = nil
    begin
      ch = @serial.read(1)
    rescue ::EOFError, ::IOError => e
      elog("serial read error: #{e.class} #{e.message}", 'cellular_modem')
      break
    end
    next unless ch
    # QISEND '>' prompt (single char, no CRLF)
    if ch == '>'
      sid = @pending_send_sid
      if sid
        conn = connection_for_id(sid)
        if conn
          conn.prompt_ok = true
          conn.prompt_event.set
        end
      end
      next
    end
    @line_buf << ch
    if @line_buf.end_with?("\r\n")
      line = @line_buf.strip
      @line_buf.clear
      handle_line(line)
    end
  end
end

#register_connection(sid, conn) ⇒ Object



522
523
524
525
526
# File 'lib/msf/base/sessions/modem/quectel.rb', line 522

def register_connection(sid, conn)
  @id_mutex.synchronize do
    @conns[sid] = conn
  end
end

#reinitialize_after_rebootObject



399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
# File 'lib/msf/base/sessions/modem/quectel.rb', line 399

def reinitialize_after_reboot
  # After a power cycle, the module often resets settings like echo.
  # If echo is enabled, the modem will echo QISEND payload bytes back on the AT port,
  # which can look like "binary URCs" in the console. Re-assert ATE0 once we're back.
  begin
    send_at('ATE0', @cfg[:cmd_timeout])
  rescue ::StandardError => e
    log_debug("reinitialize_after_reboot: ATE0 failed: #{e.class} #{e.message}")
  end

  # Best-effort drain any buffered garbage that may have accumulated during reboot.
  begin
    loop do
      r, _w, _e = ::IO.select([@serial], nil, nil, 0)
      break unless r && r.include?(@serial)
      @serial.read_nonblock(4096)
    end
  rescue ::IO::WaitReadable, ::EOFError, ::IOError
  end
end

#release_id(sid) ⇒ Object



513
514
515
516
517
518
519
520
# File 'lib/msf/base/sessions/modem/quectel.rb', line 513

def release_id(sid)
  return unless sid

  @id_mutex.synchronize do
    @conns.delete(sid)
    @free_ids << sid unless @free_ids.include?(sid)
  end
end

#send_at(cmd, timeout = nil) ⇒ Object

— AT helper with CmdWaiter —



588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
# File 'lib/msf/base/sessions/modem/quectel.rb', line 588

def send_at(cmd, timeout = nil)
  timeout ||= @cfg[:cmd_timeout]
  @at_lock.synchronize do
    waiter = CmdWaiter.new(cmd)
    @cmd_mutex.synchronize do
      @pending_cmds << waiter
    end

    log_debug("-> AT #{cmd}")
    @mutex.synchronize do
      @serial.write("#{cmd}\r")
    end

    unless waiter.event.wait(timeout)
      @cmd_mutex.synchronize do
        @pending_cmds.delete(waiter)
      end
      raise ::Rex::TimeoutError, "AT cmd timeout: #{cmd}"
    end

    waiter.buf.each do |l|
      log_debug("<- #{l}")
    end

    unless waiter.ok
      raise ::Rex::RuntimeError, "AT cmd error: #{cmd}"
    end

    waiter.buf.join("\n")
  end
end

#set_modem_ready(val, reason: nil) ⇒ Object



316
317
318
319
320
321
322
323
324
325
326
327
328
329
# File 'lib/msf/base/sessions/modem/quectel.rb', line 316

def set_modem_ready(val, reason: nil)
  changed = false
  @ready_mutex.synchronize do
    if @modem_ready != val
      @modem_ready = val
      changed = true
    end
  end
  return unless changed

  msg = val ? "[MODEM] READY#{reason ? " (#{reason})" : ''}" \
            : "[MODEM] NOT READY#{reason ? " (#{reason})" : ''}"
  val ? ilog(msg, 'cellular_modem') : wlog(msg, 'cellular_modem')
end

#start_health_watchdogObject



367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
# File 'lib/msf/base/sessions/modem/quectel.rb', line 367

def start_health_watchdog
  @health_stop = false
  @health_thread = @framework.threads.spawn('cellular_modem_health_watchdog', false) do
    loop do
      break if @health_stop
      begin
        # Probe modem liveness
        send_at('AT', @cfg[:healthcheck_timeout])
        @health_fail_count = 0

        # If we were previously NOT READY, run minimal re-init (echo off) after reboot
        unless modem_ready?
          reinitialize_after_reboot
          set_modem_ready(true, reason: 'health probe OK')
        end
      rescue ::Rex::TimeoutError, ::Rex::RuntimeError
        @health_fail_count += 1
        if @health_fail_count >= @cfg[:healthcheck_max_fails]
          set_modem_ready(false, reason: "health probe failed #{@health_fail_count}x")
        end
      rescue ::StandardError => e
        @health_fail_count += 1
        log_debug("health probe exception: #{e.class} #{e.message}")
        if @health_fail_count >= @cfg[:healthcheck_max_fails]
          set_modem_ready(false, reason: "health probe exception #{@health_fail_count}x")
        end
      end
      ::Rex.sleep(@cfg[:healthcheck_interval])
    end
  end
end

#startup_wait_for_ok(total_timeout_s, interval_s, probe_timeout_s) ⇒ Object

Poll AT until the modem responds with OK, up to total_timeout_s seconds (0 = no timeout). Raises Rex::TimeoutError if the deadline is exceeded.



333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
# File 'lib/msf/base/sessions/modem/quectel.rb', line 333

def startup_wait_for_ok(total_timeout_s, interval_s, probe_timeout_s)
  total_timeout_s = total_timeout_s.to_i
  interval_s = interval_s.to_f
  interval_s = 1.0 if interval_s <= 0

  # total_timeout_s == 0 means "no timeout" (wait indefinitely until AT returns OK)
  deadline = (total_timeout_s > 0) ? (::Time.now + total_timeout_s) : nil

  loop do
    if deadline && ::Time.now > deadline
      set_modem_ready(false, reason: 'startup probe timed out')
      raise ::Rex::TimeoutError, "Startup AT probe timed out after #{total_timeout_s}s"
    end

    begin
      send_at('AT', probe_timeout_s)
      set_modem_ready(true, reason: 'startup probe OK')
      return
    rescue ::Rex::TimeoutError, ::Rex::RuntimeError
      # modem not ready yet - keep polling
    rescue ::StandardError => e
      log_debug("startup AT probe error: #{e.class} #{e.message}")
    end

    ::Rex.sleep(interval_s)
  end
end

#wait_for_rdy(timeout) ⇒ Object

Wait up to timeout seconds for the RDY URC. Best-effort; returns true if seen, false if the modem was already past the boot banner.



363
364
365
# File 'lib/msf/base/sessions/modem/quectel.rb', line 363

def wait_for_rdy(timeout)
  @rdy_event.wait(timeout)
end