Skip to main content

runnerd_web/
lib.rs

1//! `runnerd-web`: the dashboard the daemon serves on a loopback TCP port. It is a client of the daemon's API like
2//! `runnerctl`: it reads `runnerd_core::api` types over the daemon's Unix socket (`runnerd_core::client`), renders
3//! HTML on the server (`views`, `chart`), and keeps open pages current over SSE with Datastar (`live`). Writes
4//! (pause, reseed, the config) are passed on to the same API (`actions`). It holds no state of its own beyond the
5//! session and its login codes (`auth`), and never talks to Docker or GitHub.
6
7use std::future::Future;
8use std::io;
9use std::net::SocketAddr;
10use std::path::{Path, PathBuf};
11use std::sync::Arc;
12use std::time::{SystemTime, UNIX_EPOCH};
13
14use axum::Router;
15use axum::extract::{Path as UrlPath, Query, Request, State};
16use axum::http::{HeaderValue, StatusCode, header};
17use axum::middleware::{self, Next};
18use axum::response::{Html, IntoResponse, Response};
19use axum::routing::{delete, get, post};
20use maud::Markup;
21use runnerd_core::client::{Client, ClientError};
22use runnerd_core::config::Origin;
23use tokio::net::TcpListener;
24use tokio::sync::watch;
25use tokio::task::JoinHandle;
26use tracing::{info, warn};
27
28mod actions;
29mod assets;
30mod auth;
31mod chart;
32mod format;
33mod live;
34mod views;
35
36pub use auth::{PAIRING_LIFETIME, PAIRING_MAX, PairError, Sessions, login_file, session_file};
37use views::caches::CachesView;
38use views::config::{ConfigQuery, ConfigView};
39use views::history::{Filter, History};
40use views::job::JobView;
41use views::overview::Overview;
42use views::sizing::SizingView;
43
44/// The dashboard's default address.
45pub const DEFAULT_ADDR: &str = "127.0.0.1:7477";
46
47/// What every handler shares.
48#[derive(Clone)]
49pub(crate) struct App {
50    daemon: Client,
51    /// `[web] origins`, where the config page offers to sign a phone in.
52    proxies: Arc<[String]>,
53    /// Set when the dashboard stops, so live pages end.
54    stopping: watch::Receiver<bool>,
55}
56
57pub(crate) fn now() -> u64 {
58    SystemTime::now()
59        .duration_since(UNIX_EPOCH)
60        .map_or(0, |since| since.as_secs())
61}
62
63/// Binds the dashboard's port from `runnerd serve --web`: a loopback address, or `off` for no dashboard. Only
64/// loopback: the session cookie travels in the clear.
65pub async fn listen(flag: &str) -> io::Result<Option<TcpListener>> {
66    if flag == "off" {
67        return Ok(None);
68    }
69    let addr: SocketAddr = flag
70        .parse()
71        .map_err(|error| io::Error::new(io::ErrorKind::InvalidInput, error))?;
72    if !addr.ip().is_loopback() {
73        return Err(io::Error::new(
74            io::ErrorKind::InvalidInput,
75            format!("the dashboard listens on loopback only, not {addr}"),
76        ));
77    }
78    TcpListener::bind(addr).await.map(Some)
79}
80
81/// Runs `serve` as a task, logging why it stopped if it failed.
82pub fn spawn(
83    listener: TcpListener,
84    socket: PathBuf,
85    proxies: Vec<Origin>,
86    sessions: Arc<Sessions>,
87    stop: impl Future + Send + 'static,
88) -> JoinHandle<()> {
89    tokio::spawn(async move {
90        let stop = async {
91            stop.await;
92        };
93        if let Err(error) = serve(listener, &socket, &proxies, sessions, stop).await {
94            warn!("the dashboard stopped: {error}");
95        }
96    })
97}
98
99/// Serves the dashboard on `listener` for the daemon whose API is on `socket`, until `stop` completes. Pages from
100/// `proxies` (`[web] origins`), reverse proxies in front of it, may write too. A browser signed in to `sessions`
101/// is let in. Writes a one-time login link per address to `login_file(socket)` and removes it when done.
102pub async fn serve(
103    listener: TcpListener,
104    socket: &Path,
105    proxies: &[Origin],
106    sessions: Arc<Sessions>,
107    stop: impl Future<Output = ()> + Send + 'static,
108) -> io::Result<()> {
109    let addr = listener.local_addr()?;
110    let file = login_file(socket);
111    let auth = auth::Auth::new(addr, proxies, sessions, file.clone())?;
112    info!(
113        "serving the dashboard on http://{addr}; `runnerctl web` opens it (login links in {})",
114        file.display()
115    );
116    let (stopping, stopping_receiver) = watch::channel(false);
117    let app = router(
118        Client::new(socket.to_owned()),
119        auth.clone(),
120        stopping_receiver,
121    );
122    let served = axum::serve(listener, app)
123        .with_graceful_shutdown(async move {
124            stop.await;
125            let _ = stopping.send(true);
126        })
127        .await;
128    auth.remove_login_file();
129    served
130}
131
132fn router(daemon: Client, auth: Arc<auth::Auth>, stopping: watch::Receiver<bool>) -> Router {
133    let proxies = auth.proxies().into();
134    let pages = Router::new()
135        .route("/", get(overview))
136        .route("/history", get(history))
137        .route("/caches", get(caches))
138        .route("/sizing", get(sizing))
139        .route("/jobs/{id}", get(job))
140        .route("/jobs/{id}/log", get(job_log))
141        .route("/live/overview", get(live::overview))
142        .route("/live/jobs/{id}", get(live::job))
143        .route("/config", get(config).put(actions::save_config))
144        .route("/config/check", post(actions::check_config))
145        .route(
146            "/v1/repositories/{owner}/{name}/pause",
147            post(actions::pause),
148        )
149        .route(
150            "/v1/repositories/{owner}/{name}/resume",
151            post(actions::resume),
152        )
153        .route("/v1/repositories/pause", post(actions::pause_all))
154        .route("/v1/repositories/resume", post(actions::resume_all))
155        .route("/v1/profiles/{name}/reseed", post(actions::reseed))
156        .route("/v1/caches/{name}/clean", post(actions::clean))
157        .route("/v1/records", delete(actions::remove_all_records))
158        .route("/v1/records/{job}", delete(actions::remove_record))
159        .route("/assets/{name}", get(assets::serve))
160        .fallback(|| async {
161            (
162                StatusCode::NOT_FOUND,
163                views::error_page("Not found", "There is no page at this address."),
164            )
165        })
166        .with_state(App {
167            daemon,
168            proxies,
169            stopping,
170        });
171    let login = Router::new()
172        .route("/login", get(auth::login))
173        .route("/pair", post(auth::pair))
174        .with_state(auth.clone());
175    pages
176        .merge(login)
177        .layer(middleware::from_fn_with_state(auth, auth::require_session))
178        .layer(middleware::from_fn(security_headers))
179}
180
181/// No framing, no sniffing, no referrer, and a policy that lets the page load only what this server serves.
182/// Datastar evaluates its attributes with `Function`, hence `'unsafe-eval'`; regions size their blocks with
183/// `style` attributes.
184async fn security_headers(request: Request, next: Next) -> Response {
185    let mut response = next.run(request).await;
186    let headers = response.headers_mut();
187    headers.insert(
188        header::CONTENT_SECURITY_POLICY,
189        HeaderValue::from_static(
190            "default-src 'none'; script-src 'self' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; \
191             img-src 'self' data:; font-src 'self'; connect-src 'self'; manifest-src 'self'; form-action 'self'; \
192             base-uri 'none'; frame-ancestors 'none'",
193        ),
194    );
195    headers.insert(
196        header::X_CONTENT_TYPE_OPTIONS,
197        HeaderValue::from_static("nosniff"),
198    );
199    headers.insert(
200        header::REFERRER_POLICY,
201        HeaderValue::from_static("no-referrer"),
202    );
203    response
204}
205
206/// A page, or the daemon's error as one.
207fn page(rendered: Result<Markup, ClientError>) -> Response {
208    match rendered {
209        Ok(markup) => Html(markup.into_string()).into_response(),
210        Err(ClientError::Api {
211            status: StatusCode::NOT_FOUND,
212            message,
213            ..
214        }) => (
215            StatusCode::NOT_FOUND,
216            views::error_page("Not found", &message),
217        )
218            .into_response(),
219        Err(error) => (
220            StatusCode::BAD_GATEWAY,
221            views::error_page("runnerd did not answer", &error.to_string()),
222        )
223            .into_response(),
224    }
225}
226
227async fn overview(State(app): State<App>) -> Response {
228    page(
229        Overview::load(&app.daemon, now())
230            .await
231            .map(|overview| views::overview::render(&overview)),
232    )
233}
234
235async fn history(State(app): State<App>, Query(filter): Query<Filter>) -> Response {
236    page(
237        History::load(&app.daemon, filter, now())
238            .await
239            .map(|history| views::history::render(&history)),
240    )
241}
242
243async fn caches(State(app): State<App>) -> Response {
244    page(
245        CachesView::load(&app.daemon, now())
246            .await
247            .map(|caches| views::caches::render(&caches)),
248    )
249}
250
251async fn sizing(State(app): State<App>) -> Response {
252    page(
253        SizingView::load(&app.daemon)
254            .await
255            .map(|sizing| views::sizing::render(&sizing)),
256    )
257}
258
259async fn config(State(app): State<App>, Query(query): Query<ConfigQuery>) -> Response {
260    page(
261        ConfigView::load(&app.daemon, query)
262            .await
263            .map(|config| views::config::render(&config, &app.proxies)),
264    )
265}
266
267async fn job(State(app): State<App>, UrlPath(id): UrlPath<u64>) -> Response {
268    page(
269        JobView::load(&app.daemon, id, now())
270            .await
271            .map(|job| views::job::render(&job)),
272    )
273}
274
275/// `GET /jobs/{id}/log`: the whole stored log as text.
276async fn job_log(State(app): State<App>, UrlPath(id): UrlPath<u64>) -> Response {
277    match app.daemon.text(&format!("/v1/jobs/{id}/logs")).await {
278        Ok(text) => ([(header::CONTENT_TYPE, "text/plain; charset=utf-8")], text).into_response(),
279        Err(ClientError::Api {
280            status, message, ..
281        }) => (status, message).into_response(),
282        Err(error) => (StatusCode::BAD_GATEWAY, error.to_string()).into_response(),
283    }
284}
285
286#[cfg(test)]
287mod tests;