Name update

This commit is contained in:
Miles Ward
2026-04-29 02:47:00 -04:00
commit 3517c67831
66 changed files with 9016 additions and 0 deletions
+680
View File
@@ -0,0 +1,680 @@
//! Axum router, handlers, and API endpoints.
//!
//! Route groups:
//!
//! | Group | Purpose |
//! |------------------|-------------------------------------------------------|
//! | `/` | Web UI (served from `pxeforge-webui`) |
//! | `/boot.ipxe` | Top-level iPXE menu |
//! | `/boot/_*.ipxe` | Submenu scripts (hierarchy: linux, windows, tools, …) |
//! | `/boot/<id>.ipxe`| Per-entry boot script |
//! | `/ipxe/<file>` | Bundled iPXE binaries + wimboot + memtest |
//! | `/iso/<id>.iso` | Raw ISO with Range support |
//! | `/iso/<id>/*` | Files inside the ISO (for wimboot & kernel/initrd) |
//! | `/api/*` | JSON/HTML API for the web UI |
use crate::ipxe_script::{
render_entry, render_family_menu, render_gate_entry, render_local_hdd,
render_menu, render_nic_info, render_shell, render_tools_menu, render_util,
};
use crate::iso_fs;
use crate::log_stream;
use crate::state::AppState;
use crate::terminal;
use axum::{
body::Body,
extract::{DefaultBodyLimit, Multipart, Path as AxumPath, Query, State},
http::{header, HeaderMap, HeaderValue, StatusCode},
response::{IntoResponse, Response},
routing::{delete, get, post},
Json, Router,
};
use pxeforge_core::{ClientEvent, Settings};
use pxeforge_ipxe_assets::asset_bytes;
use pxeforge_iso_store::{IsoMeta, NfsAddRequest};
use serde::Deserialize;
use serde_json::json;
use std::time::Duration;
use tokio::io::{AsyncReadExt, AsyncSeekExt};
use tower_http::trace::TraceLayer;
pub fn build_router(state: AppState) -> Router {
Router::new()
// Web UI (fully offline — no CDN, no external fonts/images).
.route("/", get(index))
.route("/assets/app.js", get(ui_js))
.route("/assets/app.css", get(ui_css))
.route("/assets/logo.svg", get(ui_logo))
// iPXE script endpoints.
.route("/boot.ipxe", get(boot_top_menu))
.route("/boot/:filename", get(boot_sub))
// Bundled binaries and raw ISO access.
.route("/ipxe/:name", get(ipxe_binary))
.route("/iso/:filename", get(iso_raw))
.route("/iso/:id/*path", get(iso_file))
// Container health/readiness probes. `/healthz` is always 200 OK
// while the HTTP task is alive. `/readyz` additionally requires at
// least one bundled iPXE binary (without one, no client can PXE).
.route("/healthz", get(healthz))
.route("/readyz", get(readyz))
// JSON API.
.route("/api/isos", get(api_list_isos).post(api_upload_iso))
.route("/api/isos/:id", delete(api_delete_iso))
.route("/api/clients", get(api_list_clients))
.route("/api/status", get(api_status))
.route("/api/settings", get(api_get_settings).put(api_put_settings))
.route("/api/gate", get(api_list_gates))
.route("/api/gate/join", get(api_gate_join))
.route("/api/gate/poll/:gate_id", get(api_gate_poll))
.route("/api/gate/assign", post(api_gate_assign))
.route("/api/gate/:gate_id", delete(api_gate_release))
// Phase 4: NFS share manager.
.route("/api/nfs", get(api_nfs_list).post(api_nfs_add))
.route("/api/nfs/:id", delete(api_nfs_remove))
.route("/api/nfs/:id/scan", post(api_nfs_scan))
// Phase 4: Network info (read-only) + DNS edit.
.route("/api/network", get(api_network).put(api_network_put))
// Phase 4: live-log stream + recent buffer for the Terminal tab.
.route("/api/log/stream", get(log_stream::stream))
.route("/api/log/recent", get(log_stream::recent))
.route("/api/log/clear", post(log_stream::clear))
// Phase 4: operator terminal commands (whitelisted).
.route("/api/terminal", post(terminal::run_command))
.layer(TraceLayer::new_for_http())
// 16 GiB upload cap — ISOs are big; chunks stream so this isn't memory use.
.layer(DefaultBodyLimit::max(16 * 1024 * 1024 * 1024))
.with_state(state)
}
// ─── UI ────────────────────────────────────────────────────────────────────
async fn index(State(state): State<AppState>) -> Response {
let html = pxeforge_webui::index_html(&state.public_base_url);
([(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"))],
pxeforge_webui::app_js(),
).into_response()
}
async fn ui_css() -> Response {
(
[(header::CONTENT_TYPE, HeaderValue::from_static("text/css"))],
pxeforge_webui::app_css(),
).into_response()
}
async fn ui_logo() -> Response {
(
[(header::CONTENT_TYPE, HeaderValue::from_static("image/svg+xml"))],
pxeforge_webui::logo_svg(),
).into_response()
}
// ─── iPXE scripts ──────────────────────────────────────────────────────────
fn text_plain(body: String) -> Response {
([(header::CONTENT_TYPE, HeaderValue::from_static("text/plain; charset=utf-8"))], body)
.into_response()
}
async fn boot_top_menu(State(state): State<AppState>) -> Response {
let isos = state.iso_store.list();
let settings = state.settings.snapshot();
text_plain(render_menu(&isos, &settings, &state.public_base_url))
}
async fn boot_sub(
State(state): State<AppState>,
AxumPath(filename): AxumPath<String>,
) -> Response {
// `/boot/<name>.ipxe` where `<name>` is either one of our reserved
// submenu names (prefixed `_`) or a boot entry id.
let name = filename.strip_suffix(".ipxe").unwrap_or(&filename);
let isos = state.iso_store.list();
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),
"_gate" => render_gate_entry(base),
other => {
for iso in &isos {
for entry in &iso.boot_entries {
if entry.id == other {
return text_plain(render_entry(entry, &settings, base));
}
}
}
return (StatusCode::NOT_FOUND, "no such boot entry").into_response();
}
};
text_plain(script)
}
// ─── bundled iPXE binaries (memtest lives here too) ───────────────────────
async fn ipxe_binary(AxumPath(name): AxumPath<String>) -> Response {
if name.contains('/') || name.contains('\\') {
return (StatusCode::BAD_REQUEST, "invalid name").into_response();
}
let Some(bytes) = asset_bytes(&name) else {
return (StatusCode::NOT_FOUND, "no such ipxe asset").into_response();
};
(
[
(header::CONTENT_TYPE, HeaderValue::from_static("application/octet-stream")),
(header::CONTENT_LENGTH, HeaderValue::from(bytes.len())),
],
bytes,
).into_response()
}
// ─── ISO streaming (raw + in-ISO) ─────────────────────────────────────────
async fn iso_raw(
State(state): State<AppState>,
AxumPath(filename): AxumPath<String>,
headers: HeaderMap,
) -> Response {
let id = filename.strip_suffix(".iso").unwrap_or(&filename);
let Some(path) = state.iso_store.iso_path_for(id) else {
return (StatusCode::NOT_FOUND, "no such iso").into_response();
};
match stream_file_range(&path, headers.get(header::RANGE)).await {
Ok(r) => r,
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")).into_response(),
}
}
async fn iso_file(
State(state): State<AppState>,
AxumPath((id, path)): AxumPath<(String, String)>,
) -> Response {
let Some(iso_path) = state.iso_store.iso_path_for(&id) else {
return (StatusCode::NOT_FOUND, "no such iso").into_response();
};
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();
let Some(loc) = loc else {
return (StatusCode::NOT_FOUND, "not found inside iso").into_response();
};
match stream_byte_range(&iso_path, loc.offset, loc.length).await {
Ok(r) => r,
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")).into_response(),
}
}
async fn stream_file_range(
path: &std::path::Path,
range: Option<&HeaderValue>,
) -> anyhow::Result<Response> {
let meta = tokio::fs::metadata(path).await?;
let total = meta.len();
let (start, end, partial) = parse_range(range, total);
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 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}"));
}
Ok(builder.body(body).unwrap())
}
async fn stream_byte_range(
path: &std::path::Path,
offset: u64,
length: u64,
) -> anyhow::Result<Response> {
let mut file = tokio::fs::File::open(path).await?;
file.seek(std::io::SeekFrom::Start(offset)).await?;
let reader = file.take(length);
let stream = tokio_util::io::ReaderStream::new(reader);
let body = Body::from_stream(stream);
Ok(Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, "application/octet-stream")
.header(header::CONTENT_LENGTH, length)
.body(body)
.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); };
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);
}
}
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)
}
// ─── ISO upload / list / delete ───────────────────────────────────────────
async fn api_list_isos(State(state): State<AppState>) -> Json<Vec<IsoMeta>> {
Json(state.iso_store.list())
}
async fn api_delete_iso(
State(state): State<AppState>,
AxumPath(id): AxumPath<String>,
) -> StatusCode {
match state.iso_store.delete(&id).await {
Ok(()) => StatusCode::NO_CONTENT,
Err(_) => StatusCode::INTERNAL_SERVER_ERROR,
}
}
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; }
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();
}
let mut handle = match state.iso_store.begin_upload(&filename).await {
Ok(h) => h,
Err(e) => return (StatusCode::CONFLICT, format!("{e}")).into_response(),
};
while let Ok(Some(chunk)) = field.chunk().await {
if let Err(e) = handle.write_chunk(&chunk).await {
let _ = handle.abort().await;
return (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")).into_response();
}
}
let meta = match handle.finish(&state.iso_store).await {
Ok(m) => m,
Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")).into_response(),
};
return (StatusCode::CREATED, Json(meta)).into_response();
}
(StatusCode::BAD_REQUEST, "no 'file' part").into_response()
}
// ─── health / readiness ───────────────────────────────────────────────────
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()
}
async fn readyz(State(state): State<AppState>) -> Response {
// Readiness — can we actually serve clients?
// 1. At least one iPXE binary must be bundled (without one, TFTP 404s
// and no client boots).
// 2. The ISO directory must exist and be readable.
let assets = pxeforge_ipxe_assets::list_assets();
let mut problems: Vec<&str> = Vec::new();
if assets.is_empty() {
problems.push("no iPXE binaries bundled (run scripts/fetch-ipxe.sh before building)");
}
let iso_dir_ok = state.iso_store.list_ok();
if !iso_dir_ok {
problems.push("iso directory not readable");
}
if problems.is_empty() {
([(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()
}
}
// ─── clients + status + settings ──────────────────────────────────────────
async fn api_list_clients(State(state): State<AppState>) -> Json<serde_json::Value> {
Json(json!({ "clients": state.clients.list() }))
}
async fn api_status(State(state): State<AppState>) -> Json<serde_json::Value> {
let smb = state.smb.as_ref().map(|s| s.snapshot());
let nfs = state.nfs.list();
let nfs_active = nfs.iter().filter(|m| m.mounted).count();
let isos = state.iso_store.list();
let gates = state.gates.list();
// Phase 4: dashboard tracks "imaging" as gates 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 now = time::OffsetDateTime::now_utc();
let uptime_secs = (now - state.started_at).whole_seconds().max(0);
Json(json!({
"version": env!("CARGO_PKG_VERSION"),
"public_base_url": state.public_base_url,
"iso_count": isos.len(),
"client_count": state.clients.list().len(),
"gate_count": gates.len(),
"imaging_count": imaging,
"waiting_count": waiting,
"ipxe_assets": pxeforge_ipxe_assets::list_assets(),
"settings": state.settings.snapshot(),
"smb": smb,
"nfs_count": nfs.len(),
"nfs_active": nfs_active,
"uptime_secs": uptime_secs,
"started_at": state.started_at,
"nic_name": state.nic_name,
"subnet_mask": state.subnet_mask,
"gateway": state.gateway,
}))
}
async fn api_get_settings(State(state): State<AppState>) -> Json<Settings> {
Json(state.settings.snapshot())
}
async fn api_put_settings(
State(state): State<AppState>,
Json(mut new): Json<Settings>,
) -> Response {
// Guardrail: Windows boot requires the wimboot shim to be bundled.
// Without it, clients chain a non-existent /ipxe/wimboot and stall.
if new.windows_enabled {
let assets = pxeforge_ipxe_assets::list_assets();
if !assets.iter().any(|n| n == "wimboot") {
return (
StatusCode::BAD_REQUEST,
"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();
}
}
new.smb_host_override = new.smb_host_override.trim().to_string();
// Detect whether this PUT changes the Windows toggle, so we only
// restart smbd when it actually flipped.
let was_enabled = state.settings.snapshot().windows_enabled;
let want_enabled = new.windows_enabled;
state.settings.replace(new);
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, false) => {}
}
}
StatusCode::NO_CONTENT.into_response()
}
// ─── Gated Deployment API ─────────────────────────────────────────────────
async fn api_list_gates(State(state): State<AppState>) -> Json<serde_json::Value> {
Json(json!({
"gates": state.gates.list(),
"count": state.gates.list().len(),
}))
}
#[derive(Debug, Deserialize)]
struct GateJoinParams {
/// Client MAC from iPXE's `${mac}` variable. iPXE substitutes before
/// the HTTP request so we receive a plain colon-separated MAC.
mac: Option<String>,
}
/// Called by iPXE via `chain --replace`. We respond with a tiny iPXE
/// script that hard-loops on `/api/gate/poll/<id>`. iPXE keeps fetching
/// until poll returns an actual boot script.
async fn api_gate_join(
State(state): State<AppState>,
Query(p): Query<GateJoinParams>,
headers: HeaderMap,
) -> Response {
let mac = p.mac.unwrap_or_else(|| "unknown".to_string());
let ip = headers
.get("x-forwarded-for")
.and_then(|h| h.to_str().ok())
.and_then(|s| s.split(',').next())
.and_then(|s| s.trim().parse().ok());
let gate = state.gates.join(&mac, ip, None);
state.clients.record(
&mac, ip, None,
ClientEvent::HttpScriptFetch { target: "gate-join".into() },
);
let base = &state.public_base_url;
// ASCII only - some iPXE builds on firmware consoles mangle non-ASCII.
let script = format!(
"#!ipxe\n\
echo\n\
echo ==========================================\n\
echo Gated Deployment - Gate 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/gate/poll/{}\n",
gate.position, gate.id
);
text_plain(script)
}
/// Long-poll endpoint. Waits up to 25s for an assignment; if none, returns
/// a script that loops back to itself. 25s keeps us well inside typical
/// HTTP idle timeouts for iPXE and intermediaries.
async fn api_gate_poll(
State(state): State<AppState>,
AxumPath(gate_id): AxumPath<String>,
) -> Response {
let Some(notify) = state.gates.notifier(&gate_id) else {
// Gate 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"));
};
// Wait for an assignment or timeout.
let _ = tokio::time::timeout(Duration::from_secs(25), notify.notified()).await;
let snap = state.gates.touch(&gate_id);
let base = &state.public_base_url;
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
// 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
// still get a valid boot script instead of falling back to the
// menu.
Some(g) if g.assigned_target.is_some() => {
let target = g.assigned_target.clone().unwrap_or_default();
tracing::info!(
target: "pxeforge::gate",
gate_id=%gate_id, mac=%g.mac, target=%target,
"gate assignment delivered"
);
text_plain(format!(
"#!ipxe\n\
echo Gate assignment received: {target}\n\
chain {base}/boot/{target}.ipxe || chain {base}/api/gate/poll/{gate_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.
text_plain(format!(
"#!ipxe\n\
echo Gate Position {} - still waiting\n\
chain {base}/api/gate/poll/{gate_id}\n",
g.position
))
}
None => text_plain(format!("#!ipxe\nchain {base}/boot.ipxe\n")),
}
}
#[derive(Debug, Deserialize)]
struct GateAssignBody {
/// 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.
gate_ids: Vec<String>,
}
async fn api_gate_assign(
State(state): State<AppState>,
Json(body): Json<GateAssignBody>,
) -> Json<serde_json::Value> {
let ids = if body.gate_ids.is_empty() {
state.gates.list().into_iter().map(|g| g.id).collect::<Vec<_>>()
} else {
body.gate_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)
});
if !found {
return Json(json!({ "ok": false, "error": "unknown target" }));
}
let n = state.gates.assign(&ids, &body.target);
Json(json!({ "ok": true, "assigned": n, "target": body.target }))
}
async fn api_gate_release(
State(state): State<AppState>,
AxumPath(gate_id): AxumPath<String>,
) -> StatusCode {
match state.gates.release(&gate_id) {
Some(_) => StatusCode::NO_CONTENT,
None => StatusCode::NOT_FOUND,
}
}
// ─── NFS share API ─────────────────────────────────────────────────────────
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 {
match state.nfs.add(req).await {
Ok(m) => (StatusCode::CREATED, Json(m)).into_response(),
// Anything from the manager surfaces as a user-fixable validation
// error — bad host, kernel without NFS support, missing
// `mount.nfs`, dead server. We pass the message through verbatim
// so the UI can show it to the operator.
Err(e) => (StatusCode::BAD_REQUEST, format!("{e}")).into_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 {
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(),
}
}
// ─── Network info API ──────────────────────────────────────────────────────
async fn api_network(State(state): State<AppState>) -> Json<serde_json::Value> {
Json(json!({
"server_ip": state.public_base_url
.strip_prefix("http://")
.unwrap_or(&state.public_base_url),
"nic_name": state.nic_name,
"subnet_mask": state.subnet_mask,
"gateway": state.gateway,
"dns_server": state.settings.snapshot().dns_server,
"public_base_url": state.public_base_url,
}))
}
#[derive(Debug, Deserialize)]
struct NetworkPut {
/// Operators can set or clear an informational DNS hint. Server IP /
/// NIC / mask / gateway are auto-detected and not editable from the
/// UI — changing them in the wrong direction would silently break
/// PXE for every client.
dns_server: String,
}
async fn api_network_put(
State(state): State<AppState>,
Json(body): Json<NetworkPut>,
) -> StatusCode {
let mut s = state.settings.snapshot();
s.dns_server = body.dns_server.trim().to_string();
state.settings.replace(s);
StatusCode::NO_CONTENT
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn range_full() {
let (s, e, p) = parse_range(None, 1000);
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);
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);
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);
assert_eq!((s, e, p), (10, 99, true));
}
}