[WIP] A simple wake-on-lan service

generate routes from ./web/dist

vielle.dev 3bb21768 23adae84

verified
+252 -72
+1
.gitignore
··· 1 /target 2 wol.toml
··· 1 /target 2 + /web/dist 3 wol.toml
+1
Cargo.lock
··· 747 dependencies = [ 748 "axum", 749 "serde", 750 "thiserror", 751 "tokio", 752 "toml",
··· 747 dependencies = [ 748 "axum", 749 "serde", 750 + "serde_json", 751 "thiserror", 752 "tokio", 753 "toml",
+6
Cargo.toml
··· 9 tokio = { version = "1.49.0", features = ["full"] } 10 toml = "1.0.2" 11 axum = "0.8.8"
··· 9 tokio = { version = "1.49.0", features = ["full"] } 10 toml = "1.0.2" 11 axum = "0.8.8" 12 + serde_json = "1.0.149" 13 + 14 + [build-dependencies] 15 + serde = { version = "1.0.228", features = ["derive"] } 16 + serde_json = "1.0.149" 17 + thiserror = "2.0.18"
+211
build.rs
···
··· 1 + use std::{ 2 + env::{self, VarError}, 3 + fs::{self, File}, 4 + io::{self, Read, Write}, 5 + path::{Path, PathBuf, StripPrefixError}, 6 + process::Command, 7 + }; 8 + 9 + use serde::Deserialize; 10 + use thiserror::Error; 11 + 12 + const DIST_SRC: &str = "./web/dist"; 13 + const WEB_SRC: &str = "./web"; 14 + const PACKAGE_JSON: &str = "./web/package.json"; 15 + 16 + #[derive(Deserialize, Debug)] 17 + struct Package { 18 + scripts: Scripts, 19 + } 20 + 21 + #[derive(Deserialize, Debug)] 22 + struct Scripts { 23 + #[serde(rename = "wol.build")] 24 + build: String, 25 + } 26 + 27 + #[derive(Error, Debug)] 28 + enum BuildError { 29 + #[error("IO Error: {}", .0)] 30 + Io(#[from] io::Error), 31 + #[error("Parse Error: {}", .0)] 32 + Parse(#[from] serde_json::Error), 33 + #[error("Command failed with error {}\nSTDOUT: {}\nSTDERR: {}", .0.map(|x| x.to_string()).unwrap_or(String::from("N/A")), .1, .2)] 34 + Command(Option<i32>, String, String), 35 + } 36 + 37 + fn build() -> Result<(), BuildError> { 38 + let mut file = File::open(PACKAGE_JSON)?; 39 + let mut package = String::new(); 40 + file.read_to_string(&mut package)?; 41 + 42 + let package = serde_json::from_str::<Package>(&package)?; 43 + let sh = package.scripts.build; 44 + 45 + let cmd = Command::new("/bin/sh") 46 + .arg("-c") 47 + .arg(sh) 48 + .current_dir(WEB_SRC) 49 + .output()?; 50 + 51 + if !cmd.status.success() { 52 + return Err(BuildError::Command( 53 + cmd.status.code(), 54 + String::from_utf8(cmd.stdout).unwrap_or(String::from("Could not parse STDOUT")), 55 + String::from_utf8(cmd.stderr).unwrap_or(String::from("Could not parse STDERR")), 56 + )); 57 + } 58 + 59 + Ok(()) 60 + } 61 + 62 + #[derive(Error, Debug)] 63 + #[error("{}", .0)] 64 + struct CopyError(#[from] io::Error); 65 + 66 + fn copy_dir(src: impl AsRef<Path>, dst: impl AsRef<Path>) -> Result<Vec<PathBuf>, CopyError> { 67 + fs::create_dir_all(&dst)?; 68 + let mut paths = Vec::new(); 69 + 70 + for entry in fs::read_dir(&src)? { 71 + let entry = entry?; 72 + let r#type = entry.file_type()?; 73 + if r#type.is_dir() { 74 + let mut res = copy_dir(entry.path(), dst.as_ref().join(entry.file_name()))?; 75 + paths.append(&mut res); 76 + } else { 77 + fs::copy(entry.path(), dst.as_ref().join(entry.file_name()))?; 78 + paths.push(entry.path()); 79 + } 80 + } 81 + 82 + Ok(paths) 83 + } 84 + 85 + #[derive(Error, Debug)] 86 + enum CodegenError { 87 + #[error("Could not strip path: {}", .0)] 88 + StripPath(#[from] StripPrefixError), 89 + #[error("Error parsing non UTF8 string")] 90 + Utf8, 91 + } 92 + 93 + fn codegen(paths: Vec<PathBuf>) -> Result<String, CodegenError> { 94 + // idx is a unique id for the current route 95 + // this is easier than converting the path to a rust variable and avoiding collisons 96 + let mut idx = 0; 97 + // source path, route, id 98 + let paths = paths 99 + .into_iter() 100 + .map(|x| { 101 + idx += 1; 102 + 103 + let route = Path::new("/").join(x.strip_prefix(DIST_SRC)?); 104 + let route = if route.ends_with("index.html") { 105 + route.parent().unwrap_or(&route) 106 + } else { 107 + &route 108 + }; 109 + let route = route.to_str().ok_or(CodegenError::Utf8)?; 110 + let route = String::from(route); 111 + 112 + let path = x.strip_prefix(WEB_SRC).map(|x| x.to_owned())?; 113 + 114 + Ok((path, route, idx)) 115 + }) 116 + .collect::<Result<Vec<_>, CodegenError>>()?; 117 + 118 + let routes = &paths 119 + .iter() 120 + .map(|(path, _, idx)| { 121 + let ext = path 122 + .extension() 123 + .and_then(|x| x.to_str()) 124 + .and_then(|x| match x { 125 + // text 126 + "html" | "htm" => Some("text/html"), 127 + "css" => Some("text/css"), 128 + "js" => Some("text/javascript"), 129 + "json" => Some("application/json"), 130 + "webmanifest" => Some("application/manifest+json"), 131 + "txt" => Some("text/plain"), 132 + 133 + // image 134 + "png" => Some("image/png"), 135 + "jpeg" | "jpg" => Some("image/jpeg"), 136 + "svg" => Some("image/svg+xml"), 137 + "webp" => Some("image/webp"), 138 + "gif" => Some("image/gif"), 139 + "ico" => Some("image/vnc.microsoft.icon"), 140 + 141 + _ => None, 142 + }) 143 + .unwrap_or("application/octet-stream"); 144 + 145 + Ok(format!( 146 + r#"async fn route_{idx}() -> impl axum::response::IntoResponse {{ 147 + let mut headers = axum::http::HeaderMap::new(); 148 + headers.insert(axum::http::header::CONTENT_TYPE, axum::http::HeaderValue::from_static("{}")); 149 + (headers, include_bytes!("{}")) 150 + }}"#, 151 + ext, 152 + path.to_str().ok_or(CodegenError::Utf8)? 153 + )) 154 + }) 155 + .collect::<Result<Vec<_>, CodegenError>>()? 156 + .join("\n"); 157 + 158 + let main = format!( 159 + r#" 160 + pub fn main() -> axum::Router {{ 161 + axum::Router::new(){} 162 + }} 163 + "#, 164 + paths 165 + .into_iter() 166 + .map(|(_, route, idx)| format!(r#".route("{route}", axum::routing::get(route_{idx}))"#)) 167 + .collect::<Vec<_>>() 168 + .join("") 169 + ); 170 + 171 + Ok(format!("mod dist {{ {routes} {main} }}")) 172 + } 173 + 174 + #[derive(Error, Debug)] 175 + enum Error { 176 + #[error("OUT_DIR env variable was not defined. Is this build.rs? {}", .0)] 177 + OutDir(#[from] VarError), 178 + #[error("Failed to compile ./web: {}", .0)] 179 + Web(#[from] BuildError), 180 + #[error("Failed to copy ./web/dist {}", .0)] 181 + Copy(#[from] CopyError), 182 + #[error("Codegen error: {}", .0)] 183 + Codegen(#[from] CodegenError), 184 + #[error("IO error: {}", .0)] 185 + Io(#[from] io::Error), 186 + } 187 + 188 + fn main() -> Result<(), ()> { 189 + fn main() -> Result<(), Error> { 190 + let out_dir = env::var("OUT_DIR")?; 191 + let dist_dst = Path::new(&out_dir).join("dist"); 192 + 193 + build()?; 194 + let paths = copy_dir(DIST_SRC, dist_dst)?; 195 + let rust = codegen(paths)?; 196 + let mut rust_file = fs::File::create(Path::new(&out_dir).join("mod.rs"))?; 197 + rust_file.write(rust.as_bytes())?; 198 + 199 + println!("cargo::rerun-if-changed=web/"); 200 + 201 + Ok(()) 202 + } 203 + 204 + let res = main(); 205 + if let Err(err) = res { 206 + eprintln!("{}", err); 207 + Err(()) 208 + } else { 209 + Ok(()) 210 + } 211 + }
+1 -1
src/main.rs
··· 23 println!("target: {k}: {} ({:?})", v.mac, v.ip); 24 } 25 let listener = tokio::net::TcpListener::bind(config.binding).await?; 26 - axum::serve(listener, server::router(config.targets)).await?; 27 28 Ok(()) 29 }
··· 23 println!("target: {k}: {} ({:?})", v.mac, v.ip); 24 } 25 let listener = tokio::net::TcpListener::bind(config.binding).await?; 26 + axum::serve(listener, server::router()).await?; 27 28 Ok(()) 29 }
+27
src/server.rs
···
··· 1 + use axum::{Json, Router, http::Response, routing::post}; 2 + use serde::Deserialize; 3 + 4 + use crate::mac::MacAddress; 5 + 6 + include!(concat!(env!("OUT_DIR"), "/mod.rs")); 7 + 8 + #[derive(Deserialize)] 9 + pub struct WakeRequest { 10 + #[serde(deserialize_with = "crate::mac::deserialize_mac")] 11 + mac: MacAddress, 12 + } 13 + 14 + pub async fn wake(Json(req): Json<WakeRequest>) -> Result<(), Response<String>> { 15 + println!("Waking {}", req.mac); 16 + req.mac.wake().await.map_err(|err| { 17 + Response::builder() 18 + .status(500) 19 + .body(err.to_string()) 20 + // unwrap is safe since this will always be a valid respose 21 + .unwrap() 22 + }) 23 + } 24 + 25 + pub fn router() -> Router { 26 + Router::new().route("/wake", post(wake)).merge(dist::main()) 27 + }
src/server/index.html web/src/index.html
-71
src/server/mod.rs
··· 1 - use std::collections::HashMap; 2 - 3 - use axum::{ 4 - Json, Router, 5 - http::{Response, StatusCode}, 6 - response::Redirect, 7 - routing::{get, post}, 8 - }; 9 - use serde::Deserialize; 10 - 11 - use crate::{config::Target, mac::MacAddress}; 12 - 13 - fn index(map: &HashMap<String, Target>) -> String { 14 - include_str!("index.html").replace( 15 - "<button-template></button-template>", 16 - &map.into_iter() 17 - .map(|(k, v)| { 18 - format!( 19 - r#"<li><button onclick="wake('{}')">{}</button></li>"#, 20 - v.mac.to_string(), 21 - k 22 - ) 23 - }) 24 - .collect::<Vec<_>>() 25 - .join(""), 26 - ) 27 - } 28 - 29 - #[derive(Deserialize)] 30 - pub struct WakeRequest { 31 - #[serde(deserialize_with = "crate::mac::deserialize_mac")] 32 - mac: MacAddress, 33 - } 34 - 35 - pub async fn wake(Json(req): Json<WakeRequest>) -> Result<(), Response<String>> { 36 - println!("Waking {}", req.mac); 37 - req.mac.wake().await.map_err(|err| { 38 - Response::builder() 39 - .status(500) 40 - .body(err.to_string()) 41 - // unwrap is safe since this will always be a valid respose 42 - .unwrap() 43 - }) 44 - } 45 - 46 - pub fn router(map: HashMap<String, Target>) -> Router { 47 - Router::new() 48 - .route( 49 - "/", 50 - get(async move || { 51 - Response::builder() 52 - .status(200) 53 - .header("Content-Type", "text/html") 54 - .body(index(&map.clone())) 55 - // this will always be a valid response 56 - .unwrap() 57 - }), 58 - ) 59 - .route("/wake", post(wake)) 60 - .route( 61 - "/styles.css", 62 - get(async || { 63 - Response::builder() 64 - .status(200) 65 - .header("Content-Type", "text/css") 66 - .body(String::from(include_str!("styles.css"))) 67 - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR) 68 - }), 69 - ) 70 - .fallback(async || Redirect::permanent("/")) 71 - }
···
src/server/styles.css web/src/styles.css
+5
web/package.json
···
··· 1 + { 2 + "scripts": { 3 + "build": "pwd && rm -rf ./dist 2> /dev/null && mkdir ./dist && cp -r ./src/. ./dist" 4 + } 5 + }