use anyhow::{Context, ensure};
use clap::Subcommand;
use httpboot_protocol::{
DEVICE_PROTOCOL_VERSION, DeviceBootImage, DeviceBootJob, LoaderDeviceStatus, SerialBinding,
SerialBindingMode, SerialParameters,
};
use ostool_serial::{BindError, BindRequest, NativeBackend, SerialManager};
use reqwest::Client;
use sha2::{Digest, Sha256};
use std::{path::PathBuf, sync::Arc, time::Duration};
use tokio::{
io::{AsyncReadExt, AsyncWriteExt},
time::{Instant, sleep},
};
const CLI_BIND_TIMEOUT: Duration = Duration::from_secs(60);
#[derive(Debug, Subcommand)]
pub enum AxloaderCommand {
Run {
#[arg(long)]
device: String,
#[arg(long)]
kernel: PathBuf,
#[arg(long)]
initramfs: Option<PathBuf>,
#[arg(long)]
cmdline: Option<String>,
},
Continue {
#[arg(long)]
device: String,
},
}
pub async fn execute(command: AxloaderCommand) -> anyhow::Result<()> {
let device = match &command {
AxloaderCommand::Run { device, .. } | AxloaderCommand::Continue { device } => {
device.trim_end_matches('/').to_string()
}
};
let client = Client::builder()
.timeout(Duration::from_secs(300))
.build()?;
let mut observed = fetch_status(&client, &device).await?;
ensure!(
observed.protocol_version == DEVICE_PROTOCOL_VERSION,
"automatic serial requires axloader v6; upgrade this device"
);
let serial = observed
.serial
.as_ref()
.context("device did not report serial status")?;
let mut binding = SerialBinding {
serial_id: serial.serial_id.clone(),
binding_id: format!("{:032x}", binding_nonce()),
mode: SerialBindingMode::Direct,
};
let AxloaderCommand::Run {
kernel,
initramfs,
cmdline,
..
} = command
else {
if let Some(current) = &serial.binding
&& current.mode == SerialBindingMode::Direct
&& current.serial_id == serial.serial_id
{
binding = current.clone();
}
grant(&client, &device, &observed.boot_epoch, &binding).await?;
println!("Direct network continue accepted (no serial tunnel)");
println!("X-Boot-Epoch: {}", observed.boot_epoch);
println!("X-Serial-Binding: {}", binding.binding_id);
return Ok(());
};
let kernel = tokio::fs::read(kernel).await?;
let initramfs = match initramfs {
Some(path) => Some(tokio::fs::read(path).await?),
None => None,
};
let manager = SerialManager::new(Arc::new(NativeBackend));
let cli_deadline = Instant::now() + CLI_BIND_TIMEOUT;
let mut generation = 0_u64;
let (binding, parameters, mut lease, owner) = loop {
let serial = observed
.serial
.as_ref()
.context("device did not report serial status")?;
if !serial.ready {
if Instant::now() >= cli_deadline {
anyhow::bail!(
"automatic serial unavailable before deadline: {:?}",
serial.error
);
}
sleep(Duration::from_millis(250)).await;
observed = fetch_status(&client, &device).await?;
ensure_v6(&observed)?;
continue;
}
let parameters = effective_parameters(serial.parameters);
ostool_serial::validate_host_parameters(parameters)?;
binding = SerialBinding {
serial_id: serial.serial_id.clone(),
binding_id: format!("{:032x}", binding_nonce()),
mode: SerialBindingMode::Bound,
};
let owner = binding.binding_id.clone();
generation = generation
.checked_add(1)
.context("serial binding generation exhausted")?;
let remaining = cli_deadline.saturating_duration_since(Instant::now());
if remaining.is_zero() {
anyhow::bail!("serial identity did not become available before deadline");
}
match manager
.bind(BindRequest {
owner: owner.clone(),
generation,
mac_address: observed.mac_address,
boot_epoch: observed.boot_epoch.clone(),
serial_id: binding.serial_id.clone(),
parameters,
deadline: Instant::now() + remaining.min(Duration::from_secs(5)),
})
.await
{
Ok(lease) => break (binding, parameters, lease, owner),
Err(error) if retryable_bind_error(&error) => {
if Instant::now() >= cli_deadline {
anyhow::bail!("serial identity discovery failed before deadline: {error}");
}
sleep(Duration::from_millis(250)).await;
observed = fetch_status(&client, &device).await?;
ensure_v6(&observed)?;
}
Err(error) => return Err(error.into()),
}
};
println!(
"Serial identity verified on {} with {:?}",
lease.locator().name,
parameters
);
if let Err(error) = grant(&client, &device, &observed.boot_epoch, &binding).await {
drop(lease);
manager.wait_owner_released(&owner).await?;
return Err(error);
}
lease.confirm();
let job = DeviceBootJob {
boot_id: format!("cli-{}", binding.binding_id),
arch: observed.arch,
image_format: httpboot_protocol::ImageFormat::Elf64,
kernel: image(&kernel),
initramfs: initramfs.as_deref().map(image),
cmdline,
entry_symbol: Some("__x86_64_efi_pe_entry".into()),
};
let base = format!("{device}/api/v1/boot/jobs");
let boot = async {
client
.post(&base)
.header("X-Boot-Epoch", &observed.boot_epoch)
.json(&job)
.send()
.await?
.error_for_status()?;
let mut files = vec![("kernel", kernel.as_slice())];
if let Some(bytes) = initramfs.as_deref() {
files.push(("initramfs", bytes));
}
for (kind, bytes) in files {
client
.put(format!("{base}/{}/{kind}", job.boot_id))
.header("X-Boot-Epoch", &observed.boot_epoch)
.header("X-Image-Sha256", image(bytes).sha256)
.body(bytes.to_vec())
.send()
.await?
.error_for_status()?;
}
client
.post(format!("{base}/{}/start", job.boot_id))
.header("X-Boot-Epoch", &observed.boot_epoch)
.header("X-Serial-Binding", &binding.binding_id)
.send()
.await?
.error_for_status()?;
std::future::pending::<anyhow::Result<()>>().await
};
let console = async {
let mut bytes = [0; 4096];
let mut stdout = tokio::io::stdout();
#[cfg(unix)]
let mut stdin = {
use std::io::IsTerminal;
std::io::stdin()
.is_terminal()
.then(crate::sterm::Input::new)
.transpose()?
};
#[cfg(not(unix))]
let mut stdin = tokio::io::stdin();
#[cfg(not(unix))]
let mut input = [0; 4096];
#[cfg(unix)]
let mut input_open = stdin.is_some();
#[cfg(not(unix))]
let mut input_open = true;
loop {
let keyboard = async {
#[cfg(unix)]
return stdin
.as_mut()
.expect("input is enabled")
.next()
.await
.transpose();
#[cfg(not(unix))]
{
let n = stdin.read(&mut input).await?;
Ok::<_, std::io::Error>((n > 0).then(|| input[..n].to_vec()))
}
};
let n = tokio::select! {
result = lease.read(&mut bytes) => result?,
result = keyboard, if input_open => {
if let Some(input) = result? {
tokio::time::timeout(Duration::from_secs(1), lease.write_all(&input)).await.context("serial write timed out")??;
} else {
input_open = false;
}
continue;
}
};
if n == 0 {
anyhow::bail!("serial closed");
}
stdout.write_all(&bytes[..n]).await?;
stdout.flush().await?;
}
};
let result = tokio::select! {result=boot=>result,result=console=>result,_=tokio::signal::ctrl_c()=>Ok(())};
drop(lease);
manager.wait_owner_released(&owner).await?;
let _ = client
.delete(format!(
"{device}/api/v1/serial/bindings/{}",
binding.binding_id
))
.header("X-Boot-Epoch", &observed.boot_epoch)
.timeout(Duration::from_secs(2))
.send()
.await;
result
}
async fn fetch_status(client: &Client, device: &str) -> anyhow::Result<LoaderDeviceStatus> {
Ok(client
.get(format!("{device}/api/v1/status"))
.timeout(Duration::from_secs(5))
.send()
.await?
.error_for_status()?
.json()
.await?)
}
fn ensure_v6(status: &LoaderDeviceStatus) -> anyhow::Result<()> {
ensure!(
status.protocol_version == DEVICE_PROTOCOL_VERSION,
"automatic serial requires axloader v6; upgrade this device"
);
Ok(())
}
fn effective_parameters(reported: Option<SerialParameters>) -> SerialParameters {
reported.unwrap_or_default()
}
fn retryable_bind_error(error: &BindError) -> bool {
matches!(
error,
BindError::Timeout | BindError::DiscoveryFailed(_) | BindError::Busy | BindError::Io(_)
)
}
fn image(bytes: &[u8]) -> DeviceBootImage {
DeviceBootImage {
size: bytes.len() as u64,
sha256: Sha256::digest(bytes)
.iter()
.map(|b| format!("{b:02x}"))
.collect(),
}
}
#[cfg(test)]
mod tests {
use super::effective_parameters;
use httpboot_protocol::{SerialFlowControl, SerialParity, SerialStopBits};
#[test]
fn missing_firmware_parameters_use_protocol_defaults() {
let parameters = effective_parameters(None);
assert_eq!(parameters.baud_rate, 115_200);
assert_eq!(parameters.data_bits, 8);
assert_eq!(parameters.parity, SerialParity::None);
assert_eq!(parameters.stop_bits, SerialStopBits::One);
assert_eq!(parameters.flow_control, SerialFlowControl::None);
}
}
fn binding_nonce() -> u128 {
use std::sync::atomic::{AtomicU64, Ordering};
static SEQUENCE: AtomicU64 = AtomicU64::new(1);
let time = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("clock before epoch")
.as_nanos();
time ^ ((std::process::id() as u128) << 64) ^ SEQUENCE.fetch_add(1, Ordering::Relaxed) as u128
}
async fn grant(
client: &Client,
device: &str,
epoch: &str,
binding: &SerialBinding,
) -> anyhow::Result<()> {
client
.post(format!("{device}/api/v1/serial/continue"))
.header("X-Boot-Epoch", epoch)
.json(binding)
.send()
.await?
.error_for_status()?;
Ok(())
}