use std::path::{Path, PathBuf};
use clap::{Args, Subcommand, ValueEnum};
#[derive(Clone, Debug, Args)]
pub struct DeviceLocalArgs {
#[arg(long, value_name = "PATH")]
pub store_root: Option<PathBuf>,
#[command(subcommand)]
pub command: DeviceLocalCommand,
}
#[derive(Clone, Debug, Subcommand)]
pub enum DeviceLocalCommand {
Enroll {
#[arg(long)]
user: String,
#[arg(long, value_enum)]
kind: CredentialKind,
},
Get {
#[arg(long)]
user: String,
},
List,
Remove {
#[arg(long)]
user: String,
},
Policy {
#[command(subcommand)]
command: PolicyCommand,
},
Audit {
#[command(subcommand)]
command: AuditCommand,
},
}
#[derive(Clone, Copy, Debug, ValueEnum)]
pub enum CredentialKind {
OsPassword,
WindowsPin,
}
#[derive(Clone, Debug, Subcommand)]
pub enum PolicyCommand {
Get,
Set {
#[arg(long, action = clap::ArgAction::Set, value_parser = clap::value_parser!(bool))]
enabled: bool,
},
}
#[derive(Clone, Debug, Subcommand)]
pub enum AuditCommand {
List {
#[arg(long, default_value_t = 0)]
cursor: u64,
#[arg(long, default_value_t = 50, value_parser = clap::value_parser!(u32).range(1..=100))]
limit: u32,
},
}
#[cfg(any(unix, windows))]
fn store_root_path(project_root: &Path, store_root: Option<&Path>) -> PathBuf {
match store_root {
Some(path) if path.is_absolute() => path.to_path_buf(),
Some(path) => project_root.join(path),
None => project_root.join(".auv").join("store"),
}
}
#[cfg(unix)]
fn socket_path(project_root: &Path, store_root: Option<&Path>) -> Result<PathBuf, String> {
let store_root = store_root_path(project_root, store_root);
auv_api_client::device_local::unix_socket_path(&store_root).map_err(|error| format!("failed to locate Device-local socket: {error}"))
}
#[cfg(any(unix, windows))]
pub async fn run(args: DeviceLocalArgs, project_root: &Path) -> Result<i32, String> {
use auv_api_client::device_local::DeviceLocalClient;
use auv_api_proto::auv::api::daemon::v1 as proto;
#[cfg(unix)]
let mut client = {
let path = socket_path(project_root, args.store_root.as_deref())?;
auv_api_client::device_local::verify_unix_socket_directory(&path)
.map_err(|error| format!("Device-local socket directory is unsafe: {error}"))?;
DeviceLocalClient::connect_unix(&path)
.await
.map_err(|error| format!("Device-local service unavailable at {}: {error}", path.display()))?
};
#[cfg(windows)]
let mut client = {
let store_root = store_root_path(project_root, args.store_root.as_deref());
DeviceLocalClient::connect_windows(&store_root)
.await
.map_err(|error| format!("Device-local service unavailable or its LocalSystem identity could not be verified: {error}"))?
};
let service = client.service();
match args.command {
DeviceLocalCommand::Enroll { user, kind } => {
#[cfg(windows)]
if !matches!(kind, CredentialKind::WindowsPin) {
return Err("Windows enrollment currently requires --kind windows-pin".to_string());
}
let mut credential = read_hidden_credential()?;
let request = proto::EnrollRequest {
user,
credential: std::mem::take(&mut *credential),
credential_kind: match kind {
CredentialKind::OsPassword => proto::EnrollmentCredentialKind::OsPassword as i32,
CredentialKind::WindowsPin => proto::EnrollmentCredentialKind::WindowsPin as i32,
},
storage_kind: proto::EnrollmentStorageKind::Protected as i32,
};
let enrollment = service
.enroll(request)
.await
.map_err(|status| format!("enrollment failed ({})", status.code()))?
.into_inner()
.enrollment;
print_enrollment(enrollment)?;
}
DeviceLocalCommand::Get { user } => {
let enrollment = service
.get_enrollment(proto::GetEnrollmentRequest { user })
.await
.map_err(|status| format!("get enrollment failed ({})", status.code()))?
.into_inner()
.enrollment;
print_enrollment(enrollment)?;
}
DeviceLocalCommand::List => {
let entries = service
.list_enrollments(proto::ListEnrollmentsRequest {})
.await
.map_err(|status| format!("list enrollments failed ({})", status.code()))?
.into_inner()
.enrollments;
for enrollment in entries {
print_enrollment(Some(enrollment))?;
}
}
DeviceLocalCommand::Remove { user } => {
service
.remove_enrollment(proto::RemoveEnrollmentRequest { user })
.await
.map_err(|status| format!("remove enrollment failed ({})", status.code()))?;
println!("enrollment removed");
}
DeviceLocalCommand::Policy { command } => {
let enabled = match command {
PolicyCommand::Get => {
service
.get_policy(proto::GetPolicyRequest {})
.await
.map_err(|status| format!("get policy failed ({})", status.code()))?
.into_inner()
.enabled
}
PolicyCommand::Set { enabled } => {
service
.set_policy(proto::SetPolicyRequest { enabled })
.await
.map_err(|status| format!("set policy failed ({})", status.code()))?
.into_inner()
.enabled
}
};
println!("remote unlock enabled: {enabled}");
}
DeviceLocalCommand::Audit {
command: AuditCommand::List { cursor, limit },
} => {
let page = service
.list_audit(proto::ListAuditRequest { cursor, limit })
.await
.map_err(|status| format!("list audit failed ({})", status.code()))?
.into_inner();
for entry in page.entries {
println!(
"{}\t{}\t{}\t{}\t{}\t{}",
entry.at_unix_millis,
entry.event,
entry.attempt_id,
entry.user.unwrap_or_default(),
entry.session_selector.unwrap_or_default(),
entry.result.unwrap_or_default()
);
}
if let Some(next) = page.next_cursor {
println!("next cursor: {next}");
}
}
}
Ok(0)
}
#[cfg(not(any(unix, windows)))]
pub async fn run(_args: DeviceLocalArgs, _project_root: &Path) -> Result<i32, String> {
Err("Device-local control requires a supported OS-local transport".to_string())
}
#[cfg(any(unix, windows))]
fn print_enrollment(enrollment: Option<auv_api_proto::auv::api::daemon::v1::Enrollment>) -> Result<(), String> {
use auv_api_proto::auv::api::daemon::v1::{EnrollmentState, EnrollmentStorageKind};
let enrollment = enrollment.ok_or_else(|| "Device-local service returned no enrollment".to_string())?;
let state = match EnrollmentState::try_from(enrollment.state) {
Ok(EnrollmentState::Ready) => "ready",
Ok(EnrollmentState::Suspended) => "suspended",
Ok(EnrollmentState::Pending) => "pending",
_ => "unknown",
};
let storage = match EnrollmentStorageKind::try_from(enrollment.storage_kind) {
Ok(EnrollmentStorageKind::Protected) => "protected",
Ok(EnrollmentStorageKind::PlaintextFile) => "plaintext-file",
_ => "unknown",
};
println!("{}\t{}\t{}\t{}", enrollment.user, enrollment.os_account_id, state, storage);
Ok(())
}
#[cfg(unix)]
fn read_hidden_credential() -> Result<zeroize::Zeroizing<Vec<u8>>, String> {
use std::fs::OpenOptions;
use std::io::{Read as _, Write as _};
use rustix::termios::{LocalModes, OptionalActions, tcgetattr, tcsetattr};
use zeroize::{Zeroize as _, Zeroizing};
let mut tty = OpenOptions::new()
.read(true)
.write(true)
.open("/dev/tty")
.map_err(|_| "enrollment requires this Device's interactive terminal".to_string())?;
let original = tcgetattr(&tty).map_err(|_| "failed to read terminal mode".to_string())?;
let restoration_tty = tty.try_clone().map_err(|_| "failed to retain terminal mode".to_string())?;
let mut hidden = original.clone();
hidden.local_modes.remove(LocalModes::ECHO | LocalModes::ECHONL);
tcsetattr(&tty, OptionalActions::Now, &hidden).map_err(|_| "failed to hide terminal input".to_string())?;
struct Restore {
tty: std::fs::File,
original: rustix::termios::Termios,
restored: bool,
}
impl Restore {
fn restore(&mut self) -> Result<(), String> {
rustix::termios::tcsetattr(&self.tty, rustix::termios::OptionalActions::Now, &self.original)
.map_err(|_| "failed to restore terminal mode".to_string())?;
self.restored = true;
Ok(())
}
}
impl Drop for Restore {
fn drop(&mut self) {
if !self.restored {
let _ = rustix::termios::tcsetattr(&self.tty, rustix::termios::OptionalActions::Now, &self.original);
}
}
}
let mut restore = Restore {
tty: restoration_tty,
original,
restored: false,
};
tty.write_all(b"OS login credential: ").map_err(|_| "failed to write terminal prompt".to_string())?;
tty.flush().map_err(|_| "failed to flush terminal prompt".to_string())?;
let mut credential = Zeroizing::new(Vec::new());
loop {
let mut byte = [0_u8; 1];
let count = tty.read(&mut byte).map_err(|_| "failed to read terminal credential".to_string())?;
if count == 0 {
return Err("terminal closed before credential was entered".to_string());
}
if byte[0] == b'\n' {
break;
}
if credential.len() >= 1024 {
return Err("credential exceeds the 1024-byte limit".to_string());
}
credential.push(byte[0]);
byte.zeroize();
}
tty.write_all(b"\n").map_err(|_| "failed to finish terminal prompt".to_string())?;
restore.restore()?;
if credential.is_empty() {
return Err("credential cannot be empty".to_string());
}
std::str::from_utf8(&credential).map_err(|_| "credential must be UTF-8".to_string())?;
Ok(credential)
}
#[cfg(windows)]
fn read_hidden_credential() -> Result<zeroize::Zeroizing<Vec<u8>>, String> {
use std::os::windows::io::{AsRawHandle, FromRawHandle, OwnedHandle};
use windows::Win32::Foundation::{GENERIC_READ, GENERIC_WRITE, HANDLE};
use windows::Win32::Storage::FileSystem::{CreateFileW, FILE_ATTRIBUTE_NORMAL, FILE_SHARE_READ, FILE_SHARE_WRITE, OPEN_EXISTING};
use windows::Win32::System::Console::{
CONSOLE_MODE, ENABLE_ECHO_INPUT, ENABLE_LINE_INPUT, GetConsoleMode, ReadConsoleW, SetConsoleMode, WriteConsoleW,
};
use windows::core::w;
use zeroize::Zeroizing;
let input = unsafe {
CreateFileW(
w!("CONIN$"),
GENERIC_READ.0 | GENERIC_WRITE.0,
FILE_SHARE_READ | FILE_SHARE_WRITE,
None,
OPEN_EXISTING,
FILE_ATTRIBUTE_NORMAL,
HANDLE::default(),
)
}
.map_err(|_| "enrollment requires this Device's interactive console".to_string())?;
let input = unsafe { OwnedHandle::from_raw_handle(input.0) };
let output = unsafe {
CreateFileW(
w!("CONOUT$"),
GENERIC_WRITE.0,
FILE_SHARE_READ | FILE_SHARE_WRITE,
None,
OPEN_EXISTING,
FILE_ATTRIBUTE_NORMAL,
HANDLE::default(),
)
}
.map_err(|_| "enrollment requires this Device's interactive console".to_string())?;
let output = unsafe { OwnedHandle::from_raw_handle(output.0) };
let input_handle = HANDLE(input.as_raw_handle());
let output_handle = HANDLE(output.as_raw_handle());
let mut original = CONSOLE_MODE(0);
unsafe { GetConsoleMode(input_handle, &mut original) }.map_err(|_| "failed to read console mode".to_string())?;
struct RestoreMode(HANDLE, CONSOLE_MODE);
impl Drop for RestoreMode {
fn drop(&mut self) {
let _ = unsafe { SetConsoleMode(self.0, self.1) };
}
}
let _restore = RestoreMode(input_handle, original);
let hidden = CONSOLE_MODE((original.0 | ENABLE_LINE_INPUT.0) & !ENABLE_ECHO_INPUT.0);
unsafe { SetConsoleMode(input_handle, hidden) }.map_err(|_| "failed to hide console input".to_string())?;
let prompt = "Windows PIN (target-local): ".encode_utf16().collect::<Vec<_>>();
unsafe { WriteConsoleW(output_handle, &prompt, None, None) }.map_err(|_| "failed to write console prompt".to_string())?;
let mut buffer = Zeroizing::new(vec![0u16; 256]);
let mut count = 0u32;
unsafe { ReadConsoleW(input_handle, buffer.as_mut_ptr().cast(), buffer.len() as u32, &mut count, None) }
.map_err(|_| "failed to read console credential".to_string())?;
let newline = "\r\n".encode_utf16().collect::<Vec<_>>();
unsafe { WriteConsoleW(output_handle, &newline, None, None) }.map_err(|_| "failed to finish console prompt".to_string())?;
let entered = &buffer[..count as usize];
let entered = entered.strip_suffix(&[b'\r' as u16, b'\n' as u16]).or_else(|| entered.strip_suffix(&[b'\n' as u16])).unwrap_or(entered);
if entered.is_empty() || entered.len() > 128 || entered.iter().any(|unit| *unit == 0) {
return Err("credential must contain 1..=128 UTF-16 characters".to_string());
}
let text = Zeroizing::new(String::from_utf16(entered).map_err(|_| "credential must be valid Unicode".to_string())?);
Ok(Zeroizing::new(text.as_bytes().to_vec()))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
#[cfg(unix)]
fn socket_is_resolved_only_from_the_local_store() {
let root = tempfile::tempdir().unwrap();
let store = root.path().join("state");
std::fs::create_dir(&store).unwrap();
std::fs::create_dir(store.join("control")).unwrap();
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(store.join("control"), std::fs::Permissions::from_mode(0o700)).unwrap();
let socket = socket_path(root.path(), Some(Path::new("state"))).unwrap();
assert!(socket.starts_with(std::fs::canonicalize("/tmp").unwrap()));
assert_eq!(socket.file_name().unwrap(), "socket");
assert_eq!(socket, auv_api_client::device_local::unix_socket_path(&store).unwrap());
}
#[test]
#[cfg(unix)]
fn replacing_control_directory_cannot_redirect_local_socket() {
use std::os::unix::fs::symlink;
let root = tempfile::tempdir().unwrap();
let store = root.path().join("state");
std::fs::create_dir(&store).unwrap();
let expected = socket_path(root.path(), Some(Path::new("state"))).unwrap();
symlink(root.path(), store.join("control")).unwrap();
assert_eq!(socket_path(root.path(), Some(Path::new("state"))).unwrap(), expected);
}
}