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 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::insights::InsightsView;
41use views::job::JobView;
42use views::overview::Overview;
43use views::sizing::SizingView;
44
45pub const DEFAULT_ADDR: &str = "127.0.0.1:7477";
47
48#[derive(Clone)]
50pub(crate) struct App {
51 daemon: Client,
52 proxies: Arc<[String]>,
54 stopping: watch::Receiver<bool>,
56}
57
58pub(crate) fn now() -> u64 {
59 SystemTime::now()
60 .duration_since(UNIX_EPOCH)
61 .map_or(0, |since| since.as_secs())
62}
63
64pub async fn listen(flag: &str) -> io::Result<Option<TcpListener>> {
67 if flag == "off" {
68 return Ok(None);
69 }
70 let addr: SocketAddr = flag
71 .parse()
72 .map_err(|error| io::Error::new(io::ErrorKind::InvalidInput, error))?;
73 if !addr.ip().is_loopback() {
74 return Err(io::Error::new(
75 io::ErrorKind::InvalidInput,
76 format!("the dashboard listens on loopback only, not {addr}"),
77 ));
78 }
79 TcpListener::bind(addr).await.map(Some)
80}
81
82pub fn spawn(
84 listener: TcpListener,
85 socket: PathBuf,
86 proxies: Vec<Origin>,
87 sessions: Arc<Sessions>,
88 stop: impl Future + Send + 'static,
89) -> JoinHandle<()> {
90 tokio::spawn(async move {
91 let stop = async {
92 stop.await;
93 };
94 if let Err(error) = serve(listener, &socket, &proxies, sessions, stop).await {
95 warn!("the dashboard stopped: {error}");
96 }
97 })
98}
99
100pub async fn serve(
104 listener: TcpListener,
105 socket: &Path,
106 proxies: &[Origin],
107 sessions: Arc<Sessions>,
108 stop: impl Future<Output = ()> + Send + 'static,
109) -> io::Result<()> {
110 let addr = listener.local_addr()?;
111 let file = login_file(socket);
112 let auth = auth::Auth::new(addr, proxies, sessions, file.clone())?;
113 info!(
114 "serving the dashboard on http://{addr}; `runnerctl web` opens it (login links in {})",
115 file.display()
116 );
117 let (stopping, stopping_receiver) = watch::channel(false);
118 let app = router(
119 Client::new(socket.to_owned()),
120 auth.clone(),
121 stopping_receiver,
122 );
123 let served = axum::serve(listener, app)
124 .with_graceful_shutdown(async move {
125 stop.await;
126 let _ = stopping.send(true);
127 })
128 .await;
129 auth.remove_login_file();
130 served
131}
132
133fn router(daemon: Client, auth: Arc<auth::Auth>, stopping: watch::Receiver<bool>) -> Router {
134 let proxies = auth.proxies().into();
135 let pages = Router::new()
136 .route("/", get(overview))
137 .route("/history", get(history))
138 .route("/caches", get(caches))
139 .route("/sizing", get(sizing))
140 .route("/insights", get(insights))
141 .route("/jobs/{id}", get(job))
142 .route("/jobs/{id}/log", get(job_log))
143 .route("/live/overview", get(live::overview))
144 .route("/live/jobs/{id}", get(live::job))
145 .route("/config", get(config).put(actions::save_config))
146 .route("/config/check", post(actions::check_config))
147 .route(
148 "/v1/repositories/{owner}/{name}/pause",
149 post(actions::pause),
150 )
151 .route(
152 "/v1/repositories/{owner}/{name}/resume",
153 post(actions::resume),
154 )
155 .route("/v1/repositories/pause", post(actions::pause_all))
156 .route("/v1/repositories/resume", post(actions::resume_all))
157 .route("/v1/profiles/{name}/reseed", post(actions::reseed))
158 .route("/v1/caches/{name}/clean", post(actions::clean))
159 .route("/v1/records", delete(actions::remove_all_records))
160 .route("/v1/records/{job}", delete(actions::remove_record))
161 .route("/assets/{name}", get(assets::serve))
162 .fallback(|| async {
163 (
164 StatusCode::NOT_FOUND,
165 views::error_page("Not found", "There is no page at this address."),
166 )
167 })
168 .with_state(App {
169 daemon,
170 proxies,
171 stopping,
172 });
173 let login = Router::new()
174 .route("/login", get(auth::login))
175 .route("/pair", post(auth::pair))
176 .with_state(auth.clone());
177 pages
178 .merge(login)
179 .layer(middleware::from_fn_with_state(auth, auth::require_session))
180 .layer(middleware::from_fn(security_headers))
181}
182
183async fn security_headers(request: Request, next: Next) -> Response {
187 let mut response = next.run(request).await;
188 let headers = response.headers_mut();
189 headers.insert(
190 header::CONTENT_SECURITY_POLICY,
191 HeaderValue::from_static(
192 "default-src 'none'; script-src 'self' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; \
193 img-src 'self' data:; font-src 'self'; connect-src 'self'; manifest-src 'self'; form-action 'self'; \
194 base-uri 'none'; frame-ancestors 'none'",
195 ),
196 );
197 headers.insert(
198 header::X_CONTENT_TYPE_OPTIONS,
199 HeaderValue::from_static("nosniff"),
200 );
201 headers.insert(
202 header::REFERRER_POLICY,
203 HeaderValue::from_static("no-referrer"),
204 );
205 response
206}
207
208fn page(rendered: Result<Markup, ClientError>) -> Response {
210 match rendered {
211 Ok(markup) => Html(markup.into_string()).into_response(),
212 Err(ClientError::Api {
213 status: StatusCode::NOT_FOUND,
214 message,
215 ..
216 }) => (
217 StatusCode::NOT_FOUND,
218 views::error_page("Not found", &message),
219 )
220 .into_response(),
221 Err(error) => (
222 StatusCode::BAD_GATEWAY,
223 views::error_page("runnerd did not answer", &error.to_string()),
224 )
225 .into_response(),
226 }
227}
228
229async fn overview(State(app): State<App>) -> Response {
230 page(
231 Overview::load(&app.daemon, now())
232 .await
233 .map(|overview| views::overview::render(&overview)),
234 )
235}
236
237async fn history(State(app): State<App>, Query(filter): Query<Filter>) -> Response {
238 page(
239 History::load(&app.daemon, filter, now())
240 .await
241 .map(|history| views::history::render(&history)),
242 )
243}
244
245async fn caches(State(app): State<App>) -> Response {
246 page(
247 CachesView::load(&app.daemon, now())
248 .await
249 .map(|caches| views::caches::render(&caches)),
250 )
251}
252
253async fn sizing(State(app): State<App>) -> Response {
254 page(
255 SizingView::load(&app.daemon, now())
256 .await
257 .map(|sizing| views::sizing::render(&sizing)),
258 )
259}
260
261async fn insights(State(app): State<App>) -> Response {
262 page(
263 InsightsView::load(&app.daemon, now())
264 .await
265 .map(|insights| views::insights::render(&insights)),
266 )
267}
268
269async fn config(State(app): State<App>, Query(query): Query<ConfigQuery>) -> Response {
270 page(
271 ConfigView::load(&app.daemon, query)
272 .await
273 .map(|config| views::config::render(&config, &app.proxies)),
274 )
275}
276
277async fn job(State(app): State<App>, UrlPath(id): UrlPath<u64>) -> Response {
278 page(
279 JobView::load(&app.daemon, id, now())
280 .await
281 .map(|job| views::job::render(&job)),
282 )
283}
284
285async fn job_log(State(app): State<App>, UrlPath(id): UrlPath<u64>) -> Response {
287 match app.daemon.text(&format!("/v1/jobs/{id}/logs")).await {
288 Ok(text) => ([(header::CONTENT_TYPE, "text/plain; charset=utf-8")], text).into_response(),
289 Err(ClientError::Api {
290 status, message, ..
291 }) => (status, message).into_response(),
292 Err(error) => (StatusCode::BAD_GATEWAY, error.to_string()).into_response(),
293 }
294}
295
296#[cfg(test)]
297mod tests;