Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
43 changes: 43 additions & 0 deletions crates/stackline-core/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>)> {
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)?;
Expand Down Expand Up @@ -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();
Expand Down
5 changes: 4 additions & 1 deletion crates/stackline-core/src/runtime.rs
Original file line number Diff line number Diff line change
Expand Up @@ -224,11 +224,13 @@ pub struct Manager {
deleting: Arc<Mutex<std::collections::HashSet<(String, String)>>>,
/// Why the last cleanup of the store failed, until one succeeds again.
maintenance_error: Mutex<Option<String>>,
/// The settings file could not be read at startup and was set aside.
pub config_warning: Option<String>,
}

impl Manager {
pub fn new(paths: Paths) -> Result<Arc<Manager>> {
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,
Expand All @@ -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 || {
Expand Down
14 changes: 13 additions & 1 deletion crates/stackline-core/src/store.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1597,7 +1597,10 @@ fn row_to_entry(r: &rusqlite::Row<'_>) -> rusqlite::Result<EntryOut> {
}

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)]
Expand Down Expand Up @@ -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();
Expand Down
1 change: 1 addition & 0 deletions crates/stackline/src/api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ async fn meta(st: St) -> Json<Value> {
"desktop": st.desktop,
"storePath": st.manager.store_path().display().to_string(),
"platform": std::env::consts::OS,
"configWarning": st.manager.config_warning,
}))
}

Expand Down
7 changes: 7 additions & 0 deletions crates/stackline/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,8 @@ pub struct AppState {
pub token: String,
pub desktop: bool,
pub allowed_hosts: Vec<String>,
/// Also answer any `Host` that is an IP address with this port.
pub ip_hosts: Option<u16>,
pub updater: Arc<Updater>,
}

Expand Down Expand Up @@ -71,6 +73,10 @@ pub async fn start(opts: ServeOptions) -> Result<Server> {
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());
Expand All @@ -79,6 +85,7 @@ pub async fn start(opts: ServeOptions) -> Result<Server> {
token: token.clone(),
desktop: opts.desktop,
allowed_hosts,
ip_hosts,
updater: updater.clone(),
});
let app = web::router(state);
Expand Down
7 changes: 7 additions & 0 deletions crates/stackline/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
3 changes: 2 additions & 1 deletion crates/stackline/src/web.rs
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,8 @@ pub fn router(state: Arc<AppState>) -> Router {
/// Only answers requests addressed to this machine, and only API calls that carry the session token.
async fn guard(State(st): State<Arc<AppState>>, 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::<std::net::SocketAddr>().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();
Expand Down
36 changes: 34 additions & 2 deletions crates/stackline/tests/api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<u16>) -> 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 }
}

Expand Down Expand Up @@ -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();
Expand Down
9 changes: 9 additions & 0 deletions ui/src/App.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,8 @@
gAt = e.key === 'g' ? Date.now() : 0
}
}
/** The note about a reset settings file was read. */
let configSeen = $state(false)
</script>

<svelte:window onkeydown={keydown} />
Expand Down Expand Up @@ -112,6 +114,13 @@
</button>
</header>
<main class="view">
{#if app.meta?.configWarning && !configSeen}
<div class="warn-banner host-banner" role="alert">
<span><b>Your settings were reset.</b><br /><span class="muted" style="font-size:12.5px">{app.meta.configWarning}</span></span>
<span class="spacer"></span>
<button class="btn sm" onclick={() => (configSeen = true)}>OK</button>
</div>
{/if}
{#if app.fatal}
<div class="center-empty"><b style="color:var(--fg)">{app.fatal}</b><button class="btn" onclick={() => location.reload()}>Try again</button></div>
{:else if app.view === 'connections'}
Expand Down
6 changes: 5 additions & 1 deletion ui/src/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading