Skip to main content

starla_controller/
telnet.rs

1//! Telnet command interface for receiving measurement requests
2//!
3//! The RIPE Atlas controller sends measurement specifications over the
4//! reverse-tunneled telnet connection. This module handles parsing and
5//! dispatching these commands.
6
7use serde::{Deserialize, Serialize};
8use tokio::io::{AsyncRead, AsyncWrite, AsyncWriteExt, BufReader};
9use tokio::net::TcpListener;
10use tokio::sync::mpsc;
11use tracing::{debug, error, trace, warn};
12
13/// Telnet command received from controller
14#[derive(Debug, Clone, Serialize, Deserialize)]
15pub enum TelnetCommand {
16    /// Ping measurement
17    Ping(PingSpec),
18    /// Traceroute measurement
19    Traceroute(TracerouteSpec),
20    /// DNS measurement
21    Dns(DnsSpec),
22    /// HTTP measurement
23    Http(HttpSpec),
24    /// TLS/SSL measurement
25    Tls(TlsSpec),
26    /// NTP measurement
27    Ntp(NtpSpec),
28    /// Schedule a host-telemetry report (buddyinfo / rptaddrs).
29    /// Not a measurement — produces a synthesized RESULT line each interval.
30    HostTelemetry(HostTelemetrySpec),
31    /// Status query
32    Status,
33    /// Stop a running measurement
34    Stop(u64),
35    /// Ignored command (known but not handled, e.g., CRONTAB, internal
36    /// commands)
37    Ignored(String),
38    /// Unknown command
39    Unknown(String),
40}
41
42/// Which host-telemetry reporter the controller is asking us to run.
43#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
44pub enum HostTelemetryKind {
45    Buddyinfo,
46    Rptaddrs,
47}
48
49/// Host-telemetry scheduling spec parsed from a CRONLINE.
50#[derive(Debug, Clone, Serialize, Deserialize)]
51pub struct HostTelemetrySpec {
52    pub kind: HostTelemetryKind,
53    /// Interval between runs (0 = one-shot).
54    pub interval: u64,
55    /// Controller-assigned measurement id (from `-A`).
56    /// buddyinfo's official applet hardcodes 9001 so this is optional;
57    /// rptaddrs is invoked as `-A 9104 ...`.
58    pub msm_id: Option<u32>,
59    /// buddyinfo's lowmem threshold (KB) — recorded but not acted on.
60    pub lowmem: Option<u32>,
61}
62
63/// Common scheduling fields for recurring measurements
64#[derive(Debug, Clone, Default, Serialize, Deserialize)]
65pub struct ScheduleSpec {
66    /// Interval between measurements in seconds (0 = one-shot)
67    pub interval: u64,
68    /// Start time (Unix timestamp, 0 = now)
69    pub start_time: i64,
70    /// Stop time (Unix timestamp, 0 = never)
71    pub stop_time: i64,
72}
73
74/// Ping measurement specification
75#[derive(Debug, Clone, Serialize, Deserialize)]
76pub struct PingSpec {
77    pub msm_id: u64,
78    pub target: String,
79    pub af: u8, // 4 or 6
80    pub packets: u32,
81    pub size: u16,
82    pub packet_interval: u32, // ms between packets
83    pub spread: Option<u32>,  // random spread time in seconds
84    pub schedule: ScheduleSpec,
85}
86
87/// Traceroute measurement specification
88#[derive(Debug, Clone, Serialize, Deserialize)]
89pub struct TracerouteSpec {
90    pub msm_id: u64,
91    pub target: String,
92    pub af: u8,
93    pub protocol: String, // ICMP, UDP, TCP
94    pub paris: Option<u32>,
95    pub first_hop: u8,
96    pub max_hops: u8,
97    pub size: u16,
98    pub spread: Option<u32>,
99    pub schedule: ScheduleSpec,
100}
101
102/// DNS measurement specification
103#[derive(Debug, Clone, Serialize, Deserialize)]
104pub struct DnsSpec {
105    pub msm_id: u64,
106    pub target: String, // DNS server
107    pub af: u8,
108    pub protocol: String, // UDP, TCP
109    pub query_type: String,
110    pub query_class: String,
111    pub query_argument: String,
112    pub use_dnssec: bool,
113    pub recursion_desired: bool,
114    pub spread: Option<u32>,
115    pub schedule: ScheduleSpec,
116}
117
118/// HTTP measurement specification
119#[derive(Debug, Clone, Serialize, Deserialize)]
120pub struct HttpSpec {
121    pub msm_id: u64,
122    pub url: String,
123    pub method: String,
124    pub af: u8,
125    pub headers: Vec<String>,
126    pub body: Option<String>,
127    pub max_body_size: Option<u32>,
128    pub spread: Option<u32>,
129    pub schedule: ScheduleSpec,
130}
131
132/// TLS/SSL measurement specification
133#[derive(Debug, Clone, Serialize, Deserialize)]
134pub struct TlsSpec {
135    pub msm_id: u64,
136    pub target: String,
137    pub port: u16,
138    pub af: u8,
139    pub hostname: Option<String>,
140    pub spread: Option<u32>,
141    pub schedule: ScheduleSpec,
142}
143
144/// NTP measurement specification
145#[derive(Debug, Clone, Serialize, Deserialize)]
146pub struct NtpSpec {
147    pub msm_id: u64,
148    pub target: String,
149    pub af: u8,
150    pub packets: u32,
151    pub spread: Option<u32>,
152    pub schedule: ScheduleSpec,
153}
154
155/// Telnet server for receiving controller commands
156pub struct TelnetServer {
157    port: u16,
158    probe_id: u32,
159    session_id: std::sync::Arc<tokio::sync::RwLock<Option<String>>>,
160    command_tx: Option<mpsc::Sender<TelnetCommand>>,
161}
162
163// RIPE Atlas telnet protocol constants
164const ATLAS_LOGIN: &str = "C_TO_P_TEST_V1";
165const LOGIN_PREFIX: &str = "Atlas probe, see http://atlas.ripe.net/\r\n\r\n";
166const LOGIN_PROMPT: &str = " login: ";
167const PASSWORD_PROMPT: &str = "\r\nPassword: ";
168const RESULT_OK: &str = "OK\r\n\r\n";
169const BAD_PASSWORD: &str = "BAD_PASSWORD\r\n\r\n";
170#[allow(dead_code)]
171const BAD_COMMAND: &str = "BAD_COMMAND\r\n\r\n";
172
173impl TelnetServer {
174    /// Create a new telnet server
175    pub fn new(port: u16, probe_id: u32) -> Self {
176        Self {
177            port,
178            probe_id,
179            session_id: std::sync::Arc::new(tokio::sync::RwLock::new(None)),
180            command_tx: None,
181        }
182    }
183
184    /// Create a new telnet server with command channel
185    pub fn with_channel(port: u16, probe_id: u32, tx: mpsc::Sender<TelnetCommand>) -> Self {
186        Self {
187            port,
188            probe_id,
189            session_id: std::sync::Arc::new(tokio::sync::RwLock::new(None)),
190            command_tx: Some(tx),
191        }
192    }
193
194    /// Set the session ID (received from controller INIT)
195    pub async fn set_session_id(&self, session_id: String) {
196        debug!("Setting session ID for telnet authentication");
197        *self.session_id.write().await = Some(session_id);
198    }
199
200    /// Get the port
201    pub fn port(&self) -> u16 {
202        self.port
203    }
204
205    /// Run the telnet server
206    pub async fn run(&self) -> anyhow::Result<()> {
207        let addr = format!("127.0.0.1:{}", self.port);
208        let listener = TcpListener::bind(&addr).await?;
209        debug!("Telnet server listening on {}", addr);
210
211        loop {
212            let (socket, remote_addr) = listener.accept().await?;
213            debug!("Accepted telnet connection from {}", remote_addr);
214
215            let command_tx = self.command_tx.clone();
216            let probe_id = self.probe_id;
217            let session_id = self.session_id.clone();
218
219            tokio::spawn(async move {
220                if let Err(e) = handle_connection(socket, command_tx, probe_id, session_id).await {
221                    error!("Error handling telnet connection: {}", e);
222                }
223                debug!("Telnet connection from {} closed", remote_addr);
224            });
225        }
226    }
227}
228
229// Telnet protocol constants
230const IAC: u8 = 255; // Interpret As Command
231const WILL: u8 = 251;
232const WONT: u8 = 252;
233const DO: u8 = 253;
234const DONT: u8 = 254;
235const SB: u8 = 250; // Subnegotiation Begin
236const SE: u8 = 240; // Subnegotiation End
237
238// Telnet options
239const TELOPT_ECHO: u8 = 1;
240const TELOPT_SGA: u8 = 3; // Suppress Go Ahead
241const TELOPT_NAWS: u8 = 31; // Negotiate About Window Size
242
243/// Connection state for the Atlas telnet protocol
244#[derive(Debug, Clone, Copy, PartialEq)]
245enum ConnectionState {
246    /// Waiting for login name
247    AwaitingLoginName,
248    /// Waiting for password (session ID)
249    AwaitingPassword,
250    /// Authenticated, ready for commands
251    Authenticated,
252}
253
254/// Handle a single telnet connection with Atlas authentication.
255///
256/// Accepts any async stream: works with both TCP sockets (for local testing)
257/// and SSH channel streams (for production controller connections).
258pub async fn handle_connection(
259    stream: impl AsyncRead + AsyncWrite + Unpin + Send + 'static,
260    command_tx: Option<mpsc::Sender<TelnetCommand>>,
261    probe_id: u32,
262    session_id: std::sync::Arc<tokio::sync::RwLock<Option<String>>>,
263) -> anyhow::Result<()> {
264    let (reader, mut writer) = tokio::io::split(stream);
265
266    // Send initial telnet negotiation (like busybox telnetd does)
267    let initial_iacs: &[u8] = &[
268        IAC,
269        DO,
270        TELOPT_ECHO, // Ask client to echo
271        IAC,
272        DO,
273        TELOPT_NAWS, // Ask client about window size
274        IAC,
275        WILL,
276        TELOPT_ECHO, // We will echo
277        IAC,
278        WILL,
279        TELOPT_SGA, // We will suppress go-ahead
280    ];
281
282    if let Err(e) = writer.write_all(initial_iacs).await {
283        warn!("Failed to send telnet negotiation: {}", e);
284    }
285
286    // Send the Atlas login banner
287    let hostname = hostname::get()
288        .map(|h: std::ffi::OsString| h.to_string_lossy().to_string())
289        .unwrap_or_else(|_| "unknown".to_string());
290
291    let banner = format!(
292        "{}Probe {} ({}){}",
293        LOGIN_PREFIX, probe_id, hostname, LOGIN_PROMPT
294    );
295    if let Err(e) = writer.write_all(banner.as_bytes()).await {
296        warn!("Failed to send login banner: {}", e);
297    }
298    let _ = writer.flush().await;
299
300    trace!("Sent telnet login banner for probe {}", probe_id);
301
302    let mut reader = BufReader::new(reader);
303    let mut raw_buf = [0u8; 4096];
304    let mut state = ConnectionState::AwaitingLoginName;
305    let mut line_buffer = String::new();
306
307    // Maximum line buffer size to prevent unbounded memory growth
308    // from a malicious or malfunctioning controller
309    const MAX_LINE_LEN: usize = 256 * 1024; // 256KB
310
311    loop {
312        use tokio::io::AsyncReadExt;
313        match reader.read(&mut raw_buf).await {
314            Ok(0) => break, // EOF
315            Ok(n) => {
316                // Filter out telnet IAC sequences and extract text
317                let text = filter_telnet_commands(&raw_buf[..n]);
318                if text.is_empty() {
319                    continue;
320                }
321
322                // Accumulate into line buffer
323                if line_buffer.len() + text.len() > MAX_LINE_LEN {
324                    error!(
325                        "Telnet line buffer exceeded {}KB, disconnecting",
326                        MAX_LINE_LEN / 1024
327                    );
328                    anyhow::bail!("Line buffer overflow");
329                }
330                line_buffer.push_str(&text);
331
332                // Process complete lines
333                while let Some(newline_pos) = line_buffer.find(['\n', '\r']) {
334                    let line: String = line_buffer.drain(..=newline_pos).collect();
335                    let line = line.trim();
336
337                    if line.is_empty() {
338                        continue;
339                    }
340
341                    debug!("Telnet state {:?}, received: '{}'", state, line);
342
343                    match state {
344                        ConnectionState::AwaitingLoginName => {
345                            if line == ATLAS_LOGIN {
346                                // Valid Atlas login, ask for password
347                                trace!("Atlas login received, requesting password");
348                                if let Err(e) = writer.write_all(PASSWORD_PROMPT.as_bytes()).await {
349                                    warn!("Failed to send password prompt: {}", e);
350                                }
351                                let _ = writer.flush().await;
352                                state = ConnectionState::AwaitingPassword;
353                            } else {
354                                // Echo back the login name (traditional mode - not supported)
355                                debug!("Unknown login '{}', closing connection", line);
356                                break;
357                            }
358                        }
359                        ConnectionState::AwaitingPassword => {
360                            // Check if password matches session ID
361                            let valid = {
362                                let sid = session_id.read().await;
363                                sid.as_ref().map(|s| s == line).unwrap_or(false)
364                            };
365
366                            if valid {
367                                debug!("Telnet session authenticated");
368                                if let Err(e) = writer.write_all(RESULT_OK.as_bytes()).await {
369                                    warn!("Failed to send OK: {}", e);
370                                }
371                                let _ = writer.flush().await;
372                                state = ConnectionState::Authenticated;
373                            } else {
374                                warn!("Bad password received");
375                                if let Err(e) = writer.write_all(BAD_PASSWORD.as_bytes()).await {
376                                    warn!("Failed to send BAD_PASSWORD: {}", e);
377                                }
378                                let _ = writer.flush().await;
379                                break; // Close connection on bad password
380                            }
381                        }
382                        ConnectionState::Authenticated => {
383                            trace!("Received command: {}", line);
384
385                            let command = parse_command(line);
386
387                            // Send acknowledgment
388                            let response = match &command {
389                                TelnetCommand::Unknown(s) => {
390                                    format!("ERROR: Unknown command: {}\r\n\r\n", s)
391                                }
392                                _ => RESULT_OK.to_string(),
393                            };
394
395                            if let Err(e) = writer.write_all(response.as_bytes()).await {
396                                // Broken pipe is expected when connection closes - log at debug
397                                // level
398                                debug!("Failed to send response: {}", e);
399                            }
400                            let _ = writer.flush().await;
401
402                            // Forward command to handler
403                            if let Some(ref tx) = command_tx {
404                                if let Err(e) = tx.send(command).await {
405                                    // Channel closed likely means shutdown is in progress
406                                    debug!("Command channel closed (shutdown in progress): {}", e);
407                                    break; // Stop processing commands
408                                }
409                            }
410                        }
411                    }
412                }
413            }
414            Err(e) => {
415                error!("Error reading from telnet: {}", e);
416                break;
417            }
418        }
419    }
420
421    Ok(())
422}
423
424/// Filter out telnet IAC sequences from raw data and return just the text
425fn filter_telnet_commands(data: &[u8]) -> String {
426    let mut result = Vec::new();
427    let mut i = 0;
428
429    while i < data.len() {
430        if data[i] == IAC && i + 1 < data.len() {
431            // Handle telnet command sequences
432            match data[i + 1] {
433                IAC => {
434                    // Escaped IAC (255 255) -> single 255
435                    result.push(IAC);
436                    i += 2;
437                }
438                WILL | WONT | DO | DONT => {
439                    // 3-byte command: IAC + cmd + option
440                    if i + 2 < data.len() {
441                        debug!(
442                            "Telnet: {:?} option {}",
443                            match data[i + 1] {
444                                WILL => "WILL",
445                                WONT => "WONT",
446                                DO => "DO",
447                                DONT => "DONT",
448                                _ => "?",
449                            },
450                            data[i + 2]
451                        );
452                        i += 3;
453                    } else {
454                        i += 2;
455                    }
456                }
457                SB => {
458                    // Subnegotiation: IAC SB ... IAC SE
459                    // Skip until we find IAC SE
460                    i += 2;
461                    while i + 1 < data.len() {
462                        if data[i] == IAC && data[i + 1] == SE {
463                            i += 2;
464                            break;
465                        }
466                        i += 1;
467                    }
468                }
469                240..=249 => {
470                    // 2-byte commands (NOP, etc.)
471                    i += 2;
472                }
473                _ => {
474                    // Unknown, skip 2 bytes
475                    i += 2;
476                }
477            }
478        } else {
479            // Regular data byte
480            result.push(data[i]);
481            i += 1;
482        }
483    }
484
485    String::from_utf8_lossy(&result).to_string()
486}
487
488/// Parse a command line into a TelnetCommand
489///
490/// Supports both JSON format and Atlas busybox CRONLINE format
491pub fn parse_command(cmd: &str) -> TelnetCommand {
492    // Try to parse as JSON first
493    if cmd.starts_with('{') {
494        if let Ok(spec) = serde_json::from_str::<serde_json::Value>(cmd) {
495            return parse_json_command(&spec);
496        }
497    }
498
499    // Try simple text commands
500    let parts: Vec<&str> = cmd.split_whitespace().collect();
501    if parts.is_empty() {
502        return TelnetCommand::Unknown(cmd.to_string());
503    }
504
505    match parts[0].to_uppercase().as_str() {
506        "STATUS" => TelnetCommand::Status,
507        "STOP" if parts.len() >= 2 => {
508            if let Ok(msm_id) = parts[1].parse() {
509                TelnetCommand::Stop(msm_id)
510            } else {
511                TelnetCommand::Unknown(cmd.to_string())
512            }
513        }
514        "CRONLINE" => parse_cronline(cmd),
515        "ONEOFF" => {
516            // ONEOFF <path> <measurement_command> <args...>
517            // One-shot measurement: parse as CRONLINE with interval=0
518            if parts.len() >= 3 {
519                // Reconstruct as a CRONLINE with interval=0 for the parser
520                let measurement_parts = &parts[2..]; // skip ONEOFF and path
521                let fake_cronline =
522                    format!("CRONLINE 0 0 0 UNIFORM 0 {}", measurement_parts.join(" "));
523                parse_cronline(&fake_cronline)
524            } else {
525                TelnetCommand::Unknown(cmd.to_string())
526            }
527        }
528        "CRONTAB" => {
529            // CRONTAB defines which cron directory following CRONLINEs belong to
530            trace!("CRONTAB command: {}", cmd);
531            TelnetCommand::Ignored(cmd.to_string())
532        }
533        _ => TelnetCommand::Unknown(cmd.to_string()),
534    }
535}
536
537/// Parse a CRONLINE command from the Atlas controller
538///
539/// Format: CRONLINE <interval> <offset> <end_time> <spread_type> <spread_value>
540/// <command> <args...>
541///
542/// Example:
543/// CRONLINE 240 274 1770761451 UNIFORM 3 evping -4 -c 3 -A "1001" -O
544/// /home/atlas/data/new/7 193.0.14.129
545fn parse_cronline(cmd: &str) -> TelnetCommand {
546    // Split the command, preserving quoted strings
547    let tokens = tokenize_cronline(cmd);
548
549    if tokens.len() < 7 {
550        warn!("CRONLINE too short: {}", cmd);
551        return TelnetCommand::Unknown(cmd.to_string());
552    }
553
554    // Parse CRONLINE header fields
555    // tokens[0] = "CRONLINE"
556    let interval: u64 = match tokens[1].parse() {
557        Ok(v) => v,
558        Err(_) => {
559            warn!("CRONLINE invalid interval '{}': {}", tokens[1], cmd);
560            return TelnetCommand::Unknown(cmd.to_string());
561        }
562    };
563    let _offset: u64 = tokens[2].parse().unwrap_or(0);
564    let end_time: i64 = tokens[3].parse().unwrap_or(0);
565    let _spread_type = &tokens[4]; // UNIFORM, etc.
566    let spread: u32 = tokens[5].parse().unwrap_or(0);
567
568    // The rest is the measurement command
569    let measurement_cmd = &tokens[6];
570    let measurement_args = &tokens[7..];
571
572    trace!(
573        "CRONLINE: interval={}, end_time={}, spread={}, cmd={}, args={:?}",
574        interval,
575        end_time,
576        spread,
577        measurement_cmd,
578        measurement_args
579    );
580
581    // Parse the measurement command
582    match measurement_cmd.as_str() {
583        "evping" => parse_evping(measurement_args, interval, end_time, spread),
584        "evtraceroute" => parse_evtraceroute(measurement_args, interval, end_time, spread),
585        "evtdig" => parse_evtdig(measurement_args, interval, end_time, spread),
586        "evhttpget" => parse_evhttpget(measurement_args, interval, end_time, spread),
587        "evsslgetcert" => parse_evsslgetcert(measurement_args, interval, end_time, spread),
588        "evntp" => parse_evntp(measurement_args, interval, end_time, spread),
589
590        // Host-telemetry reporters. Match the official probe's RESULT JSON
591        // so the RIPE backend sees no difference.
592        "buddyinfo" => parse_buddyinfo(measurement_args, interval),
593        "rptaddrs" => parse_rptaddrs(measurement_args, interval),
594
595        // Result-upload transport. starla uploads natively via
596        // starla-results::uploader, so this CRONLINE is redundant.
597        "httppost" => {
598            trace!("Ignoring httppost CRONLINE (uploader handles uploads natively)");
599            TelnetCommand::Ignored(cmd.to_string())
600        }
601
602        // Hardware-probe spool plumbing: rotates result files between
603        // data/new/ → data/out/ → data/storage/ and clears stale spool.
604        // starla queues results in memory, so the spool model doesn't apply.
605        "condmv" | "dfrm" => {
606            trace!(
607                "Ignoring {} CRONLINE (no on-disk spool to manage)",
608                measurement_cmd
609            );
610            TelnetCommand::Ignored(cmd.to_string())
611        }
612
613        // conntrack: the controller occasionally schedules a 'conntrack'
614        // applet, but it's not in the reference busybox tree we vendored
615        // (reference/probe-busybox/), so the RESULT id and field names are
616        // unknown. Stay silent rather than guess — see doc/en/protocol.html.
617        "conntrack" => {
618            trace!("Ignoring conntrack CRONLINE (no reference impl available)");
619            TelnetCommand::Ignored(cmd.to_string())
620        }
621
622        _ => {
623            warn!("Unknown measurement command: {}", measurement_cmd);
624            TelnetCommand::Unknown(cmd.to_string())
625        }
626    }
627}
628
629/// Parse a `buddyinfo [lowmem_kb] [logfile]` CRONLINE.
630/// The applet hardcodes id=9001 and accepts a single positional `lowmem`.
631fn parse_buddyinfo(args: &[String], interval: u64) -> TelnetCommand {
632    let lowmem = args.first().and_then(|s| s.parse::<u32>().ok());
633    TelnetCommand::HostTelemetry(HostTelemetrySpec {
634        kind: HostTelemetryKind::Buddyinfo,
635        interval,
636        msm_id: None,
637        lowmem,
638    })
639}
640
641/// Parse `rptaddrs [-A msm_id] [-c cache] [-O output]` CRONLINE.
642/// Per reference/probe-busybox/networking/rptaddrs.c: only `-A`, `-c`, `-O`
643/// are accepted. We need `-A` (the result id); other paths are ignored.
644fn parse_rptaddrs(args: &[String], interval: u64) -> TelnetCommand {
645    let mut msm_id: Option<u32> = None;
646    let mut it = args.iter();
647    while let Some(arg) = it.next() {
648        match arg.as_str() {
649            "-A" => {
650                msm_id = it.next().and_then(|s| s.parse().ok());
651            }
652            "-c" | "-O" => {
653                // Path arg — drop next token; starla doesn't use the spool.
654                let _ = it.next();
655            }
656            _ => {}
657        }
658    }
659    TelnetCommand::HostTelemetry(HostTelemetrySpec {
660        kind: HostTelemetryKind::Rptaddrs,
661        interval,
662        msm_id,
663        lowmem: None,
664    })
665}
666
667/// Tokenize a CRONLINE command, handling quoted strings
668fn tokenize_cronline(cmd: &str) -> Vec<String> {
669    let mut tokens = Vec::new();
670    let mut current = String::new();
671    let mut in_quotes = false;
672
673    for c in cmd.chars() {
674        match c {
675            '"' => {
676                in_quotes = !in_quotes;
677                // Don't include the quotes in the token
678            }
679            ' ' | '\t' if !in_quotes => {
680                if !current.is_empty() {
681                    tokens.push(current.clone());
682                    current.clear();
683                }
684            }
685            _ => {
686                current.push(c);
687            }
688        }
689    }
690
691    if !current.is_empty() {
692        tokens.push(current);
693    }
694
695    tokens
696}
697
698/// Parse evping command arguments
699///
700/// Usage: evping [-4|-6] [-c count] [-s size] [-A msm_id] [-O output] target
701fn parse_evping(args: &[String], interval: u64, end_time: i64, spread: u32) -> TelnetCommand {
702    let mut af: u8 = 4;
703    let mut count: u32 = 3;
704    let mut size: u16 = 64;
705    let mut msm_id: u64 = 0;
706    let mut target = String::new();
707
708    let mut i = 0;
709    while i < args.len() {
710        let arg = &args[i];
711        match arg.as_str() {
712            "-4" => af = 4,
713            "-6" => af = 6,
714            "-c" => {
715                if i + 1 < args.len() {
716                    count = args[i + 1].parse().unwrap_or(3);
717                    i += 1;
718                }
719            }
720            "-s" => {
721                if i + 1 < args.len() {
722                    size = args[i + 1].parse().unwrap_or(64);
723                    i += 1;
724                }
725            }
726            "-A" => {
727                if i + 1 < args.len() {
728                    msm_id = args[i + 1].parse().unwrap_or(0);
729                    i += 1;
730                }
731            }
732            "-O" => {
733                // Output path - skip it
734                if i + 1 < args.len() {
735                    i += 1;
736                }
737            }
738            "-I" => {
739                // Interval between packets - skip for now
740                if i + 1 < args.len() {
741                    i += 1;
742                }
743            }
744            "-R" => {
745                // Resolve flag - skip
746            }
747            _ if !arg.starts_with('-') => {
748                // This is the target
749                target = arg.clone();
750            }
751            _ => {
752                debug!("evping: ignoring unknown option {}", arg);
753            }
754        }
755        i += 1;
756    }
757
758    if target.is_empty() {
759        warn!("evping: no target specified");
760        return TelnetCommand::Unknown(format!("evping {:?}", args));
761    }
762
763    trace!(
764        "Parsed evping: msm_id={}, target={}, af={}, count={}, size={}, interval={}",
765        msm_id,
766        target,
767        af,
768        count,
769        size,
770        interval
771    );
772
773    TelnetCommand::Ping(PingSpec {
774        msm_id,
775        target,
776        af,
777        packets: count,
778        size,
779        packet_interval: 1000, // Default 1 second between packets
780        spread: Some(spread),
781        schedule: ScheduleSpec {
782            interval,
783            start_time: 0, // Start now
784            stop_time: end_time,
785        },
786    })
787}
788
789/// Parse evtraceroute command arguments
790///
791/// Usage: evtraceroute [-4|-6] [-I|-U|-T] [-a attempts] [-c count] [-w timeout]
792/// [-f first_hop]        [-m max_hops] [-p paris] [-S size] [-A msm_id] [-O
793/// output] target
794fn parse_evtraceroute(args: &[String], interval: u64, end_time: i64, spread: u32) -> TelnetCommand {
795    let mut af: u8 = 4;
796    let mut protocol = "ICMP".to_string();
797    let mut first_hop: u8 = 1;
798    let mut max_hops: u8 = 32;
799    let mut paris: Option<u32> = None;
800    let mut msm_id: u64 = 0;
801    let mut target = String::new();
802    let mut size: u16 = 40;
803
804    let mut i = 0;
805    while i < args.len() {
806        let arg = &args[i];
807        match arg.as_str() {
808            "-4" => af = 4,
809            "-6" => af = 6,
810            "-I" => protocol = "ICMP".to_string(),
811            "-U" => protocol = "UDP".to_string(),
812            "-T" => protocol = "TCP".to_string(),
813            "-a" => {
814                // Number of attempts per hop - skip for now
815                if i + 1 < args.len() {
816                    i += 1;
817                }
818            }
819            "-c" => {
820                // Count/packets per hop - skip for now
821                if i + 1 < args.len() {
822                    i += 1;
823                }
824            }
825            "-w" => {
826                // Timeout - skip for now
827                if i + 1 < args.len() {
828                    i += 1;
829                }
830            }
831            "-f" => {
832                if i + 1 < args.len() {
833                    first_hop = args[i + 1].parse().unwrap_or(1);
834                    i += 1;
835                }
836            }
837            "-m" => {
838                if i + 1 < args.len() {
839                    max_hops = args[i + 1].parse().unwrap_or(32);
840                    i += 1;
841                }
842            }
843            "-p" => {
844                if i + 1 < args.len() {
845                    paris = Some(args[i + 1].parse().unwrap_or(0));
846                    i += 1;
847                }
848            }
849            "-s" | "-S" => {
850                if i + 1 < args.len() {
851                    size = args[i + 1].parse().unwrap_or(40);
852                    i += 1;
853                }
854            }
855            "-A" => {
856                if i + 1 < args.len() {
857                    msm_id = args[i + 1].parse().unwrap_or(0);
858                    i += 1;
859                }
860            }
861            "-O" => {
862                if i + 1 < args.len() {
863                    i += 1;
864                }
865            }
866            _ if !arg.starts_with('-') => {
867                target = arg.clone();
868            }
869            _ => {
870                debug!("evtraceroute: ignoring unknown option {}", arg);
871            }
872        }
873        i += 1;
874    }
875
876    if target.is_empty() {
877        warn!("evtraceroute: no target specified");
878        return TelnetCommand::Unknown(format!("evtraceroute {:?}", args));
879    }
880
881    trace!(
882        "Parsed evtraceroute: msm_id={}, target={}, af={}, protocol={}, hops={}-{}, interval={}",
883        msm_id,
884        target,
885        af,
886        protocol,
887        first_hop,
888        max_hops,
889        interval
890    );
891
892    TelnetCommand::Traceroute(TracerouteSpec {
893        msm_id,
894        target,
895        af,
896        protocol,
897        paris,
898        first_hop,
899        max_hops,
900        size,
901        spread: Some(spread),
902        schedule: ScheduleSpec {
903            interval,
904            start_time: 0,
905            stop_time: end_time,
906        },
907    })
908}
909
910/// Parse evtdig command arguments
911///
912/// Usage: evtdig [-4|-6] [-p port] [-r] [-d] [-t qtype] [-c qclass] [-A msm_id]
913/// [-O output]        [@server] [query]
914///
915/// Atlas-specific options:
916///   --soa         Query SOA record for "."
917///   --resolv      Use system resolver (no @server needed)
918///   -h            Query HOSTNAME.BIND (hostname identification)
919///   -b            Query BIND version
920///   -i            Query ID.SERVER
921///   -r            Query RRSIG records (DNSSEC)
922///   -t            Use TCP
923///   --type N      Query type (numeric)
924///   --class N     Query class (numeric)
925///   --query NAME  Explicit query name
926///   --a NAME      A record query
927///   --aaaa NAME   AAAA record query
928///   -e SIZE       EDNS buffer size
929fn parse_evtdig(args: &[String], interval: u64, end_time: i64, spread: u32) -> TelnetCommand {
930    let mut af: u8 = 4;
931    let mut protocol = "UDP".to_string();
932    let mut query_type = "A".to_string();
933    let mut query_class = "IN".to_string();
934    let mut use_dnssec = false;
935    let mut recursion_desired = true;
936    let mut msm_id: u64 = 0;
937    let mut target = String::new(); // DNS server
938    let mut query_argument = String::new(); // Query name
939    let mut use_resolver = false; // Use system resolver
940
941    let mut i = 0;
942    while i < args.len() {
943        let arg = &args[i];
944        match arg.as_str() {
945            "-4" => af = 4,
946            "-6" => af = 6,
947            "-p" => {
948                // Port - skip for now
949                if i + 1 < args.len() {
950                    i += 1;
951                }
952            }
953            "--soa" => {
954                // SOA query for root
955                query_type = "SOA".to_string();
956                if query_argument.is_empty() {
957                    query_argument = ".".to_string();
958                }
959            }
960            "--resolv" => {
961                // Use system resolver
962                use_resolver = true;
963            }
964            "-h" => {
965                // Query HOSTNAME.BIND TXT CH
966                query_type = "TXT".to_string();
967                query_class = "CH".to_string();
968                query_argument = "hostname.bind".to_string();
969            }
970            "-b" => {
971                // Query version.bind TXT CH
972                query_type = "TXT".to_string();
973                query_class = "CH".to_string();
974                query_argument = "version.bind".to_string();
975            }
976            "-i" => {
977                // Query id.server TXT CH
978                query_type = "TXT".to_string();
979                query_class = "CH".to_string();
980                query_argument = "id.server".to_string();
981            }
982            "-r" => {
983                // Query for RRSIG (DNSSEC signing)
984                use_dnssec = true;
985            }
986            "-d" | "-D" | "+dnssec" => {
987                // DNSSEC
988                use_dnssec = true;
989            }
990            "-t" => {
991                // TCP mode (note: this is different from --type)
992                protocol = "TCP".to_string();
993            }
994            "--type" | "-type" => {
995                if i + 1 < args.len() {
996                    // Numeric query type
997                    let type_num: u16 = args[i + 1].parse().unwrap_or(1);
998                    query_type = match type_num {
999                        1 => "A",
1000                        2 => "NS",
1001                        5 => "CNAME",
1002                        6 => "SOA",
1003                        12 => "PTR",
1004                        15 => "MX",
1005                        16 => "TXT",
1006                        28 => "AAAA",
1007                        33 => "SRV",
1008                        43 => "DS",
1009                        46 => "RRSIG",
1010                        47 => "NSEC",
1011                        48 => "DNSKEY",
1012                        _ => "A",
1013                    }
1014                    .to_string();
1015                    i += 1;
1016                }
1017            }
1018            "--class" | "-class" => {
1019                if i + 1 < args.len() {
1020                    let class_num: u16 = args[i + 1].parse().unwrap_or(1);
1021                    query_class = match class_num {
1022                        1 => "IN",
1023                        3 => "CH",
1024                        _ => "IN",
1025                    }
1026                    .to_string();
1027                    i += 1;
1028                }
1029            }
1030            "--query" | "-query" => {
1031                if i + 1 < args.len() {
1032                    query_argument = args[i + 1].clone();
1033                    i += 1;
1034                }
1035            }
1036            "--a" | "-a" => {
1037                query_type = "A".to_string();
1038                if i + 1 < args.len() && !args[i + 1].starts_with('-') {
1039                    query_argument = args[i + 1].clone();
1040                    i += 1;
1041                }
1042            }
1043            "--aaaa" | "-aaaa" => {
1044                query_type = "AAAA".to_string();
1045                if i + 1 < args.len() && !args[i + 1].starts_with('-') {
1046                    query_argument = args[i + 1].clone();
1047                    i += 1;
1048                }
1049            }
1050            "-e" => {
1051                // EDNS buffer size - implies EDNS
1052                if i + 1 < args.len() {
1053                    i += 1;
1054                }
1055            }
1056            "-R" => {
1057                // Disable recursion desired
1058                recursion_desired = false;
1059            }
1060            "--retry" => {
1061                // Retry count - skip
1062                if i + 1 < args.len() {
1063                    i += 1;
1064                }
1065            }
1066            "--qbuf" => {
1067                // Include query buffer in result - skip
1068            }
1069            "-T" => {
1070                // TCP
1071                protocol = "TCP".to_string();
1072            }
1073            "-A" => {
1074                if i + 1 < args.len() {
1075                    msm_id = args[i + 1].parse().unwrap_or(0);
1076                    i += 1;
1077                }
1078            }
1079            "-O" => {
1080                if i + 1 < args.len() {
1081                    i += 1;
1082                }
1083            }
1084            _ if arg.starts_with('@') => {
1085                // DNS server
1086                target = arg[1..].to_string();
1087            }
1088            _ if arg.starts_with('+') => {
1089                // dig-style options like +dnssec, +tcp, etc.
1090                match arg.as_str() {
1091                    "+dnssec" | "+do" => use_dnssec = true,
1092                    "+tcp" => protocol = "TCP".to_string(),
1093                    "+nord" | "+norecurse" => recursion_desired = false,
1094                    _ => debug!("evtdig: ignoring option {}", arg),
1095                }
1096            }
1097            _ if arg.starts_with("--") => {
1098                // Unknown long option - skip
1099                debug!("evtdig: ignoring unknown option {}", arg);
1100            }
1101            _ if !arg.starts_with('-') => {
1102                // Positional argument - could be server or query
1103                if target.is_empty() && arg.parse::<std::net::IpAddr>().is_ok() {
1104                    // Looks like an IP address - it's the server
1105                    target = arg.clone();
1106                } else if query_argument.is_empty() {
1107                    // Query name
1108                    query_argument = arg.clone();
1109                }
1110            }
1111            _ => {
1112                debug!("evtdig: ignoring unknown option {}", arg);
1113            }
1114        }
1115        i += 1;
1116    }
1117
1118    // Handle resolver mode - use a placeholder for system resolver
1119    if use_resolver && target.is_empty() {
1120        // Use Google DNS as fallback when --resolv is specified
1121        // In a real implementation, we'd read /etc/resolv.conf
1122        target = if af == 4 {
1123            "8.8.8.8"
1124        } else {
1125            "2001:4860:4860::8888"
1126        }
1127        .to_string();
1128    }
1129
1130    // If still no server, try to use the query_argument as server
1131    if target.is_empty()
1132        && !query_argument.is_empty()
1133        && query_argument.parse::<std::net::IpAddr>().is_ok()
1134    {
1135        target = query_argument.clone();
1136        query_argument = ".".to_string();
1137    }
1138
1139    if target.is_empty() {
1140        warn!("evtdig: no DNS server specified, args={:?}", args);
1141        return TelnetCommand::Unknown(format!("evtdig {:?}", args));
1142    }
1143
1144    if query_argument.is_empty() {
1145        // Default query if none specified
1146        query_argument = ".".to_string();
1147    }
1148
1149    // Remove trailing dot from query name if present
1150    if query_argument.ends_with('.') && query_argument.len() > 1 {
1151        query_argument = query_argument[..query_argument.len() - 1].to_string();
1152    }
1153
1154    trace!(
1155        "Parsed evtdig: msm_id={}, server={}, query={} {} {}, protocol={}, interval={}",
1156        msm_id,
1157        target,
1158        query_argument,
1159        query_type,
1160        query_class,
1161        protocol,
1162        interval
1163    );
1164
1165    TelnetCommand::Dns(DnsSpec {
1166        msm_id,
1167        target,
1168        af,
1169        protocol,
1170        query_type,
1171        query_class,
1172        query_argument,
1173        use_dnssec,
1174        recursion_desired,
1175        spread: Some(spread),
1176        schedule: ScheduleSpec {
1177            interval,
1178            start_time: 0,
1179            stop_time: end_time,
1180        },
1181    })
1182}
1183
1184/// Parse evhttpget command arguments
1185///
1186/// Usage: evhttpget [-4|-6] [-1] [-A msm_id] [-O output] [--store-headers size]
1187/// url
1188fn parse_evhttpget(args: &[String], interval: u64, end_time: i64, spread: u32) -> TelnetCommand {
1189    let mut af: u8 = 4;
1190    let mut msm_id: u64 = 0;
1191    let mut url = String::new();
1192    let mut max_body_size: Option<u32> = None;
1193
1194    let mut i = 0;
1195    while i < args.len() {
1196        let arg = &args[i];
1197        match arg.as_str() {
1198            "-4" => af = 4,
1199            "-6" => af = 6,
1200            "-1" => {
1201                // HTTP/1.1 mode - skip
1202            }
1203            "-A" => {
1204                if i + 1 < args.len() {
1205                    msm_id = args[i + 1].parse().unwrap_or(0);
1206                    i += 1;
1207                }
1208            }
1209            "-O" => {
1210                if i + 1 < args.len() {
1211                    i += 1;
1212                }
1213            }
1214            "-M" | "--max-body" => {
1215                if i + 1 < args.len() {
1216                    max_body_size = args[i + 1].parse().ok();
1217                    i += 1;
1218                }
1219            }
1220            "--store-headers" => {
1221                // Max header storage size - skip
1222                if i + 1 < args.len() {
1223                    i += 1;
1224                }
1225            }
1226            _ if !arg.starts_with('-') => {
1227                url = arg.clone();
1228            }
1229            _ => {
1230                debug!("evhttpget: ignoring unknown option {}", arg);
1231            }
1232        }
1233        i += 1;
1234    }
1235
1236    if url.is_empty() {
1237        warn!("evhttpget: no URL specified");
1238        return TelnetCommand::Unknown(format!("evhttpget {:?}", args));
1239    }
1240
1241    trace!(
1242        "Parsed evhttpget: msm_id={}, url={}, af={}, interval={}",
1243        msm_id,
1244        url,
1245        af,
1246        interval
1247    );
1248
1249    TelnetCommand::Http(HttpSpec {
1250        msm_id,
1251        url,
1252        method: "GET".to_string(),
1253        af,
1254        headers: vec![],
1255        body: None,
1256        max_body_size,
1257        spread: Some(spread),
1258        schedule: ScheduleSpec {
1259            interval,
1260            start_time: 0,
1261            stop_time: end_time,
1262        },
1263    })
1264}
1265
1266/// Parse evsslgetcert command arguments
1267///
1268/// Usage: evsslgetcert [-4|-6] [-p port] [-h hostname] [-A msm_id] [-O output]
1269/// target
1270fn parse_evsslgetcert(args: &[String], interval: u64, end_time: i64, spread: u32) -> TelnetCommand {
1271    let mut af: u8 = 4;
1272    let mut port: u16 = 443;
1273    let mut msm_id: u64 = 0;
1274    let mut target = String::new();
1275    let mut hostname: Option<String> = None;
1276
1277    let mut i = 0;
1278    while i < args.len() {
1279        let arg = &args[i];
1280        match arg.as_str() {
1281            "-4" => af = 4,
1282            "-6" => af = 6,
1283            "-p" => {
1284                if i + 1 < args.len() {
1285                    port = args[i + 1].parse().unwrap_or(443);
1286                    i += 1;
1287                }
1288            }
1289            "-A" => {
1290                if i + 1 < args.len() {
1291                    msm_id = args[i + 1].parse().unwrap_or(0);
1292                    i += 1;
1293                }
1294            }
1295            "-O" => {
1296                if i + 1 < args.len() {
1297                    i += 1;
1298                }
1299            }
1300            // Note: Atlas uses -h for hostname (SNI), not -H
1301            "-h" | "-H" | "--hostname" => {
1302                if i + 1 < args.len() {
1303                    hostname = Some(args[i + 1].clone());
1304                    i += 1;
1305                }
1306            }
1307            _ if !arg.starts_with('-') => {
1308                target = arg.clone();
1309            }
1310            _ => {
1311                debug!("evsslgetcert: ignoring unknown option {}", arg);
1312            }
1313        }
1314        i += 1;
1315    }
1316
1317    if target.is_empty() {
1318        warn!("evsslgetcert: no target specified");
1319        return TelnetCommand::Unknown(format!("evsslgetcert {:?}", args));
1320    }
1321
1322    trace!(
1323        "Parsed evsslgetcert: msm_id={}, target={}, hostname={:?}, port={}, af={}, interval={}",
1324        msm_id,
1325        target,
1326        hostname,
1327        port,
1328        af,
1329        interval
1330    );
1331
1332    TelnetCommand::Tls(TlsSpec {
1333        msm_id,
1334        target: target.clone(),
1335        port,
1336        af,
1337        hostname: hostname.or(Some(target)),
1338        spread: Some(spread),
1339        schedule: ScheduleSpec {
1340            interval,
1341            start_time: 0,
1342            stop_time: end_time,
1343        },
1344    })
1345}
1346
1347/// Parse evntp command arguments
1348///
1349/// Usage: evntp [-4|-6] [-c count] [-A msm_id] [-O output] target
1350fn parse_evntp(args: &[String], interval: u64, end_time: i64, spread: u32) -> TelnetCommand {
1351    let mut af: u8 = 4;
1352    let mut packets: u32 = 3;
1353    let mut msm_id: u64 = 0;
1354    let mut target = String::new();
1355
1356    let mut i = 0;
1357    while i < args.len() {
1358        let arg = &args[i];
1359        match arg.as_str() {
1360            "-4" => af = 4,
1361            "-6" => af = 6,
1362            "-c" => {
1363                if i + 1 < args.len() {
1364                    packets = args[i + 1].parse().unwrap_or(3);
1365                    i += 1;
1366                }
1367            }
1368            "-A" => {
1369                if i + 1 < args.len() {
1370                    msm_id = args[i + 1].parse().unwrap_or(0);
1371                    i += 1;
1372                }
1373            }
1374            "-O" => {
1375                if i + 1 < args.len() {
1376                    i += 1;
1377                }
1378            }
1379            _ if !arg.starts_with('-') => {
1380                target = arg.clone();
1381            }
1382            _ => {
1383                debug!("evntp: ignoring unknown option {}", arg);
1384            }
1385        }
1386        i += 1;
1387    }
1388
1389    if target.is_empty() {
1390        warn!("evntp: no target specified");
1391        return TelnetCommand::Unknown(format!("evntp {:?}", args));
1392    }
1393
1394    trace!(
1395        "Parsed evntp: msm_id={}, target={}, af={}, packets={}, interval={}",
1396        msm_id,
1397        target,
1398        af,
1399        packets,
1400        interval
1401    );
1402
1403    TelnetCommand::Ntp(NtpSpec {
1404        msm_id,
1405        target,
1406        af,
1407        packets,
1408        spread: Some(spread),
1409        schedule: ScheduleSpec {
1410            interval,
1411            start_time: 0,
1412            stop_time: end_time,
1413        },
1414    })
1415}
1416
1417/// Parse scheduling fields from JSON spec
1418fn parse_schedule(spec: &serde_json::Value) -> ScheduleSpec {
1419    ScheduleSpec {
1420        interval: spec.get("interval").and_then(|v| v.as_u64()).unwrap_or(0),
1421        start_time: spec.get("start_time").and_then(|v| v.as_i64()).unwrap_or(0),
1422        stop_time: spec.get("stop_time").and_then(|v| v.as_i64()).unwrap_or(0),
1423    }
1424}
1425
1426/// Parse a JSON measurement specification
1427fn parse_json_command(spec: &serde_json::Value) -> TelnetCommand {
1428    let msm_type = spec
1429        .get("type")
1430        .and_then(|v| v.as_str())
1431        .unwrap_or("")
1432        .to_lowercase();
1433
1434    let msm_id = spec.get("msm_id").and_then(|v| v.as_u64()).unwrap_or(0);
1435
1436    let target = spec
1437        .get("target")
1438        .or_else(|| spec.get("dst_name"))
1439        .and_then(|v| v.as_str())
1440        .unwrap_or("")
1441        .to_string();
1442
1443    let af = spec.get("af").and_then(|v| v.as_u64()).unwrap_or(4) as u8;
1444
1445    let spread = spec
1446        .get("spread")
1447        .and_then(|v| v.as_u64())
1448        .map(|v| v as u32);
1449
1450    let schedule = parse_schedule(spec);
1451
1452    match msm_type.as_str() {
1453        "ping" => TelnetCommand::Ping(PingSpec {
1454            msm_id,
1455            target,
1456            af,
1457            packets: spec.get("packets").and_then(|v| v.as_u64()).unwrap_or(3) as u32,
1458            size: spec.get("size").and_then(|v| v.as_u64()).unwrap_or(64) as u16,
1459            packet_interval: spec
1460                .get("packet_interval")
1461                .and_then(|v| v.as_u64())
1462                .unwrap_or(1000) as u32,
1463            spread,
1464            schedule,
1465        }),
1466
1467        "traceroute" => TelnetCommand::Traceroute(TracerouteSpec {
1468            msm_id,
1469            target,
1470            af,
1471            protocol: spec
1472                .get("protocol")
1473                .and_then(|v| v.as_str())
1474                .unwrap_or("ICMP")
1475                .to_string(),
1476            paris: spec.get("paris").and_then(|v| v.as_u64()).map(|v| v as u32),
1477            first_hop: spec.get("first_hop").and_then(|v| v.as_u64()).unwrap_or(1) as u8,
1478            max_hops: spec.get("max_hops").and_then(|v| v.as_u64()).unwrap_or(32) as u8,
1479            size: spec.get("size").and_then(|v| v.as_u64()).unwrap_or(40) as u16,
1480            spread,
1481            schedule,
1482        }),
1483
1484        "dns" => TelnetCommand::Dns(DnsSpec {
1485            msm_id,
1486            target,
1487            af,
1488            protocol: spec
1489                .get("protocol")
1490                .and_then(|v| v.as_str())
1491                .unwrap_or("UDP")
1492                .to_string(),
1493            query_type: spec
1494                .get("query_type")
1495                .and_then(|v| v.as_str())
1496                .unwrap_or("A")
1497                .to_string(),
1498            query_class: spec
1499                .get("query_class")
1500                .and_then(|v| v.as_str())
1501                .unwrap_or("IN")
1502                .to_string(),
1503            query_argument: spec
1504                .get("query_argument")
1505                .and_then(|v| v.as_str())
1506                .unwrap_or("")
1507                .to_string(),
1508            use_dnssec: spec
1509                .get("use_dnssec")
1510                .and_then(|v| v.as_bool())
1511                .unwrap_or(false),
1512            recursion_desired: spec
1513                .get("recursion_desired")
1514                .and_then(|v| v.as_bool())
1515                .unwrap_or(true),
1516            spread,
1517            schedule,
1518        }),
1519
1520        "http" | "https" => TelnetCommand::Http(HttpSpec {
1521            msm_id,
1522            url: spec
1523                .get("url")
1524                .or_else(|| spec.get("target"))
1525                .and_then(|v| v.as_str())
1526                .unwrap_or("")
1527                .to_string(),
1528            method: spec
1529                .get("method")
1530                .and_then(|v| v.as_str())
1531                .unwrap_or("GET")
1532                .to_string(),
1533            af,
1534            headers: spec
1535                .get("headers")
1536                .and_then(|v| v.as_array())
1537                .map(|arr| {
1538                    arr.iter()
1539                        .filter_map(|v| v.as_str().map(String::from))
1540                        .collect()
1541                })
1542                .unwrap_or_default(),
1543            body: spec.get("body").and_then(|v| v.as_str()).map(String::from),
1544            max_body_size: spec
1545                .get("max_body_size")
1546                .and_then(|v| v.as_u64())
1547                .map(|v| v as u32),
1548            spread,
1549            schedule,
1550        }),
1551
1552        "sslcert" | "tls" => TelnetCommand::Tls(TlsSpec {
1553            msm_id,
1554            target: target.clone(),
1555            port: spec.get("port").and_then(|v| v.as_u64()).unwrap_or(443) as u16,
1556            af,
1557            hostname: spec
1558                .get("hostname")
1559                .and_then(|v| v.as_str())
1560                .map(String::from)
1561                .or(Some(target)),
1562            spread,
1563            schedule,
1564        }),
1565
1566        "ntp" => TelnetCommand::Ntp(NtpSpec {
1567            msm_id,
1568            target,
1569            af,
1570            packets: spec.get("packets").and_then(|v| v.as_u64()).unwrap_or(3) as u32,
1571            spread,
1572            schedule,
1573        }),
1574
1575        _ => TelnetCommand::Unknown(format!("Unknown measurement type: {}", msm_type)),
1576    }
1577}
1578
1579#[cfg(test)]
1580mod tests {
1581    use super::*;
1582
1583    #[test]
1584    fn test_parse_ping_command() {
1585        let json = r#"{"type":"ping","msm_id":1001,"target":"8.8.8.8","af":4,"packets":3}"#;
1586        let cmd = parse_command(json);
1587        match cmd {
1588            TelnetCommand::Ping(spec) => {
1589                assert_eq!(spec.msm_id, 1001);
1590                assert_eq!(spec.target, "8.8.8.8");
1591                assert_eq!(spec.packets, 3);
1592                assert_eq!(spec.schedule.interval, 0); // default one-shot
1593            }
1594            _ => panic!("Expected Ping command"),
1595        }
1596    }
1597
1598    #[test]
1599    fn test_parse_recurring_ping_command() {
1600        let json = r#"{"type":"ping","msm_id":1001,"target":"8.8.8.8","af":4,"packets":3,"interval":300,"start_time":1700000000,"stop_time":1700086400}"#;
1601        let cmd = parse_command(json);
1602        match cmd {
1603            TelnetCommand::Ping(spec) => {
1604                assert_eq!(spec.msm_id, 1001);
1605                assert_eq!(spec.schedule.interval, 300);
1606                assert_eq!(spec.schedule.start_time, 1700000000);
1607                assert_eq!(spec.schedule.stop_time, 1700086400);
1608            }
1609            _ => panic!("Expected Ping command"),
1610        }
1611    }
1612
1613    #[test]
1614    fn test_parse_dns_command() {
1615        let json = r#"{"type":"dns","msm_id":1002,"target":"9.9.9.9","query_argument":"example.com","query_type":"A"}"#;
1616        let cmd = parse_command(json);
1617        match cmd {
1618            TelnetCommand::Dns(spec) => {
1619                assert_eq!(spec.msm_id, 1002);
1620                assert_eq!(spec.target, "9.9.9.9");
1621                assert_eq!(spec.query_argument, "example.com");
1622            }
1623            _ => panic!("Expected Dns command"),
1624        }
1625    }
1626
1627    #[test]
1628    fn test_parse_status_command() {
1629        let cmd = parse_command("STATUS");
1630        assert!(matches!(cmd, TelnetCommand::Status));
1631    }
1632
1633    #[test]
1634    fn test_parse_stop_command() {
1635        let cmd = parse_command("STOP 12345");
1636        match cmd {
1637            TelnetCommand::Stop(id) => assert_eq!(id, 12345),
1638            _ => panic!("Expected Stop command"),
1639        }
1640    }
1641
1642    // CRONLINE tests
1643
1644    #[test]
1645    fn test_tokenize_cronline() {
1646        let tokens = tokenize_cronline(
1647            r#"CRONLINE 240 274 1770761451 UNIFORM 3 evping -4 -c 3 -A "1001" -O /home/atlas/data/new/7 193.0.14.129"#,
1648        );
1649        assert_eq!(tokens[0], "CRONLINE");
1650        assert_eq!(tokens[1], "240");
1651        assert_eq!(tokens[6], "evping");
1652        assert_eq!(tokens[11], "1001"); // Quoted string without quotes
1653        assert_eq!(tokens[tokens.len() - 1], "193.0.14.129");
1654    }
1655
1656    #[test]
1657    fn test_parse_cronline_evping() {
1658        let cmd = parse_command(
1659            r#"CRONLINE 240 274 1770761451 UNIFORM 3 evping -4 -c 3 -A "1001" -O /home/atlas/data/new/7 193.0.14.129"#,
1660        );
1661        match cmd {
1662            TelnetCommand::Ping(spec) => {
1663                assert_eq!(spec.msm_id, 1001);
1664                assert_eq!(spec.target, "193.0.14.129");
1665                assert_eq!(spec.af, 4);
1666                assert_eq!(spec.packets, 3);
1667                assert_eq!(spec.schedule.interval, 240);
1668                assert_eq!(spec.schedule.stop_time, 1770761451);
1669                assert_eq!(spec.spread, Some(3));
1670            }
1671            _ => panic!("Expected Ping command, got {:?}", cmd),
1672        }
1673    }
1674
1675    #[test]
1676    fn test_parse_cronline_evping_ipv6() {
1677        let cmd = parse_command(
1678            r#"CRONLINE 900 123 1800000000 UNIFORM 5 evping -6 -c 5 -s 128 -A "2001" 2001:4860:4860::8888"#,
1679        );
1680        match cmd {
1681            TelnetCommand::Ping(spec) => {
1682                assert_eq!(spec.msm_id, 2001);
1683                assert_eq!(spec.target, "2001:4860:4860::8888");
1684                assert_eq!(spec.af, 6);
1685                assert_eq!(spec.packets, 5);
1686                assert_eq!(spec.size, 128);
1687                assert_eq!(spec.schedule.interval, 900);
1688            }
1689            _ => panic!("Expected Ping command"),
1690        }
1691    }
1692
1693    #[test]
1694    fn test_parse_cronline_evtraceroute_udp() {
1695        let cmd = parse_command(
1696            r#"CRONLINE 1800 331 1925935851 UNIFORM 3 evtraceroute -4 -U -c 3 -w 1000 -A "5001" -O /home/atlas/data/new/7 193.0.14.129"#,
1697        );
1698        match cmd {
1699            TelnetCommand::Traceroute(spec) => {
1700                assert_eq!(spec.msm_id, 5001);
1701                assert_eq!(spec.target, "193.0.14.129");
1702                assert_eq!(spec.af, 4);
1703                assert_eq!(spec.protocol, "UDP");
1704                assert_eq!(spec.schedule.interval, 1800);
1705            }
1706            _ => panic!("Expected Traceroute command, got {:?}", cmd),
1707        }
1708    }
1709
1710    #[test]
1711    fn test_parse_cronline_evtraceroute_icmp() {
1712        let cmd = parse_command(
1713            r#"CRONLINE 3600 0 0 UNIFORM 10 evtraceroute -4 -I -m 64 -f 1 -A "5002" 8.8.8.8"#,
1714        );
1715        match cmd {
1716            TelnetCommand::Traceroute(spec) => {
1717                assert_eq!(spec.msm_id, 5002);
1718                assert_eq!(spec.target, "8.8.8.8");
1719                assert_eq!(spec.protocol, "ICMP");
1720                assert_eq!(spec.first_hop, 1);
1721                assert_eq!(spec.max_hops, 64);
1722            }
1723            _ => panic!("Expected Traceroute command"),
1724        }
1725    }
1726
1727    #[test]
1728    fn test_parse_cronline_evtdig() {
1729        // Test with --aaaa (Atlas format for AAAA query)
1730        let cmd = parse_command(
1731            r#"CRONLINE 900 0 0 UNIFORM 5 evtdig -4 --aaaa example.com. -A "6001" @9.9.9.9"#,
1732        );
1733        match cmd {
1734            TelnetCommand::Dns(spec) => {
1735                assert_eq!(spec.msm_id, 6001);
1736                assert_eq!(spec.target, "9.9.9.9");
1737                assert_eq!(spec.query_argument, "example.com"); // trailing dot stripped
1738                assert_eq!(spec.query_type, "AAAA");
1739                assert_eq!(spec.af, 4);
1740            }
1741            _ => panic!("Expected Dns command, got {:?}", cmd),
1742        }
1743    }
1744
1745    #[test]
1746    fn test_parse_cronline_evtdig_dnssec() {
1747        // Test with -d for DNSSEC and --a for A record query
1748        let cmd = parse_command(
1749            r#"CRONLINE 1800 0 0 UNIFORM 3 evtdig -4 -d --a dnssec-test.org. -A "6002" @8.8.8.8"#,
1750        );
1751        match cmd {
1752            TelnetCommand::Dns(spec) => {
1753                assert_eq!(spec.msm_id, 6002);
1754                assert!(spec.use_dnssec);
1755                assert_eq!(spec.query_argument, "dnssec-test.org");
1756            }
1757            _ => panic!("Expected Dns command"),
1758        }
1759    }
1760
1761    #[test]
1762    fn test_parse_cronline_evtdig_soa() {
1763        // Test real Atlas SOA query format
1764        let cmd = parse_command(
1765            r#"CRONLINE 1800 795 1770762780 UNIFORM 3 evtdig -4 --soa . -A "10001" -O /home/atlas/data/new/7 193.0.14.129"#,
1766        );
1767        match cmd {
1768            TelnetCommand::Dns(spec) => {
1769                assert_eq!(spec.msm_id, 10001);
1770                assert_eq!(spec.target, "193.0.14.129");
1771                assert_eq!(spec.query_argument, ".");
1772                assert_eq!(spec.query_type, "SOA");
1773                assert_eq!(spec.af, 4);
1774                assert_eq!(spec.protocol, "UDP");
1775            }
1776            _ => panic!("Expected Dns command, got {:?}", cmd),
1777        }
1778    }
1779
1780    #[test]
1781    fn test_parse_cronline_evtdig_tcp_soa() {
1782        // Test TCP SOA query (with -t for TCP)
1783        let cmd = parse_command(
1784            r#"CRONLINE 1800 1945 1770762780 UNIFORM 3 evtdig -4 -t --soa . -A "10101" -O /home/atlas/data/new/7 193.0.14.129"#,
1785        );
1786        match cmd {
1787            TelnetCommand::Dns(spec) => {
1788                assert_eq!(spec.msm_id, 10101);
1789                assert_eq!(spec.target, "193.0.14.129");
1790                assert_eq!(spec.query_type, "SOA");
1791                assert_eq!(spec.protocol, "TCP");
1792            }
1793            _ => panic!("Expected Dns command, got {:?}", cmd),
1794        }
1795    }
1796
1797    #[test]
1798    fn test_parse_cronline_evtdig_hostname() {
1799        // Test -h for hostname.bind query
1800        let cmd = parse_command(
1801            r#"CRONLINE 240 659 1770762780 UNIFORM 3 evtdig -4 -h -A "10301" -O /home/atlas/data/new/7 193.0.14.129"#,
1802        );
1803        match cmd {
1804            TelnetCommand::Dns(spec) => {
1805                assert_eq!(spec.msm_id, 10301);
1806                assert_eq!(spec.target, "193.0.14.129");
1807                assert_eq!(spec.query_argument, "hostname.bind");
1808                assert_eq!(spec.query_type, "TXT");
1809                assert_eq!(spec.query_class, "CH");
1810            }
1811            _ => panic!("Expected Dns command, got {:?}", cmd),
1812        }
1813    }
1814
1815    #[test]
1816    fn test_parse_cronline_evhttpget() {
1817        let cmd = parse_command(
1818            r#"CRONLINE 3600 0 0 UNIFORM 60 evhttpget -4 -A "7001" http://example.com/path"#,
1819        );
1820        match cmd {
1821            TelnetCommand::Http(spec) => {
1822                assert_eq!(spec.msm_id, 7001);
1823                assert_eq!(spec.url, "http://example.com/path");
1824                assert_eq!(spec.method, "GET");
1825                assert_eq!(spec.af, 4);
1826            }
1827            _ => panic!("Expected Http command, got {:?}", cmd),
1828        }
1829    }
1830
1831    #[test]
1832    fn test_parse_cronline_evsslgetcert() {
1833        let cmd = parse_command(
1834            r#"CRONLINE 1800 0 0 UNIFORM 10 evsslgetcert -4 -p 443 -A "8001" example.com"#,
1835        );
1836        match cmd {
1837            TelnetCommand::Tls(spec) => {
1838                assert_eq!(spec.msm_id, 8001);
1839                assert_eq!(spec.target, "example.com");
1840                assert_eq!(spec.port, 443);
1841                assert_eq!(spec.af, 4);
1842            }
1843            _ => panic!("Expected Tls command, got {:?}", cmd),
1844        }
1845    }
1846
1847    #[test]
1848    fn test_parse_cronline_evsslgetcert_custom_port() {
1849        let cmd = parse_command(
1850            r#"CRONLINE 900 0 0 UNIFORM 5 evsslgetcert -6 -p 8443 -A "8002" secure.example.com"#,
1851        );
1852        match cmd {
1853            TelnetCommand::Tls(spec) => {
1854                assert_eq!(spec.msm_id, 8002);
1855                assert_eq!(spec.target, "secure.example.com");
1856                assert_eq!(spec.port, 8443);
1857                assert_eq!(spec.af, 6);
1858            }
1859            _ => panic!("Expected Tls command"),
1860        }
1861    }
1862
1863    #[test]
1864    fn test_parse_cronline_evntp() {
1865        let cmd =
1866            parse_command(r#"CRONLINE 1800 0 0 UNIFORM 3 evntp -4 -c 5 -A "9001" pool.ntp.org"#);
1867        match cmd {
1868            TelnetCommand::Ntp(spec) => {
1869                assert_eq!(spec.msm_id, 9001);
1870                assert_eq!(spec.target, "pool.ntp.org");
1871                assert_eq!(spec.packets, 5);
1872                assert_eq!(spec.af, 4);
1873            }
1874            _ => panic!("Expected Ntp command, got {:?}", cmd),
1875        }
1876    }
1877
1878    #[test]
1879    fn test_parse_cronline_buddyinfo() {
1880        let cmd = parse_command("CRONLINE 600 0 0 UNIFORM 5 buddyinfo 2048");
1881        match cmd {
1882            TelnetCommand::HostTelemetry(spec) => {
1883                assert_eq!(spec.kind, HostTelemetryKind::Buddyinfo);
1884                assert_eq!(spec.interval, 600);
1885                assert_eq!(spec.lowmem, Some(2048));
1886                assert_eq!(spec.msm_id, None);
1887            }
1888            _ => panic!("Expected HostTelemetry/Buddyinfo, got {:?}", cmd),
1889        }
1890    }
1891
1892    #[test]
1893    fn test_parse_cronline_rptaddrs() {
1894        let cmd = parse_command(
1895            r#"CRONLINE 14400 0 0 UNIFORM 30 rptaddrs -A 9104 -c /home/atlas/data/new/v6addr.vol -O /home/atlas/data/new/v6addr.txt"#,
1896        );
1897        match cmd {
1898            TelnetCommand::HostTelemetry(spec) => {
1899                assert_eq!(spec.kind, HostTelemetryKind::Rptaddrs);
1900                assert_eq!(spec.interval, 14400);
1901                assert_eq!(spec.msm_id, Some(9104));
1902            }
1903            _ => panic!("Expected HostTelemetry/Rptaddrs, got {:?}", cmd),
1904        }
1905    }
1906
1907    #[test]
1908    fn test_parse_cronline_httppost_ignored() {
1909        // httppost shouldn't be dispatched — we upload natively.
1910        let cmd = parse_command(
1911            "CRONLINE 60 0 0 UNIFORM 5 httppost -O /home/atlas/data/out/foo --post-file foo",
1912        );
1913        assert!(matches!(cmd, TelnetCommand::Ignored(_)));
1914    }
1915
1916    #[test]
1917    fn test_parse_cronline_condmv_ignored() {
1918        let cmd = parse_command(
1919            "CRONLINE 60 0 0 UNIFORM 5 condmv /home/atlas/data/new/foo /home/atlas/data/out/foo",
1920        );
1921        assert!(matches!(cmd, TelnetCommand::Ignored(_)));
1922    }
1923
1924    #[test]
1925    fn test_parse_cronline_conntrack_ignored() {
1926        let cmd = parse_command("CRONLINE 600 0 0 UNIFORM 5 conntrack");
1927        assert!(matches!(cmd, TelnetCommand::Ignored(_)));
1928    }
1929}