diff --git a/CHANGELOG.md b/CHANGELOG.md index f0df8e4..ddddffd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,9 @@ its GitHub release, and Stackline shows it in the update dialog. - **"Clear stored logs" during a read could leave part of that read behind**, and those lines then kept the cleared range from filling again - **A replica read for the first time held up all the others.** Reading hours of a busy replica can take minutes; other replicas and container events now go on being read meanwhile - **Deploys could go missing from the Overview** when a service crashed thousands of times after them +- **`--host 0.0.0.0` answered nobody.** Other machines were turned away because they address Stackline by an IP address it did not know. Any IP address of the machine now works; names are still refused, which keeps DNS rebinding out. Stackline also warns that the link travels unencrypted +- **A settings file that could not be read kept Stackline from starting.** It is now set aside as `config.broken.json`, Stackline starts with the default settings and says so +- **A log line could run as a formula** when a CSV download was opened in a spreadsheet app. Cells that start with `=`, `+`, `-` or `@` now start with `'` - **A Docker that accepted a request and then never answered stopped log collection for good.** Requests now give up after 30 seconds without an answer and are tried again on the next poll ## 0.5.1 diff --git a/crates/stackline-core/src/config.rs b/crates/stackline-core/src/config.rs index c9b4fae..5931531 100644 --- a/crates/stackline-core/src/config.rs +++ b/crates/stackline-core/src/config.rs @@ -129,6 +129,31 @@ impl AppConfig { } } + /// Like [`AppConfig::load`], but a file that cannot be read as settings + /// (edited by hand, say) does not keep Stackline from starting: it is + /// moved aside as `config.broken.json` and the defaults are used. The + /// second value says what happened, for the UI to show. + pub fn load_or_set_aside(path: &Path) -> Result<(AppConfig, Option)> { + let bytes = match std::fs::read(path) { + Ok(b) => b, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok((AppConfig::default(), None)), + Err(e) => return Err(e.into()), + }; + match serde_json::from_slice(&bytes) { + Ok(c) => Ok((c, None)), + Err(e) => { + let aside = path.with_file_name("config.broken.json"); + std::fs::rename(path, &aside)?; + tracing::warn!("{} could not be read ({e}); moved to {}", path.display(), aside.display()); + let msg = format!( + "Your settings file could not be read ({e}), so Stackline started with the default settings. The old file was kept as {}.", + aside.display() + ); + Ok((AppConfig::default(), Some(msg))) + } + } + } + pub fn save(&self, path: &Path) -> Result<()> { if let Some(dir) = path.parent() { std::fs::create_dir_all(dir)?; @@ -255,6 +280,24 @@ mod tests { assert!(!base.exists()); } + #[test] + fn a_broken_settings_file_is_set_aside_not_fatal() { + let dir = std::env::temp_dir().join(format!("stackline-broken-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("config.json"); + std::fs::write(&path, r#"{"connections": [ oops"#).unwrap(); + let (c, warning) = AppConfig::load_or_set_aside(&path).unwrap(); + assert_eq!(c.connections.len(), 1); + assert!(warning.unwrap().contains("config.broken.json")); + assert!(!path.exists()); + assert_eq!(std::fs::read_to_string(dir.join("config.broken.json")).unwrap(), r#"{"connections": [ oops"#); + // A good file loads as it is, with nothing to say. + AppConfig::default().save(&path).unwrap(); + assert!(AppConfig::load_or_set_aside(&path).unwrap().1.is_none()); + let _ = std::fs::remove_dir_all(&dir); + } + #[test] fn collect_lists_exceptions_or_choices() { let all = Collect::default(); diff --git a/crates/stackline-core/src/runtime.rs b/crates/stackline-core/src/runtime.rs index f73e8b7..1cc2017 100644 --- a/crates/stackline-core/src/runtime.rs +++ b/crates/stackline-core/src/runtime.rs @@ -224,11 +224,13 @@ pub struct Manager { deleting: Arc>>, /// Why the last cleanup of the store failed, until one succeeds again. maintenance_error: Mutex>, + /// The settings file could not be read at startup and was set aside. + pub config_warning: Option, } impl Manager { pub fn new(paths: Paths) -> Result> { - let config = AppConfig::load(&paths.config)?; + let (config, config_warning) = AppConfig::load_or_set_aside(&paths.config)?; let store = Arc::new(Store::open(&paths.store)?); let m = Arc::new(Manager { paths, @@ -238,6 +240,7 @@ impl Manager { runtimes: Mutex::new(HashMap::new()), deleting: Arc::default(), maintenance_error: Mutex::default(), + config_warning, }); let store = m.store.clone(); tokio::task::spawn_blocking(move || { diff --git a/crates/stackline-core/src/store.rs b/crates/stackline-core/src/store.rs index 9781402..1a8ff44 100644 --- a/crates/stackline-core/src/store.rs +++ b/crates/stackline-core/src/store.rs @@ -1597,7 +1597,10 @@ fn row_to_entry(r: &rusqlite::Row<'_>) -> rusqlite::Result { } fn csv(s: &str) -> String { - if s.contains([',', '"', '\n', '\r']) { format!("\"{}\"", s.replace('"', "\"\"")) } else { s.to_string() } + // A cell starting like a formula is run by spreadsheet apps when the file + // is opened; a log line must never do that, so it starts with ' instead. + let s = if s.starts_with(['=', '+', '-', '@', '\t', '\r']) { format!("'{s}") } else { s.to_string() }; + if s.contains([',', '"', '\n', '\r']) { format!("\"{}\"", s.replace('"', "\"\"")) } else { s } } #[cfg(test)] @@ -1763,6 +1766,15 @@ mod tests { assert!(keys(&all, 10).is_empty()); } + #[test] + fn csv_cells_never_start_a_formula() { + assert_eq!(csv("=HYPERLINK(\"http://x\")"), "\"'=HYPERLINK(\"\"http://x\"\")\""); + assert_eq!(csv("-1 retries left"), "'-1 retries left"); + assert_eq!(csv("@admin logged in"), "'@admin logged in"); + assert_eq!(csv("plain, text"), "\"plain, text\""); + assert_eq!(csv("ok"), "ok"); + } + #[test] fn deploys_are_found_among_many_other_events() { let store = Store::open_in_memory().unwrap(); diff --git a/crates/stackline/src/api.rs b/crates/stackline/src/api.rs index 9683d7e..03b095b 100644 --- a/crates/stackline/src/api.rs +++ b/crates/stackline/src/api.rs @@ -77,6 +77,7 @@ async fn meta(st: St) -> Json { "desktop": st.desktop, "storePath": st.manager.store_path().display().to_string(), "platform": std::env::consts::OS, + "configWarning": st.manager.config_warning, })) } diff --git a/crates/stackline/src/lib.rs b/crates/stackline/src/lib.rs index c0e3b2d..8400ac8 100644 --- a/crates/stackline/src/lib.rs +++ b/crates/stackline/src/lib.rs @@ -44,6 +44,8 @@ pub struct AppState { pub token: String, pub desktop: bool, pub allowed_hosts: Vec, + /// Also answer any `Host` that is an IP address with this port. + pub ip_hosts: Option, pub updater: Arc, } @@ -71,6 +73,10 @@ pub async fn start(opts: ServeOptions) -> Result { if !addr.ip().is_loopback() { allowed_hosts.push(addr.to_string()); } + // Listening beyond this computer (`--host`): other machines address it by + // one of its IP addresses, which cannot be known in advance for 0.0.0.0. + // Names stay refused, which is what keeps DNS rebinding out. + let ip_hosts = (!addr.ip().is_loopback()).then_some(addr.port()); manager.open_all(); let updater = Updater::new(opts.installer); updater.spawn_checks(manager.clone()); @@ -79,6 +85,7 @@ pub async fn start(opts: ServeOptions) -> Result { token: token.clone(), desktop: opts.desktop, allowed_hosts, + ip_hosts, updater: updater.clone(), }); let app = web::router(state); diff --git a/crates/stackline/src/main.rs b/crates/stackline/src/main.rs index 68f659b..08cdb97 100644 --- a/crates/stackline/src/main.rs +++ b/crates/stackline/src/main.rs @@ -54,6 +54,13 @@ async fn main() -> Result<()> { println!("Stackline {} is running at {url}", env!("CARGO_PKG_VERSION")); } else { println!("Stackline is running. Open this link to use it:\n\n {url}\n\nPress Ctrl+C to stop."); + if !host.is_loopback() { + println!( + "\nStackline listens beyond this computer ({host}). Other machines open it with this computer's IP address\n\ + in place of {}. The link carries the sign-in and travels unencrypted: share it only on a network you trust.", + server.addr.ip() + ); + } if !no_open { let _ = webbrowser::open(&url); } diff --git a/crates/stackline/src/web.rs b/crates/stackline/src/web.rs index d6cc314..b36a5a5 100644 --- a/crates/stackline/src/web.rs +++ b/crates/stackline/src/web.rs @@ -32,7 +32,8 @@ pub fn router(state: Arc) -> Router { /// Only answers requests addressed to this machine, and only API calls that carry the session token. async fn guard(State(st): State>, req: Request, next: Next) -> Response { let host = req.headers().get(header::HOST).and_then(|h| h.to_str().ok()).unwrap_or(""); - if !st.allowed_hosts.iter().any(|h| h == host) { + let ip_host = st.ip_hosts.is_some_and(|port| host.parse::().is_ok_and(|a| a.port() == port)); + if !ip_host && !st.allowed_hosts.iter().any(|h| h == host) { return (StatusCode::FORBIDDEN, "Stackline only answers on its own address").into_response(); } let path = req.uri().path().to_string(); diff --git a/crates/stackline/tests/api.rs b/crates/stackline/tests/api.rs index 5af22e3..3b3e5ef 100644 --- a/crates/stackline/tests/api.rs +++ b/crates/stackline/tests/api.rs @@ -30,14 +30,25 @@ impl Drop for App { } fn app() -> App { + app_with(None) +} + +/// An app listening beyond this computer (`--host`) on this port. +fn app_with(ip_hosts: Option) -> App { // A counter, because tests run in parallel and macOS clocks tick in microseconds. static NEXT: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0); let n = NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed); let nanos = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos(); let dir = std::env::temp_dir().join(format!("stackline-api-test-{}-{nanos}-{n}", std::process::id())); let manager = Manager::new(Paths::in_dir(&dir)).unwrap(); - let state = - Arc::new(AppState { manager, token: TOKEN.into(), desktop: false, allowed_hosts: vec![HOST.into()], updater: Updater::new(None) }); + let state = Arc::new(AppState { + manager, + token: TOKEN.into(), + desktop: false, + allowed_hosts: vec![HOST.into()], + ip_hosts, + updater: Updater::new(None), + }); App { router: router(state.clone()), state, dir } } @@ -109,6 +120,27 @@ async fn only_answers_on_its_own_address() { assert_eq!(send(&app, elsewhere).await.0, StatusCode::FORBIDDEN); } +#[tokio::test] +async fn listening_on_the_network_answers_ip_addresses_but_no_names() { + let at = |app: &App, host: &str| { + let r = Request::builder() + .uri("/api/meta") + .header(header::HOST, host) + .header(header::COOKIE, format!("stackline_token={TOKEN}")) + .body(Body::empty()) + .unwrap(); + let router = app.router.clone(); + async move { router.oneshot(r).await.unwrap().status() } + }; + let app = app_with(Some(7070)); + assert_eq!(at(&app, "192.168.1.20:7070").await, StatusCode::OK); + assert_eq!(at(&app, "[fd00::5]:7070").await, StatusCode::OK); + assert_eq!(at(&app, "192.168.1.20:8080").await, StatusCode::FORBIDDEN); + assert_eq!(at(&app, "attacker.example:7070").await, StatusCode::FORBIDDEN); + // On this computer only, an IP address of the network is refused like a name. + assert_eq!(at(&app_with(None), "192.168.1.20:7070").await, StatusCode::FORBIDDEN); +} + #[tokio::test] async fn the_link_with_the_token_signs_the_browser_in() { let app = app(); diff --git a/ui/src/App.svelte b/ui/src/App.svelte index 8d7b279..4d7e1e9 100644 --- a/ui/src/App.svelte +++ b/ui/src/App.svelte @@ -61,6 +61,8 @@ gAt = e.key === 'g' ? Date.now() : 0 } } + /** The note about a reset settings file was read. */ + let configSeen = $state(false) @@ -112,6 +114,13 @@
+ {#if app.meta?.configWarning && !configSeen} + + {/if} {#if app.fatal}
{app.fatal}
{:else if app.view === 'connections'} diff --git a/ui/src/lib/types.ts b/ui/src/lib/types.ts index 687f87c..a1bfcab 100644 --- a/ui/src/lib/types.ts +++ b/ui/src/lib/types.ts @@ -52,7 +52,11 @@ export interface Settings { alertEnabled: boolean; alertWindowMins: number; alertThreshold: number } -export interface Meta { version: string; desktop: boolean; storePath: string; platform: string } +export interface Meta { + version: string; desktop: boolean; storePath: string; platform: string + /** The settings file could not be read at startup and was set aside. */ + configWarning?: string | null +} export interface Bucket { error: number; warn: number; info: number; debug: number; exc: number } export interface Group {