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