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::{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
42pub const DEFAULT_ADDR: &str = "127.0.0.1:7477";
44
45#[derive(Clone)]
47pub(crate) struct App {
48 daemon: Client,
49 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
59pub 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
77pub 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
93pub 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
163async 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
188fn 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
249async 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;