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
42 changes: 41 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,7 @@ jobs:
working-directory: e2e
- name: Wait until the demo has logged every kind of error
run: timeout 90 sh -c 'until docker logs demo-worker-1 2>&1 | grep -q import-catalog; do sleep 2; done'
- run: npx playwright test
- run: npx playwright test --project ui --project read-only
working-directory: e2e
- uses: actions/upload-artifact@v7
if: failure()
Expand All @@ -117,3 +117,43 @@ jobs:
e2e/playwright-report
e2e/test-results
retention-days: 7

e2e-swarm:
needs: ui
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/download-artifact@v8
with:
name: ui-dist
path: ui/dist
- name: Start the swarm demo stack
working-directory: dev/demo
run: |
docker pull -q mirror.gcr.io/library/alpine:3.20 && docker tag mirror.gcr.io/library/alpine:3.20 alpine:3.20 || docker pull -q alpine:3.20
docker swarm init
docker stack deploy --resolve-image never -c stack.yaml shop
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
- run: cargo build -p stackline
- uses: actions/setup-node@v7
with:
node-version: 24
cache: npm
cache-dependency-path: e2e/package-lock.json
- run: npm ci
working-directory: e2e
- run: npx playwright install --with-deps chromium
working-directory: e2e
- name: Wait until every replica runs
run: timeout 120 sh -c 'until docker service ls --filter name=shop_api --format "{{.Replicas}}" | grep -q "3/3"; do sleep 2; done'
- run: npx playwright test --project swarm
working-directory: e2e
- uses: actions/upload-artifact@v7
if: failure()
with:
name: e2e-swarm-report
path: |
e2e/playwright-report
e2e/test-results
retention-days: 7
41 changes: 39 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ that opens the dashboard in your browser.

[Download](https://github.com/snaketech-tu/stackline/releases/latest) ·
[Get started](#get-started) · [Connect a host](#connect-a-host) ·
[Development](#development) · [Changelog](CHANGELOG.md)
[Security](#security) · [Development](#development) · [Changelog](CHANGELOG.md)

## Features

Expand Down Expand Up @@ -231,6 +231,38 @@ When an update is available, Stackline shows the release notes and lets you
choose when to install it. Every update is verified against the app's built-in
public key before installation.

## Security

Stackline can reach the Docker of every server you connect, and whoever
controls a server's Docker controls the server. This is how it keeps that
access to you:

- **Only reads by default.** Stackline sends Docker requests that read:
lists, logs and events. Log downloads are files written on your computer.
- **SSH connections stay private.** Stackline reaches a server's Docker
socket through your own `ssh`. On macOS and Linux the tunnel ends in a
socket file in a folder only your user can open, which is removed when
Stackline stops. On Windows it still ends in a port on `127.0.0.1` that
other programs on the computer could use, so do not run Stackline on a
Windows machine you share with people you do not trust. Your private keys
are never read or copied.
- **Only your browser tab can use it.** The standalone server listens on
`127.0.0.1`. Every API call needs the random sign-in token from the link it
prints; the link stores it in a cookie that only Stackline's own pages send
(`HttpOnly`, `SameSite=Strict`). Requests addressed to any other name than
`localhost` or `127.0.0.1` are refused, which keeps web pages from reaching
Stackline through DNS rebinding.
- **`--host 0.0.0.0` makes it reachable from the network**, for anyone who has
the link. The connection is plain HTTP, so the token can be read on the way.
Only do this on a network you trust, or put Stackline behind a reverse proxy
that adds TLS. Stackline prints a warning when it listens beyond loopback.
- **Updates are signed.** An update is installed only when its signature
matches the public key built into the app.

To report a security problem, open a private
[security advisory](https://github.com/snaketech-tu/stackline/security/advisories/new)
on GitHub instead of an issue.

## Development

You need **Rust stable** and **Node.js 24**. From a fresh clone:
Expand Down Expand Up @@ -315,13 +347,18 @@ start the demo project, build the UI and Stackline, then:
cd e2e
npm ci
npx playwright install chromium
npx playwright test
npx playwright test --project ui --project read-only
```

They start their own Stackline on port 7171 with an empty data folder
(`e2e/.run`), so your own connections and stored logs are not touched. Set
`STACKLINE_BIN` to test another build, such as `target/release/stackline`.

The [swarm tests](e2e/tests/swarm.spec.ts) need the demo Swarm stack instead
(`docker swarm init`, then `docker stack deploy -c stack.yaml shop` in
`dev/demo`) and run with `npx playwright test --project swarm`. CI runs them in
a job of their own.

## License

Licensed under [Apache-2.0](LICENSE).
58 changes: 58 additions & 0 deletions crates/stackline-core/src/store/export.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
//! Writing stored lines out as text, JSON lines or CSV.

use super::*;

pub(super) fn export(db: &Connection, conn: &str, f: &LogFilter, format: ExportFormat, out: &mut dyn Write) -> Result<u64> {
let (w, p) = f.where_clause(conn, true);
let mut st = db.prepare(&format!(
"SELECT id, ts, stack, service, replica, node, source, stream, level, exc, lines, fp, text, raw
FROM entries WHERE {w} ORDER BY ts ASC, id ASC"
))?;
let mut rows = st.query(params_from_iter(p.iter()))?;
let mut n = 0;
if format == ExportFormat::Csv {
writeln!(out, "time,level,stack,service,replica,node,stream,message")?;
}
while let Some(r) = rows.next()? {
let e = row_to_entry(r)?;
let time = chrono::DateTime::from_timestamp_micros(e.ts)
.map(|d| d.to_rfc3339_opts(chrono::SecondsFormat::Micros, true))
.unwrap_or_default();
match format {
ExportFormat::Log => {
writeln!(out, "{time} {:<5} {}.{} {}", e.level.name(), e.service, e.replica, e.text)?;
}
ExportFormat::Jsonl => {
let v = serde_json::json!({
"time": time, "level": e.level.name(), "stack": e.stack, "service": e.service,
"replica": e.replica, "node": e.node, "stream": e.stream, "message": e.text,
"raw": e.raw.as_deref().and_then(|r| serde_json::from_str::<serde_json::Value>(r).ok()),
});
writeln!(out, "{v}")?;
}
ExportFormat::Csv => {
writeln!(
out,
"{},{},{},{},{},{},{},{}",
time,
e.level.name(),
csv(&e.stack),
csv(&e.service),
csv(&e.replica),
csv(&e.node),
e.stream,
csv(&e.text)
)?;
}
}
n += 1;
}
Ok(n)
}

pub(super) fn csv(s: &str) -> 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 }
}
Loading
Loading