cat_dev/mion/cgis/
setup.rs

1//! APIs for parsing the data from `/setup.cgi`, an HTTP interface usually
2//! only meant for humans.
3
4use crate::{
5	errors::CatBridgeError,
6	mion::{
7		cgis::do_simple_request,
8		proto::cgis::{MIONCGIErrors, SetupParameters},
9	},
10};
11use mac_address::MacAddress;
12use reqwest::{Body, Client, Method};
13use std::net::Ipv4Addr;
14
15/// Get the setup parameters from a particular MION IP.
16///
17/// ## Errors
18///
19/// If we cannot make a network request to the `setup.cgi` page, or get some
20/// sort of error condition back.
21pub async fn get_setup_parameters(mion_ip: Ipv4Addr) -> Result<SetupParameters, CatBridgeError> {
22	get_setup_parameters_with_raw_client(&Client::default(), mion_ip).await
23}
24
25/// Get the setup parameters from a particular MION IP, but with an existing
26/// HTTP client.
27///
28/// ## Errors
29///
30/// If we cannot make a network request to the `setup.cgi` page, or get some
31/// sort of error condition back.
32pub async fn get_setup_parameters_with_raw_client(
33	client: &Client,
34	mion_ip: Ipv4Addr,
35) -> Result<SetupParameters, CatBridgeError> {
36	let body_as_string = do_simple_request::<Body>(
37		client,
38		Method::GET,
39		format!("http://{mion_ip}/setup.cgi"),
40		None,
41	)
42	.await?;
43
44	parse_response_body_from_setup(&body_as_string)
45}
46
47/// Parse the actual setup parameters from an HTML body.
48///
49/// This is seperated into it's own method for testability.
50///
51/// ## Errors
52///
53/// - If we can't parse the response as the HTML format MIONs should respond
54///   with.
55/// - If the type of inputs were incorrect.
56fn parse_response_body_from_setup(body_as_string: &str) -> Result<SetupParameters, CatBridgeError> {
57	Ok(SetupParameters::new(
58		find_input_from_body(&InputType::Text, "id_5", body_as_string)?
59			.parse::<Ipv4Addr>()
60			.map_err(MIONCGIErrors::HtmlResponseIpExpectedButNotThere)?,
61		find_input_from_body(&InputType::Text, "id_6", body_as_string)?
62			.parse::<Ipv4Addr>()
63			.map_err(MIONCGIErrors::HtmlResponseIpExpectedButNotThere)?,
64		find_input_from_body(&InputType::Text, "id_7", body_as_string)?
65			.parse::<Ipv4Addr>()
66			.map_err(MIONCGIErrors::HtmlResponseIpExpectedButNotThere)?,
67		find_input_from_body(&InputType::BooleanRadio, "id_4", body_as_string)?
68			.parse::<bool>()
69			.expect("impossible, this function only returns Ok(true) or Ok(false)"),
70		find_input_from_body(&InputType::BooleanRadio, "id_8", body_as_string)?
71			.parse::<bool>()
72			.expect("impossible, this function only returns Ok(true) or Ok(false)"),
73		find_input_from_body(&InputType::Text, "id_9", body_as_string)?
74			.parse::<Ipv4Addr>()
75			.map_err(MIONCGIErrors::HtmlResponseIpExpectedButNotThere)?,
76		find_input_from_body(&InputType::Text, "id_10", body_as_string)?
77			.parse::<Ipv4Addr>()
78			.map_err(MIONCGIErrors::HtmlResponseIpExpectedButNotThere)?,
79		find_input_from_body(&InputType::BooleanRadio, "id_11", body_as_string)?
80			.parse::<bool>()
81			.expect("impossible, this function only returns Ok(true) or Ok(false)"),
82		find_input_from_body(&InputType::Text, "id_12", body_as_string)?
83			.parse::<Ipv4Addr>()
84			.map_err(MIONCGIErrors::HtmlResponseIpExpectedButNotThere)?,
85		find_input_from_body(&InputType::SelectedResultValue, "id_26", body_as_string)?
86			.parse::<u32>()
87			.map_err(MIONCGIErrors::HtmlResponseNumberExpectedButNotThere)?
88			.try_into()?,
89		find_input_from_body(&InputType::Text, "id_27", body_as_string)?
90			.parse::<u8>()
91			.map_err(MIONCGIErrors::HtmlResponseNumberExpectedButNotThere)?,
92		find_input_from_body(&InputType::Text, "id_13", body_as_string)?
93			.parse::<u16>()
94			.map_err(MIONCGIErrors::HtmlResponseNumberExpectedButNotThere)?,
95		find_input_from_body(&InputType::Text, "id_14", body_as_string)?
96			.parse::<u16>()
97			.map_err(MIONCGIErrors::HtmlResponseNumberExpectedButNotThere)?,
98		find_input_from_body(&InputType::Text, "id_15", body_as_string)?
99			.parse::<u16>()
100			.map_err(MIONCGIErrors::HtmlResponseNumberExpectedButNotThere)?,
101		find_input_from_body(&InputType::Text, "id_16", body_as_string)?
102			.parse::<u16>()
103			.map_err(MIONCGIErrors::HtmlResponseNumberExpectedButNotThere)?,
104		find_input_from_body(&InputType::Text, "id_17", body_as_string)?
105			.parse::<u16>()
106			.map_err(MIONCGIErrors::HtmlResponseNumberExpectedButNotThere)?,
107		find_input_from_body(&InputType::BooleanRadio, "id_33", body_as_string)?
108			.parse::<bool>()
109			.expect("impossible, this function only returns Ok(true) or Ok(false)"),
110		find_input_from_body(&InputType::BooleanRadio, "id_32", body_as_string)?
111			.parse::<bool>()
112			.expect("impossible, this function only returns Ok(true) or Ok(false)"),
113		find_input_from_body(&InputType::Text, "id_28", body_as_string)?,
114		find_input_from_body(&InputType::Text, "id_29", body_as_string)?,
115		find_input_from_body(&InputType::Text, "id_30", body_as_string)?,
116		find_input_from_body(&InputType::Text, "id_31", body_as_string)?,
117		find_input_from_body(&InputType::TextArea, "id_2", body_as_string)?,
118		find_input_from_body(
119			&InputType::TableItemWithPrefix("MAC Address"),
120			"",
121			body_as_string,
122		)?
123		.parse::<MacAddress>()
124		.map_err(MIONCGIErrors::HtmlResponseMacExpectedButNotThere)?,
125	))
126}
127
128enum InputType<'inner> {
129	Text,
130	TextArea,
131	BooleanRadio,
132	SelectedResultValue,
133	TableItemWithPrefix(&'inner str),
134}
135
136fn find_input_from_body(
137	input_typ: &InputType<'_>,
138	input_id: &str,
139	html_response: &str,
140) -> Result<String, MIONCGIErrors> {
141	match input_typ {
142		InputType::Text => {
143			let string_to_find = format!("<input type=\"text\" name=\"{input_id}\"");
144			let pos = get_html_value(input_id, html_response, html_response, &string_to_find)?;
145
146			let starts_at_input = &html_response[pos..];
147			let value_start = get_html_value(input_id, html_response, starts_at_input, "value=\"")?;
148			let next_value = &starts_at_input[value_start + 7..];
149			let ending_value = get_html_value(input_id, html_response, next_value, "\">")?;
150			let final_value = &next_value[..ending_value];
151
152			Ok(final_value.to_owned())
153		}
154		InputType::TextArea => {
155			let pos = get_html_value(
156				input_id,
157				html_response,
158				html_response,
159				&format!("<textarea name=\"{input_id}\""),
160			)?;
161			let starts_at_input = &html_response[pos..];
162			let end_tag_pos = get_html_value(input_id, html_response, starts_at_input, ">")?;
163			let minus_start_tag = &starts_at_input[end_tag_pos + 1..];
164			let closing_tag =
165				get_html_value(input_id, html_response, minus_start_tag, "</textarea>")?;
166			let value = &minus_start_tag[..closing_tag];
167
168			Ok(value.to_owned())
169		}
170		InputType::BooleanRadio => {
171			if html_response.contains(&format!(
172				"<input type=\"radio\" name=\"{input_id}\" value=\"1\" checked>"
173			)) {
174				Ok("true".to_owned())
175			} else if html_response.contains(&format!(
176				"<input type=\"radio\" name=\"{input_id}\" value=\"0\" checked>"
177			)) {
178				Ok("false".to_owned())
179			} else {
180				Err(MIONCGIErrors::HtmlResponseNoRadioChecked(
181					html_response.to_owned(),
182				))
183			}
184		}
185		InputType::SelectedResultValue => {
186			let start_input_pos = get_html_value(
187				input_id,
188				html_response,
189				html_response,
190				&format!("<select name=\"{input_id}\">"),
191			)?;
192			let start_at_select_value = &html_response[start_input_pos + 15 + input_id.len()..];
193			let select_end_pos =
194				get_html_value(input_id, html_response, start_at_select_value, "</select>")?;
195			let selects = &start_at_select_value[..select_end_pos];
196			let end_input_pos = get_html_value(input_id, html_response, selects, "\" selected>")?;
197			let ends_at_selected_value = &selects[..end_input_pos];
198			let start_option_location = rget_html_value(
199				input_id,
200				html_response,
201				ends_at_selected_value,
202				"<option value=\"",
203			)?;
204
205			let value_as_string = &ends_at_selected_value[start_option_location + 15..];
206			Ok(value_as_string.to_owned())
207		}
208		InputType::TableItemWithPrefix(table_prefix) => {
209			for table_item in get_all_table_items(html_response) {
210				let stripped_beginning = table_item
211					.replace("&nbsp;", "")
212					.replace("<br>", "")
213					.replace("<br/>", "")
214					.replace("<br />", "");
215				let trimmed = stripped_beginning.trim();
216				if !trimmed.starts_with(table_prefix) {
217					continue;
218				}
219				let value = trimmed[table_prefix.len()..].trim();
220
221				return Ok(value.to_owned());
222			}
223
224			Err(MIONCGIErrors::HtmlResponseNoTableItemWithPrefix(
225				html_response.to_owned(),
226				table_prefix.to_owned().to_owned(),
227			))
228		}
229	}
230}
231
232fn get_all_table_items(body: &str) -> Vec<&str> {
233	let mut table_items = Vec::new();
234
235	let mut response = body;
236	loop {
237		let Some(next_table_start) = response.find("<td") else {
238			break;
239		};
240		let minus_start_of_td = &response[next_table_start + 3..];
241		let Some(tag_end) = minus_start_of_td.find('>') else {
242			break;
243		};
244		let minus_opening_tag = &minus_start_of_td[tag_end + 1..];
245
246		let Some(end_tag_start) = minus_opening_tag.find("</td>") else {
247			break;
248		};
249		let minus_ending_tag = &minus_opening_tag[..end_tag_start];
250		table_items.push(minus_ending_tag);
251
252		response = &response[end_tag_start + 5..];
253	}
254
255	table_items
256}
257
258fn get_html_value(
259	input_id: &str,
260	html_response: &str,
261	haystack: &str,
262	needle: &str,
263) -> Result<usize, MIONCGIErrors> {
264	haystack.find(needle).ok_or_else(|| {
265		MIONCGIErrors::HtmlResponseMissingTaggedInput(input_id.to_owned(), html_response.to_owned())
266	})
267}
268fn rget_html_value(
269	input_id: &str,
270	html_response: &str,
271	haystack: &str,
272	needle: &str,
273) -> Result<usize, MIONCGIErrors> {
274	haystack.rfind(needle).ok_or_else(|| {
275		MIONCGIErrors::HtmlResponseMissingTaggedInput(input_id.to_owned(), html_response.to_owned())
276	})
277}
278
279#[cfg(test)]
280mod unit_tests {
281	use super::*;
282	use crate::mion::proto::cgis::CatDevBankSize;
283
284	/// A real world response to the MION setup page.
285	const REAL_WORLD_SETUP_RESPONSE: &str = r##"<HTML>
286<head>
287<meta http-equiv="Content-Type" content="text/html; charset=ASCII">
288<title>MION Board Parameter Settings</title>
289</head>
290<body bgcolor="#FFFFFF" text="#000000">
291<div align="center">
292<h1><u>MION Board Parameter Settings</u></h1>
293<form method="POST" name="param_form" action="setup.cgi">
294<table border=1 cellspacing=1 cellpadding=8>
295 <tr>
296  <td rowspan=9><font size="+1">Network</font></td>
297  <td>IP Address<br><input type="text" name="id_5" maxlength=15 size=25 value="192.168.0.1"></td>
298 </tr>
299 <tr>
300  <td>Subnet Mask<br><input type="text" name="id_6" maxlength=15 size=25 value="255.255.255.0"></td>
301 </tr>
302 <tr>
303  <td>Default Gateway<br><input type="text" name="id_7" maxlength=15 size=25 value="0.0.0.0"></td>
304 </tr>
305 <tr>
306  <td>
307   DHCP<br>
308   <input type="radio" name="id_4" value="1" checked>ON
309   <input type="radio" name="id_4" value="0">OFF
310  </td>
311 </tr>
312 <tr>
313  <td>
314   DNS<br>
315   <input type="radio" name="id_8" value="1">ON
316   <input type="radio" name="id_8" value="0" checked>OFF
317  </td>
318 </tr>
319 <tr>
320  <td>Preferred DNS Server Address<br><input type="text" name="id_9" maxlength=15 size=25 value="0.0.0.0"></td>
321 </tr>
322 <tr>
323  <td>Alternate DNS Server Address<br><input type="text" name="id_10" maxlength=15 size=25 value="0.0.0.0"></td>
324 </tr>
325 <tr>
326  <td>
327   Jumbo Frame<br>
328   <input type="radio" name="id_11" value="1" checked>ON
329   <input type="radio" name="id_11" value="0">OFF
330  </td>
331 </tr>
332 <tr>
333  <td>
334   Host PC IP Address<br>
335   <input type="text" name="id_12" maxlength=15 size=25 value="0.0.0.0">
336  </td>
337 </tr>
338 <tr>
339  <td rowspan=2><font size="+1">SATA</font></td>
340  <td>
341   Internal HDD Bank Size<br>
342   <select name="id_26">
343    <option value="4294967295">&nbsp;
344    <option value="0" selected>25GB
345    <option value="1">5GB
346    <option value="2">9GB
347    <option value="3">12GB
348    <option value="4">14GB
349    <option value="5">16GB
350    <option value="6">18GB
351    <option value="7">21GB
352   </select>
353  </td>
354 </tr>
355 <tr>
356  <td>HDD Bank No.<br><input type="text" name="id_27" size=25 value="0"></td>
357 </tr>
358 <tr>
359  <td><font size="+1">ATAPI Emulator</font></td>
360  <td>Port No. for ATAPI Emulator<br><input type="text" name="id_13" maxlength=5 size=25 value="7974"></td>
361 </tr>
362 <tr>
363  <td><font size="+1">SDIO Printf/Control</font></td>
364  <td>Port No. for SDIO Printf/Control<br><input type="text" name="id_14" maxlength=5 size=25 value="7975"></td>
365 </tr>
366 <tr>
367  <td><font size="+1">SDIO Block Data</font></td>
368  <td>Port No. for SDIO Block Data<br><input type="text" name="id_15" maxlength=5 size=25 value="7976"></td>
369 </tr>
370 <tr>
371  <td><font size="+1">EXI</font></td>
372  <td>Port No. for EXI<br><input type="text" name="id_16" maxlength=5 size=25 value="7977"></td>
373 </tr>
374 <tr>
375  <td><font size="+1">Parameter Space</font></td>
376  <td>Port No. for Parameter Space<br><input type="text" name="id_17" maxlength=5 size=25 value="7978"></td>
377 </tr>
378 <tr>
379  <td><font size="+1">Drive Setting</font></td>
380  <td>
381   Drive Timing Emulation<br>
382   <input type="radio" name="id_33" value="1">enable
383   <input type="radio" name="id_33" value="0" checked>disable
384  </td>
385 </tr>
386 <tr>
387  <td><font size="+1">Operational Mode</font></td>
388  <td>
389   Operational Mode<br>
390   <input type="radio" name="id_32" value="0" checked>CAT-DEV Mode
391   <input type="radio" name="id_32" value="1">H-Reader Mode
392  </td>
393 </tr>
394 <tr>
395  <td rowspan=4><font size="+1">Drive Information</font></td>
396  <td>Product Revision<br><input type="text" name="id_28" maxlength=4 size=25 value="0000">(HEX)</td>
397 </tr>
398 <tr>
399  <td>Vendor Code<br><input type="text" name="id_29" maxlength=2 size=25 value="02">(HEX)</td>
400 </tr>
401 <tr>
402  <td>Device Code<br><input type="text" name="id_30" maxlength=2 size=25 value="06">(HEX)</td>
403 </tr>
404 <tr>
405  <td>   Release Date<br><input type="text" name="id_31" maxlength=8 size=25 value="20100430">(HEX)</td>
406 </tr>
407 <tr>
408  <td rowspan=2><font size="+1">Device Unique</font></td>
409  <td>Machine Name<br><textarea name="id_2" cols=50 rows=3>00-25-5C-BA-5A-00</textarea></td>
410 </tr>
411 <tr>
412  <td>
413   MAC Address<br>
414   &nbsp;&nbsp;&nbsp;00-25-5C-BA-5A-00
415  </td>
416 </tr>
417 <tr>
418  <td colspan=2>
419   <div align="center">
420    <input type="hidden" name="op" value="0">
421    <input type="submit" value="Write">&nbsp;&nbsp;<input type="reset" value="Form Reset">
422   </div>
423  </td>
424 </tr>
425</table>
426</form>
427<form method="POST" name="param_read" action="setup.cgi">
428 <input type="hidden" name="op" value="1">
429  <div align="center">
430   <input type="submit" value="Read">
431  </div>
432</form>
433</div>
434<br>
435<hr>
436<a href="../">Homepage</a>
437</body>
438</HTML>"##;
439
440	#[test]
441	pub fn can_parse_setup_cgis() {
442		let result = parse_response_body_from_setup(REAL_WORLD_SETUP_RESPONSE);
443		assert!(
444			result.is_ok(),
445			"Failed to parse response body from `setup.cgi`:\n  {:?}",
446			result,
447		);
448		let parameters = result.expect("impossible probably, past assert.");
449
450		assert!(
451			parameters.static_ip_address().is_none(),
452			"Expected static ip address to be empty, is using DHCP",
453		);
454		assert_eq!(
455			parameters.raw_static_ip_address(),
456			"192.168.0.1"
457				.parse::<Ipv4Addr>()
458				.expect("Failed to parse test IP Address"),
459		);
460		assert_eq!(
461			parameters.subnet_mask(),
462			"255.255.255.0"
463				.parse::<Ipv4Addr>()
464				.expect("Failed to parse test IP Address"),
465		);
466		assert_eq!(
467			parameters.default_gateway(),
468			"0.0.0.0"
469				.parse::<Ipv4Addr>()
470				.expect("Failed to parse test IP Address"),
471		);
472
473		assert!(parameters.using_dhcp());
474		assert!(!parameters.using_self_managed_dns());
475
476		assert!(
477			parameters.primary_dns().is_none(),
478			"Expected Primary DNS to be empty, is not using self-managed DNS",
479		);
480		assert!(
481			parameters.secondary_dns().is_none(),
482			"Expected Primary DNS to be empty, is not using self-managed DNS",
483		);
484		assert_eq!(
485			parameters.raw_primary_dns(),
486			"0.0.0.0"
487				.parse::<Ipv4Addr>()
488				.expect("Failed to parse static IP Address for testing")
489		);
490		assert_eq!(
491			parameters.raw_secondary_dns(),
492			"0.0.0.0"
493				.parse::<Ipv4Addr>()
494				.expect("Failed to parse static IP Address for testing")
495		);
496
497		assert!(parameters.jumbo_frame());
498
499		assert_eq!(
500			parameters.host_pc_ip_address(),
501			"0.0.0.0"
502				.parse::<Ipv4Addr>()
503				.expect("Failed to parse static IP Address for testing")
504		);
505		assert_eq!(parameters.hdd_bank_size(), CatDevBankSize::TwentyFiveGbs);
506		assert_eq!(parameters.hdd_bank_no(), 0);
507
508		assert_eq!(parameters.atapi_emulator_port(), 7974);
509		assert_eq!(parameters.sdio_printf_port(), 7975);
510		assert_eq!(parameters.sdio_block_port(), 7976);
511		assert_eq!(parameters.exi_port(), 7977);
512		assert_eq!(parameters.parameter_space_port(), 7978);
513
514		assert!(!parameters.drive_timing_emulation_enabled());
515
516		assert!(parameters.is_cat_dev_mode());
517		assert!(!parameters.is_h_reader_mode());
518
519		assert_eq!(parameters.drive_product_revision(), "0000");
520		assert_eq!(parameters.drive_vendor_code(), "02");
521		assert_eq!(parameters.drive_device_code(), "06");
522		assert_eq!(parameters.drive_release_date(), "20100430");
523
524		assert_eq!(parameters.device_name(), "00-25-5C-BA-5A-00");
525		assert_eq!(
526			parameters.mac_address(),
527			"00-25-5C-BA-5A-00"
528				.parse::<MacAddress>()
529				.expect("Failed to parse static MacAddress for tests"),
530		);
531	}
532}