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