1use std::fmt::Write as _;
9use std::io::{self, Read as _, Write as _};
10use std::net::SocketAddr;
11use std::os::unix::fs::OpenOptionsExt;
12use std::path::{Path, PathBuf};
13use std::sync::{Arc, Mutex};
14
15use axum::extract::{Query, Request, State};
16use axum::http::{HeaderMap, Method, StatusCode, header};
17use axum::middleware::Next;
18use axum::response::{IntoResponse, Redirect, Response};
19use serde::Deserialize;
20use tracing::warn;
21
22use crate::views::{self, SignedOut};
23
24pub fn login_file(socket: &Path) -> PathBuf {
27 socket.with_extension("web")
28}
29
30fn random_token() -> io::Result<String> {
32 let mut bytes = [0u8; 32];
33 std::fs::File::open("/dev/urandom")?.read_exact(&mut bytes)?;
34 Ok(bytes
35 .iter()
36 .fold(String::with_capacity(64), |mut hex, byte| {
37 let _ = write!(hex, "{byte:02x}");
38 hex
39 }))
40}
41
42fn same(a: &str, b: &str) -> bool {
44 a.len() == b.len()
45 && a.bytes()
46 .zip(b.bytes())
47 .fold(0u8, |diff, (x, y)| diff | (x ^ y))
48 == 0
49}
50
51#[derive(Debug)]
53pub(crate) struct Auth {
54 token: String,
55 code: Mutex<String>,
56 file: PathBuf,
57 cookie: String,
58 origins: Vec<String>,
59 base: String,
60}
61
62impl Auth {
63 pub(crate) fn new(addr: SocketAddr, file: PathBuf) -> io::Result<Arc<Auth>> {
65 let base = format!("http://{addr}");
66 let mut origins = vec![base.clone()];
67 if addr.ip().is_loopback() {
68 origins.push(format!("http://localhost:{}", addr.port()));
69 }
70 let auth = Auth {
71 token: random_token()?,
72 code: Mutex::new(random_token()?),
73 file,
74 cookie: format!("runnerd-{}", addr.port()),
75 origins,
76 base,
77 };
78 auth.write_login_file(&auth.current_code())?;
79 Ok(Arc::new(auth))
80 }
81
82 fn current_code(&self) -> String {
83 self.code.lock().map_or_else(
84 |poisoned| poisoned.into_inner().clone(),
85 |code| code.clone(),
86 )
87 }
88
89 fn write_login_file(&self, code: &str) -> io::Result<()> {
91 let temporary = self.file.with_extension("web.new");
92 let _ = std::fs::remove_file(&temporary);
93 let mut file = std::fs::OpenOptions::new()
94 .write(true)
95 .create_new(true)
96 .mode(0o600)
97 .open(&temporary)?;
98 writeln!(file, "{}/login?code={code}", self.base)?;
99 file.sync_all()?;
100 std::fs::rename(&temporary, &self.file)
101 }
102
103 fn redeem(&self, code: &str) -> bool {
105 let Ok(mut current) = self.code.lock() else {
106 return false;
107 };
108 if !same(code, ¤t) {
109 return false;
110 }
111 match random_token() {
112 Ok(next) => {
113 if let Err(error) = self.write_login_file(&next) {
114 warn!(
115 "cannot write the dashboard's login URL to {}: {error}",
116 self.file.display()
117 );
118 }
119 *current = next;
120 }
121 Err(error) => warn!("cannot make a new dashboard login code: {error}"),
122 }
123 true
124 }
125
126 pub(crate) fn remove_login_file(&self) {
128 let _ = std::fs::remove_file(&self.file);
129 }
130
131 fn has_session(&self, headers: &HeaderMap) -> bool {
132 headers
133 .get_all(header::COOKIE)
134 .iter()
135 .filter_map(|value| value.to_str().ok())
136 .flat_map(|value| value.split(';'))
137 .filter_map(|pair| pair.trim().split_once('='))
138 .any(|(name, value)| name == self.cookie && same(value, &self.token))
139 }
140
141 fn same_origin(&self, headers: &HeaderMap) -> bool {
142 headers
143 .get(header::ORIGIN)
144 .and_then(|origin| origin.to_str().ok())
145 .is_some_and(|origin| self.origins.iter().any(|allowed| allowed == origin))
146 }
147}
148
149#[derive(Deserialize)]
150pub(crate) struct Login {
151 code: String,
152}
153
154pub(crate) async fn login(State(auth): State<Arc<Auth>>, Query(login): Query<Login>) -> Response {
156 if !auth.redeem(&login.code) {
157 return (
158 StatusCode::UNAUTHORIZED,
159 views::signed_out(SignedOut::CodeUsed),
160 )
161 .into_response();
162 }
163 let cookie = format!(
164 "{}={}; Path=/; HttpOnly; SameSite=Strict",
165 auth.cookie, auth.token
166 );
167 ([(header::SET_COOKIE, cookie)], Redirect::to("/")).into_response()
168}
169
170pub(crate) async fn require_session(
173 State(auth): State<Arc<Auth>>,
174 request: Request,
175 next: Next,
176) -> Response {
177 let path = request.uri().path();
178 if path == "/login" || path.starts_with("/assets/") {
179 return next.run(request).await;
180 }
181 if !auth.has_session(request.headers()) {
182 return (
183 StatusCode::UNAUTHORIZED,
184 views::signed_out(SignedOut::NoSession),
185 )
186 .into_response();
187 }
188 let safe = matches!(*request.method(), Method::GET | Method::HEAD);
189 if !safe && !auth.same_origin(request.headers()) {
190 return (
191 StatusCode::FORBIDDEN,
192 "writes must come from the dashboard's own pages",
193 )
194 .into_response();
195 }
196 next.run(request).await
197}
198
199#[cfg(test)]
200mod tests {
201 use super::*;
202
203 #[test]
204 fn a_login_code_works_once_and_the_file_then_holds_a_new_one() {
205 let dir = crate::tests::TempDir::new();
206 let file = dir.0.join("runnerd.web");
207 let auth = Auth::new("127.0.0.1:7477".parse().unwrap(), file.clone()).unwrap();
208 let url = std::fs::read_to_string(&file).unwrap();
209 let code = url.trim().split_once("code=").unwrap().1.to_owned();
210 assert!(url.starts_with("http://127.0.0.1:7477/login?code="));
211 assert!(auth.redeem(&code));
212 assert!(!auth.redeem(&code));
213 let next = std::fs::read_to_string(&file).unwrap();
214 assert_ne!(url, next);
215 let mode = std::os::unix::fs::PermissionsExt::mode(
216 &std::fs::metadata(&file).unwrap().permissions(),
217 );
218 assert_eq!(mode & 0o777, 0o600);
219 }
220}