Skip to main content

hennery_kernel/
admin.rs

1//! The admin socket (kernel spec §4.2): `<data>/admin.sock`, a Unix socket
2//! only the collector's own user may use, for `hennery admin …` recovery
3//! commands that work without a browser or a session: print the setup link,
4//! reset the password or `public_url`, list the hosts, mint a pairing code.
5//!
6//! - **Who may connect:** the socket is 0600, and a peer whose user id is
7//!   not the collector's is dropped without an answer. The client refuses,
8//!   in turn, a socket served by another user. **Any process of the
9//!   collector's own user is the operator here**, agents of the all-in-one
10//!   install included (kernel spec §10): it can reset the password or
11//!   `public_url` and mint pairing codes. The CLI's confirmation on a
12//!   terminal stops accidents, not a process that speaks this protocol.
13//! - **The protocol:** one JSON `AdminRequest` on one line, at most
14//!   `MAX_REQUEST_BYTES`, within `REQUEST_TIMEOUT`; one JSON `AdminResponse`
15//!   on one line back; then the connection closes.
16//! - **One collector per data directory:** binding refuses a socket that
17//!   still answers, replaces one that refuses the connection (a collector
18//!   that was killed), and stops at any other error. The socket is removed
19//!   when its `AdminSocket` is dropped.
20
21use 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
32/// The socket's name in the collector's data directory (kernel spec §1).
33pub const ADMIN_SOCKET: &str = "admin.sock";
34
35/// The longest request line read, newline included.
36pub const MAX_REQUEST_BYTES: u64 = 16 * 1024;
37
38/// A connection that has not sent its whole request by then, or not taken
39/// its whole answer, is dropped.
40pub const REQUEST_TIMEOUT: Duration = Duration::from_secs(10);
41
42/// How long the server waits after a failed accept (out of descriptors,
43/// say) before it tries again, so it does not spin.
44const ACCEPT_BACKOFF: Duration = Duration::from_millis(100);
45
46/// The longest response the client reads: a long host list fits.
47const 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    /// The live setup link, or a fresh one (`Operator::setup_link`).
53    SetupUrl,
54    /// A new password for the owner; every session ends.
55    ResetPassword {
56        password: String,
57    },
58    ListHosts,
59    MintPairingCode,
60    /// A new `public_url`; every session ends (3b decision 4's recovery).
61    ResetPublicUrl {
62        public_url: String,
63    },
64}
65
66impl AdminRequest {
67    /// Whether it changes state or hands out a credential: logged at
68    /// `warn`, with the peer's process id.
69    pub fn changes_state(&self) -> bool {
70        !matches!(self, Self::SetupUrl | Self::ListHosts)
71    }
72
73    /// The command's name, for the log: never its arguments.
74    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
85/// Written by hand: the password is left out.
86impl 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    /// `setup_url` once there is an owner.
109    AlreadySetUp,
110    /// A reset before there is an owner: setup is the way in.
111    NotSetUp,
112    PasswordReset {
113        sessions_ended: usize,
114        /// Every passkey (plan 3c decision 2).
115        passkeys_removed: usize,
116    },
117    PublicUrlReset {
118        public_url: String,
119        sessions_ended: usize,
120        /// Passkeys bound to the old host name (plan 3c decision 9).
121        passkeys_removed: usize,
122    },
123    Hosts {
124        hosts: Vec<AdminHost>,
125    },
126    PairingCode {
127        code: String,
128        expires_at: i64,
129    },
130    /// The request is not acceptable (why); nothing changed.
131    Refused {
132        message: String,
133    },
134    /// The collector failed to carry it out.
135    Failed {
136        message: String,
137    },
138}
139
140/// Written by hand: the setup link and the pairing code are credentials,
141/// and are left out.
142impl 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/// One paired host, as `list_hosts` shows it. Times are seconds since the
180/// Unix epoch.
181#[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/// What the admin commands act on: the router's own `Operator` and
193/// `Hosts`, never a second instance, since the cached `public_url` and the
194/// setup token live in that one.
195#[derive(Clone)]
196pub struct Admin {
197    pub operator: Arc<Operator>,
198    pub hosts: Arc<Hosts>,
199    /// The data directory, where a fresh setup link is written.
200    pub dir: PathBuf,
201    /// The setup link's base (kernel spec §3.1).
202    pub base_url: String,
203}
204
205/// A bound admin socket. Dropping it removes the socket file, however the
206/// collector stops.
207pub 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
224/// Removes the file at the given path when dropped. Used between
225/// `UnixListener::bind` and the `AdminSocket` it will become: a failure in
226/// between (permissions, non-blocking, the tokio handoff) would otherwise
227/// leave the just-bound socket file behind, with no `AdminSocket` yet to
228/// remove it.
229struct 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
237/// The longest path a Unix socket can be bound or reached at, in bytes:
238/// `sun_path` less its terminating NUL (103 on macOS, 107 on Linux).
239pub fn max_socket_path_bytes() -> usize {
240    // SAFETY: all-zero bytes are a valid `sockaddr_un`.
241    let addr: libc::sockaddr_un = unsafe { std::mem::zeroed() };
242    addr.sun_path.len() - 1
243}
244
245/// Whether `path` is too long for a Unix socket: the collector then runs
246/// without its admin socket, and the client says so rather than failing
247/// to connect (3b-ii's O7).
248pub fn socket_path_too_long(path: &Path) -> bool {
249    path.as_os_str().len() > max_socket_path_bytes()
250}
251
252/// Bind `dir/admin.sock`, mode 0600. `Ok(None)`, with a warning, when that
253/// path is too long for a Unix socket: the collector then runs without it.
254/// Refused when the socket there still answers: another collector serves
255/// this data directory. One that refuses the connection is replaced; any
256/// other error (not a socket, not ours to connect to) stops the start and
257/// is named.
258pub 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        // Left by a collector that did not shut down cleanly.
274        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
296/// Answer admin commands until `shutdown` resolves, then remove the socket
297/// (by dropping it).
298pub 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
322/// One connection: check the peer, read its request, answer it.
323async fn answer(mut stream: tokio::net::UnixStream, admin: &Admin) -> Result<()> {
324    let peer = match stream.peer_cred() {
325        // Gone already: `hennery admin` checks the socket before it prompts
326        // by connecting and closing at once (`probe`).
327        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    // SAFETY: geteuid(2) cannot fail.
334    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        // Closed without a byte: the same check as above, a little later.
349        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
458/// How long the client waits for the whole exchange: connecting, sending
459/// the request and reading the answer. The slowest command is a password
460/// reset, one Argon2 hash on a blocking thread: about 0.1 s, measured in a
461/// debug build (where `argon2` is built optimised), run at most
462/// `MAX_CONCURRENT_HASHES` at a time with the logins'. Only a login flood
463/// queued ahead of it (rate limited per address, not in total) could take
464/// it past this; the client then says the outcome is unknown.
465pub const CLIENT_TIMEOUT: Duration = Duration::from_secs(30);
466
467/// The client's side: send `request` to the collector at `socket` and read
468/// its answer, within `CLIENT_TIMEOUT`.
469pub async fn request(socket: &Path, request: &AdminRequest) -> Result<AdminResponse> {
470    request_within(socket, request, CLIENT_TIMEOUT).await
471}
472
473/// `request`, bounded by `timeout`. The answer is read one line, not to
474/// the end of the stream: a collector that closes with part of a request
475/// unread resets the connection on Linux, after its answer.
476pub 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        // Part of the request, or all of it, may have arrived.
488        Err(_) => bail!(
489            "the collector at {} did not answer within {timeout:?}; the command's outcome is unknown",
490            socket.display()
491        ),
492    }
493}
494
495/// Check, sending nothing, that a collector of this user's serves
496/// `socket`: what a command that prompts does first, so that nothing is
497/// typed into a dead end. The connection is closed unused. Bounded by
498/// `timeout`, like `request_within`.
499pub 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
504/// Connect to the collector at `socket` by `deadline`, and check that it
505/// runs as this user. Nothing is sent yet.
506async 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    // The other way round too: a password is sent only to a collector of
531    // this user's.
532    let server = stream.peer_cred().context("the collector's credentials")?.uid();
533    // SAFETY: geteuid(2) cannot fail.
534    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
544/// One `AdminResponse` line from `stream`, at most `MAX_RESPONSE_BYTES`.
545pub 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    // The connection closed before a single byte arrived: a reset in flight
552    // when the collector stopped, or a peer dropped for a uid mismatch.
553    // Serde's own message for an empty input ("EOF while parsing a value")
554    // reads like a parse bug, not like what happened.
555    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}