Name update

This commit is contained in:
Miles Ward
2026-05-21 02:13:08 -04:00
parent 91848e02e3
commit 115ba779da
37 changed files with 1387 additions and 589 deletions
+245 -127
View File
@@ -14,8 +14,8 @@
//! | `/api/*` | JSON/HTML API for the web UI |
use crate::ipxe_script::{
render_entry, render_family_menu, render_queue_entry, render_local_hdd,
render_menu, render_nic_info, render_shell, render_tools_menu, render_util,
render_entry, render_family_menu, render_local_hdd, render_menu, render_nic_info,
render_queue_entry, render_shell, render_tools_menu, render_util,
};
use crate::iso_fs;
use crate::log_stream;
@@ -61,7 +61,7 @@ pub fn build_router(state: AppState) -> Router {
// JSON API.
.route("/api/isos", get(api_list_isos).post(api_upload_iso))
.route("/api/isos/:id", delete(api_delete_iso))
// v0.3.1: per-ISO password gate. PUT body `{ "password": "..." }`
// Per-ISO password prompt. PUT body `{ "password": "..." }`
// sets, `{ "password": null }` (or DELETE) clears.
.route(
"/api/isos/:id/password",
@@ -105,42 +105,67 @@ pub fn build_router(state: AppState) -> Router {
async fn index(State(state): State<AppState>) -> Response {
let html = openpxe_webui::index_html(&state.public_base_url);
([(header::CONTENT_TYPE, HeaderValue::from_static("text/html; charset=utf-8"))], html)
(
[(
header::CONTENT_TYPE,
HeaderValue::from_static("text/html; charset=utf-8"),
)],
html,
)
.into_response()
}
async fn ui_js() -> Response {
(
[(header::CONTENT_TYPE, HeaderValue::from_static("application/javascript"))],
[(
header::CONTENT_TYPE,
HeaderValue::from_static("application/javascript"),
)],
openpxe_webui::app_js(),
).into_response()
)
.into_response()
}
async fn ui_css() -> Response {
(
[(header::CONTENT_TYPE, HeaderValue::from_static("text/css"))],
openpxe_webui::app_css(),
).into_response()
)
.into_response()
}
async fn ui_logo() -> Response {
(
[(header::CONTENT_TYPE, HeaderValue::from_static("image/svg+xml"))],
[(
header::CONTENT_TYPE,
HeaderValue::from_static("image/svg+xml"),
)],
openpxe_webui::logo_svg(),
).into_response()
)
.into_response()
}
async fn ui_loader() -> Response {
(
[(header::CONTENT_TYPE, HeaderValue::from_static("image/svg+xml"))],
[(
header::CONTENT_TYPE,
HeaderValue::from_static("image/svg+xml"),
)],
openpxe_webui::loader_svg(),
).into_response()
)
.into_response()
}
// ─── iPXE scripts ──────────────────────────────────────────────────────────
fn text_plain(body: String) -> Response {
([(header::CONTENT_TYPE, HeaderValue::from_static("text/plain; charset=utf-8"))], body)
(
[(
header::CONTENT_TYPE,
HeaderValue::from_static("text/plain; charset=utf-8"),
)],
body,
)
.into_response()
}
@@ -148,11 +173,10 @@ fn text_plain(body: String) -> Response {
/// requesting client carries a `?mac=...` query param (iPXE's `${mac}`
/// substitution) and that MAC has a binding, we short-circuit straight
/// to the bound target instead of rendering the menu.
async fn boot_top_menu(
State(state): State<AppState>,
Query(p): Query<BootMenuParams>,
) -> Response {
state.metrics.record_http(openpxe_core::HttpRoute::BootScript);
async fn boot_top_menu(State(state): State<AppState>, Query(p): Query<BootMenuParams>) -> Response {
state
.metrics
.record_http(openpxe_core::HttpRoute::BootScript);
let isos = state.iso_store.list();
let settings = state.settings.snapshot();
let base = &state.public_base_url;
@@ -212,19 +236,19 @@ async fn boot_sub(
let settings = state.settings.snapshot();
let base = &state.public_base_url;
let script = match name {
"_local" => render_local_hdd(base),
"_linux_menu" => render_family_menu(&isos, base, false),
"_windows_menu" => render_family_menu(&isos, base, true),
"_tools_menu" => render_tools_menu(base),
"_util" => render_util(base),
"_shell" => render_shell(base),
"_nic" => render_nic_info(base),
"_queue" => render_queue_entry(base),
"_local" => render_local_hdd(base),
"_linux_menu" => render_family_menu(&isos, base, false),
"_windows_menu" => render_family_menu(&isos, base, true),
"_tools_menu" => render_tools_menu(base),
"_util" => render_util(base),
"_shell" => render_shell(base),
"_nic" => render_nic_info(base),
"_queue" => render_queue_entry(base),
other => {
for iso in &isos {
for entry in &iso.boot_entries {
if entry.id == other {
// Password gate. If the ISO has a password set
// Password prompt. If the ISO has a password set
// we block the actual boot script behind it:
// - no token -> render a prompt
// - wrong token -> render auth-fail
@@ -235,38 +259,45 @@ async fn boot_sub(
match p.token.as_deref() {
None | Some("") => {
return text_plain(crate::ipxe_script::render_password_prompt(
&entry.id, &iso.filename, base,
&entry.id,
&iso.filename,
base,
));
}
Some(token) => match state.iso_store.verify_password(&iso.id, token) {
Ok(true) => { /* fall through to render the entry */ }
Ok(false) => {
// Don't log the candidate — just the
// mac (when iPXE supplies one) and
// the entry id, so an operator can
// see brute-force attempts in the
// live log.
tracing::warn!(
target: "openpxe::http::boot",
entry = %other,
"wrong password supplied for protected boot entry"
);
return text_plain(
crate::ipxe_script::render_password_failed(
&entry.id, base,
),
);
Some(token) => {
match state.iso_store.verify_password(&iso.id, token) {
Ok(true) => { /* fall through to render the entry */ }
Ok(false) => {
// Don't log the candidate — just the
// mac (when iPXE supplies one) and
// the entry id, so an operator can
// see brute-force attempts in the
// live log.
tracing::warn!(
target: "openpxe::http::boot",
entry = %other,
"wrong password supplied for protected boot entry"
);
return text_plain(
crate::ipxe_script::render_password_failed(
&entry.id, base,
),
);
}
Err(e) => {
tracing::error!(
target: "openpxe::http::boot",
entry = %other, error = %e,
"password verify failed unexpectedly"
);
return (
StatusCode::INTERNAL_SERVER_ERROR,
"password check failed",
)
.into_response();
}
}
Err(e) => {
tracing::error!(
target: "openpxe::http::boot",
entry = %other, error = %e,
"password verify failed unexpectedly"
);
return (StatusCode::INTERNAL_SERVER_ERROR,
"password check failed").into_response();
}
},
}
}
}
return text_plain(render_entry(entry, &settings, base));
@@ -290,11 +321,15 @@ async fn ipxe_binary(AxumPath(name): AxumPath<String>) -> Response {
};
(
[
(header::CONTENT_TYPE, HeaderValue::from_static("application/octet-stream")),
(
header::CONTENT_TYPE,
HeaderValue::from_static("application/octet-stream"),
),
(header::CONTENT_LENGTH, HeaderValue::from(bytes.len())),
],
bytes,
).into_response()
)
.into_response()
}
// ─── ISO streaming (raw + in-ISO) ─────────────────────────────────────────
@@ -324,7 +359,9 @@ async fn iso_file(
let p = iso_path.clone();
let in_path = format!("/{path}");
let loc = tokio::task::spawn_blocking(move || iso_fs::lookup(&p, &in_path))
.await.ok().flatten();
.await
.ok()
.flatten();
let Some(loc) = loc else {
return (StatusCode::NOT_FOUND, "not found inside iso").into_response();
};
@@ -340,21 +377,43 @@ async fn stream_file_range(
) -> anyhow::Result<Response> {
let meta = tokio::fs::metadata(path).await?;
let total = meta.len();
let (start, end, partial) = parse_range(range, total);
if total == 0 {
return Ok(Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, "application/octet-stream")
.header(header::ACCEPT_RANGES, "bytes")
.header(header::CONTENT_LENGTH, 0)
.body(Body::empty())
.unwrap());
}
let Some((start, end, partial)) = parse_range(range, total) else {
return Ok(Response::builder()
.status(StatusCode::RANGE_NOT_SATISFIABLE)
.header(header::CONTENT_RANGE, format!("bytes */{total}"))
.body(Body::empty())
.unwrap());
};
let len = end - start + 1;
let mut file = tokio::fs::File::open(path).await?;
file.seek(std::io::SeekFrom::Start(start)).await?;
let reader = file.take(len);
let stream = tokio_util::io::ReaderStream::new(reader);
let body = Body::from_stream(stream);
let status = if partial { StatusCode::PARTIAL_CONTENT } else { StatusCode::OK };
let status = if partial {
StatusCode::PARTIAL_CONTENT
} else {
StatusCode::OK
};
let mut builder = Response::builder()
.status(status)
.header(header::CONTENT_TYPE, "application/octet-stream")
.header(header::ACCEPT_RANGES, "bytes")
.header(header::CONTENT_LENGTH, len);
if partial {
builder = builder.header(header::CONTENT_RANGE, format!("bytes {start}-{end}/{total}"));
builder = builder.header(
header::CONTENT_RANGE,
format!("bytes {start}-{end}/{total}"),
);
}
Ok(builder.body(body).unwrap())
}
@@ -377,21 +436,40 @@ async fn stream_byte_range(
.unwrap())
}
fn parse_range(h: Option<&HeaderValue>, total: u64) -> (u64, u64, bool) {
let Some(h) = h else { return (0, total.saturating_sub(1), false); };
let Ok(s) = h.to_str() else { return (0, total.saturating_sub(1), false); };
let Some(spec) = s.strip_prefix("bytes=") else { return (0, total.saturating_sub(1), false); };
fn parse_range(h: Option<&HeaderValue>, total: u64) -> Option<(u64, u64, bool)> {
let Some(h) = h else {
return Some((0, total.saturating_sub(1), false));
};
let Ok(s) = h.to_str() else {
return Some((0, total.saturating_sub(1), false));
};
let Some(spec) = s.strip_prefix("bytes=") else {
return Some((0, total.saturating_sub(1), false));
};
let spec = spec.split(',').next().unwrap_or("").trim();
if let Some(suffix) = spec.strip_prefix('-') {
if let Ok(n) = suffix.parse::<u64>() {
let n = n.min(total);
return (total.saturating_sub(n), total.saturating_sub(1), true);
return Some((total.saturating_sub(n), total.saturating_sub(1), true));
}
}
let mut parts = spec.splitn(2, '-');
let start = parts.next().and_then(|s| s.parse::<u64>().ok()).unwrap_or(0);
let end = parts.next().and_then(|s| s.parse::<u64>().ok()).unwrap_or(total.saturating_sub(1));
(start, end.min(total.saturating_sub(1)), true)
let start = parts
.next()
.and_then(|s| s.parse::<u64>().ok())
.unwrap_or(0);
let end = parts
.next()
.and_then(|s| s.parse::<u64>().ok())
.unwrap_or(total.saturating_sub(1));
if start >= total {
return None;
}
let end = end.min(total.saturating_sub(1));
if start > end {
return None;
}
Some((start, end, true))
}
// ─── ISO upload / list / delete ───────────────────────────────────────────
@@ -442,8 +520,10 @@ async fn api_set_iso_password(
);
StatusCode::NO_CONTENT.into_response()
}
Err(pxeforge_error_invalid) if matches!(pxeforge_error_invalid, openpxe_core::Error::Invalid(_)) => {
(StatusCode::NOT_FOUND, format!("{pxeforge_error_invalid}")).into_response()
Err(openpxe_error_invalid)
if matches!(openpxe_error_invalid, openpxe_core::Error::Invalid(_)) =>
{
(StatusCode::NOT_FOUND, format!("{openpxe_error_invalid}")).into_response()
}
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")).into_response(),
}
@@ -465,12 +545,11 @@ async fn api_clear_iso_password(
}
}
async fn api_upload_iso(
State(state): State<AppState>,
mut multipart: Multipart,
) -> Response {
async fn api_upload_iso(State(state): State<AppState>, mut multipart: Multipart) -> Response {
while let Ok(Some(mut field)) = multipart.next_field().await {
if field.name() != Some("file") { continue; }
if field.name() != Some("file") {
continue;
}
let filename = field.file_name().unwrap_or("uploaded.iso").to_string();
if !filename.to_ascii_lowercase().ends_with(".iso") {
return (StatusCode::BAD_REQUEST, "only .iso uploads accepted").into_response();
@@ -499,7 +578,11 @@ async fn api_upload_iso(
async fn healthz() -> Response {
// Simple liveness — HTTP task is responsive. Does not touch storage or
// other subsystems so we never fail for downstream reasons.
([(header::CONTENT_TYPE, HeaderValue::from_static("text/plain"))], "ok\n").into_response()
(
[(header::CONTENT_TYPE, HeaderValue::from_static("text/plain"))],
"ok\n",
)
.into_response()
}
async fn readyz(State(state): State<AppState>) -> Response {
@@ -517,7 +600,11 @@ async fn readyz(State(state): State<AppState>) -> Response {
problems.push("iso directory not readable");
}
if problems.is_empty() {
([(header::CONTENT_TYPE, HeaderValue::from_static("text/plain"))], "ready\n").into_response()
(
[(header::CONTENT_TYPE, HeaderValue::from_static("text/plain"))],
"ready\n",
)
.into_response()
} else {
let body = format!("not ready:\n- {}\n", problems.join("\n- "));
(StatusCode::SERVICE_UNAVAILABLE, body).into_response()
@@ -536,17 +623,22 @@ async fn api_status(State(state): State<AppState>) -> Json<serde_json::Value> {
let nfs_active = nfs.iter().filter(|m| m.mounted).count();
let isos = state.iso_store.list();
let clients = state.clients.list();
let gates = state.queue.list();
// Phase 4: dashboard tracks "imaging" as gates with an assignment
let queue_entries = state.queue.list();
// Phase 4: dashboard tracks "imaging" as queue entries with an assignment
// already issued — they're the ones actively chaining a boot script.
let imaging = gates.iter().filter(|g| g.assigned_target.is_some()).count();
let waiting = gates.len() - imaging;
let imaging = queue_entries
.iter()
.filter(|entry| entry.assigned_target.is_some())
.count();
let waiting = queue_entries.len() - imaging;
// Side-effect: push gauge values out to the Prometheus surface.
// Doing it here (in the most-frequently-polled endpoint) keeps the
// gauges fresh without a dedicated scrape-time hook.
state.metrics.set_iso_count(isos.len() as u64);
state.metrics.set_client_count(clients.len() as u64);
state.metrics.set_queue_counts(gates.len() as u64, imaging as u64);
state
.metrics
.set_queue_counts(queue_entries.len() as u64, imaging as u64);
state.metrics.set_nfs_active(nfs_active as u64);
state.metrics.record_http(openpxe_core::HttpRoute::Api);
let now = time::OffsetDateTime::now_utc();
@@ -556,7 +648,7 @@ async fn api_status(State(state): State<AppState>) -> Json<serde_json::Value> {
"public_base_url": state.public_base_url,
"iso_count": isos.len(),
"client_count": clients.len(),
"queue_count": gates.len(),
"queue_count": queue_entries.len(),
"imaging_count": imaging,
"waiting_count": waiting,
"ipxe_assets": openpxe_ipxe_assets::list_assets(),
@@ -591,7 +683,8 @@ async fn api_put_settings(
"cannot enable Windows: 'wimboot' binary is not bundled. \
Place a signed wimboot build at assets/ipxe/wimboot and rebuild \
the container. See docs/architecture.md for details.",
).into_response();
)
.into_response();
}
}
new.smb_host_override = new.smb_host_override.trim().to_string();
@@ -604,9 +697,15 @@ async fn api_put_settings(
if let Some(smb) = &state.smb {
match (was_enabled, want_enabled) {
(false, true) => { let _ = smb.start(); }
(true, false) => { smb.stop(); }
(true, true) => { let _ = smb.reconcile(); }
(false, true) => {
let _ = smb.start();
}
(true, false) => {
smb.stop();
}
(true, true) => {
let _ = smb.reconcile();
}
(false, false) => {}
}
}
@@ -623,7 +722,7 @@ async fn api_list_queue(State(state): State<AppState>) -> Json<serde_json::Value
}
#[derive(Debug, Deserialize)]
struct GateJoinParams {
struct QueueJoinParams {
/// Client MAC from iPXE's `${mac}` variable. iPXE substitutes before
/// the HTTP request so we receive a plain colon-separated MAC.
mac: Option<String>,
@@ -634,7 +733,7 @@ struct GateJoinParams {
/// until poll returns an actual boot script.
async fn api_queue_join(
State(state): State<AppState>,
Query(p): Query<GateJoinParams>,
Query(p): Query<QueueJoinParams>,
headers: HeaderMap,
) -> Response {
let mac = p.mac.unwrap_or_else(|| "unknown".to_string());
@@ -644,10 +743,14 @@ async fn api_queue_join(
.and_then(|s| s.split(',').next())
.and_then(|s| s.trim().parse().ok());
let gate = state.queue.join(&mac, ip, None);
let queue_entry = state.queue.join(&mac, ip, None);
state.clients.record(
&mac, ip, None,
ClientEvent::HttpScriptFetch { target: "queue-join".into() },
&mac,
ip,
None,
ClientEvent::HttpScriptFetch {
target: "queue-join".into(),
},
);
let base = &state.public_base_url;
@@ -656,12 +759,12 @@ async fn api_queue_join(
"#!ipxe\n\
echo\n\
echo ==========================================\n\
echo Queued Deployment - Gate Position {}\n\
echo Queued Deployment - Queue Position {}\n\
echo Waiting for operator to assign an image\n\
echo (Ctrl-B returns to the iPXE shell)\n\
echo ==========================================\n\
chain {base}/api/queue/poll/{}\n",
gate.position, gate.id
queue_entry.position, queue_entry.id
);
text_plain(script)
}
@@ -674,7 +777,7 @@ async fn api_queue_poll(
AxumPath(entry_id): AxumPath<String>,
) -> Response {
let Some(notify) = state.queue.notifier(&entry_id) else {
// Gate was released; send client back to the main menu.
// Queue entry was released; send client back to the main menu.
let base = &state.public_base_url;
return text_plain(format!("#!ipxe\nchain {base}/boot.ipxe\n"));
};
@@ -687,7 +790,7 @@ async fn api_queue_poll(
match snap {
// Bind `target` directly so we can't observe an Option::None between
// the guard and the unwrap (the old code had a race with concurrent
// `release`). We also do NOT release the gate here — the web UI
// `release`). We also do NOT release the queue entry here — the web UI
// operator releases it explicitly, which keeps a record of "this
// machine was assigned image X" visible until the client is known
// to have started. Clients that retry on transient network errors
@@ -698,20 +801,20 @@ async fn api_queue_poll(
tracing::info!(
target: "openpxe::queue",
entry_id=%entry_id, mac=%g.mac, target=%target,
"gate assignment delivered"
"queue assignment delivered"
);
text_plain(format!(
"#!ipxe\n\
echo Gate assignment received: {target}\n\
echo Queue assignment received: {target}\n\
chain {base}/boot/{target}.ipxe || chain {base}/api/queue/poll/{entry_id}\n"
))
}
Some(g) => {
// No assignment yet - loop and re-poll. Repaint position so the
// UI count stays accurate if other gates were released meanwhile.
// UI count stays accurate if other queue entries were released meanwhile.
text_plain(format!(
"#!ipxe\n\
echo Gate Position {} - still waiting\n\
echo Queue Position {} - still waiting\n\
chain {base}/api/queue/poll/{entry_id}\n",
g.position
))
@@ -721,27 +824,34 @@ async fn api_queue_poll(
}
#[derive(Debug, Deserialize)]
struct GateAssignBody {
struct QueueAssignBody {
/// Boot entry id (from `BootEntry::id`). Same one used in
/// `/boot/<id>.ipxe`.
target: String,
/// Gate ids to assign. Empty = assign to all currently queued gates.
/// Queue entry ids to assign. Empty = assign to all currently queued clients.
entry_ids: Vec<String>,
}
async fn api_queue_assign(
State(state): State<AppState>,
Json(body): Json<GateAssignBody>,
Json(body): Json<QueueAssignBody>,
) -> Json<serde_json::Value> {
let ids = if body.entry_ids.is_empty() {
state.queue.list().into_iter().map(|g| g.id).collect::<Vec<_>>()
state
.queue
.list()
.into_iter()
.map(|g| g.id)
.collect::<Vec<_>>()
} else {
body.entry_ids
};
// Guard: target must exist as a BootEntry id.
let found = state.iso_store.list().into_iter().any(|i| {
i.boot_entries.iter().any(|e| e.id == body.target)
});
let found = state
.iso_store
.list()
.into_iter()
.any(|i| i.boot_entries.iter().any(|e| e.id == body.target));
if !found {
return Json(json!({ "ok": false, "error": "unknown target" }));
}
@@ -765,10 +875,7 @@ async fn api_nfs_list(State(state): State<AppState>) -> Json<serde_json::Value>
Json(json!({ "mounts": state.nfs.list() }))
}
async fn api_nfs_add(
State(state): State<AppState>,
Json(req): Json<NfsAddRequest>,
) -> Response {
async fn api_nfs_add(State(state): State<AppState>, Json(req): Json<NfsAddRequest>) -> Response {
match state.nfs.add(req).await {
Ok(m) => (StatusCode::CREATED, Json(m)).into_response(),
// Anything from the manager surfaces as a user-fixable validation
@@ -779,20 +886,14 @@ async fn api_nfs_add(
}
}
async fn api_nfs_remove(
State(state): State<AppState>,
AxumPath(id): AxumPath<String>,
) -> Response {
async fn api_nfs_remove(State(state): State<AppState>, AxumPath(id): AxumPath<String>) -> Response {
match state.nfs.remove(&id).await {
Ok(()) => StatusCode::NO_CONTENT.into_response(),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")).into_response(),
}
}
async fn api_nfs_scan(
State(state): State<AppState>,
AxumPath(id): AxumPath<String>,
) -> Response {
async fn api_nfs_scan(State(state): State<AppState>, AxumPath(id): AxumPath<String>) -> Response {
match state.nfs.rescan(&id).await {
Ok(n) => Json(json!({ "ok": true, "iso_count": n })).into_response(),
Err(e) => (StatusCode::BAD_REQUEST, format!("{e}")).into_response(),
@@ -899,11 +1000,14 @@ async fn api_metrics(State(state): State<AppState>) -> Response {
state
.metrics
.set_client_count(state.clients.list().len() as u64);
let gates = state.queue.list();
let imaging = gates.iter().filter(|g| g.assigned_target.is_some()).count();
let queue_entries = state.queue.list();
let imaging = queue_entries
.iter()
.filter(|entry| entry.assigned_target.is_some())
.count();
state
.metrics
.set_queue_counts(gates.len() as u64, imaging as u64);
.set_queue_counts(queue_entries.len() as u64, imaging as u64);
state
.metrics
.set_nfs_active(state.nfs.list().iter().filter(|m| m.mounted).count() as u64);
@@ -927,25 +1031,39 @@ mod tests {
#[test]
fn range_full() {
let (s, e, p) = parse_range(None, 1000);
let (s, e, p) = parse_range(None, 1000).unwrap();
assert_eq!((s, e, p), (0, 999, false));
}
#[test]
fn range_open_ended() {
let h = HeaderValue::from_static("bytes=500-");
let (s, e, p) = parse_range(Some(&h), 1000);
let (s, e, p) = parse_range(Some(&h), 1000).unwrap();
assert_eq!((s, e, p), (500, 999, true));
}
#[test]
fn range_suffix() {
let h = HeaderValue::from_static("bytes=-100");
let (s, e, p) = parse_range(Some(&h), 1000);
let (s, e, p) = parse_range(Some(&h), 1000).unwrap();
assert_eq!((s, e, p), (900, 999, true));
}
#[test]
fn range_explicit() {
let h = HeaderValue::from_static("bytes=10-99");
let (s, e, p) = parse_range(Some(&h), 1000);
let (s, e, p) = parse_range(Some(&h), 1000).unwrap();
assert_eq!((s, e, p), (10, 99, true));
}
#[test]
fn range_rejects_out_of_bounds_start() {
let h = HeaderValue::from_static("bytes=1000-");
let got = parse_range(Some(&h), 1000);
assert_eq!(got, None);
}
#[test]
fn range_rejects_start_after_end() {
let h = HeaderValue::from_static("bytes=99-10");
let got = parse_range(Some(&h), 1000);
assert_eq!(got, None);
}
}