Skip to main content

recall_server/server/
mod.rs

1//! The HTTP surface.
2//!
3//! Routes and their auth posture are frozen:
4//!
5//! | route | auth |
6//! |---|---|
7//! | `GET /health` | none — uptime tooling holds no secret |
8//! | `GET /admin` | none — static markup, no data |
9//! | `GET /.well-known/recall` | none — a client asks before it can authenticate |
10//! | `POST /sync`, `GET /sync`, `GET /v1/devices/me` | bearer token, or any device's signature |
11//! | `POST /v1/devices/enroll`, `POST /v1/devices/enroll/poll` | none, but rate limited, and small bodies only |
12//! | `GET /admin/stats`, the rest of `/v1/devices`, and `/v1/authkeys` | bearer token, or an admin device's signature |
13//! | anything else | 404 JSON |
14//!
15//! This module owns the shared state, the router, and the background jobs.
16//! What it wires together are private submodules, each living next to its
17//! own tests: `middleware.rs` (rate limiting, then the protocol check, then
18//! auth), `auth.rs` (device signatures and the replay cache),
19//! `handlers.rs` (one function per route), `devices.rs` (the device
20//! routes), `respond.rs` (the JSON shape of every reply, errors included),
21//! `limit.rs` (the per-IP window the middleware consults) and `tls.rs` (the
22//! direct-TLS accept loop, used only when `Config::tls` is on; plain HTTP,
23//! the default, never touches it). Both transports serve the one router
24//! [`Server::router`] builds, every route group and layer included.
25
26use std::future::Future;
27use std::net::SocketAddr;
28use std::sync::atomic::{AtomicI64, Ordering};
29use std::sync::{Arc, PoisonError, RwLock};
30use std::time::{SystemTime, UNIX_EPOCH};
31
32use anyhow::{Context, Result};
33use axum::extract::DefaultBodyLimit;
34// Imported by name because this module has a `middleware` of its own, and
35// an unqualified `middleware::` would resolve to that one.
36use axum::middleware::{from_fn, from_fn_with_state};
37use axum::routing::{get, post};
38use axum::Router;
39use recall_wire::devices as paths;
40use recall_wire::MergeError;
41use tokio::net::TcpListener;
42use tokio::task::JoinHandle;
43
44use crate::config::TlsMode;
45use crate::merge::{Merger, Status};
46use crate::{format_timestamp, now, Config, Store};
47
48mod auth;
49mod devices;
50mod handlers;
51mod limit;
52mod middleware;
53mod respond;
54mod tls;
55
56use auth::ReplayCache;
57use devices::{
58    handle_approve, handle_create_authkey, handle_deny, handle_enroll, handle_list_authkeys,
59    handle_list_devices, handle_me, handle_pending, handle_poll, handle_revoke_authkey,
60    handle_revoke_device,
61};
62use handlers::{
63    handle_admin_page, handle_admin_stats, handle_discovery, handle_health, handle_pull,
64    handle_push, not_found,
65};
66use limit::RateLimiter;
67use middleware::{admin_only, guard, limited};
68
69/// How often idle ephemeral devices and long-expired enrolments are swept
70/// away. Removal is at most this late, which against a TTL counted in
71/// hours is nothing.
72const SWEEP_EVERY: std::time::Duration = std::time::Duration::from_secs(10 * 60);
73
74/// Bounds a single push. Memory files are prose; anything this large is a
75/// bug or an attack, not a note.
76const MAX_BODY_BYTES: usize = 5 << 20;
77
78/// Bounds a request to the routes anyone may call. An enrolment is a name,
79/// a key and an agent, and a poll is an id; nobody who has not proved
80/// anything gets to make the server hold megabytes.
81const ENROLL_BODY_BYTES: usize = 8 << 10;
82
83struct Runtime {
84    last_backup_at: String,
85    last_merge_at: String,
86    last_merge_error: Option<MergeError>,
87    claude_status: Status,
88}
89
90struct AppState {
91    cfg: Config,
92    store: Arc<Store>,
93    merger: Merger,
94    started_at: String,
95    /// When this process started, as a UNIX time: signatures the process
96    /// before could have accepted are refused, since the nonces that would
97    /// catch their replay were in its memory (see `auth.rs`). Moved only
98    /// by tests, through [`Server::backdate_start`].
99    started_unix: AtomicI64,
100    /// Added to the clock signatures are judged by. Zero except in tests.
101    clock_offset: AtomicI64,
102    runtime: RwLock<Runtime>,
103    limiter: RateLimiter,
104    replay: ReplayCache,
105}
106
107impl AppState {
108    fn read(&self) -> std::sync::RwLockReadGuard<'_, Runtime> {
109        self.runtime.read().unwrap_or_else(PoisonError::into_inner)
110    }
111    fn write(&self) -> std::sync::RwLockWriteGuard<'_, Runtime> {
112        self.runtime.write().unwrap_or_else(PoisonError::into_inner)
113    }
114
115    /// The UNIX time signatures are judged by.
116    fn now(&self) -> i64 {
117        unix_now() + self.clock_offset.load(Ordering::Relaxed)
118    }
119
120    /// When this process started, as a UNIX time.
121    fn started(&self) -> i64 {
122        self.started_unix.load(Ordering::Relaxed)
123    }
124}
125
126fn unix_now() -> i64 {
127    SystemTime::now()
128        .duration_since(UNIX_EPOCH)
129        .map(|d| d.as_secs() as i64)
130        .unwrap_or(0)
131}
132
133/// How many live nonces one device may have: as many requests as one
134/// address may make while a nonce stays live, which is up to the window
135/// and the few seconds `created` may be ahead of the clock.
136fn nonces_per_device(cfg: &Config) -> usize {
137    let live_ms = auth::NONCE_LIFETIME as u128 * 1000;
138    let window_ms = cfg.rate_limit_window.as_millis().max(1);
139    let windows = live_ms.div_ceil(window_ms);
140    (cfg.rate_limit_max as u128 * windows).min(usize::MAX as u128) as usize
141}
142
143/// The HTTP API, its background jobs, and the state they share.
144pub struct Server {
145    state: Arc<AppState>,
146}
147
148impl Server {
149    /// Builds a server around an already-open store.
150    pub fn new(cfg: Config, store: Arc<Store>) -> Self {
151        let limiter = RateLimiter::new(cfg.rate_limit_window, cfg.rate_limit_max);
152        let merger = Merger::new(cfg.claude_bin.clone(), cfg.merge_timeout);
153        let replay = ReplayCache::new(auth::WINDOW, nonces_per_device(&cfg));
154        Self {
155            state: Arc::new(AppState {
156                cfg,
157                store,
158                merger,
159                started_at: now(),
160                started_unix: AtomicI64::new(unix_now()),
161                clock_offset: AtomicI64::new(0),
162                runtime: RwLock::new(Runtime {
163                    last_backup_at: String::new(),
164                    last_merge_at: String::new(),
165                    last_merge_error: None,
166                    claude_status: Status::default(),
167                }),
168                limiter,
169                replay,
170            }),
171        }
172    }
173
174    /// The router, built separately from binding a port so tests can drive
175    /// it without real sockets.
176    pub fn router(&self) -> Router {
177        let state = self.state.clone();
178        // Managing devices: authenticated, then held to the admin scope.
179        // The last layer added runs first, so `guard` has put the caller
180        // in place by the time `admin_only` looks for it.
181        let admin = Router::new()
182            .route("/admin/stats", get(handle_admin_stats).fallback(not_found))
183            .route(
184                paths::DEVICES_PATH,
185                get(handle_list_devices).fallback(not_found),
186            )
187            .route(
188                paths::APPROVE_PATH,
189                post(handle_approve).fallback(not_found),
190            )
191            .route(paths::DENY_PATH, post(handle_deny).fallback(not_found))
192            .route(
193                "/v1/devices/pending/{user_code}",
194                get(handle_pending).fallback(not_found),
195            )
196            .route(
197                "/v1/devices/{id}/revoke",
198                post(handle_revoke_device).fallback(not_found),
199            )
200            .route(
201                paths::AUTHKEYS_PATH,
202                get(handle_list_authkeys)
203                    .post(handle_create_authkey)
204                    .fallback(not_found),
205            )
206            .route(
207                "/v1/authkeys/{id}/revoke",
208                post(handle_revoke_authkey).fallback(not_found),
209            )
210            .route_layer(from_fn(admin_only))
211            .route_layer(from_fn_with_state(state.clone(), guard));
212        // Enrolling: a machine has no credential yet, so no auth, but the
213        // same rate limit and protocol check as everything else, and a
214        // body limit sized for what an enrolment is. The inner limit wins
215        // over the router-wide one below.
216        let enrolment = Router::new()
217            .route(paths::ENROLL_PATH, post(handle_enroll).fallback(not_found))
218            .route(
219                paths::ENROLL_POLL_PATH,
220                post(handle_poll).fallback(not_found),
221            )
222            .route_layer(DefaultBodyLimit::max(ENROLL_BODY_BYTES))
223            .route_layer(from_fn_with_state(state.clone(), limited));
224        Router::new()
225            // Go's mux dispatched every method through one guarded handler
226            // and 404'd the ones it didn't implement; the method fallbacks
227            // keep that shape (and its JSON body) instead of axum's bare
228            // 405.
229            .route(
230                "/sync",
231                get(handle_pull).post(handle_push).fallback(not_found),
232            )
233            .route(paths::DEVICES_ME_PATH, get(handle_me).fallback(not_found))
234            // Registered before the layer, so only these routes are rate
235            // limited and authenticated here.
236            .route_layer(from_fn_with_state(state.clone(), guard))
237            .merge(admin)
238            .merge(enrolment)
239            .route("/health", get(handle_health).fallback(not_found))
240            .route(
241                recall_wire::DISCOVERY_PATH,
242                get(handle_discovery).fallback(not_found),
243            )
244            .route("/admin", get(handle_admin_page).fallback(not_found))
245            .fallback(not_found)
246            .layer(DefaultBodyLimit::max(MAX_BODY_BYTES))
247            .with_state(state)
248    }
249
250    /// Re-runs the local `claude auth status` probe.
251    pub async fn refresh_claude_status(&self) {
252        let status = self.state.merger.check_status().await;
253        self.state.write().claude_status = status;
254    }
255
256    /// The last known state of the `claude` CLI.
257    pub fn claude_status(&self) -> Status {
258        self.state.read().claude_status.clone()
259    }
260
261    /// Overrides the cached CLI status.
262    ///
263    /// Exposed so tests can exercise the merge-failure path — reaching it
264    /// otherwise needs a real, logged-in CLI on the machine running them.
265    pub fn set_claude_status(&self, status: Status) {
266        self.state.write().claude_status = status;
267    }
268
269    /// Moves the clock device signatures are judged by, in seconds.
270    ///
271    /// Exposed so tests can reach the edges of the signature window, and
272    /// what happens after it, without waiting a minute for each.
273    pub fn set_clock_offset(&self, seconds: i64) {
274        self.state.clock_offset.store(seconds, Ordering::Relaxed);
275    }
276
277    /// Makes this server act as if it had started `seconds` earlier than
278    /// it did.
279    ///
280    /// Exposed so tests can sign requests at once, rather than waiting out
281    /// the few seconds after a start in which every signature is refused.
282    pub fn backdate_start(&self, seconds: i64) {
283        self.state
284            .started_unix
285            .fetch_sub(seconds, Ordering::Relaxed);
286    }
287
288    /// Writes a backup now. Failure is logged, never propagated: it becomes
289    /// visible through `/health`'s `last_backup_at` going stale.
290    pub fn run_backup(&self) {
291        run_backup(&self.state);
292    }
293
294    /// Removes ephemeral devices idle for longer than
295    /// [`Config::ephemeral_device_ttl`], and enrolments that expired over
296    /// an hour ago. Answers how many of each went.
297    pub fn sweep_devices(&self) -> Result<(usize, usize)> {
298        sweep_devices(&self.state)
299    }
300
301    /// Starts background work: the first Claude CLI status check, its
302    /// refresh loop, backups, and the device sweep. All of it is
303    /// best-effort — none of it may take the sync API down.
304    pub fn start_background(&self) -> Vec<JoinHandle<()>> {
305        let mut tasks = Vec::new();
306        {
307            let state = self.state.clone();
308            tasks.push(tokio::spawn(async move {
309                loop {
310                    let s = state.clone();
311                    match tokio::task::spawn_blocking(move || sweep_devices(&s)).await {
312                        Ok(Ok((0, 0))) => {}
313                        Ok(Ok((devices, enrollments))) => eprintln!(
314                            "removed {devices} idle ephemeral devices and {enrollments} expired enrolments"
315                        ),
316                        Ok(Err(e)) => eprintln!("device sweep failed: {e:#}"),
317                        Err(_) => {}
318                    }
319                    tokio::time::sleep(SWEEP_EVERY).await;
320                }
321            }));
322        }
323        if self.state.cfg.merge_enabled {
324            let state = self.state.clone();
325            tasks.push(tokio::spawn(async move {
326                let every = state.cfg.claude_status_interval;
327                loop {
328                    let status = state.merger.check_status().await;
329                    state.write().claude_status = status;
330                    tokio::time::sleep(every).await;
331                }
332            }));
333        }
334        if !self.state.cfg.backup_dir.is_empty() {
335            let state = self.state.clone();
336            tasks.push(tokio::spawn(async move {
337                let every = state.cfg.backup_interval;
338                loop {
339                    // VACUUM INTO can take a while on a large database and
340                    // holds the store lock, so it stays off the async
341                    // worker threads.
342                    let s = state.clone();
343                    let _ = tokio::task::spawn_blocking(move || run_backup(&s)).await;
344                    tokio::time::sleep(every).await;
345                }
346            }));
347        }
348        tasks
349    }
350
351    /// Binds `cfg.addr` and serves until SIGTERM or ctrl-c, then shuts down
352    /// gracefully so an in-flight merge isn't cut off mid-write.
353    pub async fn serve(&self) -> Result<()> {
354        let listener = TcpListener::bind(&self.state.cfg.addr)
355            .await
356            .with_context(|| format!("binding {}", self.state.cfg.addr))?;
357        self.serve_with_shutdown(listener, shutdown_signal()).await
358    }
359
360    /// Serves on an already-bound listener until `shutdown` resolves, in
361    /// whichever transport `cfg.tls` names (see `server/tls.rs`; plain HTTP,
362    /// the default, still goes through `axum::serve` directly, unchanged).
363    pub async fn serve_with_shutdown<F>(&self, listener: TcpListener, shutdown: F) -> Result<()>
364    where
365        F: Future<Output = ()> + Send + 'static,
366    {
367        // The certificate is loaded (or the ACME state built) before
368        // anything claims the server is up, so a bad path or an unreadable
369        // key is the last line in the log rather than one after
370        // "listening".
371        let transport = match &self.state.cfg.tls {
372            TlsMode::Off => None,
373            mode => Some(tls::prepare(mode).await?),
374        };
375        eprintln!(
376            "recall server listening on {} ({}, db: {})",
377            listener
378                .local_addr()
379                .map_or_else(|_| self.state.cfg.addr.clone(), |a| a.to_string()),
380            transport
381                .as_ref()
382                .map_or("plain http", tls::Prepared::description),
383            self.state.cfg.db_path
384        );
385        let tasks = self.start_background();
386        let result = match transport {
387            None => axum::serve(
388                listener,
389                self.router()
390                    .into_make_service_with_connect_info::<SocketAddr>(),
391            )
392            .with_graceful_shutdown(shutdown)
393            .await
394            .map_err(Into::into),
395            Some(prepared) => {
396                // axum-server runs its own accept loop rather than
397                // axum::serve's, so the listener crosses over to std here.
398                // It is already non-blocking (tokio bound it), which is
399                // exactly what tokio::net::TcpListener::from_std, which
400                // axum-server calls internally, requires.
401                let listener = listener.into_std().context("preparing the TLS listener")?;
402                let limits = tls::Limits::from_config(&self.state.cfg);
403                tls::serve(self.router(), listener, prepared, limits, shutdown).await
404            }
405        };
406        for task in tasks {
407            task.abort();
408        }
409        result
410    }
411}
412
413fn sweep_devices(state: &AppState) -> Result<(usize, usize)> {
414    let now = time::OffsetDateTime::now_utc();
415    state.store.sweep_devices(
416        &format_timestamp(now - state.cfg.ephemeral_device_ttl),
417        &format_timestamp(now - devices::EXPIRED_ENROLLMENT_KEPT),
418    )
419}
420
421fn run_backup(state: &AppState) {
422    if state.cfg.backup_dir.is_empty() {
423        return;
424    }
425    match state
426        .store
427        .backup(&state.cfg.backup_dir, state.cfg.backup_keep)
428    {
429        Ok(dest) => {
430            state.write().last_backup_at = now();
431            eprintln!("backup written: {}", dest.display());
432        }
433        Err(e) => eprintln!("backup failed: {e:#}"),
434    }
435}
436
437async fn shutdown_signal() {
438    let ctrl_c = async {
439        let _ = tokio::signal::ctrl_c().await;
440    };
441    #[cfg(unix)]
442    let terminate = async {
443        match tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) {
444            Ok(mut sig) => {
445                sig.recv().await;
446            }
447            Err(_) => std::future::pending::<()>().await,
448        }
449    };
450    #[cfg(not(unix))]
451    let terminate = std::future::pending::<()>();
452
453    tokio::select! {
454        _ = ctrl_c => {}
455        _ = terminate => {}
456    }
457}