use anyhow::{Context, Result, bail};
use clap::{Args, Subcommand};
use hennery_kernel::admin::{ADMIN_SOCKET, AdminRequest, AdminResponse, CLIENT_TIMEOUT};
use std::io::{BufRead, IsTerminal, Write};
use std::path::{Path, PathBuf};
use std::time::Duration;
#[derive(Args)]
pub struct AdminArgs {
#[arg(long, env = "HENNERY_DATA_DIR")]
data_dir: Option<PathBuf>,
#[arg(long, env = "HENNERY_ADMIN_TIMEOUT_MS", hide = true, value_parser = clap::value_parser!(u64).range(1..=600_000))]
timeout_ms: Option<u64>,
#[command(subcommand)]
command: AdminCommand,
}
#[derive(Subcommand)]
enum AdminCommand {
SetupUrl,
ResetPassword,
Hosts,
PairingCode,
ResetPublicUrl {
public_url: String,
},
}
fn socket_path(dir: &Path) -> PathBuf {
let own = dir.join(ADMIN_SOCKET);
let ups = dir.join("collector");
if !own.exists() && ups.is_dir() {
ups.join(ADMIN_SOCKET)
} else {
own
}
}
pub async fn run(args: AdminArgs) -> Result<()> {
let data_dir = crate::data_dir::collector_or_installed(args.data_dir.clone())?;
let socket = socket_path(&data_dir);
let timeout = args.timeout_ms.map_or(CLIENT_TIMEOUT, Duration::from_millis);
let prompts = !matches!(args.command, AdminCommand::SetupUrl | AdminCommand::Hosts);
if prompts {
require_terminal(args.command.name())?;
hennery_kernel::admin::probe(&socket, timeout).await?;
}
let request = match args.command {
AdminCommand::SetupUrl => AdminRequest::SetupUrl,
AdminCommand::Hosts => AdminRequest::ListHosts,
AdminCommand::PairingCode => {
confirm(
"pairing-code",
"A pairing code pairs any machine it is given to, for ten minutes.",
)?;
AdminRequest::MintPairingCode
}
AdminCommand::ResetPassword => {
eprintln!(
"This removes every passkey; register them again after signing in. It also signs out every session."
);
let password = read_secret("New password: ")?;
if read_secret("The same again: ")? != password {
bail!("the two passwords differ; nothing changed");
}
confirm(
"reset-password",
"This removes every passkey and signs out every session.",
)?;
AdminRequest::ResetPassword { password }
}
AdminCommand::ResetPublicUrl { public_url } => {
confirm(
"reset-public-url",
&format!(
"Browsers will have to reach hennery at {public_url}. Passkeys stop working if the host name \
changes, and are then removed. This signs out every session."
),
)?;
AdminRequest::ResetPublicUrl { public_url }
}
};
match hennery_kernel::admin::request_within(&socket, &request, timeout).await? {
AdminResponse::SetupUrl { url } => println!("{url}"),
AdminResponse::AlreadySetUp => bail!("hennery is set up already: there is no setup link"),
AdminResponse::NotSetUp => {
bail!("hennery is not set up yet: `hennery admin setup-url` prints the setup link")
}
AdminResponse::PasswordReset {
sessions_ended,
passkeys_removed,
} => println!(
"The password is reset; {sessions_ended} session(s) signed out, {passkeys_removed} passkey(s) removed."
),
AdminResponse::PublicUrlReset {
public_url,
sessions_ended,
passkeys_removed,
} => println!(
"public_url is now {public_url}; {sessions_ended} session(s) signed out, {passkeys_removed} passkey(s) removed."
),
AdminResponse::Hosts { hosts } => {
for host in hosts {
let state = if host.revoked_at.is_some() { "revoked" } else { "paired" };
println!("{}\t{}\t{}\t{state}", host.id, host.name, host.platform);
}
}
AdminResponse::PairingCode { code, .. } => {
println!("{code}");
eprintln!("Valid for ten minutes: `hennery host join <public_url>` on the machine, then type it.");
}
AdminResponse::Refused { message } => bail!("refused: {message}"),
AdminResponse::Failed { message } => bail!("the collector failed: {message}"),
}
Ok(())
}
impl AdminCommand {
fn name(&self) -> &'static str {
match self {
Self::SetupUrl => "setup-url",
Self::ResetPassword => "reset-password",
Self::Hosts => "hosts",
Self::PairingCode => "pairing-code",
Self::ResetPublicUrl { .. } => "reset-public-url",
}
}
}
fn require_terminal(command: &str) -> Result<()> {
if !std::io::stdin().is_terminal() {
bail!("`hennery admin {command}` asks for confirmation on a terminal; run it from one (nothing was sent)");
}
Ok(())
}
fn confirm(command: &str, what: &str) -> Result<()> {
require_terminal(command)?;
eprint!("{what}\nType yes to go on: ");
std::io::stderr().flush()?;
let mut answer = String::new();
std::io::stdin()
.lock()
.read_line(&mut answer)
.context("read the answer")?;
if answer.trim() != "yes" {
bail!("not confirmed; nothing changed");
}
Ok(())
}
fn read_secret(prompt: &str) -> Result<String> {
let _echo = EchoOff::new()?;
eprint!("{prompt}");
std::io::stderr().flush()?;
let mut line = String::new();
std::io::stdin()
.lock()
.read_line(&mut line)
.context("read the password")?;
eprintln!();
Ok(line.trim_end_matches(['\r', '\n']).to_string())
}
struct EchoOff(libc::termios);
impl EchoOff {
fn new() -> Result<Self> {
let mut saved: libc::termios = unsafe { std::mem::zeroed() };
if unsafe { libc::tcgetattr(libc::STDIN_FILENO, &mut saved) } != 0 {
return Err(std::io::Error::last_os_error()).context("read the terminal's settings");
}
let mut quiet = saved;
quiet.c_lflag &= !libc::ECHO;
if unsafe { libc::tcsetattr(libc::STDIN_FILENO, libc::TCSANOW, &quiet) } != 0 {
return Err(std::io::Error::last_os_error()).context("turn the terminal's echo off");
}
Ok(Self(saved))
}
}
impl Drop for EchoOff {
fn drop(&mut self) {
unsafe {
libc::tcsetattr(libc::STDIN_FILENO, libc::TCSANOW, &self.0);
}
}
}