1mod files;
10mod pvws;
11
12use std::net::SocketAddr;
13use std::path::PathBuf;
14use std::sync::Arc;
15use std::time::Duration;
16
17use axum::Router;
18use axum::extract::Request;
19use axum::http::header::{CONTENT_SECURITY_POLICY, X_CONTENT_TYPE_OPTIONS, X_FRAME_OPTIONS};
20use axum::http::{HeaderValue, StatusCode};
21use axum::middleware::{self, Next};
22use axum::response::{IntoResponse, Response};
23use axum::routing::get;
24use tower_http::services::ServeDir;
25
26const USAGE: &str = "\
27usage: bobview-server [OPTIONS]
28
29Serve the bobview web build, .bob files and a PV Web Socket on one port.
30Open http://HOST:PORT/?display=FILE.bob in a browser (default index.bob).
31
32 -p, --port PORT port to listen on (default 8080)
33 --bind ADDR address to listen on (default 127.0.0.1, this machine
34 only; 0.0.0.0 is every interface)
35 --web DIR the web build from scripts/build-web.sh (default
36 ../share/bobview/web next to this binary, else dist/web)
37 --opi DIR serve .bob files from here; they take precedence over
38 files of the same name in the web build
39 --follow-symlinks serve files through symbolic links that lead out of
40 --opi or --web (refused by default)
41 --write let browsers write to PVs (off by default, as in PVWS);
42 every write must carry the write token
43 --write-token TOKEN the token --write wants (default: $BOBVIEW_WRITE_TOKEN
44 if set and not empty, else a random one printed at
45 startup); a page passes it as &token=TOKEN. Prefer
46 the variable: arguments show in `ps`
47 --allow-origin ORIGIN also let pages from ORIGIN connect, e.g.
48 https://host.example.com (may be repeated); other
49 pages than this server's own are refused
50 --host NAME let browsers call this server NAME, as well as an IP
51 address, localhost, this machine's name and the hosts
52 of --allow-origin (may be repeated)
53 --max-per-ip N most clients at once from one address (default 10;
54 raise it behind a reverse proxy, which is one address)
55 --throttle-ms MS least time between two batches of updates to one
56 client (default 100); a PV that changes faster sends
57 only its latest value
58 -h, --help show this help
59 -V, --version show the version
60";
61
62fn main() {
63 env_logger::Builder::new()
64 .filter_level(log::LevelFilter::Info)
65 .filter_module("epics", log::LevelFilter::Warn)
66 .parse_default_env()
67 .init();
68
69 let mut port = 8080;
70 let mut bind = "127.0.0.1".to_string();
71 let mut web = None;
72 let mut opi = None;
73 let mut follow_symlinks = false;
74 let mut token = std::env::var(TOKEN_VAR)
77 .ok()
78 .filter(|t| !t.is_empty())
79 .map(|t| (t, TOKEN_VAR));
80 let mut config = pvws::Config {
81 write: false,
82 write_token: String::new(),
83 allow_origins: Vec::new(),
84 hosts: Vec::new(),
85 throttle: Duration::from_millis(100),
86 max_per_ip: 10,
87 ping: Duration::from_secs(30),
88 };
89 let mut args = std::env::args().skip(1);
90 while let Some(arg) = args.next() {
91 match arg.as_str() {
92 "-p" | "--port" => port = parse(&arg, args.next()),
93 "--bind" => bind = args.next().unwrap_or(bind),
94 "--web" => web = Some(PathBuf::from(args.next().unwrap_or_default())),
95 "--opi" => opi = args.next().map(PathBuf::from),
96 "--write" => config.write = true,
97 "--write-token" => token = args.next().map(|t| (t, "--write-token")),
98 "--follow-symlinks" => follow_symlinks = true,
99 "--allow-origin" => config.allow_origins.extend(args.next()),
100 "--host" => config.hosts.extend(args.next()),
101 "--max-per-ip" => config.max_per_ip = parse(&arg, args.next()),
102 "--throttle-ms" => config.throttle = Duration::from_millis(parse(&arg, args.next())),
103 "-h" | "--help" => {
104 print!("{USAGE}");
105 return;
106 }
107 "-V" | "--version" => {
108 println!("bobview-server {}", env!("CARGO_PKG_VERSION"));
109 return;
110 }
111 _ => {
112 eprintln!("unexpected argument {arg:?}\n\n{USAGE}");
113 std::process::exit(2);
114 }
115 }
116 }
117 if config.write {
118 config.write_token = match token {
119 Some((token, _)) if !token.is_empty() => token,
120 Some((_, from)) => {
121 eprintln!("{from}: the token is empty");
122 std::process::exit(2);
123 }
124 None => {
125 let token = random_token();
126 eprintln!("writes are on; pages must pass &token={token}");
128 token
129 }
130 };
131 log::info!("writes are on, and need the write token");
132 } else if let Some((_, from)) = token {
133 if from == TOKEN_VAR {
135 log::debug!("{TOKEN_VAR} is set, but writes are off (--write)");
136 } else {
137 log::warn!("{from} does nothing without --write");
138 }
139 }
140 let web = web.unwrap_or_else(default_web);
141 if !web.join("index.html").is_file() {
142 eprintln!(
143 "no web build in {} - run scripts/build-web.sh or pass --web DIR",
144 web.display()
145 );
146 std::process::exit(1);
147 }
148 let addr = match bind.trim_matches(['[', ']']).parse::<std::net::IpAddr>() {
149 Ok(ip) => SocketAddr::new(ip, port),
150 Err(e) => {
151 eprintln!("--bind {bind}: {e}");
152 std::process::exit(2);
153 }
154 };
155
156 let manager = pv::PvManager::new();
157 let hub = Arc::new(pvws::Hub::new(manager.clone(), config));
158 let app = Router::new()
159 .route("/pvws/pv", get(pvws::socket))
160 .route("/pvws/info", get(pvws::info));
161 let app = match &opi {
162 Some(opi) if follow_symlinks => {
163 app.fallback_service(ServeDir::new(opi).fallback(ServeDir::new(&web)))
164 }
165 None if follow_symlinks => app.fallback_service(ServeDir::new(&web)),
166 _ => {
167 let dirs: Vec<PathBuf> = opi.iter().chain([&web]).cloned().collect();
168 let roots = Arc::new(files::Roots::new(&dirs));
169 app.fallback(move |request: Request| files::serve(roots.clone(), request))
170 }
171 }
172 .layer(middleware::from_fn(hide_dot_files))
173 .layer(middleware::from_fn(no_framing))
174 .with_state(hub);
175
176 manager.runtime().block_on(async move {
177 let listener = match tokio::net::TcpListener::bind(addr).await {
178 Ok(l) => l,
179 Err(e) => {
180 eprintln!("cannot listen on {addr}: {e}");
181 std::process::exit(1);
182 }
183 };
184 log::info!("serving {} on http://{addr}/", web.display());
185 if let Some(opi) = &opi {
186 log::info!("displays from {}", opi.display());
187 }
188 let app = app.into_make_service_with_connect_info::<SocketAddr>();
189 if let Err(e) = axum::serve(listener, app).await {
190 log::error!("server stopped: {e}");
191 }
192 });
193}
194
195const TOKEN_VAR: &str = "BOBVIEW_WRITE_TOKEN";
197
198fn random_token() -> String {
200 use std::io::Read;
201 let mut bytes = [0u8; 16];
202 if let Err(e) = std::fs::File::open("/dev/urandom").and_then(|mut f| f.read_exact(&mut bytes)) {
203 eprintln!("cannot make a write token ({e}): pass --write-token TOKEN");
204 std::process::exit(1);
205 }
206 bytes.iter().map(|b| format!("{b:02x}")).collect()
207}
208
209fn default_web() -> PathBuf {
212 std::env::current_exe()
213 .ok()
214 .and_then(|exe| Some(exe.parent()?.parent()?.join("share/bobview/web")))
215 .filter(|web| web.join("index.html").is_file())
216 .unwrap_or_else(|| PathBuf::from("dist/web"))
217}
218
219async fn hide_dot_files(request: Request, next: Next) -> Response {
221 if is_hidden(request.uri().path()) {
222 StatusCode::NOT_FOUND.into_response()
223 } else {
224 next.run(request).await
225 }
226}
227
228fn is_hidden(path: &str) -> bool {
232 percent_encoding::percent_decode_str(path)
233 .decode_utf8_lossy()
234 .split('/')
235 .any(|segment| segment.starts_with('.'))
236}
237
238async fn no_framing(request: Request, next: Next) -> Response {
241 let mut response = next.run(request).await;
242 let headers = response.headers_mut();
243 headers.insert(X_FRAME_OPTIONS, HeaderValue::from_static("SAMEORIGIN"));
244 headers.insert(
245 CONTENT_SECURITY_POLICY,
246 HeaderValue::from_static("frame-ancestors 'self'"),
247 );
248 headers.insert(X_CONTENT_TYPE_OPTIONS, HeaderValue::from_static("nosniff"));
249 response
250}
251
252fn parse<T: std::str::FromStr>(flag: &str, value: Option<String>) -> T {
253 match value.as_deref().map(str::parse) {
254 Some(Ok(v)) => v,
255 _ => {
256 eprintln!("{flag}: expected a number, not {value:?}");
257 std::process::exit(2);
258 }
259 }
260}
261
262#[cfg(test)]
263mod tests {
264 use super::is_hidden;
265
266 #[test]
267 fn hides_dot_files() {
268 assert!(is_hidden("/.git/config"));
269 assert!(is_hidden("/%2egit/config"));
270 assert!(is_hidden("/%2Egit/config"));
271 assert!(is_hidden("/sub%2F.git%2Fconfig"));
272 assert!(is_hidden("/a/.env"));
273 assert!(!is_hidden("/a/b.bob"));
274 assert!(!is_hidden("/a..b/c.bob"));
275 assert!(!is_hidden("/"));
276 }
277}