tangled
alpha
login
or
join now
vielle.dev
/
wol
1
fork
atom
[WIP] A simple wake-on-lan service
1
fork
atom
overview
issues
pulls
pipelines
generate routes from ./web/dist
vielle.dev
2 weeks ago
3bb21768
23adae84
verified
This commit was signed with the committer's
known signature
.
vielle.dev
SSH Key Fingerprint:
SHA256:EoUuRRBFQKUfYh74C568g83i9g4fVi5OTtOENMSfa+0=
+252
-72
10 changed files
expand all
collapse all
unified
split
.gitignore
Cargo.lock
Cargo.toml
build.rs
src
main.rs
server
mod.rs
server.rs
web
package.json
src
index.html
styles.css
+1
.gitignore
···
1
/target
0
2
wol.toml
···
1
/target
2
+
/web/dist
3
wol.toml
+1
Cargo.lock
···
747
dependencies = [
748
"axum",
749
"serde",
0
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"
0
0
0
0
0
0
···
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
···
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
···
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
···
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
···
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
-
}
···
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
src/server/styles.css
web/src/styles.css
+5
web/package.json
···
0
0
0
0
0
···
1
+
{
2
+
"scripts": {
3
+
"build": "pwd && rm -rf ./dist 2> /dev/null && mkdir ./dist && cp -r ./src/. ./dist"
4
+
}
5
+
}