1use 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 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::{ConfigQuery, ConfigView};
38use views::history::{Filter, History};
39use views::job::JobView;
40use views::overview::Overview;
41use views::sizing::SizingView;
42
43pub const DEFAULT_ADDR: &str = "127.0.0.1:7477";
45
46#[derive(Clone)]
48pub(crate) struct App {
49 daemon: Client,
50 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
60pub 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
78pub 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
94pub 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(
110 Client::new(socket.to_owned()),
111 auth.clone(),
112 stopping_receiver,
113 );
114 let served = axum::serve(listener, app)
115 .with_graceful_shutdown(async move {
116 stop.await;
117 let _ = stopping.send(true);
118 })
119 .await;
120 auth.remove_login_file();
121 served
122}
123
124fn router(daemon: Client, auth: Arc<auth::Auth>, stopping: watch::Receiver<bool>) -> Router {
125 let pages = Router::new()
126 .route("/", get(overview))
127 .route("/history", get(history))
128 .route("/caches", get(caches))
129 .route("/sizing", get(sizing))
130 .route("/jobs/{id}", get(job))
131 .route("/jobs/{id}/log", get(job_log))
132 .route("/live/overview", get(live::overview))
133 .route("/live/jobs/{id}", get(live::job))
134 .route("/config", get(config).put(actions::save_config))
135 .route("/config/check", post(actions::check_config))
136 .route(
137 "/v1/repositories/{owner}/{name}/pause",
138 post(actions::pause),
139 )
140 .route(
141 "/v1/repositories/{owner}/{name}/resume",
142 post(actions::resume),
143 )
144 .route("/v1/repositories/pause", post(actions::pause_all))
145 .route("/v1/repositories/resume", post(actions::resume_all))
146 .route("/v1/profiles/{name}/reseed", post(actions::reseed))
147 .route("/v1/caches/{name}/clean", post(actions::clean))
148 .route("/v1/records", delete(actions::remove_all_records))
149 .route("/v1/records/{job}", delete(actions::remove_record))
150 .route("/assets/{name}", get(assets::serve))
151 .fallback(|| async {
152 (
153 StatusCode::NOT_FOUND,
154 views::error_page("Not found", "There is no page at this address."),
155 )
156 })
157 .with_state(App { daemon, stopping });
158 let login = Router::new()
159 .route("/login", get(auth::login))
160 .with_state(auth.clone());
161 pages
162 .merge(login)
163 .layer(middleware::from_fn_with_state(auth, auth::require_session))
164 .layer(middleware::from_fn(security_headers))
165}
166
167async fn security_headers(request: Request, next: Next) -> Response {
171 let mut response = next.run(request).await;
172 let headers = response.headers_mut();
173 headers.insert(
174 header::CONTENT_SECURITY_POLICY,
175 HeaderValue::from_static(
176 "default-src 'none'; script-src 'self' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; \
177 img-src 'self' data:; font-src 'self'; connect-src 'self'; form-action 'self'; \
178 base-uri 'none'; frame-ancestors 'none'",
179 ),
180 );
181 headers.insert(
182 header::X_CONTENT_TYPE_OPTIONS,
183 HeaderValue::from_static("nosniff"),
184 );
185 headers.insert(
186 header::REFERRER_POLICY,
187 HeaderValue::from_static("no-referrer"),
188 );
189 response
190}
191
192fn page(rendered: Result<Markup, ClientError>) -> Response {
194 match rendered {
195 Ok(markup) => Html(markup.into_string()).into_response(),
196 Err(ClientError::Api {
197 status: StatusCode::NOT_FOUND,
198 message,
199 ..
200 }) => (
201 StatusCode::NOT_FOUND,
202 views::error_page("Not found", &message),
203 )
204 .into_response(),
205 Err(error) => (
206 StatusCode::BAD_GATEWAY,
207 views::error_page("runnerd did not answer", &error.to_string()),
208 )
209 .into_response(),
210 }
211}
212
213async fn overview(State(app): State<App>) -> Response {
214 page(
215 Overview::load(&app.daemon, now())
216 .await
217 .map(|overview| views::overview::render(&overview)),
218 )
219}
220
221async fn history(State(app): State<App>, Query(filter): Query<Filter>) -> Response {
222 page(
223 History::load(&app.daemon, filter, now())
224 .await
225 .map(|history| views::history::render(&history)),
226 )
227}
228
229async fn caches(State(app): State<App>) -> Response {
230 page(
231 CachesView::load(&app.daemon, now())
232 .await
233 .map(|caches| views::caches::render(&caches)),
234 )
235}
236
237async fn sizing(State(app): State<App>) -> Response {
238 page(
239 SizingView::load(&app.daemon)
240 .await
241 .map(|sizing| views::sizing::render(&sizing)),
242 )
243}
244
245async fn config(State(app): State<App>, Query(query): Query<ConfigQuery>) -> Response {
246 page(
247 ConfigView::load(&app.daemon, query.resize)
248 .await
249 .map(|config| views::config::render(&config)),
250 )
251}
252
253async fn job(State(app): State<App>, UrlPath(id): UrlPath<u64>) -> Response {
254 page(
255 JobView::load(&app.daemon, id, now())
256 .await
257 .map(|job| views::job::render(&job)),
258 )
259}
260
261async fn job_log(State(app): State<App>, UrlPath(id): UrlPath<u64>) -> Response {
263 match app.daemon.text(&format!("/v1/jobs/{id}/logs")).await {
264 Ok(text) => ([(header::CONTENT_TYPE, "text/plain; charset=utf-8")], text).into_response(),
265 Err(ClientError::Api {
266 status, message, ..
267 }) => (status, message).into_response(),
268 Err(error) => (StatusCode::BAD_GATEWAY, error.to_string()).into_response(),
269 }
270}
271
272#[cfg(test)]
273mod tests;