Skip to main content

bobview_server/
main.rs

1//! Serve bobview to browsers.
2//!
3//! One process gives a browser everything the web build needs, all from the
4//! same origin: the app itself, the .bob files and a PV Web Socket (PVWS) at
5//! `/pvws/pv` that speaks the protocol of <https://github.com/ornl-epics/pvws>
6//! and reaches PVs through bobview's own CA/PVA layer. Run it anywhere
7//! `caget` works and point a browser at it.
8
9mod 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    // The token and where it came from, for messages about it. An empty
75    // variable is as good as unset.
76    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                // Not the log: RUST_LOG=warn would hide it.
127                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        // A variable exported for every start is no mistake; a flag is.
134        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
195/// The environment variable holding the write token.
196const TOKEN_VAR: &str = "BOBVIEW_WRITE_TOKEN";
197
198/// 128 random bits as hex, from the operating system.
199fn 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
209/// The web build installed next to this binary, else the one
210/// scripts/build-web.sh writes in a checkout.
211fn 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
219/// Hide dot-files and dot-dirs, such as `.git` when `--opi` is a checkout.
220async 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
228/// Whether a path names a dot-file or something in a dot-dir. ServeDir
229/// decodes the whole path before splitting it, so this does too: `%2F` is
230/// a separator to it.
231fn 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
238/// Stop other sites' pages framing this server's (clickjacking: a page
239/// could lay its own over a display's buttons).
240async 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}