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