v0.26.2 http sink backlog for late streams, web updates

This commit is contained in:
lixen committed 2026-10-05 02:48:00 -04:00
1 parent cdd4fc01a7
commit d745a128a2
14 files changed
+115 -37

No files matched your search

+1
View File
@@ -335,6 +335,7 @@ target = "stdout" # stdout|stderr ("split" NOT supported)
# client_buffer_size = 256 # Per-client send queue # client_buffer_size = 256 # Per-client send queue
# write_timeout_ms = 0 # Per-event deadline, 0 = none # write_timeout_ms = 0 # Per-event deadline, 0 = none
# max_connections = 0 # 0 = unlimited # max_connections = 0 # 0 = unlimited
# replay_lines = 0 # Last entries a new stream gets first, 0 = none
# login_page = false # GET /auth/login; scram behind auth.trusted_proxies # login_page = false # GET /auth/login; scram behind auth.trusted_proxies
# viewer_page = false # GET /auth/view, needs login_page; scram + trusted_proxies only # viewer_page = false # GET /auth/view, needs login_page; scram + trusted_proxies only
# (without auth or under mtls the viewer is always on) # (without auth or under mtls the viewer is always on)
+2 -1
View File
@@ -177,7 +177,8 @@ unknown key fails and lists the valid ones. A `path` is a file, a directory
- `tail`: follow files to stdout, like `tail -F` - `tail`: follow files to stdout, like `tail -F`
- `path` (required), `from`, `format` (`raw`) - `path` (required), `from`, `format` (`raw`)
- `serve`: a live stream, an [http sink](sinks.md#http): a browser at - `serve`: a live stream, an [http sink](sinks.md#http): a browser at
`listen` gets the viewer, `/stream` is SSE `listen` gets the viewer, `/stream` is SSE, each stream starting with the
last 1000 entries
- `path`, `from`, `format` (`json`), `listen` (`127.0.0.1:8080`) - `path`, `from`, `format` (`json`), `listen` (`127.0.0.1:8080`)
- `tls`: `off` (default), `self` (a self-signed certificate made at - `tls`: `off` (default), `self` (a self-signed certificate made at
startup), `issuer` (one signed by `issuer_cert` and `issuer_key`), or startup), `issuer` (one signed by `issuer_cert` and `issuer_key`), or
+2 -1
View File
@@ -304,7 +304,8 @@ Follow files to standard output, like
.It Cm serve Oo Cm path , listen , format , tls , users , proxy , viewer , allow , deny Oc .It Cm serve Oo Cm path , listen , format , tls , users , proxy , viewer , allow , deny Oc
Serve a live HTTP stream on Serve a live HTTP stream on
.Cm listen .Cm listen
.Pq default Li 127.0.0.1:8080 . .Pq default Li 127.0.0.1:8080 ,
each stream starting with the last 1000 entries.
.Cm tls .Cm tls
is is
.Cm off , .Cm off ,
+5 -2
View File
@@ -170,6 +170,9 @@ Options, each as type and default:
- `client_buffer_size` (int, `256`): per-client send queue depth. - `client_buffer_size` (int, `256`): per-client send queue depth.
- `write_timeout_ms` (int, `0`): per-event write deadline; `0` = none. - `write_timeout_ms` (int, `0`): per-event write deadline; `0` = none.
- `max_connections` (int, `0`): concurrent stream cap; `0` = unlimited. - `max_connections` (int, `0`): concurrent stream cap; `0` = unlimited.
- `replay_lines` (int, `0`; `1000` from the `serve` preset): the last entries
a new stream gets after its `connected` event, before the live ones, so a
browser opened late sees a backlog; `0` = none.
- `login_page` (bool, `false`): `scram` in proxy mode only (an error - `login_page` (bool, `false`): `scram` in proxy mode only (an error
elsewhere): the browser login page at `/auth/login`. elsewhere): the browser login page at `/auth/login`.
- `viewer_page` (bool, `false`): `scram` in proxy mode only (an error - `viewer_page` (bool, `false`): `scram` in proxy mode only (an error
@@ -212,8 +215,8 @@ Options, each as type and default:
- never under `scram` on the sink's own TLS, which a browser cannot log in - never under `scram` on the sink's own TLS, which a browser cannot log in
to: `/` is refused like any other path; to: `/` is refused like any other path;
- an endpoint at `/` keeps the root, and the viewer stays at `/auth/view`. - an endpoint at `/` keeps the root, and the viewer stays at `/auth/view`.
The viewer shows entries from when it connects (the sink keeps no backlog) The viewer starts with the `replay_lines` backlog, holds one stream against
and holds one stream against `max_connections`. `max_connections`, and says so when all are taken (`server full`).
- Refusals are logged at WARN and counted in `auth_rejected`. The authorized - Refusals are logged at WARN and counted in `auth_rejected`. The authorized
identity is recorded in the client's session as `auth_method` / identity is recorded in the client's session as `auth_method` /
`auth_identity`. `auth_identity`.
+4 -11
View File
@@ -65,16 +65,11 @@ identity, and both skeletons download the tagged source.
Smaller items found along the way, each independent of the others. Smaller items found along the way, each independent of the others.
1. A late browser sees no backlog: the `http` sink keeps none, so the viewer 1. Level detection. `source.ExtractLogLevel` needs a delimiter after the name
shows entries from when it connects. A bounded replay (a `replay_lines`
option, sent after `event: connected`) would fill it. The viewer also hides
a `503` from `max_connections` behind "retrying"; the status JSON it already
fetches could name it.
2. Level detection. `source.ExtractLogLevel` needs a delimiter after the name
(`ERROR:`, `[WARN]`, ` INFO `), so `warning disk full` or `DBG x` get no (`ERROR:`, `[WARN]`, ` INFO `), so `warning disk full` or `DBG x` get no
level, hence no color. Match the names as words, as the console sink's level, hence no color. Match the names as words, as the console sink's
painter does, with one table for both. painter does, with one table for both.
3. Command line: 2. Command line:
- a positional word at the top level (`lw tail`, `lw -t FILE`) still reaches - a positional word at the top level (`lw tail`, `lw -t FILE`) still reaches
config with an unclear message; name it ("unexpected argument X; the file config with an unclear message; name it ("unexpected argument X; the file
is -c FILE"); is -c FILE");
@@ -84,11 +79,9 @@ Smaller items found along the way, each independent of the others.
- `lw tls cert --host` could become `--hosts` (keeping `--host`), to match - `lw tls cert --host` could become `--hosts` (keeping `--host`), to match
`tls.hosts` and the preset key; `tls.hosts` and the preset key;
- top-level usage errors exit 1, subcommand ones 2. - top-level usage errors exit 1, subcommand ones 2.
4. `GET /favicon.ico` answers 404; the logo could serve as the icon once the 3. TLS: the generated-certificate key is one per process, so a pin taken from a
pages' CSP allows `img-src 'self'`.
5. TLS: the generated-certificate key is one per process, so a pin taken from a
`self_signed` listener also matches an `issuer` listener of the same `self_signed` listener also matches an `issuer` listener of the same
process; document it or key per listener. Pins are checked in process; document it or key per listener. Pins are checked in
`VerifyPeerCertificate`, which Go skips on resumption: guard it if a dialer `VerifyPeerCertificate`, which Go skips on resumption: guard it if a dialer
ever keeps a `ClientSessionCache`. ever keeps a `ClientSessionCache`.
6. `core.ShutdownTimeout` is unused. 4. `core.ShutdownTimeout` is unused.
+1
View File
@@ -293,6 +293,7 @@ type HTTPSinkOptions struct {
ClientBufferSize int64 `toml:"client_buffer_size"` // per-client send queue ClientBufferSize int64 `toml:"client_buffer_size"` // per-client send queue
WriteTimeoutMS int64 `toml:"write_timeout_ms"` // per-SSE-write deadline, 0 = none WriteTimeoutMS int64 `toml:"write_timeout_ms"` // per-SSE-write deadline, 0 = none
MaxConnections int64 `toml:"max_connections"` // 0 = unlimited MaxConnections int64 `toml:"max_connections"` // 0 = unlimited
ReplayLines int64 `toml:"replay_lines"` // entries a new stream gets first, 0 = none
LoginPage bool `toml:"login_page"` // GET /auth/login, needs auth.trusted_proxies LoginPage bool `toml:"login_page"` // GET /auth/login, needs auth.trusted_proxies
ViewerPage bool `toml:"viewer_page"` // GET /auth/view, needs login_page ViewerPage bool `toml:"viewer_page"` // GET /auth/view, needs login_page
Auth *AuthOptions `toml:"auth"` Auth *AuthOptions `toml:"auth"`
+1
View File
@@ -97,6 +97,7 @@ var presets = []Preset{
default: default:
return fmt.Errorf("viewer %q (valid: true, false)", v["viewer"]) return fmt.Errorf("viewer %q (valid: true, false)", v["viewer"])
} }
sink["replay_lines"] = int64(1000) // a browser opened late sees the recent entries
p.Flow.Format = &FormatConfig{Type: v["format"]} p.Flow.Format = &FormatConfig{Type: v["format"]}
p.PluginSinks = append(p.PluginSinks, PluginSinkConfig{ID: "http", Type: "http", Config: sink}) p.PluginSinks = append(p.PluginSinks, PluginSinkConfig{ID: "http", Type: "http", Config: sink})
return nil return nil
+27 -13
View File
@@ -11,6 +11,7 @@ import (
"net" "net"
"net/http" "net/http"
"path" "path"
"slices"
"strconv" "strconv"
"strings" "strings"
"sync" "sync"
@@ -67,8 +68,9 @@ type HTTPSink struct {
input chan core.TransportEvent input chan core.TransportEvent
logger *log.Logger logger *log.Logger
// Client registry // Client registry, and the last replay_lines entries, under clientsMu
clients map[uint64]*sseClient clients map[uint64]*sseClient
replay [][]byte
clientsMu sync.Mutex clientsMu sync.Mutex
nextClientID atomic.Uint64 nextClientID atomic.Uint64
writeTimeout time.Duration writeTimeout time.Duration
@@ -399,6 +401,9 @@ func (h *HTTPSink) brokerLoop() {
var stale []uint64 var stale []uint64
h.clientsMu.Lock() h.clientsMu.Lock()
if n := int(h.config.ReplayLines); n > 0 {
h.replay = append(h.replay[max(len(h.replay)+1-n, 0):], event.Payload)
}
for id, c := range h.clients { for id, c := range h.clients {
if _, exists := h.proxy.GetSession(c.sessionID); !exists { if _, exists := h.proxy.GetSession(c.sessionID); !exists {
stale = append(stale, id) stale = append(stale, id)
@@ -506,18 +511,6 @@ func (h *HTTPSink) handleStream(w http.ResponseWriter, r *http.Request) {
return return
} }
// Registered only now: a client the broker can queue into before its reader
// reaches the loop below loses a burst to a buffer nobody is draining.
h.clientsMu.Lock()
h.clients[id] = c
h.clientsMu.Unlock()
// A stream with nothing to carry still has to prove the peer is there. The
// comment refreshes the session the broker evicts on, and fails on a peer
// that stopped reading.
idle := time.NewTicker(h.keepalive)
defer idle.Stop()
send := func(payload []byte) bool { send := func(payload []byte) bool {
if writeSSE(w, payload) != nil || rc.Flush() != nil { if writeSSE(w, payload) != nil || rc.Flush() != nil {
return false return false
@@ -525,6 +518,27 @@ func (h *HTTPSink) handleStream(w http.ResponseWriter, r *http.Request) {
h.proxy.UpdateActivity(sess.ID) h.proxy.UpdateActivity(sess.ID)
return true return true
} }
// Registered only now: a client the broker can queue into before its reader
// reaches the loop below loses a burst to a buffer nobody is draining. The
// backlog is taken under the same lock, so it ends where the queue begins.
h.clientsMu.Lock()
h.clients[id] = c
backlog := slices.Clone(h.replay)
h.clientsMu.Unlock()
for _, payload := range backlog {
h.armWrite(rc)
if !send(payload) {
return
}
}
// A stream with nothing to carry still has to prove the peer is there. The
// comment refreshes the session the broker evicts on, and fails on a peer
// that stopped reading.
idle := time.NewTicker(h.keepalive)
defer idle.Stop()
clientGone := r.Context().Done() clientGone := r.Context().Done()
for { for {
select { select {
+44 -2
View File
@@ -229,7 +229,7 @@ func TestProxyModeServesBrowserFiles(t *testing.T) {
client, baseURL := serveTestHTTPSink(t, h) client, baseURL := serveTestHTTPSink(t, h)
for file, ctype := range map[string]string{ for file, ctype := range map[string]string{
"scram.js": "text/javascript", "login": "text/html", "login.js": "text/javascript", "scram.js": "text/javascript", "login": "text/html", "login.js": "text/javascript",
"style.css": "text/css", "view": "text/html", "view.js": "text/javascript", "style.css": "text/css", "view": "text/html", "view.js": "text/javascript", "favicon.svg": "image/svg+xml",
} { } {
req, _ := http.NewRequest(http.MethodGet, baseURL+"/auth/"+file, nil) req, _ := http.NewRequest(http.MethodGet, baseURL+"/auth/"+file, nil)
resp, err := client.Do(proxied(req)) resp, err := client.Do(proxied(req))
@@ -239,7 +239,7 @@ func TestProxyModeServesBrowserFiles(t *testing.T) {
body, _ := io.ReadAll(resp.Body) body, _ := io.ReadAll(resp.Body)
resp.Body.Close() resp.Body.Close()
if resp.StatusCode != http.StatusOK || !strings.HasPrefix(resp.Header.Get("Content-Type"), ctype) || if resp.StatusCode != http.StatusOK || !strings.HasPrefix(resp.Header.Get("Content-Type"), ctype) ||
!strings.Contains(resp.Header.Get("Content-Security-Policy"), "script-src 'self'") { !strings.Contains(resp.Header.Get("Content-Security-Policy"), "script-src 'self'; connect-src 'self'; style-src 'self'; img-src 'self';") {
t.Errorf("/auth/%s: %d %q %q", file, resp.StatusCode, resp.Header.Get("Content-Type"), resp.Header.Get("Content-Security-Policy")) t.Errorf("/auth/%s: %d %q %q", file, resp.StatusCode, resp.Header.Get("Content-Type"), resp.Header.Get("Content-Security-Policy"))
} }
if file == "view" && !bytes.Contains(body, []byte(`content="api/status"`)) { if file == "view" && !bytes.Contains(body, []byte(`content="api/status"`)) {
@@ -474,3 +474,45 @@ func TestStopWritesQueuedEventsToReadingStreams(t *testing.T) {
t.Fatalf("reading stream received %d of %d lines, disconnect %v", r.lines, n, r.disconnect) t.Fatalf("reading stream received %d of %d lines, disconnect %v", r.lines, n, r.disconnect)
} }
} }
// A stream that connects late gets the last replay_lines entries after its
// connected event, then the live ones, none twice
func TestLateStreamGetsTheBacklog(t *testing.T) {
httpSink, _ := newTestHTTPSink(t, map[string]any{"replay_lines": int64(2)})
client, baseURL := serveTestHTTPSink(t, httpSink)
until := func(cond func() bool) {
for deadline := time.Now().Add(2 * time.Second); !cond(); time.Sleep(10 * time.Millisecond) {
if time.Now().After(deadline) {
t.Fatal("timed out")
}
}
}
for i := range 3 {
httpSink.Input() <- core.TransportEvent{Payload: fmt.Appendf(nil, "line %d\n", i)}
}
until(func() bool { return httpSink.totalProcessed.Load() == 3 })
resp, err := client.Get(baseURL + "/stream")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = resp.Body.Close() })
scanner := bufio.NewScanner(resp.Body)
for scanner.Scan() && scanner.Text() != "event: connected" {
}
scanner.Scan() // its data
until(func() bool {
httpSink.clientsMu.Lock()
defer httpSink.clientsMu.Unlock()
return len(httpSink.clients) == 1
})
httpSink.Input() <- core.TransportEvent{Payload: []byte("line 3\n")}
var got []string
for len(got) < 3 && scanner.Scan() {
if line, ok := strings.CutPrefix(scanner.Text(), "data: "); ok {
got = append(got, line)
}
}
if !slices.Equal(got, []string{"line 1", "line 2", "line 3"}) {
t.Fatalf("stream got %q", got)
}
}
+6 -4
View File
@@ -16,18 +16,19 @@ import (
"github.com/lixenwraith/logwisp/internal/netacl" "github.com/lixenwraith/logwisp/internal/netacl"
) )
//go:embed web/*.js web/*.html web/*.css //go:embed web/*.js web/*.html web/*.css web/*.svg
var webFiles embed.FS var webFiles embed.FS
// pageCSP confines the pages to their own scripts and styles: no inline code, // pageCSP confines the pages to their own scripts, styles and icon: no inline
// no other origin, no framing // code, no other origin, no framing
const pageCSP = "default-src 'none'; script-src 'self'; connect-src 'self'; style-src 'self'; " + const pageCSP = "default-src 'none'; script-src 'self'; connect-src 'self'; style-src 'self'; " +
"form-action 'self'; frame-ancestors 'none'; base-uri 'none'" "img-src 'self'; form-action 'self'; frame-ancestors 'none'; base-uri 'none'"
var webTypes = map[string]string{ var webTypes = map[string]string{
".js": "text/javascript; charset=utf-8", ".js": "text/javascript; charset=utf-8",
".css": "text/css; charset=utf-8", ".css": "text/css; charset=utf-8",
".html": "text/html; charset=utf-8", ".html": "text/html; charset=utf-8",
".svg": "image/svg+xml",
} }
// webRoutes are the browser files under /auth/, each with the page that needs // webRoutes are the browser files under /auth/, each with the page that needs
@@ -38,6 +39,7 @@ var webRoutes = []struct {
}{ }{
{"scram.js", "scram.js", ""}, {"scram.js", "scram.js", ""},
{"style.css", "style.css", ""}, {"style.css", "style.css", ""},
{"favicon.svg", "favicon.svg", ""},
{"login", "login.html", "login"}, {"login", "login.html", "login"},
{"login.js", "login.js", "login"}, {"login.js", "login.js", "login"},
{"view", "view.html", "view"}, {"view", "view.html", "view"},
+12
View File
@@ -0,0 +1,12 @@
<svg viewBox="1 1 98 98" xmlns="http://www.w3.org/2000/svg">
<!-- The wisps of logwisp-logo.svg, broadened to read at 16 px -->
<defs>
<path id="wisp" d="M50,50 C50,37 59.2,27 71,25.8 C83.5,24.6 92,33.5 97.2,48
A3.6,3.6 0 0 1 90.6,51.8 C86.6,46.2 80.6,43.6 74,43.9 C65.5,44.3 57,46.6 50,50 Z"/>
</defs>
<use href="#wisp" fill="#E0409A"/>
<use href="#wisp" fill="#D4640A" transform="rotate(72 50 50)"/>
<use href="#wisp" fill="#169B58" transform="rotate(144 50 50)"/>
<use href="#wisp" fill="#0891B2" transform="rotate(216 50 50)"/>
<use href="#wisp" fill="#8E5CF0" transform="rotate(288 50 50)"/>
</svg>

After

Width:  |  Height:  |  Size: 630 B

+1
View File
@@ -5,6 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>LogWisp sign-in</title> <title>LogWisp sign-in</title>
<link rel="stylesheet" href="style.css"> <link rel="stylesheet" href="style.css">
<link rel="icon" href="favicon.svg" type="image/svg+xml">
<script type="module" src="login.js"></script> <script type="module" src="login.js"></script>
</head> </head>
<body> <body>
+1
View File
@@ -7,6 +7,7 @@
<meta name="logwisp-status" content="status"> <meta name="logwisp-status" content="status">
<meta name="logwisp-login" content="scram"> <meta name="logwisp-login" content="scram">
<link rel="stylesheet" href="style.css"> <link rel="stylesheet" href="style.css">
<link rel="icon" href="favicon.svg" type="image/svg+xml">
<script type="module" src="view.js"></script> <script type="module" src="view.js"></script>
</head> </head>
<body class="viewer"> <body class="viewer">
+8 -3
View File
@@ -36,8 +36,9 @@ function append(line) {
if (atBottom) log.scrollTop = log.scrollHeight; if (atBottom) log.scrollTop = log.scrollHeight;
} }
// The status answer names the stream path; its 401 is the only way to tell an // The status answer names the stream path and whether the stream has room; its
// ended session apart, since EventSource hides the status of a failure. // 401 is the only way to tell an ended session apart, since EventSource hides
// the status of a failure.
async function connect() { async function connect() {
if (tokenMode && !token) return showLogin(""); if (tokenMode && !token) return showLogin("");
let res; let res;
@@ -52,10 +53,14 @@ async function connect() {
// An open page has no session to end: a 401 there is a proxy's, so it retries // An open page has no session to end: a 401 there is a proxy's, so it retries
if (res.status === 401 && !open) return sessionEnded(); if (res.status === 401 && !open) return sessionEnded();
fresh = false; fresh = false;
const path = res.ok ? (await res.json().catch(() => ({})))?.endpoints?.stream : null; const body = res.ok ? await res.json().catch(() => ({})) : {};
const path = body?.endpoints?.stream;
if (typeof path !== "string" || !path.startsWith("/")) { if (typeof path !== "string" || !path.startsWith("/")) {
return retry(`status unavailable (${res.status})`); return retry(`status unavailable (${res.status})`);
} }
// The stream would answer 503, which EventSource hides
const { active_clients: active, max_connections: max } = body?.server ?? {};
if (max > 0 && active >= max) return retry(`server full (${active} of ${max} streams)`);
// Stream paths are absolute in logwisp; the proxy may mount it under a prefix // Stream paths are absolute in logwisp; the proxy may mount it under a prefix
const url = new URL("./" + path.replace(/^\/+/, ""), base); const url = new URL("./" + path.replace(/^\/+/, ""), base);
if (tokenMode) streamWithToken(url); if (tokenMode) streamWithToken(url);