1use crate::hosts::Hosts;
22use crate::operator::{Operator, Reset};
23use crate::secret::unix_now;
24use anyhow::{Context, Result, bail};
25use serde::{Deserialize, Serialize};
26use std::os::unix::fs::PermissionsExt;
27use std::path::{Path, PathBuf};
28use std::sync::Arc;
29use std::time::Duration;
30use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader};
31
32pub const ADMIN_SOCKET: &str = "admin.sock";
34
35pub const MAX_REQUEST_BYTES: u64 = 16 * 1024;
37
38pub const REQUEST_TIMEOUT: Duration = Duration::from_secs(10);
41
42const ACCEPT_BACKOFF: Duration = Duration::from_millis(100);
45
46const MAX_RESPONSE_BYTES: u64 = 4 * 1024 * 1024;
48
49#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
50#[serde(tag = "command", rename_all = "snake_case")]
51pub enum AdminRequest {
52 SetupUrl,
54 ResetPassword {
56 password: String,
57 },
58 ListHosts,
59 MintPairingCode,
60 ResetPublicUrl {
62 public_url: String,
63 },
64}
65
66impl AdminRequest {
67 pub fn changes_state(&self) -> bool {
70 !matches!(self, Self::SetupUrl | Self::ListHosts)
71 }
72
73 pub fn name(&self) -> &'static str {
75 match self {
76 Self::SetupUrl => "setup_url",
77 Self::ResetPassword { .. } => "reset_password",
78 Self::ListHosts => "list_hosts",
79 Self::MintPairingCode => "mint_pairing_code",
80 Self::ResetPublicUrl { .. } => "reset_public_url",
81 }
82 }
83}
84
85impl std::fmt::Debug for AdminRequest {
87 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
88 match self {
89 Self::ResetPassword { .. } => f
90 .debug_struct("ResetPassword")
91 .field("password", &format_args!("<redacted>"))
92 .finish(),
93 Self::ResetPublicUrl { public_url } => f
94 .debug_struct("ResetPublicUrl")
95 .field("public_url", public_url)
96 .finish(),
97 other => f.write_str(other.name()),
98 }
99 }
100}
101
102#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
103#[serde(tag = "result", rename_all = "snake_case")]
104pub enum AdminResponse {
105 SetupUrl {
106 url: String,
107 },
108 AlreadySetUp,
110 NotSetUp,
112 PasswordReset {
113 sessions_ended: usize,
114 passkeys_removed: usize,
116 },
117 PublicUrlReset {
118 public_url: String,
119 sessions_ended: usize,
120 passkeys_removed: usize,
122 },
123 Hosts {
124 hosts: Vec<AdminHost>,
125 },
126 PairingCode {
127 code: String,
128 expires_at: i64,
129 },
130 Refused {
132 message: String,
133 },
134 Failed {
136 message: String,
137 },
138}
139
140impl std::fmt::Debug for AdminResponse {
143 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
144 let redacted = format_args!("<redacted>");
145 match self {
146 Self::SetupUrl { .. } => f.debug_struct("SetupUrl").field("url", &redacted).finish(),
147 Self::AlreadySetUp => f.write_str("AlreadySetUp"),
148 Self::NotSetUp => f.write_str("NotSetUp"),
149 Self::PasswordReset {
150 sessions_ended,
151 passkeys_removed,
152 } => f
153 .debug_struct("PasswordReset")
154 .field("sessions_ended", sessions_ended)
155 .field("passkeys_removed", passkeys_removed)
156 .finish(),
157 Self::PublicUrlReset {
158 public_url,
159 sessions_ended,
160 passkeys_removed,
161 } => f
162 .debug_struct("PublicUrlReset")
163 .field("public_url", public_url)
164 .field("sessions_ended", sessions_ended)
165 .field("passkeys_removed", passkeys_removed)
166 .finish(),
167 Self::Hosts { hosts } => f.debug_struct("Hosts").field("hosts", hosts).finish(),
168 Self::PairingCode { expires_at, .. } => f
169 .debug_struct("PairingCode")
170 .field("code", &redacted)
171 .field("expires_at", expires_at)
172 .finish(),
173 Self::Refused { message } => f.debug_struct("Refused").field("message", message).finish(),
174 Self::Failed { message } => f.debug_struct("Failed").field("message", message).finish(),
175 }
176 }
177}
178
179#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
182pub struct AdminHost {
183 pub id: String,
184 pub name: String,
185 pub platform: String,
186 pub host_version: String,
187 pub created_at: i64,
188 pub last_seen_at: Option<i64>,
189 pub revoked_at: Option<i64>,
190}
191
192#[derive(Clone)]
196pub struct Admin {
197 pub operator: Arc<Operator>,
198 pub hosts: Arc<Hosts>,
199 pub dir: PathBuf,
201 pub base_url: String,
203}
204
205pub struct AdminSocket {
208 listener: tokio::net::UnixListener,
209 path: PathBuf,
210}
211
212impl AdminSocket {
213 pub fn path(&self) -> &Path {
214 &self.path
215 }
216}
217
218impl Drop for AdminSocket {
219 fn drop(&mut self) {
220 let _ = std::fs::remove_file(&self.path);
221 }
222}
223
224struct RemoveOnDrop<'a>(&'a Path);
230
231impl Drop for RemoveOnDrop<'_> {
232 fn drop(&mut self) {
233 let _ = std::fs::remove_file(self.0);
234 }
235}
236
237pub fn max_socket_path_bytes() -> usize {
240 let addr: libc::sockaddr_un = unsafe { std::mem::zeroed() };
242 addr.sun_path.len() - 1
243}
244
245pub fn socket_path_too_long(path: &Path) -> bool {
249 path.as_os_str().len() > max_socket_path_bytes()
250}
251
252pub fn bind(dir: &Path) -> Result<Option<AdminSocket>> {
259 let path = dir.join(ADMIN_SOCKET);
260 if socket_path_too_long(&path) {
261 tracing::warn!(
262 path = %path.display(),
263 "the data directory's path is too long for a Unix socket: `hennery admin` cannot reach this collector"
264 );
265 return Ok(None);
266 }
267 match std::os::unix::net::UnixStream::connect(&path) {
268 Ok(_) => bail!(
269 "{} answers: another collector serves this data directory",
270 path.display()
271 ),
272 Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
273 Err(err) if err.raw_os_error() == Some(libc::ECONNREFUSED) => {
275 std::fs::remove_file(&path).with_context(|| format!("remove the stale {}", path.display()))?
276 }
277 Err(err) => {
278 return Err(err).with_context(|| {
279 format!(
280 "{} is there and cannot be checked; remove it if no collector serves this data directory",
281 path.display()
282 )
283 });
284 }
285 }
286 let listener = std::os::unix::net::UnixListener::bind(&path).with_context(|| format!("bind {}", path.display()))?;
287 let cleanup = RemoveOnDrop(&path);
288 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600))
289 .with_context(|| format!("make {} private", path.display()))?;
290 listener.set_nonblocking(true)?;
291 let listener = tokio::net::UnixListener::from_std(listener)?;
292 std::mem::forget(cleanup);
293 Ok(Some(AdminSocket { listener, path }))
294}
295
296pub async fn serve(socket: AdminSocket, admin: Admin, shutdown: impl std::future::Future<Output = ()>) {
299 tokio::pin!(shutdown);
300 loop {
301 tokio::select! {
302 accepted = socket.listener.accept() => match accepted {
303 Ok((stream, _)) => {
304 let admin = admin.clone();
305 tokio::spawn(async move {
306 if let Err(err) = answer(stream, &admin).await {
307 tracing::warn!(error = %format!("{err:#}"), "admin socket connection");
308 }
309 });
310 }
311 Err(err) => {
312 tracing::warn!(error = %err, "admin socket accept");
313 tokio::time::sleep(ACCEPT_BACKOFF).await;
314 }
315 },
316 () = &mut shutdown => break,
317 }
318 }
319 drop(socket);
320}
321
322async fn answer(mut stream: tokio::net::UnixStream, admin: &Admin) -> Result<()> {
324 let peer = match stream.peer_cred() {
325 Err(err) if err.raw_os_error() == Some(libc::ENOTCONN) => {
328 tracing::debug!("admin socket: a connection closed before it was read");
329 return Ok(());
330 }
331 peer => peer.context("the peer's credentials")?,
332 };
333 let own = unsafe { libc::geteuid() };
335 if peer.uid() != own {
336 tracing::warn!(
337 peer = peer.uid(),
338 "admin socket: refused a connection from another user"
339 );
340 return Ok(());
341 }
342 let peer_pid = peer.pid();
343 let (read, mut write) = stream.split();
344 let mut line = String::new();
345 let mut reader = BufReader::new(read.take(MAX_REQUEST_BYTES));
346 let response = match tokio::time::timeout(REQUEST_TIMEOUT, reader.read_line(&mut line)).await {
347 Err(_) => bail!("no request within {REQUEST_TIMEOUT:?}"),
348 Ok(Ok(0)) => {
350 tracing::debug!("admin socket: a connection closed without a request");
351 return Ok(());
352 }
353 Ok(Err(err)) => refused(format!("the request is not a line of UTF-8: {err}")),
354 Ok(Ok(_)) if !line.ends_with('\n') => refused(format!(
355 "the request must be one line of at most {MAX_REQUEST_BYTES} bytes"
356 )),
357 Ok(Ok(_)) => match serde_json::from_str::<AdminRequest>(&line) {
358 Ok(request) => {
359 if request.changes_state() {
360 tracing::warn!(command = request.name(), ?peer_pid, "admin command");
361 } else {
362 tracing::info!(command = request.name(), ?peer_pid, "admin command");
363 }
364 carry_out(request, admin).await
365 }
366 Err(err) => refused(format!("not an admin request: {err}")),
367 },
368 };
369 let mut out = serde_json::to_vec(&response)?;
370 out.push(b'\n');
371 let sent = tokio::time::timeout(REQUEST_TIMEOUT, async {
372 write.write_all(&out).await?;
373 write.shutdown().await
374 });
375 match sent.await {
376 Ok(result) => result?,
377 Err(_) => bail!("the answer was not taken within {REQUEST_TIMEOUT:?}"),
378 }
379 Ok(())
380}
381
382fn refused(message: String) -> AdminResponse {
383 AdminResponse::Refused { message }
384}
385
386async fn carry_out(request: AdminRequest, admin: &Admin) -> AdminResponse {
387 let now = unix_now();
388 let outcome = match request {
389 AdminRequest::SetupUrl => admin
390 .operator
391 .setup_link(&admin.dir, &admin.base_url, now)
392 .map(|link| match link {
393 Some(link) => AdminResponse::SetupUrl { url: link.url },
394 None => AdminResponse::AlreadySetUp,
395 }),
396 AdminRequest::ResetPassword { password } => {
397 admin
398 .operator
399 .reset_password(password, now)
400 .await
401 .map(|reset| match reset {
402 Reset::Done {
403 sessions_ended,
404 passkeys_removed,
405 } => AdminResponse::PasswordReset {
406 sessions_ended,
407 passkeys_removed,
408 },
409 Reset::NotSetUp => AdminResponse::NotSetUp,
410 Reset::Invalid(message) => AdminResponse::Refused { message },
411 })
412 }
413 AdminRequest::ListHosts => admin.hosts.list().map(|records| AdminResponse::Hosts {
414 hosts: records
415 .into_iter()
416 .map(|r| AdminHost {
417 id: r.id,
418 name: r.name,
419 platform: r.platform,
420 host_version: r.host_version,
421 created_at: r.created_at,
422 last_seen_at: r.last_seen_at,
423 revoked_at: r.revoked_at,
424 })
425 .collect(),
426 }),
427 AdminRequest::MintPairingCode => admin
428 .hosts
429 .mint_pairing_code(now)
430 .map(|code| AdminResponse::PairingCode {
431 code: code.code,
432 expires_at: code.expires_at,
433 }),
434 AdminRequest::ResetPublicUrl { public_url } => {
435 admin.operator.reset_public_url(&public_url).map(|reset| match reset {
436 Reset::Done {
437 sessions_ended,
438 passkeys_removed,
439 } => AdminResponse::PublicUrlReset {
440 public_url: admin
441 .operator
442 .public_url()
443 .map(|url| url.origin().to_string())
444 .unwrap_or_default(),
445 sessions_ended,
446 passkeys_removed,
447 },
448 Reset::NotSetUp => AdminResponse::NotSetUp,
449 Reset::Invalid(message) => AdminResponse::Refused { message },
450 })
451 }
452 };
453 outcome.unwrap_or_else(|err| AdminResponse::Failed {
454 message: format!("{err:#}"),
455 })
456}
457
458pub const CLIENT_TIMEOUT: Duration = Duration::from_secs(30);
466
467pub async fn request(socket: &Path, request: &AdminRequest) -> Result<AdminResponse> {
470 request_within(socket, request, CLIENT_TIMEOUT).await
471}
472
473pub async fn request_within(socket: &Path, request: &AdminRequest, timeout: Duration) -> Result<AdminResponse> {
477 let deadline = tokio::time::Instant::now() + timeout;
478 let mut stream = connect_by(socket, deadline, timeout).await?;
479 let mut line = serde_json::to_vec(request)?;
480 line.push(b'\n');
481 let exchange = async {
482 stream.write_all(&line).await?;
483 read_answer(&mut stream).await
484 };
485 match tokio::time::timeout_at(deadline, exchange).await {
486 Ok(answer) => answer,
487 Err(_) => bail!(
489 "the collector at {} did not answer within {timeout:?}; the command's outcome is unknown",
490 socket.display()
491 ),
492 }
493}
494
495pub async fn probe(socket: &Path, timeout: Duration) -> Result<()> {
500 let deadline = tokio::time::Instant::now() + timeout;
501 connect_by(socket, deadline, timeout).await.map(drop)
502}
503
504async fn connect_by(
507 socket: &Path,
508 deadline: tokio::time::Instant,
509 timeout: Duration,
510) -> Result<tokio::net::UnixStream> {
511 if socket_path_too_long(socket) {
512 bail!(
513 "{} is {} bytes long, longer than a Unix socket path can be ({} bytes at most): \
514 the collector serving that data directory has no admin socket; \
515 move the data directory to a shorter path",
516 socket.display(),
517 socket.as_os_str().len(),
518 max_socket_path_bytes()
519 );
520 }
521 let stream = match tokio::time::timeout_at(deadline, tokio::net::UnixStream::connect(socket)).await {
522 Ok(connected) => {
523 connected.with_context(|| format!("connect to {} (is the collector running?)", socket.display()))?
524 }
525 Err(_) => bail!(
526 "connecting to {} took over {timeout:?}; nothing was sent",
527 socket.display()
528 ),
529 };
530 let server = stream.peer_cred().context("the collector's credentials")?.uid();
533 let own = unsafe { libc::geteuid() };
535 if server != own {
536 bail!(
537 "{} is served by user id {server}, not by this user ({own}); nothing was sent",
538 socket.display()
539 );
540 }
541 Ok(stream)
542}
543
544pub async fn read_answer(stream: &mut tokio::net::UnixStream) -> Result<AdminResponse> {
546 let mut answer = String::new();
547 let read = BufReader::new(stream.take(MAX_RESPONSE_BYTES))
548 .read_line(&mut answer)
549 .await
550 .context("read the collector's answer")?;
551 if read == 0 {
556 bail!(
557 "the collector closed the connection without answering (it may have stopped); \
558 the command's outcome is unknown"
559 );
560 }
561 serde_json::from_str(&answer).context("the collector's answer")
562}