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