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