v0.22.1 cli flag and behavior, and test output improvement

This commit is contained in:
lixen committed 2026-10-03 21:57:41 -04:00
1 parent 71d1b82f83
commit 4eba6fc834
89 files changed
+1504 -634

No files matched your search

+6 -6
View File
@@ -7,11 +7,11 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 5 timeout-minutes: 5
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v5
- uses: actions/setup-go@v5 - uses: actions/setup-go@v6
with: with:
go-version-file: go.mod go-version-file: go.mod
- uses: actions/setup-node@v4 - uses: actions/setup-node@v5
with: with:
node-version: 22 node-version: 22
- run: go test -timeout 60s -race ./... - run: go test -timeout 60s -race ./...
@@ -28,11 +28,11 @@ jobs:
env: env:
FORCE_COLOR: "1" FORCE_COLOR: "1"
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v5
- uses: actions/setup-go@v5 - uses: actions/setup-go@v6
with: with:
go-version-file: go.mod go-version-file: go.mod
- uses: actions/setup-node@v4 - uses: actions/setup-node@v5
with: with:
node-version: 22 node-version: 22
# "chromium" fetches the headless shell and full Chromium, which the # "chromium" fetches the headless shell and full Chromium, which the
+1 -1
View File
@@ -24,7 +24,7 @@ ARG BUILD_TIME=unknown
# REVISION identifies the commit: the context has no .git, so no VCS stamping. # REVISION identifies the commit: the context has no .git, so no VCS stamping.
RUN CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} \ RUN CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} \
go build -trimpath -buildvcs=false \ go build -trimpath -buildvcs=false \
-ldflags="-s -w -X logwisp/internal/version.Version=${VERSION} -X logwisp/internal/version.GitCommit=${REVISION} -X logwisp/internal/version.BuildTime=${BUILD_TIME}" \ -ldflags="-s -w -X github.com/lixenwraith/logwisp/internal/version.Version=${VERSION} -X github.com/lixenwraith/logwisp/internal/version.GitCommit=${REVISION} -X github.com/lixenwraith/logwisp/internal/version.BuildTime=${BUILD_TIME}" \
-o /out/lw ./cmd/lw -o /out/lw ./cmd/lw
FROM scratch FROM scratch
+3 -3
View File
@@ -18,9 +18,9 @@ BUILD_TIME ?= $(BUILD_TIME_DEFAULT)
# The image stamps the commit time, so a rebuild of one commit stays cached # The image stamps the commit time, so a rebuild of one commit stays cached
IMAGE_BUILD_TIME_DEFAULT != TZ=UTC git log -1 --format=%cd --date=format-local:%Y-%m-%d_%H:%M:%S 2>/dev/null || echo unknown IMAGE_BUILD_TIME_DEFAULT != TZ=UTC git log -1 --format=%cd --date=format-local:%Y-%m-%d_%H:%M:%S 2>/dev/null || echo unknown
IMAGE_BUILD_TIME ?= $(IMAGE_BUILD_TIME_DEFAULT) IMAGE_BUILD_TIME ?= $(IMAGE_BUILD_TIME_DEFAULT)
VERSION_LDFLAGS = -X 'logwisp/internal/version.Version=$(VERSION)' \ VERSION_LDFLAGS = -X 'github.com/lixenwraith/logwisp/internal/version.Version=$(VERSION)' \
-X 'logwisp/internal/version.GitCommit=$(GIT_COMMIT)' \ -X 'github.com/lixenwraith/logwisp/internal/version.GitCommit=$(GIT_COMMIT)' \
-X 'logwisp/internal/version.BuildTime=$(BUILD_TIME)' -X 'github.com/lixenwraith/logwisp/internal/version.BuildTime=$(BUILD_TIME)'
# Not LDFLAGS/GOFLAGS: packagers export those for the C linker and go itself. # Not LDFLAGS/GOFLAGS: packagers export those for the C linker and go itself.
GO_BUILDFLAGS ?= GO_BUILDFLAGS ?=
GO_LDFLAGS ?= GO_LDFLAGS ?=
+36 -18
View File
@@ -19,23 +19,28 @@ streams, or downstream LogWisp nodes.
### Pipeline ### Pipeline
- **A Unix filter out of the box**: with no configuration, `lw` copies stdin
to stdout line for line and exits at the end of input; flags add filters,
formats, sources and sinks
- **Independent pipelines**, each `sources → flow → sinks`, running concurrently - **Independent pipelines**, each `sources → flow → sinks`, running concurrently
in one process in one process
- **Fan-in and fan-out**: many sources and many sinks per pipeline - **Fan-in and fan-out**: many sources and many sinks per pipeline
- **Never blocks**: a stalled sink drops its own events and is counted, rather - **Isolated sinks**: a stalled sink drops and counts its own events rather
than stalling the pipeline or its sibling sinks than stalling the pipeline or its sibling sinks; only the console sink
waits, so a filter loses no line to a slow reader
- **Hot reload** via `SIGHUP`/`SIGUSR1` or a config file watch, with the new - **Hot reload** via `SIGHUP`/`SIGUSR1` or a config file watch, with the new
configuration validated before the old service is torn down configuration validated before the old service is torn down
### Inputs ### Inputs
`file` (directory tail with rotation detection and JSON line parsing), `file` (directory tail with rotation detection and JSON line parsing),
`console` (stdin), `random` (synthetic generator), `null`, and the chain ingest `console` (stdin, one reader per process), `random` (synthetic generator),
listeners `tcp_chain` and `http_chain`. `null`, and the chain ingest listeners `tcp_chain` and `http_chain`.
### Outputs ### Outputs
`console`, `file` (rotating with retention), `http` (Server-Sent Events plus a `console` (control characters escaped on a terminal), `file` (rotating with
retention), `http` (Server-Sent Events plus a
JSON status endpoint), `tcp` (broadcast server), `null`, and the chain JSON status endpoint), `tcp` (broadcast server), `null`, and the chain
forwarders `tcp_chain` and `http_chain`. forwarders `tcp_chain` and `http_chain`.
@@ -99,8 +104,8 @@ authentication designs for the rationale and what is deliberately left out.
- [Security](doc/security.md): TLS, mTLS, peer authorization, threat model - [Security](doc/security.md): TLS, mTLS, peer authorization, threat model
- Design notes: [mTLS](doc/mtls-auth-plan.md) and - Design notes: [mTLS](doc/mtls-auth-plan.md) and
[password](doc/scram-auth-plan.md) authentication [password](doc/scram-auth-plan.md) authentication
- [CLI](doc/cli.md): flags, pipeline specifications, signals, exit codes, - [CLI](doc/cli.md): filter use, flags, pipeline specifications, signals,
`lw auth`; also the `lw(1)` manual, [`doc/lw.1`](doc/lw.1) exit codes, `lw auth`; also the `lw(1)` manual, [`doc/lw.1`](doc/lw.1)
- [Operations](doc/operations.md): running, monitoring, tuning, - [Operations](doc/operations.md): running, monitoring, tuning,
troubleshooting troubleshooting
- [To Do](doc/todo.md): planned work in priority order: network access - [To Do](doc/todo.md): planned work in priority order: network access
@@ -114,16 +119,30 @@ A fully annotated configuration covering every option ships as
```bash ```bash
make build # plain `make` lists the targets make build # plain `make` lists the targets
./bin/lw --source 'file,directory=/var/log/myapp,pattern=*.log' \
--format json,sanitizer_policy=json --sink console
``` ```
Or as a configuration file: Without a configuration file, `lw` is a filter: stdin to stdout, line for
line, exiting 0 at the end of input. A pipeline without `--source` reads stdin,
one without `--sink` writes stdout:
```bash
./bin/lw < app.log > copy.log
tail -F app.log | ./bin/lw --filter include,patterns=ERROR,patterns=WARN
# stdin as a live SSE stream; the http sink binds 0.0.0.0 unless told
journalctl -f | ./bin/lw --sink http,host=127.0.0.1,port=8080
# tail a directory instead of stdin
./bin/lw --source 'file,directory=/var/log/myapp,pattern=*.log' \
--format json,sanitizer_policy=json > all.json
```
As a service, a configuration file holds the pipelines:
```toml ```toml
# logwisp.toml # logwisp.toml
[[pipelines]] [[pipelines]]
name = "default" name = "app"
[pipelines.flow.format] [pipelines.flow.format]
type = "json" type = "json"
@@ -137,19 +156,18 @@ directory = "/var/log/myapp"
pattern = "*.log" pattern = "*.log"
[[pipelines.plugin_sinks]] [[pipelines.plugin_sinks]]
id = "stdout" id = "sse"
type = "console" type = "http"
[pipelines.plugin_sinks.config] [pipelines.plugin_sinks.config]
target = "stdout" host = "127.0.0.1"
port = 8080
``` ```
```bash ```bash
./bin/lw -c logwisp.toml ./bin/lw -c logwisp.toml # until SIGINT or SIGTERM; logs to stderr
curl -N http://127.0.0.1:8080/stream # from another shell
``` ```
Running with neither starts a self-demonstrating pipeline: a synthetic
generator writing JSON to stdout.
In a container, read-only and without capabilities: In a container, read-only and without capabilities:
```bash ```bash
+4 -4
View File
@@ -18,10 +18,10 @@ import (
"strings" "strings"
"syscall" "syscall"
"logwisp/internal/authz" "github.com/lixenwraith/logwisp/internal/authz"
"logwisp/internal/config" "github.com/lixenwraith/logwisp/internal/config"
"logwisp/internal/core" "github.com/lixenwraith/logwisp/internal/core"
"logwisp/internal/tlsx" "github.com/lixenwraith/logwisp/internal/tlsx"
"github.com/lixenwraith/auth" "github.com/lixenwraith/auth"
) )
+1 -1
View File
@@ -8,7 +8,7 @@ import (
"strings" "strings"
"testing" "testing"
"logwisp/internal/authz" "github.com/lixenwraith/logwisp/internal/authz"
"github.com/lixenwraith/auth" "github.com/lixenwraith/auth"
) )
+22 -17
View File
@@ -2,27 +2,28 @@ package main
import ( import (
"context" "context"
"errors"
"fmt" "fmt"
"os" "os"
_ "logwisp/internal/source/console" _ "github.com/lixenwraith/logwisp/internal/source/console"
_ "logwisp/internal/source/file" _ "github.com/lixenwraith/logwisp/internal/source/file"
_ "logwisp/internal/source/httpchain" _ "github.com/lixenwraith/logwisp/internal/source/httpchain"
_ "logwisp/internal/source/null" _ "github.com/lixenwraith/logwisp/internal/source/null"
_ "logwisp/internal/source/random" _ "github.com/lixenwraith/logwisp/internal/source/random"
_ "logwisp/internal/source/tcpchain" _ "github.com/lixenwraith/logwisp/internal/source/tcpchain"
_ "logwisp/internal/sink/console" _ "github.com/lixenwraith/logwisp/internal/sink/console"
_ "logwisp/internal/sink/file" _ "github.com/lixenwraith/logwisp/internal/sink/file"
_ "logwisp/internal/sink/http" _ "github.com/lixenwraith/logwisp/internal/sink/http"
_ "logwisp/internal/sink/httpchain" _ "github.com/lixenwraith/logwisp/internal/sink/httpchain"
_ "logwisp/internal/sink/null" _ "github.com/lixenwraith/logwisp/internal/sink/null"
_ "logwisp/internal/sink/tcp" _ "github.com/lixenwraith/logwisp/internal/sink/tcp"
_ "logwisp/internal/sink/tcpchain" _ "github.com/lixenwraith/logwisp/internal/sink/tcpchain"
"logwisp/internal/config" "github.com/lixenwraith/logwisp/internal/config"
"logwisp/internal/service" "github.com/lixenwraith/logwisp/internal/service"
"logwisp/internal/version" "github.com/lixenwraith/logwisp/internal/version"
"github.com/lixenwraith/log" "github.com/lixenwraith/log"
"github.com/lixenwraith/log/sanitizer" "github.com/lixenwraith/log/sanitizer"
@@ -48,6 +49,10 @@ func bootstrapInitial(ctx context.Context, cfg *config.Config) (*service.Service
return svc, statusCancel, nil return svc, statusCancel, nil
} }
// errServiceStopped is a reload that failed after the old service stopped:
// no pipeline runs until a later reload succeeds.
var errServiceStopped = errors.New("old service stopped, new one failed to start")
// handleReload orchestrates the entire hot-reload process including status reporter lifecycle // handleReload orchestrates the entire hot-reload process including status reporter lifecycle
func handleReload(ctx context.Context, newCfg *config.Config, oldSvc *service.Service, statusCancel context.CancelFunc) (*service.Service, *config.Config, context.CancelFunc, error) { func handleReload(ctx context.Context, newCfg *config.Config, oldSvc *service.Service, statusCancel context.CancelFunc) (*service.Service, *config.Config, context.CancelFunc, error) {
logger.Info("msg", "Starting configuration hot reload") logger.Info("msg", "Starting configuration hot reload")
@@ -78,7 +83,7 @@ func handleReload(ctx context.Context, newCfg *config.Config, oldSvc *service.Se
if err := newService.Start(); err != nil { if err := newService.Start(); err != nil {
newService.Shutdown() newService.Shutdown()
logger.Error("msg", "Failed to start new service pipelines after reload. The application may be in a non-functional state.", "error", err) logger.Error("msg", "Failed to start new service pipelines after reload. The application may be in a non-functional state.", "error", err)
return nil, nil, nil, fmt.Errorf("failed to start new service: %w", err) return nil, nil, nil, fmt.Errorf("%w: %w", errServiceStopped, err)
} }
// Manage status reporter lifecycle // Manage status reporter lifecycle
+18 -5
View File
@@ -2,15 +2,18 @@ package main
import ( import (
"fmt" "fmt"
"logwisp/internal/version"
"os" "os"
"github.com/lixenwraith/logwisp/internal/version"
) )
// helpText is the CLI usage reference. Scalar flags map 1:1 to TOML config paths. // helpText is the CLI usage reference. Scalar flags map 1:1 to TOML config paths.
const helpText = `LogWisp %s - log collection, processing, and distribution const helpText = `LogWisp %s - log collection, processing, and distribution
Usage: Usage:
lw [options] lw [options] [< input] No file, no pipeline options: stdin to stdout,
line for line, like cat; exits at end of input
lw -c <file> [options] Run the file's pipelines
lw --check [options] Build every pipeline and plugin, report, exit lw --check [options] Build every pipeline and plugin, report, exit
lw help | -h | --help lw help | -h | --help
lw --version lw --version
@@ -25,10 +28,12 @@ Scalar configuration keys are settable as flags using their TOML paths:
Common options: Common options:
-c, --config <path> Configuration file (default: ./logwisp.toml) -c, --config <path> Configuration file (default: ./logwisp.toml)
--quiet Suppress console output --quiet Suppress console output
--status_reporter=<bool> Periodic status logging (default: true) --status_reporter=<bool> Periodic status logging (default: true with
a configuration file)
--auto_reload=<bool> Config hot reload on file change (default: false) --auto_reload=<bool> Config hot reload on file change (default: false)
Logging: Logging (lw's own log goes to stderr, at info with a configuration file and
at warn without one):
--logging.output=<mode> file|stdout|stderr|split|all|none --logging.output=<mode> file|stdout|stderr|split|all|none
--logging.level=<level> debug|info|warn|error --logging.level=<level> debug|info|warn|error
--logging.file.directory=<path> --logging.file.directory=<path>
@@ -41,9 +46,17 @@ Pipelines (replace the file's pipelines; see doc/cli.md):
--format <spec> json|txt|raw[,key=value...] --format <spec> json|txt|raw[,key=value...]
--rate-limit <spec> rate=N[,burst=N,policy=drop|pass] --rate-limit <spec> rate=N[,burst=N,policy=drop|pass]
--heartbeat <spec> interval_ms=N[,include_stats=true,...] --heartbeat <spec> interval_ms=N[,include_stats=true,...]
A pipeline without --source reads stdin, one without --sink writes stdout.
Keys nest with '.' (tls.cert_file=...), a repeated key makes a list, and Keys nest with '.' (tls.cert_file=...), a repeated key makes a list, and
'\' escapes ',' '=' '\' in values. -- ends option parsing. '\' escapes ',' '=' '\' in values. -- ends option parsing.
Examples:
lw < app.log > copy.log Copy, line for line
tail -F app.log | lw --filter include,patterns=ERROR,patterns=WARN
journalctl -f | lw --sink http,host=127.0.0.1,port=8080
Serve stdin as a live stream
lw --source file,directory=/var/log/app,pattern='*.log' --format txt
Environment: Environment:
LOGWISP_<PATH> Config path, '.' -> '_', uppercase LOGWISP_<PATH> Config path, '.' -> '_', uppercase
e.g. LOGWISP_LOGGING_LEVEL=debug e.g. LOGWISP_LOGGING_LEVEL=debug
@@ -59,7 +72,7 @@ Signals:
SIGHUP, SIGUSR1 Reload configuration SIGHUP, SIGUSR1 Reload configuration
Exit codes: Exit codes:
0 success 0 success, including the end of input
1 general error 1 general error
2 configuration file not found 2 configuration file not found
` `
+32 -4
View File
@@ -9,11 +9,12 @@ import (
"reflect" "reflect"
"syscall" "syscall"
"logwisp/internal/config" "github.com/lixenwraith/logwisp/internal/config"
"logwisp/internal/core" "github.com/lixenwraith/logwisp/internal/core"
"logwisp/internal/version" "github.com/lixenwraith/logwisp/internal/version"
"github.com/lixenwraith/log" "github.com/lixenwraith/log"
"golang.org/x/term"
) )
var logger *log.Logger var logger *log.Logger
@@ -88,6 +89,10 @@ func main() {
os.Exit(1) os.Exit(1)
} }
if !cfg.Quiet && readsStdin(cfg) && term.IsTerminal(int(os.Stdin.Fd())) {
fmt.Fprintln(os.Stderr, "lw: reading standard input; Ctrl-D ends it (lw --help for usage)")
}
// --- 3. Setup signals and shutdown --- // --- 3. Setup signals and shutdown ---
sigChan := make(chan os.Signal, 1) sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM, syscall.SIGHUP, syscall.SIGUSR1) signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM, syscall.SIGHUP, syscall.SIGUSR1)
@@ -112,6 +117,9 @@ func main() {
logger.Info("msg", "Shutdown complete") logger.Info("msg", "Shutdown complete")
}() }()
// Closed when every pipeline's input ended (stdin); nil while none runs
done := svc.Done()
reload := func(fromDisk bool) { reload := func(fromDisk bool) {
var next *config.Config var next *config.Config
var err error var err error
@@ -130,8 +138,12 @@ func main() {
return return
} }
newSvc, newCfg, newStatusCancel, err := handleReload(ctx, next, svc, statusReporterCancel) newSvc, newCfg, newStatusCancel, err := handleReload(ctx, next, svc, statusReporterCancel)
if err == nil { switch {
case err == nil:
svc, cfg, statusReporterCancel = newSvc, newCfg, newStatusCancel svc, cfg, statusReporterCancel = newSvc, newCfg, newStatusCancel
done = svc.Done()
case errors.Is(err, errServiceStopped):
done = nil // the stopped service's pipelines did not reach the end of input
} }
} }
@@ -158,6 +170,10 @@ func main() {
reload(false) reload(false)
} }
case <-done:
logger.Info("msg", "Every pipeline finished: its input ended")
return
case <-ctx.Done(): case <-ctx.Done():
return // Exit the loop and trigger deferred shutdown return // Exit the loop and trigger deferred shutdown
} }
@@ -172,3 +188,15 @@ func shutdownLogger() {
} }
} }
} }
// readsStdin reports a console source: typed into, it waits for the keyboard
func readsStdin(cfg *config.Config) bool {
for _, p := range cfg.Pipelines {
for _, src := range p.PluginSources {
if src.Type == "console" {
return true
}
}
}
return false
}
+3 -3
View File
@@ -11,9 +11,9 @@ import (
"testing" "testing"
"time" "time"
"logwisp/internal/plugin" "github.com/lixenwraith/logwisp/internal/plugin"
"logwisp/internal/session" "github.com/lixenwraith/logwisp/internal/session"
"logwisp/internal/testutil" "github.com/lixenwraith/logwisp/internal/testutil"
lconfig "github.com/lixenwraith/config" lconfig "github.com/lixenwraith/config"
) )
+1 -1
View File
@@ -5,7 +5,7 @@ import (
"fmt" "fmt"
"time" "time"
"logwisp/internal/service" "github.com/lixenwraith/logwisp/internal/service"
) )
// startStatusReporter starts a new status reporter for a service and returns its cancel function. // startStatusReporter starts a new status reporter for a service and returns its cancel function.
+2 -2
View File
@@ -3,8 +3,8 @@ package main
import ( import (
"testing" "testing"
"logwisp/internal/config" "github.com/lixenwraith/logwisp/internal/config"
"logwisp/internal/testutil" "github.com/lixenwraith/logwisp/internal/testutil"
"github.com/lixenwraith/log" "github.com/lixenwraith/log"
) )
+6 -5
View File
@@ -23,8 +23,8 @@
### Global Settings ### Global Settings
############################################################################### ###############################################################################
quiet = false # Suppress console output quiet = false # No log of lw's own; pipeline output still flows
status_reporter = true # Periodic status logging (30s, DEBUG level) status_reporter = true # Periodic status logging (30s, DEBUG level); false without a file
auto_reload = false # Watch file; read at startup (signals always reread it) auto_reload = false # Watch file; read at startup (signals always reread it)
############################################################################### ###############################################################################
@@ -32,8 +32,8 @@ auto_reload = false # Watch file; read at startup (signals a
############################################################################### ###############################################################################
[logging] [logging]
output = "stdout" # file|stdout|stderr|split|all|none output = "stderr" # file|stdout|stderr|split|all|none; stdout is the console sink's
level = "info" # debug|info|warn|error level = "info" # debug|info|warn|error; warn without a file
# format = "txt" # raw|txt|json # format = "txt" # raw|txt|json
# sanitization = "" # raw|json|txt|shell (empty = logger default) # sanitization = "" # raw|json|txt|shell (empty = logger default)
@@ -168,7 +168,7 @@ check_interval_ms = 100 # Directory rescan interval (min 10)
raw = false # Keep the whole line as the message raw = false # Keep the whole line as the message
from = "end" # "end" or "start" of a newly discovered file from = "end" # "end" or "start" of a newly discovered file
## Console source (stdin, single instance per pipeline) ## Console source (stdin; one in the whole configuration, ends lw's pipeline at end of input)
# [[pipelines.plugin_sources]] # [[pipelines.plugin_sources]]
# id = "console_in" # id = "console_in"
# type = "console" # type = "console"
@@ -256,6 +256,7 @@ type = "console"
[pipelines.plugin_sinks.config] [pipelines.plugin_sinks.config]
target = "stdout" # stdout|stderr ("split" NOT supported) target = "stdout" # stdout|stderr ("split" NOT supported)
# buffer_size = 1000 # buffer_size = 1000
# escape = "auto" # auto|always|never: control characters as <hex>, auto on a terminal
## File sink (rotating) ## File sink (rotating)
# [[pipelines.plugin_sinks]] # [[pipelines.plugin_sinks]]
+2 -2
View File
@@ -1,6 +1,6 @@
# Maintainer: <name> <email> # Maintainer: <name> <email>
# Skeleton: source and checksum wait for a tagged release; run namcap and # Skeleton: pkgver, sha256sums and the maintainer wait for a tagged release;
# makepkg --printsrcinfo > .SRCINFO before publishing to the AUR. # doc/todo.md lists the steps to publish it to the AUR.
pkgname=logwisp pkgname=logwisp
pkgver=0.21.0 pkgver=0.21.0
+6 -6
View File
@@ -1,7 +1,7 @@
# Skeleton for sysutils/logwisp. GO_MODULE fetches the module by its canonical # Skeleton for sysutils/logwisp: GO_MODULE fetches the tagged module from the
# path, so it waits for the module rename to github.com/lixenwraith/logwisp # Go proxy, so it waits for a release tag, a maintainer and a UIDs/GIDs entry
# (the -X paths below follow it), a tagged release, a maintainer, and a # for logwisp (doc/todo.md). files/logwisp.in is a symlink to
# UIDs/GIDs entry for logwisp. files/logwisp.in links to ../logwisp.rc: cp -L. # deploy/package/logwisp.rc: copy the port with cp -RL.
PORTNAME= logwisp PORTNAME= logwisp
DISTVERSIONPREFIX= v DISTVERSIONPREFIX= v
@@ -22,8 +22,8 @@ SUB_LIST= BINDIR=${PREFIX}/bin
GO_MODULE= github.com/lixenwraith/logwisp GO_MODULE= github.com/lixenwraith/logwisp
GO_TARGET= ./cmd/lw GO_TARGET= ./cmd/lw
GO_BUILDFLAGS= -ldflags="-s -w \ GO_BUILDFLAGS= -ldflags="-s -w \
-X logwisp/internal/version.Version=${DISTVERSION} \ -X github.com/lixenwraith/logwisp/internal/version.Version=${DISTVERSION} \
-X logwisp/internal/version.GitCommit=${DISTVERSIONFULL}" -X github.com/lixenwraith/logwisp/internal/version.GitCommit=${DISTVERSIONFULL}"
USERS= logwisp USERS= logwisp
GROUPS= logwisp GROUPS= logwisp
+19 -5
View File
@@ -41,21 +41,24 @@ A fully annotated configuration covering every option lives at
### Pipeline ### Pipeline
- A Unix filter without configuration: stdin to stdout, line for line
- Independent named pipelines, each `sources → flow → sinks` - Independent named pipelines, each `sources → flow → sinks`
- Fan-in (many sources per pipeline) and fan-out (many sinks per pipeline) - Fan-in (many sources per pipeline) and fan-out (many sinks per pipeline)
- Non-blocking sink dispatch: a stalled sink drops its own events and never - Isolated sinks: a stalled sink drops its own events rather than stall the
stalls the pipeline or its sibling sinks pipeline; only the console sink waits, so a filter loses no line
- Hot reload of pipeline configuration via `SIGHUP`/`SIGUSR1` or a file watch - Hot reload of pipeline configuration via `SIGHUP`/`SIGUSR1` or a file watch
### Inputs ### Inputs
`file` (directory tail with rotation detection), `console` (stdin), `file` (directory tail with rotation detection), `console` (stdin, one
reader per process),
`random` (synthetic generator), `null`, and the chain ingest listeners `random` (synthetic generator), `null`, and the chain ingest listeners
`tcp_chain` and `http_chain`. `tcp_chain` and `http_chain`.
### Outputs ### Outputs
`console`, `file` (rotating), `http` (Server-Sent Events plus a JSON status `console` (control characters escaped on a terminal), `file` (rotating),
`http` (Server-Sent Events plus a JSON status
endpoint), `tcp` (broadcast server), `null`, and the chain forwarders endpoint), `tcp` (broadcast server), `null`, and the chain forwarders
`tcp_chain` and `http_chain`. `tcp_chain` and `http_chain`.
@@ -83,9 +86,20 @@ See [Security](security.md) for what each layer does and does not give you.
## Quick Start ## Quick Start
Without a configuration file `lw` is a filter, exiting 0 at the end of input;
a pipeline without `--source` reads stdin and one without `--sink` writes
stdout:
```bash
lw < app.log > copy.log
tail -F app.log | lw --filter include,patterns=ERROR,patterns=WARN
```
As a service, a file holds the pipelines:
```toml ```toml
[[pipelines]] [[pipelines]]
name = "default" name = "app"
[pipelines.flow.format] [pipelines.flow.format]
type = "json" type = "json"
+20 -9
View File
@@ -64,14 +64,16 @@ registry rejects a second instance of any such type.
### Entry lifecycle ### Entry lifecycle
1. **Source** produces a `core.LogEntry` and publishes it to every subscriber 1. **Source** produces a `core.LogEntry` and publishes it to every subscriber
channel it has handed out. Publication is non-blocking: a full subscriber channel it has handed out; the pipeline subscribes before the source
channel increments the source's `dropped_entries` counter. starts. Publication is non-blocking: a full subscriber channel increments
the source's `dropped_entries` counter. The console source alone waits,
since stdin can simply be read later.
2. **Flow** applies, in order: rate limit → filter chain → formatter. A drop at 2. **Flow** applies, in order: rate limit → filter chain → formatter. A drop at
any stage ends the entry's life and increments `flow.total_dropped`. any stage ends the entry's life and increments `flow.total_dropped`.
3. The formatter output becomes a `core.TransportEvent`, which carries both the 3. The formatter output becomes a `core.TransportEvent`, which carries both the
formatted `Payload` and the original structured `Entry`. formatted `Payload` and the original structured `Entry`.
4. **Dispatch** sends the event to every sink's input channel with a 4. **Dispatch** sends the event to every sink's input channel with a
non-blocking send. non-blocking send, except to a sink declaring `CapBackpressure`.
`LogEntry` fields: `LogEntry` fields:
@@ -90,15 +92,17 @@ structured entry rather than shipping whatever text the local formatter chose.
### Back-pressure and drops ### Back-pressure and drops
There is exactly one drop policy and it is not configurable: **never block**. The drop policy is not configurable: **never block**, except where waiting
Each stage drops and counts instead of waiting: loses nothing. Each stage drops and counts instead of waiting:
- Source → subscriber: a full channel drops the entry; source - Source → subscriber: a full channel drops the entry; source
`dropped_entries`. `dropped_entries`. The console source waits instead and reads stdin later.
- Flow: the rate limiter, a filter rejection or a format error drops it; - Flow: the rate limiter, a filter rejection or a format error drops it;
`flow.total_dropped`. `flow.total_dropped`.
- Pipeline → sink: a full sink input drops it for that sink only; pipeline - Pipeline → sink: a full sink input drops it for that sink only; pipeline
`total_dropped_by_sink`. `total_dropped_by_sink`. A sink declaring `core.CapBackpressure`, the console
sink, makes dispatch wait instead, so a filter loses no line to a slow
reader and its pipeline runs at the reader's pace; `Stop` ends the wait.
- TCP/HTTP sink → client queue: a full queue drops it for that client only; - TCP/HTTP sink → client queue: a full queue drops it for that client only;
sink `dropped_writes`. sink `dropped_writes`.
@@ -126,9 +130,16 @@ on a non-retryable response or on shutdown (`dropped_batches`).
`Pipeline.Stop` is deliberately ordered so in-flight data drains: `Pipeline.Stop` is deliberately ordered so in-flight data drains:
1. Stop all sources concurrently; each closes its subscriber channels. 1. Release dispatch waits on backpressure sinks; stop all sources
concurrently, each closing its subscriber channels.
2. Wait for the run loop, which ends when every subscription channel closes. 2. Wait for the run loop, which ends when every subscription channel closes.
3. Stop all sinks concurrently. 3. Stop all sinks concurrently; console and file sinks write their queue
first. Then cancel the pipeline context.
A finite source ends on its own: the console source at the end of stdin. The
run loop then returns and the pipeline's `Finished` channel closes; once every
pipeline has finished, the service's `Done` closes and `main` shuts down as on
`SIGTERM`, exiting 0.
## Network Architecture ## Network Architecture
+102 -31
View File
@@ -1,13 +1,19 @@
# Command Line Interface # Command Line Interface
``` ```
lw [options] lw [options] [< input]
lw -c <file> [options]
lw --check [options] lw --check [options]
lw auth <command> [flags] lw auth <command> [flags]
lw help | -h | --help lw help | -h | --help
lw --version lw --version
``` ```
Without a configuration file or pipeline options, lw is a filter: stdin to
stdout, line for line, until the end of input ([Built-in
Defaults](#built-in-defaults)). With a file it runs the file's pipelines as a
service.
`lw auth` manages SCRAM credentials and logs in to `scram` listeners; see `lw auth` manages SCRAM credentials and logs in to `scram` listeners; see
[below](#lw-auth). There is no certificate-generation command: use [below](#lw-auth). There is no certificate-generation command: use
`openssl` or your PKI tooling — see [Security](security.md#enabling-mtls). `openssl` or your PKI tooling — see [Security](security.md#enabling-mtls).
@@ -25,12 +31,15 @@ pipelines have [their own flags](#pipelines):
### Common ### Common
- `-c <path>`, `--config <path>`, `--config=<path>`: the configuration file, - `-c <path>`, `--config <path>`, `--config=<path>`: the configuration file;
default `./logwisp.toml` unnamed, `~/.config/logwisp/logwisp.toml` if it exists, else
`./logwisp.toml` ([Configuration](configuration.md#file-location))
- `--check`: build every pipeline and plugin, bind nothing, report and exit; - `--check`: build every pipeline and plugin, bind nothing, report and exit;
see [Usage Patterns](#usage-patterns) see [Usage Patterns](#usage-patterns)
- `--quiet`: suppress all application output, default `false` - `--quiet`: silence lw's own log and notices, default `false`; pipeline
- `--status_reporter=<bool>`: periodic status logging, default `true` output still flows
- `--status_reporter=<bool>`: periodic status logging, default `true` with a
configuration file, `false` without
- `--auto_reload=<bool>`: reload when the configuration file changes, default - `--auto_reload=<bool>`: reload when the configuration file changes, default
`false` `false`
- `--version`: print the version and exit - `--version`: print the version and exit
@@ -41,8 +50,10 @@ path returns an error, and `--` ends option parsing.
### Logging ### Logging
- `--logging.output`: `file`, `stdout`, `stderr`, `split`, `all` or `none` - `--logging.output`: `file`, `stdout`, `stderr`, `split`, `all` or `none`,
- `--logging.level`: `debug`, `info`, `warn` or `error` default `stderr`, so stdout carries only data
- `--logging.level`: `debug`, `info`, `warn` or `error`, default `info` with a
configuration file, `warn` without
- `--logging.format`: `raw`, `txt` or `json` - `--logging.format`: `raw`, `txt` or `json`
- `--logging.sanitization`: `raw`, `json`, `txt` or `shell` - `--logging.sanitization`: `raw`, `json`, `txt` or `shell`
- `--logging.file.directory`: a path - `--logging.file.directory`: a path
@@ -63,6 +74,10 @@ pipelines and the built-in default; every other key keeps its precedence.
- `--source SPEC`, `--sink SPEC` and `--filter SPEC` add a stage, repeatable - `--source SPEC`, `--sink SPEC` and `--filter SPEC` add a stage, repeatable
- `--format SPEC`, `--rate-limit SPEC` and `--heartbeat SPEC`, once per pipeline - `--format SPEC`, `--rate-limit SPEC` and `--heartbeat SPEC`, once per pipeline
- both `--flag SPEC` and `--flag=SPEC` work; `--` ends options - both `--flag SPEC` and `--flag=SPEC` work; `--` ends options
- a pipeline without `--source` reads stdin (console source `stdin`), one
without `--sink` writes stdout (console sink `stdout`)
- stdin has one reader, so the whole configuration holds at most one console
source: `pipeline "b": a console source already reads stdin in pipeline "a"`
A SPEC is a comma-separated list: A SPEC is a comma-separated list:
@@ -96,7 +111,7 @@ A misspelled plugin key fails when the plugin is built, before any listener
opens: `failed to create sink http: ... unknown key "tls.enabeld"`. opens: `failed to create sink http: ... unknown key "tls.enabeld"`.
```bash ```bash
# tail a directory, serve it over SSE # tail a directory, serve it over SSE on every interface
lw --source 'file,directory=/var/log/app,pattern=*.log' \ lw --source 'file,directory=/var/log/app,pattern=*.log' \
--sink http,host=0.0.0.0,port=8080 --sink http,host=0.0.0.0,port=8080
@@ -106,12 +121,14 @@ lw --source file,directory=/var/log/app \
--sink "http,port=8443,auth.type=scram,auth.credentials_file=/etc/logwisp/users.toml,\ --sink "http,port=8443,auth.type=scram,auth.credentials_file=/etc/logwisp/users.toml,\
tls.enabled=true,tls.cert_file=/etc/logwisp/server.crt,tls.key_file=/etc/logwisp/server.key" tls.enabled=true,tls.cert_file=/etc/logwisp/server.crt,tls.key_file=/etc/logwisp/server.key"
# two pipelines # two pipelines: app writes stdout, relay a file
lw --pipeline app --source file,directory=/var/log/app --sink console \ lw --pipeline app --source file,directory=/var/log/app \
--pipeline relay --source tcp_chain,port=9000 \ --pipeline relay --source tcp_chain,port=9000 \
--sink file,directory=/var/log/relay,name=relay --sink file,directory=/var/log/relay,name=relay
``` ```
Pipelines over stdin and stdout are under [Usage Patterns](#usage-patterns).
## Environment Variables ## Environment Variables
Configuration paths map to environment variables by replacing `.` with `_`, Configuration paths map to environment variables by replacing `.` with `_`,
@@ -154,21 +171,28 @@ docker run --rm -p 8080:8080 -v /var/log/app:/logs:ro \
-e LOGWISP_FILTER='exclude,patterns=DEBUG' \ -e LOGWISP_FILTER='exclude,patterns=DEBUG' \
-e LOGWISP_SINK='http,host=0.0.0.0,port=8080' \ -e LOGWISP_SINK='http,host=0.0.0.0,port=8080' \
-e LOGWISP_SINK_1=console \ -e LOGWISP_SINK_1=console \
-e LOGWISP_LOGGING_LEVEL=info \
logwisp logwisp
``` ```
Without a file, lw logs at `warn` with no status reporter, so a container
configured through the environment passes `LOGWISP_LOGGING_LEVEL=info` (and
`LOGWISP_STATUS_REPORTER=true`) to keep the service defaults. The image does
not set them: environment variables would override a mounted file's values.
## Precedence ## Precedence
- Pipelines - Pipelines
- pipeline flags, if any are given - pipeline flags, if any are given
- else pipeline variables, if any are set - else pipeline variables, if any are set
- else the configuration file's `[[pipelines]]` - else the configuration file's `[[pipelines]]`
- else the built-in default - else the built-in `pipe` ([Built-in Defaults](#built-in-defaults))
- Every other key - Every other key
1. command-line flags 1. command-line flags
2. environment variables 2. environment variables
3. configuration file 3. configuration file
4. built-in defaults 4. built-in defaults; `logging.level` and `status_reporter` depend on
whether a file was found
A reload rereads the file and keeps the command-line or environment pipelines. A reload rereads the file and keeps the command-line or environment pipelines.
@@ -186,10 +210,13 @@ service untouched; see [Configuration](configuration.md#hot-reload).
## Exit Codes ## Exit Codes
- `0`: clean shutdown, `--version` / `--help`, or a valid `--check` - `0`: the end of input, a clean shutdown (`SIGINT`, `SIGTERM`), `--version` /
`--help`, or a valid `--check`
- `1`: general error: a configuration load or validation failure (`--check` - `1`: general error: a configuration load or validation failure (`--check`
included), a logger init failure, a service bootstrap failure included), a logger init failure, a service bootstrap failure
- `2`: an explicitly requested configuration file is not found - `2`: an explicitly requested configuration file is not found
- killed by `SIGPIPE` (shell status `141`): the reader of stdout went away, as
in `lw | head`; `cat` ends the same way
Exit code 2 applies only when the file was named explicitly (`-c`, Exit code 2 applies only when the file was named explicitly (`-c`,
`--config=`, or the `LOGWISP_CONFIG_*` variables). A missing discovered default `--config=`, or the `LOGWISP_CONFIG_*` variables). A missing discovered default
@@ -197,35 +224,77 @@ is not an error, and LogWisp starts on built-in defaults.
## Built-in Defaults ## Built-in Defaults
With no configuration file present, LogWisp runs one pipeline named Without a configuration file and without pipeline flags or variables, lw runs
`default_pipeline`: a `random` source with `special = true`, JSON formatting, one built-in pipeline, `pipe`: console source `stdin`, `raw` format, console
a rate limit of 5 entries/second with a burst of 10 and `policy = "drop"`, and a sink `stdout`, no filter, no rate limit. lw is then a Unix filter, like `cat`:
`console` sink on stdout. It is a self-demonstrating idle mode, not a useful
production configuration.
As soon as the file defines `[[pipelines]]`, or pipeline flags or variables are ```bash
given, that entire default pipeline — rate limit included — is replaced rather lw < app.log > copy.log
than merged. ```
- Each line of stdin is one entry and one line of stdout.
- The terminator (`\n` or `\r\n`) is written back as `\n`; an unterminated
last line is kept and terminated.
- Blank lines are skipped.
- A line over 1 MiB continues in the next entry; no byte is lost.
- Nothing is dropped: a slow reader slows the reading of stdin.
- On a terminal, control characters are written as `<hex>` (`ESC` becomes
`<1b>`); pipes and files get the bytes unchanged
([console sink](sinks.md#console)).
- When a console source reads a terminal, lw says so once on stderr, unless
`--quiet`: `lw: reading standard input; Ctrl-D ends it (lw --help for usage)`.
A file that defines no `[[pipelines]]` still runs `pipe`. The file's
`[[pipelines]]`, or pipeline flags or variables, replace it whole.
Without a file lw also logs less: `logging.level` defaults to `warn` and
`status_reporter` to `false`, against `info` and `true` with one. Explicit
values from the file, the environment or flags win.
**End of input.** When stdin ends, the console source ends. A pipeline
finishes when all its sources have ended, and once every pipeline has
finished lw shuts down gracefully and exits 0. Only a console source ends on
its own: file and network sources run until a signal, and so does a pipeline
holding one. Console and file sinks write what is queued before the
exit. The `http` and `tcp` sinks do not yet flush their clients' queues, so a
connected client can miss the last entries of a finite input
([To Do](todo.md)).
## Usage Patterns ## Usage Patterns
**Filter**
```bash
# copy, line for line; exits 0 at the end of input
lw < app.log > copy.log
# errors and warnings only
tail -F app.log | lw --filter include,patterns=ERROR,patterns=WARN
# stdin as a live SSE stream; the http and tcp sinks bind 0.0.0.0 by default
journalctl -f | lw --sink http,host=127.0.0.1,port=8080
# a directory's files to one file, through stdout
lw --source file,directory=/var/log/app,pattern='*.log' > all.log
```
A console sink never drops: when its reader is slow, the pipeline waits, and
so does the reading of stdin.
**Development** **Development**
```bash ```bash
# verbose, everything to stderr # a file's pipelines, verbose
lw -c dev.toml --logging.output=stderr --logging.level=debug lw -c dev.toml --logging.level=debug
# no config at all: synthetic generator to stdout
lw
``` ```
**Configuration check** **Configuration check**
`lw --check` validates without running: it builds every pipeline and plugin as `lw --check` validates without running: it builds every pipeline and plugin as
a start would (options, TLS files, credentials files, startup warnings), but a start would (options, TLS files, credentials files, startup warnings), but
binds no port and opens, reads or creates no log file; it prints binds no port, reads no stdin, and opens, reads or creates no log file; it
`configuration ok` and exits 0, or the error and exits 1 (2 when a named file prints `configuration ok: N pipeline(s)` and exits 0, or the error and exits 1
is missing, see [Exit Codes](#exit-codes)): (2 when a named file is missing, see [Exit Codes](#exit-codes)):
```bash ```bash
lw --check -c /etc/logwisp/logwisp.toml lw --check -c /etc/logwisp/logwisp.toml
@@ -238,8 +307,10 @@ lw --check --source file,directory=/var/log/app --sink http,port=8080
lw -c /etc/logwisp/logwisp.toml --logging.output=file lw -c /etc/logwisp/logwisp.toml --logging.output=file
``` ```
Run under a supervisor (systemd, rc.d) rather than backgrounding it — there is Run it in the foreground under a supervisor (systemd, rc.d, a container
no `--background` flag; earlier releases had one and it was removed. See runtime); there is no `--background` flag. A supervisor's stdin is usually
`/dev/null`, where a console source ends at once, so a service's file defines
its pipelines. See [Operations](operations.md#starting) and
[Installation](installation.md). [Installation](installation.md).
**Reload** **Reload**
+15 -6
View File
@@ -41,9 +41,9 @@ runtime metadata and cannot be redirected by a `config_file` key inside the file
Top-level keys, each as type and default: Top-level keys, each as type and default:
- `quiet` (bool, `false`): disable all application logging and console - `quiet` (bool, `false`): disable all application logging and console
diagnostics. diagnostics; pipeline output still flows.
- `status_reporter` (bool, `true`): emit a status report every 30 s at DEBUG - `status_reporter` (bool, `true` with a configuration file, `false` without):
level. emit a status report every 30 s at DEBUG level.
- `auto_reload` (bool, `false`): watch the config file and reload pipelines on - `auto_reload` (bool, `false`): watch the config file and reload pipelines on
change. change.
@@ -56,11 +56,12 @@ Note that `status_reporter` writes at DEBUG level, so it produces nothing unless
## Application Logging ## Application Logging
This configures LogWisp's own operational log, not the log data it transports. This configures LogWisp's own operational log, not the log data it transports.
It goes to stderr by default, so stdout carries only a console sink's data.
```toml ```toml
[logging] [logging]
output = "stdout" # file | stdout | stderr | split | all | none output = "stderr" # file | stdout | stderr | split | all | none
level = "info" # debug | info | warn | error level = "info" # debug | info | warn | error; warn without a file
format = "txt" # raw | txt | json format = "txt" # raw | txt | json
# sanitization = "" # raw | json | txt | shell # sanitization = "" # raw | json | txt | shell
@@ -81,7 +82,15 @@ retention_hours = 168.0
- `all`: files plus split console. - `all`: files plus split console.
- `none`: no application logging. - `none`: no application logging.
`[logging.file]` applies only to the `file` and `all` modes. `[logging.file]` applies only to the `file` and `all` modes. The `stdout`,
`split` and `all` modes mix the log into a console sink's stdout.
Without a configuration file (none named, none found) lw is a command-line
tool: `level` defaults to `warn` and `status_reporter` to `false`. Explicit
values from the environment or flags still win. A container configured only
through variables or flags passes `LOGWISP_LOGGING_LEVEL=info` to log as a
service; the image does not set it, since a variable would override a mounted
file's value.
> `[logging.console].target` is accepted and validated (`stdout`, `stderr`, > `[logging.console].target` is accepted and validated (`stdout`, `stderr`,
> `split`) but **not applied**. The console destination is derived from > `split`) but **not applied**. The console destination is derived from
+9 -7
View File
@@ -11,8 +11,8 @@ flags = 0
timestamp_format = "" timestamp_format = ""
``` ```
One formatter serves the whole pipeline. Sinks receive an identical payload; One formatter serves the whole pipeline. Sinks receive an identical payload,
there is no per-sink formatting. When you need two shapes of the same data, run one record ending in exactly one newline; there is no per-sink formatting. When you need two shapes of the same data, run
two pipelines, or chain to a node that formats differently. two pipelines, or chain to a node that formats differently.
Omitting `[pipelines.flow.format]` entirely selects `raw`. Omitting `[pipelines.flow.format]` entirely selects `raw`.
@@ -33,9 +33,10 @@ Each as type and default:
### raw ### raw
Passthrough. `FlagRaw` bypasses formatting and sanitization: the message reaches Passthrough. `FlagRaw` bypasses formatting and sanitization: the message reaches
the sink exactly as the source produced it, with no timestamp, level or source the sink as the source produced it, with no timestamp, level or source prefix
prefix added. An entry that also carries `fields` gets the fields JSON appended added, and a newline appended unless the message ends in one. An entry that also
verbatim after a single space — `raw` never drops data and never re-encodes it. carries `fields` gets the fields JSON appended verbatim after a single space —
`raw` never drops data and never re-encodes it.
```toml ```toml
[pipelines.flow.format] [pipelines.flow.format]
@@ -47,8 +48,9 @@ its final form. Note that it also bypasses sanitization, so control characters
in the source data reach your sinks intact. in the source data reach your sinks intact.
Byte-exact transport needs a source that does not split the line: the `console` Byte-exact transport needs a source that does not split the line: the `console`
source, or the `file` source with `raw = true`. Both put the whole line — source, or the `file` source with `raw = true`. Both put the whole line, its
newline included — in the message and leave `fields` empty. The `file` source's terminator removed, in the message and leave `fields` empty; `raw` writes it
back as one `\n`. The `file` source's
JSON branch splits a line into message and fields, so `raw` reassembles it as JSON branch splits a line into message and fields, so `raw` reassembles it as
`<msg> <fields>` rather than reproducing the original object. `<msg> <fields>` rather than reproducing the original object.
+14 -7
View File
@@ -52,9 +52,11 @@ Variables:
- `SOURCE_DATE_EPOCH`: when set, the embedded build time comes from it, so - `SOURCE_DATE_EPOCH`: when set, the embedded build time comes from it, so
package builds are reproducible. package builds are reproducible.
A plain `go build -o bin/lw ./cmd/lw` works too, and reports `dev` for the A plain `go build -o bin/lw ./cmd/lw` works too, as does
version metadata. `go install github.com/lixenwraith/logwisp/cmd/lw@latest` `go install github.com/lixenwraith/logwisp/cmd/lw@latest` (or `@vX.Y.Z`).
does not work until the module is renamed to its canonical path. Without the Makefile's `-ldflags`, `lw --version` reports what Go stamps into
the binary: the module version, or a pseudo-version with the commit in a
checkout.
## Installing ## Installing
@@ -201,6 +203,12 @@ docker run -d --name logwisp \
-e LOGWISP_SINK=console -e LOGWISP_SINK=console
``` ```
Without a file lw logs warnings only and runs no status reporter; add
`-e LOGWISP_LOGGING_LEVEL=info` (and `-e LOGWISP_STATUS_REPORTER=true`) for
the service defaults. The image cannot set them: a variable would override
a mounted file's value. Without `-i`, stdin is `/dev/null`, so a container
needs a source other than `console`.
- Secrets: only as mounted files, at `/run/secrets` (Docker or Compose - Secrets: only as mounted files, at `/run/secrets` (Docker or Compose
secrets, a Kubernetes secret volume), named by `tls.key_file`, secrets, a Kubernetes secret volume), named by `tls.key_file`,
`auth.credentials_file` and `auth.password_file`. Never put a password in `auth.credentials_file` and `auth.password_file`. Never put a password in
@@ -237,10 +245,9 @@ The foundation for distribution packages is in place:
- skeletons: `deploy/package/arch/PKGBUILD` and a `sysutils/logwisp` port in - skeletons: `deploy/package/arch/PKGBUILD` and a `sysutils/logwisp` port in
`deploy/package/freebsd/` `deploy/package/freebsd/`
Still missing: tagged, signed release tarballs; the module rename to Still missing: release tags, which both skeletons download; finishing and
`github.com/lixenwraith/logwisp`, which `go install` and FreeBSD's submitting the AUR package and the port; Debian packaging.
`go:modules` need; finishing and submitting the AUR package and the port; [To Do](todo.md) has the steps.
Debian packaging. [To Do](todo.md) has the steps.
## Verification ## Verification
+135 -17
View File
@@ -1,4 +1,4 @@
.Dd October 2, 2026 .Dd October 3, 2026
.Dt LW 1 .Dt LW 1
.Os .Os
.Sh NAME .Sh NAME
@@ -36,14 +36,65 @@ forward to other nodes.
Every network source and sink can use TLS, mutual TLS and Every network source and sink can use TLS, mutual TLS and
Argon2id-SCRAM password authentication. Argon2id-SCRAM password authentication.
.Pp .Pp
With no configuration file and no pipeline options,
.Nm .Nm
stays in the foreground and logs to standard output; run it under a is a filter like
supervisor such as .Xr cat 1 :
it copies standard input to standard output line by line and exits 0 at the
end of input.
This built-in pipeline,
.Dq pipe ,
joins the console source
.Dq stdin
to the console sink
.Dq stdout
in the raw format, without a filter or rate limit; a configuration file that
defines no pipelines runs it too.
Each line is written with one newline, whether it ended in a newline, a
carriage return and newline, or nothing; blank lines are skipped, and a line
over 1 MiB continues in the next one.
When standard input is a terminal,
.Nm
prints a reminder on standard error that Control-D ends the input.
.Pp
A pipeline finishes when all its sources have ended.
Only a console source ends, at the end of standard input; every other
source runs until shutdown.
When every pipeline has finished,
.Nm
shuts down gracefully and exits 0.
Console and file sinks first write what they hold; the
.Cm http
and
.Cm tcp
sinks do not yet, so a client can miss the last lines of a finite input.
Under a supervisor, standard input is usually
.Pa /dev/null :
a console source there ends at once.
.Pp
The console source and sink never drop a line.
A busy pipeline slows the reading of standard input; a console sink whose
output is slow makes its pipeline wait, other sinks included.
On a terminal the console sink writes control characters, escape sequences
included, as the hex of their bytes in angle brackets, such as
.Ql <1b> ,
so that a log line cannot drive the terminal; pipes and files get the bytes
unchanged.
Its
.Cm escape
option sets this:
.Cm auto
.Pq the default ,
.Cm always
or
.Cm never .
.Pp
.Nm
stays in the foreground and logs to standard error; run a configuration as a
service under a supervisor such as
.Xr systemd 1 .Xr systemd 1
or or
.Xr daemon 8 . .Xr daemon 8 .
With no configuration file it runs a demonstration pipeline: a synthetic
generator writing JSON to standard output.
.Pp .Pp
The options are as follows: The options are as follows:
.Bl -tag -width Ds .Bl -tag -width Ds
@@ -59,23 +110,39 @@ A bare
.Fl \- Ns Ar key .Fl \- Ns Ar key
sets a boolean to true. sets a boolean to true.
.It Fl \-quiet .It Fl \-quiet
Suppress console output. Turn off the log of
.Nm
and the terminal reminder; console sinks still write.
.It Fl \-status_reporter= Ns Ar bool .It Fl \-status_reporter= Ns Ar bool
Log pipeline statistics every 30 seconds at the debug level Log pipeline statistics every 30 seconds at the debug level
.Pq default true . .Pq default true with a configuration file, false without .
.It Fl \-auto_reload= Ns Ar bool .It Fl \-auto_reload= Ns Ar bool
Reload when the configuration file changes Reload when the configuration file changes
.Pq default false . .Pq default false .
.It Fl \-logging.output= Ns Ar mode .It Fl \-logging.output= Ns Ar mode
One of One of
.Cm file , stdout , stderr , split , all .Cm stderr
.Pq the default ,
.Cm stdout ,
.Cm split
.Pq warnings and errors to standard error, the rest to standard output ,
.Cm file ,
.Cm all
.Pq split and file
or or
.Cm none . .Cm none .
The modes that use standard output mix the log into what a console sink
writes there.
.It Fl \-logging.level= Ns Ar level .It Fl \-logging.level= Ns Ar level
One of One of
.Cm debug , info , warn .Cm debug , info , warn
or or
.Cm error . .Cm error ;
the default is
.Cm info
with a configuration file and
.Cm warn
without.
.It Fl \-check .It Fl \-check
Build every pipeline and plugin as a start would, loading options, TLS files Build every pipeline and plugin as a start would, loading options, TLS files
and credentials files and printing startup warnings, without binding a port and credentials files and printing startup warnings, without binding a port
@@ -97,6 +164,12 @@ the configuration file.
They then replace the file's pipelines and the built-in default; every other They then replace the file's pipelines and the built-in default; every other
key keeps its usual precedence. key keeps its usual precedence.
A reload rereads the file but keeps these pipelines. A reload rereads the file but keeps these pipelines.
A pipeline without a source reads standard input with the console source
.Dq stdin ;
one without a sink writes standard output with the console sink
.Dq stdout .
Standard input has one reader: a second console source anywhere in the
configuration fails validation.
.Bl -tag -width Ds .Bl -tag -width Ds
.It Fl \-pipeline Ar name .It Fl \-pipeline Ar name
Start a new pipeline. Start a new pipeline.
@@ -265,7 +338,7 @@ The service's working directory on Linux and on
.El .El
.Sh EXIT STATUS .Sh EXIT STATUS
.Nm .Nm
exits 0 after a clean shutdown, exits 0 at the end of input, after a clean shutdown,
.Fl \-version .Fl \-version
or help; 1 on a configuration, logger or startup error; and 2 when a or help; 1 on a configuration, logger or startup error; and 2 when a
configuration file named by configuration file named by
@@ -273,6 +346,15 @@ configuration file named by
or or
.Ev LOGWISP_CONFIG_* .Ev LOGWISP_CONFIG_*
does not exist. does not exist.
A reader of standard output that goes away, as in
.Ql lw | head ,
ends
.Nm
with
.Dv SIGPIPE ,
as it ends
.Xr cat 1 ;
the shell reports status 141.
.Pp .Pp
.Nm .Nm
.Cm auth .Cm auth
@@ -287,23 +369,59 @@ every pipeline.
An invalid configuration leaves the running service untouched. An invalid configuration leaves the running service untouched.
.El .El
.Sh EXAMPLES .Sh EXAMPLES
Copy a file line by line:
.Bd -literal -offset indent
$ lw < app.log > copy.log
.Ed
.Pp
Follow a log and keep only errors and warnings:
.Bd -literal -offset indent
$ tail \-F app.log | lw \-\-filter include,patterns=ERROR,patterns=WARN
.Ed
.Pp
Prefix each line with the time, the level found in it and the source:
.Bd -literal -offset indent
$ tail \-F app.log | lw \-\-format txt
.Ed
.Pp
Serve standard input as a live stream on the loopback address only, since
the
.Cm http
sink listens on every address by default, and read it from another terminal
as server-sent events; a client gets the lines that arrive while it is
connected:
.Bd -literal -offset indent
$ journalctl \-f | lw \-\-sink http,host=127.0.0.1,port=8080
$ curl \-N http://127.0.0.1:8080/stream
.Ed
.Pp
Follow every
.Pa .log
file in a directory, as
.Ql tail \-F
does, and write the new lines unparsed to one file:
.Bd -literal -offset indent
$ lw \-\-source 'file,directory=/var/log/app,pattern=*.log,raw=true' \e
> all.log
.Ed
.Pp
Check a configuration without binding a port or touching a log file:
.Bd -literal -offset indent
$ lw \-\-check \-c /etc/logwisp/logwisp.toml
.Ed
.Pp
Run a configuration in the foreground with debug logging: Run a configuration in the foreground with debug logging:
.Bd -literal -offset indent .Bd -literal -offset indent
$ lw \-c /etc/logwisp/logwisp.toml \-\-logging.level=debug $ lw \-c /etc/logwisp/logwisp.toml \-\-logging.level=debug
.Ed .Ed
.Pp .Pp
Tail a directory and serve it as a live stream, without a file:
.Bd -literal -offset indent
$ lw \-\-source 'file,directory=/var/log/app,pattern=*.log' \e
\-\-sink http,host=127.0.0.1,port=8080
.Ed
.Pp
Create a credentials file the service account can read, add a user, Create a credentials file the service account can read, add a user,
letting letting
.Nm .Nm
generate the password into a file, then reload the server: generate the password into a file, then reload the server:
.Bd -literal -offset indent .Bd -literal -offset indent
# install \-m 0600 \-o logwisp \-g logwisp /dev/null /etc/logwisp/users.toml # install \-m 0600 \-o logwisp \-g logwisp \e
/dev/null /etc/logwisp/users.toml
# lw auth add-user \-credentials /etc/logwisp/users.toml \e # lw auth add-user \-credentials /etc/logwisp/users.toml \e
\-user alice \-password-file /root/alice.pass \-user alice \-password-file /root/alice.pass
# systemctl reload logwisp # systemctl reload logwisp
+42 -9
View File
@@ -5,17 +5,32 @@ Running, monitoring, and maintaining LogWisp.
## Starting ## Starting
```bash ```bash
# foreground, explicit config # a filter: stdin to stdout, exits 0 at the end of input
lw -c /etc/logwisp/logwisp.toml lw < app.log > copy.log
tail -F app.log | lw --filter include,patterns=ERROR
# no config: built-in demo pipeline (random source -> stdout) # a service: the file's pipelines, until SIGINT or SIGTERM
lw lw -c /etc/logwisp/logwisp.toml
``` ```
lw exits on its own only at the end of input: once every pipeline has
finished, a pipeline finishing when all its sources have ended. Only a console
source ends, when stdin does; file and network sources run until a signal, and
lw shuts down gracefully, exit 0, either way.
Console and file sinks write what is queued before the exit; the `http` and
`tcp` sinks do not yet flush their clients' queues ([To Do](todo.md)). See
[CLI](cli.md#built-in-defaults).
There is no built-in daemon mode. Run LogWisp in the foreground under a There is no built-in daemon mode. Run LogWisp in the foreground under a
supervisor — systemd, rc.d, or a container runtime — which is where restart, supervisor — systemd, rc.d, or a container runtime — which is where restart,
log capture, and resource limits belong. See [Installation](installation.md). log capture, and resource limits belong. See [Installation](installation.md).
A supervisor's stdin is `/dev/null` (systemd's default, `docker run` without
`-i`), where a console source ends at once. So a service defines its
pipelines: a file without `[[pipelines]]` runs the built-in stdin-to-stdout
pipeline and exits 0 at once, which `Restart=on-failure` does not restart, and
a container configured through variables needs a `LOGWISP_SOURCE`.
**systemd** **systemd**
```bash ```bash
@@ -64,6 +79,9 @@ What reload does *not* do:
construction and `auto_reload` watches only the configuration file, so construction and `auto_reload` watches only the configuration file, so
rotation requires `SIGHUP`. rotation requires `SIGHUP`.
Standard input survives a reload: one reader serves the process, so the new
console source continues where the old one stopped, and no line is read twice.
Plan reloads on a busy relay the way you would plan a restart. Plan reloads on a busy relay the way you would plan a restart.
Listener bind/start failures happen after the old service stops; these can leave Listener bind/start failures happen after the old service stops; these can leave
the application without working pipelines until a corrected configuration reloads. the application without working pipelines until a corrected configuration reloads.
@@ -94,8 +112,9 @@ proves itself when the pipeline is actually built.
### Status reporter ### Status reporter
Enabled by default, every 30 seconds. It logs at **DEBUG**, so it produces Enabled by default with a configuration file (off without one), every 30
nothing unless `logging.level = "debug"` — a common surprise. seconds. It logs at **DEBUG**, so it produces nothing unless
`logging.level = "debug"` — a common surprise.
```toml ```toml
status_reporter = true status_reporter = true
@@ -153,10 +172,12 @@ query it with a client certificate or a token from `lw auth token`.
Each counter, where it is reported, and what a rise means: Each counter, where it is reported, and what a rise means:
- `dropped_entries` (source): downstream cannot keep up with the source - `dropped_entries` (source): downstream cannot keep up with the source; the
console source never drops, it reads stdin slower
- `total_dropped` (flow): the rate limit or filters are discarding entries, - `total_dropped` (flow): the rate limit or filters are discarding entries,
often by intent often by intent
- `total_dropped_by_sink` (pipeline): a sink's input queue is full - `total_dropped_by_sink` (pipeline): a sink's input queue is full; a console
sink waits instead, so its entries count only when shutdown cuts the wait
- `dropped_writes` (`tcp` and `http` sinks): a client's queue overflowed, - `dropped_writes` (`tcp` and `http` sinks): a client's queue overflowed,
because it is too slow or one burst exceeded `client_buffer_size` because it is too slow or one burst exceeded `client_buffer_size`
- `rejected_conns` (`tcp` sink, `tcp_chain` source), `rejected_clients` - `rejected_conns` (`tcp` sink, `tcp_chain` source), `rejected_clients`
@@ -172,7 +193,11 @@ Each counter, where it is reported, and what a rise means:
## Log Management ## Log Management
LogWisp's own operational log: LogWisp's own operational log goes to stderr by default, at `info` with a
configuration file and at `warn` without one; explicit values win. A container
configured only through variables or flags therefore logs at `warn`: pass
`-e LOGWISP_LOGGING_LEVEL=info`. `lw-deploy.sh` always passes `-c`, so its
nodes log at `info`. To files:
```toml ```toml
[logging] [logging]
@@ -253,6 +278,14 @@ Walk the pipeline in order and read the counters: source `total_entries` (is
anything being produced?), flow `total_dropped` (filters or rate limit?), anything being produced?), flow `total_dropped` (filters or rate limit?),
pipeline `total_dropped_by_sink` (sink backed up?), sink `total_processed`. pipeline `total_dropped_by_sink` (sink backed up?), sink `total_processed`.
**A pipeline stalls behind a console sink**
A console sink never drops: when its output is slow (a paused terminal, a full
pipe, a stalled log collector) its pipeline waits, and the pipeline's other
sinks with it. Stdin is read slower; other sources queue, then drop and count
`dropped_entries`. Where a service's stdout may stall, give it a `file` sink
instead. A closed stdout pipe ends lw with `SIGPIPE`.
**File source reads nothing** **File source reads nothing**
- The watcher seeks to end-of-file on start; only content appended afterwards is - The watcher seeks to end-of-file on start; only content appended afterwards is
+6
View File
@@ -853,3 +853,9 @@ Choose a sanitizer policy that matches the sink — `json` for JSON output,
`txt` for files and consoles — so control characters in log data cannot break `txt` for files and consoles — so control characters in log data cannot break
framing or inject terminal escapes downstream. See framing or inject terminal escapes downstream. See
[Formatters](formatters.md). [Formatters](formatters.md).
The `raw` format passes control characters through. A console sink writing to
a terminal escapes them itself (`escape = "auto"`), so a log line cannot move
the cursor, retitle the window, write the clipboard (OSC 52) or reorder text
with bidi controls; pipes and files get the bytes unchanged. See
[Sinks](sinks.md#console).
+29 -6
View File
@@ -16,7 +16,8 @@ sibling sinks are unaffected.
## console ## console
Writes formatted payloads to stdout or stderr. Writes formatted payloads to stdout or stderr. It is the sink of the built-in
`pipe` pipeline, and of every spec pipeline given no `--sink`.
```toml ```toml
[[pipelines.plugin_sinks]] [[pipelines.plugin_sinks]]
@@ -25,19 +26,35 @@ type = "console"
[pipelines.plugin_sinks.config] [pipelines.plugin_sinks.config]
target = "stdout" target = "stdout"
buffer_size = 1000 buffer_size = 1000
escape = "auto"
``` ```
Options, each as type and default: Options, each as type and default:
- `target` (string, `stdout`): `stdout` or `stderr`. - `target` (string, `stdout`): `stdout` or `stderr`.
- `buffer_size` (int, `1000`): sink input queue depth. - `buffer_size` (int, `1000`): sink input queue depth.
- `escape` (string, `auto`): write control characters as `<hex>` of their
bytes, `ESC` as `<1b>`: `auto` when the output is a terminal, `always`, or
`never`.
> `split` is **not** a valid target for this sink and is rejected at startup. > `split` is **not** a valid target for this sink and is rejected at startup.
> Level-based splitting exists only for LogWisp's own application log > Level-based splitting exists only for LogWisp's own application log
> (`logging.output = "split"`). > (`logging.output = "split"`).
Payloads are written verbatim; the sink adds no framing. Whether entries are Payloads are written as the formatter made them, one record per line.
newline-terminated is decided by the formatter.
- Escaping: a log line must not drive the terminal that shows it (cursor and
screen control, title changes, OSC 52 clipboard writes, bidi spoofing). C0
and C1 controls other than tab, DEL, the bidi controls, the line and
paragraph separators and invalid UTF-8 are escaped; other invisible
characters, such as the joiners emoji and Indic scripts need, are kept.
`never` keeps an application's own ANSI colours; under `auto`, pipes and
files get the bytes unchanged.
- Backpressure: the sink never drops. When its output is slow (a slow reader,
a paused terminal, a stalled log collector) the pipeline waits for it, with
its other sinks. A service whose stdout may stall writes to a `file` sink
instead. A closed stdout pipe ends lw with `SIGPIPE`, as it ends `cat`.
- On stop it writes what is queued.
--- ---
@@ -68,7 +85,8 @@ Options, each as type and default:
- `max_total_size_mb` (int, `1000`): cap across all rotated files. - `max_total_size_mb` (int, `1000`): cap across all rotated files.
- `min_disk_free_mb` (int, `0`): free-space floor before writing; `0` = none. - `min_disk_free_mb` (int, `0`): free-space floor before writing; `0` = none.
- `retention_hours` (float, `168.0`): delete rotated files older than this. - `retention_hours` (float, `168.0`): delete rotated files older than this.
- `buffer_size` (int, `1000`): sink input queue depth. - `buffer_size` (int, `1000`): sink input queue depth, and the depth of the
internal writer's queue, which drops and counts when full.
- `flush_interval_ms` (int, `100`): forced flush interval. - `flush_interval_ms` (int, `100`): forced flush interval.
> `min_disk_free_mb` has an unusual default. The constructor replaces only > `min_disk_free_mb` has an unusual default. The constructor replaces only
@@ -76,7 +94,8 @@ Options, each as type and default:
> free-space floor. Set it explicitly if you want one. > free-space floor. Set it explicitly if you want one.
The sink drives an internal writer configured for raw output with timestamps and The sink drives an internal writer configured for raw output with timestamps and
levels disabled, so what lands on disk is exactly the formatted payload. levels disabled, so what lands on disk is exactly the formatted payload. On stop
it hands the writer what is queued before closing the file.
--- ---
@@ -187,7 +206,9 @@ Options, each as type and default:
evicted by the broker. With the idle comment above, that reaches only a peer evicted by the broker. With the idle comment above, that reaches only a peer
that has stopped accepting bytes on a sink configured `write_timeout_ms = 0`. that has stopped accepting bytes on a sink configured `write_timeout_ms = 0`.
- On shutdown, connected clients receive - On shutdown, connected clients receive
`event: disconnect / data: {"reason":"server_shutdown"}`. `event: disconnect / data: {"reason":"server_shutdown"}`. Entries still
queued for them are not flushed first, so a client can miss the last ones
of a finite input ([To Do](todo.md)).
- HTTP/2 is negotiated via ALPN when TLS is enabled; plaintext is HTTP/1.1. - HTTP/2 is negotiated via ALPN when TLS is enabled; plaintext is HTTP/1.1.
**Status endpoint** returns service and version identity, host, port, TLS flag, **Status endpoint** returns service and version identity, host, port, TLS flag,
@@ -266,6 +287,8 @@ Options, each as type and default:
whole timeout, so the client is disconnected immediately rather than retried. whole timeout, so the client is disconnected immediately rather than retried.
- A client whose send queue is full has that event dropped (`dropped_writes`) - A client whose send queue is full has that event dropped (`dropped_writes`)
and stays connected. and stays connected.
- On shutdown, entries still queued for clients are not flushed first, as
with the `http` sink.
- With TLS enabled the handshake runs under a 10 s bound *after* the - With TLS enabled the handshake runs under a 10 s bound *after* the
`max_connections` check, so concurrent handshakes are bounded too. `max_connections` check, so concurrent handshakes are bounded too.
- With an `auth` block, authorization runs after that handshake and *before* - With an `auth` block, authorization runs after that handshake and *before*
+19 -4
View File
@@ -84,7 +84,8 @@ last read time, plus `active_watchers`.
## console ## console
Reads newline-delimited entries from standard input. Reads newline-delimited entries from standard input. It is the source of the
built-in `pipe` pipeline, and of every spec pipeline given no `--source`.
```toml ```toml
[[pipelines.plugin_sources]] [[pipelines.plugin_sources]]
@@ -98,9 +99,23 @@ Option, as type and default:
- `buffer_size` (int, `1000`): subscriber channel depth. - `buffer_size` (int, `1000`): subscriber channel depth.
At most **one** instance per pipeline: the type is registered with Lines:
`MaxInstances: 1`, and a second instance is rejected at pipeline construction. - Each line is one entry, its terminator (`\n` or `\r\n`) removed; the
The level is inferred from the line text, and `Source` is set to `console`. formatter writes one back. An unterminated last line is kept.
- Blank lines are skipped.
- A line over 1 MiB continues in the next entry; no byte is lost.
- The level is inferred from the line text, and `Source` is set to `console`.
Delivery and lifetime:
- Nothing is dropped: the source waits for its pipeline, so a slow pipeline
slows the reading of stdin.
- One reader serves the process. A reload's new source continues where the old
one stopped; no line is read twice. Hence at most **one** console source in
the whole configuration, checked at validation.
- At the end of input the source ends, and with it a pipeline whose sources
have all ended; lw exits once every pipeline has
([CLI](cli.md#built-in-defaults)). Under a supervisor whose stdin is
`/dev/null` that is at once.
--- ---
+51 -35
View File
@@ -122,7 +122,16 @@ connection level, below TLS, so a refused peer costs no handshake.
- Denied and allowed clients; per-client SCRAM budgets behind one proxy - Denied and allowed clients; per-client SCRAM budgets behind one proxy
address. address.
## 2. Hardening review of `lixenwraith/config` and `lixenwraith/toml` ## 2. Flush network sinks at shutdown
At the end of a finite input lw shuts down, and the `http` and `tcp` sinks
disconnect their clients without writing the entries still queued for them, so
a client can miss the last ones. On `Stop`, each sink's broker should first move
its input queue into the client queues, then let every client writer drain its
queue within a bound (`write_timeout_ms`) before the disconnect frame. Verify
with a finite stdin into each sink and a connected client counting lines.
## 3. Hardening review of `lixenwraith/config` and `lixenwraith/toml`
Both parse untrusted-shaped input (config files, environment, command line) Both parse untrusted-shaped input (config files, environment, command line)
and were not reviewed with the auth work. Review them as `auth` was reviewed: and were not reviewed with the auth work. Review them as `auth` was reviewed:
@@ -151,52 +160,59 @@ the library repository, then a dependency bump here.
- Fuzz targets committed in the library repositories. - Fuzz targets committed in the library repositories.
- A note in this repository's security.md once done. - A note in this repository's security.md once done.
## 3. Packaging: AUR, FreeBSD ports, Debian ## 4. Packaging: AUR, FreeBSD ports, Debian
The foundation exists: The foundation exists:
- The `lw` binary name. - The `lw` binary name, free in Arch (official repositories and AUR), Ubuntu
24.04 and 26.04, and the FreeBSD 15.1 ports tree.
- The canonical module path `github.com/lixenwraith/logwisp`, which
`go install`, FreeBSD's `USES=go:modules` and Debian's dh-golang expect.
- `make install` with `DESTDIR`, `PREFIX` and `SYSCONFDIR`. - `make install` with `DESTDIR`, `PREFIX` and `SYSCONFDIR`.
- The `doc/lw.1` manual. - The `doc/lw.1` manual.
- Service files in `deploy/package/`: systemd unit, sysusers, tmpfiles and the - Service files in `deploy/package/`: systemd unit, sysusers, tmpfiles and the
FreeBSD rc.d script. FreeBSD rc.d script.
- Skeletons in `deploy/package/arch/` and `deploy/package/freebsd/`. - Skeletons in `deploy/package/arch/` and `deploy/package/freebsd/`.
What remains: What remains. Steps 1 to 3 are the maintainer's: they need push rights and an
identity, and both skeletons download the tagged source.
- Releases: 1. Tag the release on the merged main commit, `vX.Y.Z`:
- The repository has no tags, so versions come from `git describe` and fall ```
back to a commit hash. Tag releases `vX.Y.Z`. git tag -a vX.Y.Z -m vX.Y.Z # git tag -s signs it with your key
- Publish source tarballs with checksums, and sign them. git push origin vX.Y.Z
- Module path: ```
- The module is `logwisp`, so `go install github.com/lixenwraith/logwisp/cmd/lw@latest` - `make` then stamps `vX.Y.Z` (from `git describe`), and
does not work. `go install github.com/lixenwraith/logwisp/cmd/lw@vX.Y.Z` works.
- FreeBSD's `USES=go:modules` and Debian's dh-golang expect the canonical - A GitHub release for the tag is optional; the tag alone serves
path. `archive/vX.Y.Z.tar.gz`, which the PKGBUILD downloads.
- Rename the module to `github.com/lixenwraith/logwisp`: a mechanical 2. Arch (AUR), `deploy/package/arch/PKGBUILD`:
import rewrite in one commit. - `pkgver=X.Y.Z` and the `# Maintainer: Name <email>` line.
- Name check before submission: - `updpkgsums` (pacman-contrib) replaces `sha256sums=('SKIP')` with the
- Arch: `pkgfile -s lw` against the official repositories, and the AUR tarball's checksum.
package list for `lw` and `logwisp`. - `namcap PKGBUILD`, then `makepkg -si` (builds, runs `check()`,
- Already checked: Ubuntu 26.04 and 24.04 contents and the FreeBSD 15.1 installs), then `makepkg --printsrcinfo > .SRCINFO`.
ports tree ship no `bin/lw`. - Push `PKGBUILD` and `.SRCINFO` to
- Arch (AUR): `deploy/package/arch/PKGBUILD` already has the source URL, the `ssh://aur@aur.archlinux.org/logwisp.git`; optionally a `logwisp-git`
Arch Go flags (`-trimpath -buildmode=pie -mod=readonly -modcacherw`), package built from the main branch.
`backup` for the config and `check()`. Remaining: 3. FreeBSD, `deploy/package/freebsd/` (`sysutils/logwisp`):
- the checksum of a tagged tarball in place of `SKIP`, and a maintainer; - `DISTVERSION=X.Y.Z` and `MAINTAINER=` your address.
- namcap, `.SRCINFO`, and publishing `logwisp` (optionally `logwisp-git`). - `make makesum` writes `distinfo`: the module zip that the Go proxy
- FreeBSD: `deploy/package/freebsd/` already has `USES=go:1.27,modules`, serves for `GO_MODULE` at the tag.
`USE_RC_SUBR=logwisp`, `USERS`/`GROUPS`, and a pkg-plist with the `.sample` - The `logwisp` user needs an ID registered in the ports tree. Take a
config and the manual. Remaining: number below 1000 that is free in both `/usr/ports/UIDs` and
- `distinfo` (or `GH_TUPLE`), which needs the module rename and a tag; `/usr/ports/GIDs`, and add to the same patch:
- a real `MAINTAINER` and a `UIDs`/`GIDs` entry for `logwisp`; ```
- poudriere on 14.x and 15.x, then submission through Bugzilla UIDs: logwisp:*:NNN:NNN::0:0:LogWisp daemon:/nonexistent:/usr/sbin/nologin
(`sysutils/logwisp`). GIDs: logwisp:*:NNN:
- Debian: ```
- `portlint -AC`, `poudriere testport` on 14.x and 15.x jails, then a
Bugzilla report with the port directory and the UIDs/GIDs diff.
4. Debian:
- Debian policy wants every Go dependency packaged; the four `lixenwraith` - Debian policy wants every Go dependency packaged; the four `lixenwraith`
libraries are not. libraries are not.
- Either package them too (dh-golang), or start with an `.deb` built by the - Either package them too (dh-golang), or start with an `.deb` built by the
Makefile `install` target (nfpm or `dpkg-deb`) and a PPA, and move to the Makefile `install` target (nfpm or `dpkg-deb`) and a PPA, and move to the
archive later. archive later.
- Shell completion for bash, zsh and fish, installed by `make install`. 5. Shell completion for bash, zsh and fish, installed by `make install`.
- A packaging CI job: build the AUR package in an Arch container and the port 6. A packaging CI job: build the AUR package in an Arch container and the port
in a FreeBSD VM, run `lw --version` and `make image-check`. in a FreeBSD VM, run `lw --version` and `make image-check`.
+2 -1
View File
@@ -1,4 +1,4 @@
module logwisp module github.com/lixenwraith/logwisp
go 1.27.1 go 1.27.1
@@ -7,6 +7,7 @@ require (
github.com/lixenwraith/config v0.2.1 github.com/lixenwraith/config v0.2.1
github.com/lixenwraith/log v0.1.11 github.com/lixenwraith/log v0.1.11
github.com/lixenwraith/toml v0.0.0-20261001212158-cede215365c0 github.com/lixenwraith/toml v0.0.0-20261001212158-cede215365c0
golang.org/x/term v0.46.0
) )
require ( require (
+2
View File
@@ -12,3 +12,5 @@ golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M=
golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA= golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA=
golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo= golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
golang.org/x/term v0.46.0 h1:3+OXuTbaKDgwk8jTi3aSLHRlmWqHEUDUtxnbFigO4YE=
golang.org/x/term v0.46.0/go.mod h1:+K02xbkittuwc0Am4abfA3Fc+XRGXkvBXNO88NCXPoc=
+3 -3
View File
@@ -19,9 +19,9 @@ import (
"sync/atomic" "sync/atomic"
"time" "time"
"logwisp/internal/chain" "github.com/lixenwraith/logwisp/internal/chain"
"logwisp/internal/config" "github.com/lixenwraith/logwisp/internal/config"
"logwisp/internal/tlsx" "github.com/lixenwraith/logwisp/internal/tlsx"
"github.com/lixenwraith/log" "github.com/lixenwraith/log"
) )
+2 -2
View File
@@ -8,8 +8,8 @@ import (
"strings" "strings"
"testing" "testing"
"logwisp/internal/config" "github.com/lixenwraith/logwisp/internal/config"
"logwisp/internal/tlsx" "github.com/lixenwraith/logwisp/internal/tlsx"
) )
// peerState fakes a completed handshake. Only the leaf's identity fields are // peerState fakes a completed handshake. Only the leaf's identity fields are
+2 -2
View File
@@ -9,8 +9,8 @@ import (
"strings" "strings"
"testing" "testing"
"logwisp/internal/chain" "github.com/lixenwraith/logwisp/internal/chain"
"logwisp/internal/config" "github.com/lixenwraith/logwisp/internal/config"
"github.com/lixenwraith/auth" "github.com/lixenwraith/auth"
) )
+4 -4
View File
@@ -22,10 +22,10 @@ import (
"sync/atomic" "sync/atomic"
"time" "time"
"logwisp/internal/chain" "github.com/lixenwraith/logwisp/internal/chain"
"logwisp/internal/config" "github.com/lixenwraith/logwisp/internal/config"
"logwisp/internal/tlsx" "github.com/lixenwraith/logwisp/internal/tlsx"
"logwisp/internal/tokenbucket" "github.com/lixenwraith/logwisp/internal/tokenbucket"
"github.com/lixenwraith/auth" "github.com/lixenwraith/auth"
"github.com/lixenwraith/toml" "github.com/lixenwraith/toml"
+3 -3
View File
@@ -21,9 +21,9 @@ import (
"testing" "testing"
"time" "time"
"logwisp/internal/chain" "github.com/lixenwraith/logwisp/internal/chain"
"logwisp/internal/config" "github.com/lixenwraith/logwisp/internal/config"
"logwisp/internal/testutil" "github.com/lixenwraith/logwisp/internal/testutil"
"github.com/lixenwraith/auth" "github.com/lixenwraith/auth"
) )
+2 -1
View File
@@ -3,9 +3,10 @@ package chain
import ( import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"logwisp/internal/core"
"math/rand/v2" "math/rand/v2"
"time" "time"
"github.com/lixenwraith/logwisp/internal/core"
) )
// ProtocolVersion is declared in the hello preamble // ProtocolVersion is declared in the hello preamble
+1
View File
@@ -244,6 +244,7 @@ type NullSinkOptions struct{}
type ConsoleSinkOptions struct { type ConsoleSinkOptions struct {
Target string `toml:"target"` // "stdout", "stderr" Target string `toml:"target"` // "stdout", "stderr"
BufferSize int64 `toml:"buffer_size"` BufferSize int64 `toml:"buffer_size"`
Escape string `toml:"escape"` // control characters as <hex>: "auto" (on a terminal), "always", "never"
} }
// FileSinkOptions defines settings for a file-based sink // FileSinkOptions defines settings for a file-based sink
+22 -48
View File
@@ -3,11 +3,12 @@ package config
import ( import (
"errors" "errors"
"fmt" "fmt"
"io/fs"
"os" "os"
"path/filepath" "path/filepath"
"strings" "strings"
"logwisp/internal/core" "github.com/lixenwraith/logwisp/internal/core"
lconfig "github.com/lixenwraith/config" lconfig "github.com/lixenwraith/config"
) )
@@ -44,6 +45,11 @@ func Load(args []string) (*Manager, error) {
} }
m := &Manager{path: configPath, explicit: isExplicit, specs: specs} m := &Manager{path: configPath, explicit: isExplicit, specs: specs}
initial := defaults() initial := defaults()
if _, err := os.Stat(configPath); errors.Is(err, fs.ErrNotExist) {
// Without a file lw is a command-line tool: warnings and errors only
initial.Logging.Level = "warn"
initial.StatusReporter = false
}
cfg, err := lconfig.NewBuilder(). cfg, err := lconfig.NewBuilder().
WithTarget(initial). WithTarget(initial).
WithEnvPrefix("LOGWISP_"). WithEnvPrefix("LOGWISP_").
@@ -134,20 +140,13 @@ func (m *Manager) Watch() <-chan string {
func (m *Manager) Close() { m.config.StopAutoUpdate() } func (m *Manager) Close() { m.config.StopAutoUpdate() }
// defaults provides the default configuration values for the application // defaults provides the default configuration values for the application.
// Data goes to stdout, so lw's own log goes to stderr.
func defaults() *Config { func defaults() *Config {
return &Config{ return &Config{
// Top-level flag defaults
ShowVersion: false,
Quiet: false,
// Runtime behavior defaults
StatusReporter: true, StatusReporter: true,
ConfigAutoReload: false,
// Existing defaults
Logging: &LogConfig{ Logging: &LogConfig{
Output: "stdout", Output: "stderr",
Level: "info", Level: "info",
Format: "txt", Format: "txt",
File: &LogFileConfig{ File: &LogFileConfig{
@@ -158,45 +157,20 @@ func defaults() *Config {
RetentionHours: 168, // 7 days RetentionHours: 168, // 7 days
}, },
Console: &LogConsoleConfig{ Console: &LogConsoleConfig{
Target: "stdout", Target: "stderr",
},
},
Pipelines: []PipelineConfig{
{
Name: "default_pipeline",
Flow: &FlowConfig{
RateLimit: &RateLimitConfig{
Rate: 5,
Burst: 10,
Policy: "drop",
MaxEntrySizeBytes: 65536,
},
Format: &FormatConfig{
Type: "json",
SanitizerPolicy: "json",
},
},
PluginSources: []PluginSourceConfig{
{
ID: "default_source",
Type: "random",
Config: map[string]any{
"special": true,
},
},
},
PluginSinks: []PluginSinkConfig{
{
ID: "default_sink",
Type: "console",
Config: map[string]any{
"target": "stdout",
"buffer_size": 100,
},
},
},
}, },
}, },
Pipelines: []PipelineConfig{pipeDefault("pipe")},
}
}
// pipeDefault is a filter in the Unix sense: stdin to stdout, line for line
func pipeDefault(name string) PipelineConfig {
return PipelineConfig{
Name: name,
Flow: &FlowConfig{Format: &FormatConfig{Type: "raw"}},
PluginSources: []PluginSourceConfig{{ID: "stdin", Type: "console", Config: map[string]any{}}},
PluginSinks: []PluginSinkConfig{{ID: "stdout", Type: "console", Config: map[string]any{}}},
} }
} }
+36 -3
View File
@@ -9,7 +9,7 @@ import (
"testing" "testing"
"time" "time"
"logwisp/internal/testutil" "github.com/lixenwraith/logwisp/internal/testutil"
) )
func TestResolveConfigArguments(t *testing.T) { func TestResolveConfigArguments(t *testing.T) {
@@ -56,9 +56,9 @@ func TestLoadPrecedenceAndDetachedSnapshots(t *testing.T) {
t.Fatalf("snapshot: %+v %v", first, err) t.Fatalf("snapshot: %+v %v", first, err)
} }
first.Logging.Level = "mutated" first.Logging.Level = "mutated"
first.Pipelines[0].PluginSources[0].Config["special"] = false first.Pipelines[0].PluginSources[0].Config["buffer_size"] = 1
second, err := m.Snapshot() second, err := m.Snapshot()
if err != nil || second.Logging.Level != "warn" || second.Pipelines[0].PluginSources[0].Config["special"] != true { if _, aliased := second.Pipelines[0].PluginSources[0].Config["buffer_size"]; err != nil || second.Logging.Level != "warn" || aliased {
t.Fatalf("snapshot aliases another result: %+v %v", second, err) t.Fatalf("snapshot aliases another result: %+v %v", second, err)
} }
env, err := Load([]string{"-c=selected.toml"}) env, err := Load([]string{"-c=selected.toml"})
@@ -268,3 +268,36 @@ port = 9000
} }
} }
} }
// A file makes lw a service, logging at info with the status reporter; without
// one it is a command-line tool that reports warnings and errors only.
func TestNoConfigFileMeansQuietLogging(t *testing.T) {
isolateConfig(t)
for _, c := range []struct {
file, level string
reporter bool
}{{"", "warn", false}, {"[logging]\noutput = \"stderr\"\n", "info", true}} {
if c.file != "" {
testutil.WriteFile(t, "logwisp.toml", c.file)
}
m, err := Load(nil)
if err != nil {
t.Fatal(err)
}
cfg, err := m.Snapshot()
m.Close()
if err != nil || cfg.Logging.Level != c.level || cfg.StatusReporter != c.reporter || cfg.Logging.Output != "stderr" {
t.Fatalf("file %q: %+v %+v %v", c.file, cfg, cfg.Logging, err)
}
}
t.Setenv("LOGWISP_LOGGING_LEVEL", "debug")
os.Remove("logwisp.toml")
m, err := Load(nil)
if err != nil {
t.Fatal(err)
}
defer m.Close()
if cfg, err := m.Snapshot(); err != nil || cfg.Logging.Level != "debug" {
t.Fatalf("environment over the file-less default: %+v %v", cfg, err)
}
}
+11 -1
View File
@@ -131,7 +131,8 @@ func envPipelineSpecs() []pipelineSpec {
} }
// buildPipelines turns specs, in order, into pipelines; specs before the first // buildPipelines turns specs, in order, into pipelines; specs before the first
// --pipeline go to one named "cli". Every call returns fresh maps. // --pipeline go to one named "cli". A pipeline without a source reads stdin,
// one without a sink writes stdout. Every call returns fresh maps.
func buildPipelines(specs []pipelineSpec) ([]PipelineConfig, error) { func buildPipelines(specs []pipelineSpec) ([]PipelineConfig, error) {
var pipelines []PipelineConfig var pipelines []PipelineConfig
var seen map[*specKind]bool var seen map[*specKind]bool
@@ -160,6 +161,15 @@ func buildPipelines(specs []pipelineSpec) ([]PipelineConfig, error) {
return nil, fmt.Errorf("%s %s: %w", s.name, s.value, err) return nil, fmt.Errorf("%s %s: %w", s.name, s.value, err)
} }
} }
for i := range pipelines {
pipe := pipeDefault(pipelines[i].Name)
if len(pipelines[i].PluginSources) == 0 {
pipelines[i].PluginSources = pipe.PluginSources
}
if len(pipelines[i].PluginSinks) == 0 {
pipelines[i].PluginSinks = pipe.PluginSinks
}
}
return pipelines, nil return pipelines, nil
} }
+20 -4
View File
@@ -4,9 +4,10 @@ import (
"errors" "errors"
"os" "os"
"reflect" "reflect"
"strings"
"testing" "testing"
"logwisp/internal/testutil" "github.com/lixenwraith/logwisp/internal/testutil"
) )
func loadPipelines(t *testing.T, args ...string) []PipelineConfig { func loadPipelines(t *testing.T, args ...string) []PipelineConfig {
@@ -160,6 +161,21 @@ func TestSpecPipelinesReplaceFilePipelines(t *testing.T) {
} }
} }
// A spec pipeline without a source reads stdin and one without a sink writes
// stdout, as a filter does; two pipelines cannot both read stdin.
func TestSpecPipelinesDefaultToStdio(t *testing.T) {
isolateConfig(t)
got := loadPipelines(t, "--filter", "include,patterns=ERROR")
if p := got[0]; len(p.PluginSources) != 1 || p.PluginSources[0].Type != "console" || p.PluginSources[0].ID != "stdin" ||
len(p.PluginSinks) != 1 || p.PluginSinks[0].Type != "console" || p.PluginSinks[0].ID != "stdout" {
t.Fatalf("pipeline: %+v", p)
}
_, err := Load([]string{"--pipeline", "a", "--sink", "null", "--pipeline", "b", "--sink", "null"})
if err == nil || !strings.Contains(err.Error(), `console source already reads stdin in pipeline "a"`) {
t.Fatalf("second stdin reader: %v", err)
}
}
// Without a file a reload still rebuilds; once the discovered default // Without a file a reload still rebuilds; once the discovered default
// appears it is read, and its pipelines still yield to the specs. Its later // appears it is read, and its pipelines still yield to the specs. Its later
// removal fails the reload instead of keeping the removed file's values. // removal fails the reload instead of keeping the removed file's values.
@@ -171,13 +187,13 @@ func TestReloadKeepsSpecPipelines(t *testing.T) {
} }
defer m.Close() defer m.Close()
first, err := m.Reload() first, err := m.Reload()
if err != nil || !first.StatusReporter { if err != nil || first.StatusReporter {
t.Fatalf("reload without a file: %+v %v", first, err) t.Fatalf("reload without a file: %+v %v", first, err)
} }
first.Pipelines[0].PluginSources[0].Config["special"] = "mutated" first.Pipelines[0].PluginSources[0].Config["special"] = "mutated"
testutil.WriteFile(t, "logwisp.toml", "status_reporter = false\n[[pipelines]]\nname = \"file\"\n") testutil.WriteFile(t, "logwisp.toml", "status_reporter = true\n[[pipelines]]\nname = \"file\"\n")
next, err := m.Reload() next, err := m.Reload()
if err != nil || next.StatusReporter || len(next.Pipelines) != 1 || next.Pipelines[0].Name != "cli" || if err != nil || !next.StatusReporter || len(next.Pipelines) != 1 || next.Pipelines[0].Name != "cli" ||
next.Pipelines[0].PluginSources[0].Config["special"] != "true" { next.Pipelines[0].PluginSources[0].Config["special"] != "true" {
t.Fatalf("reload: %+v %v", next, err) t.Fatalf("reload: %+v %v", next, err)
} }
+1 -1
View File
@@ -3,7 +3,7 @@ package config
import ( import (
"testing" "testing"
"logwisp/internal/testutil" "github.com/lixenwraith/logwisp/internal/testutil"
) )
func isolateConfig(t *testing.T) { func isolateConfig(t *testing.T) {
+10
View File
@@ -38,6 +38,7 @@ func ValidateConfig(cfg *Config) error {
return fmt.Errorf("logging: %w", err) return fmt.Errorf("logging: %w", err)
} }
stdinReader := ""
for i, p := range cfg.Pipelines { for i, p := range cfg.Pipelines {
if err := lconfig.NonEmpty(p.Name); err != nil { if err := lconfig.NonEmpty(p.Name); err != nil {
return fmt.Errorf("pipeline[%d].name: %w", i, err) return fmt.Errorf("pipeline[%d].name: %w", i, err)
@@ -48,6 +49,15 @@ func ValidateConfig(cfg *Config) error {
if len(p.PluginSinks) == 0 { if len(p.PluginSinks) == 0 {
return fmt.Errorf("pipeline[%d]: no sinks defined", i) return fmt.Errorf("pipeline[%d]: no sinks defined", i)
} }
for _, src := range p.PluginSources {
if src.Type != "console" {
continue
}
if stdinReader != "" {
return fmt.Errorf("pipeline %q: a console source already reads stdin in pipeline %q", p.Name, stdinReader)
}
stdinReader = p.Name
}
} }
return nil return nil
+3
View File
@@ -15,6 +15,9 @@ const (
CapMultiSession Capability = "multi_session" CapMultiSession Capability = "multi_session"
CapSingleInstance Capability = "single_instance" CapSingleInstance Capability = "single_instance"
// Flow capabilities
CapBackpressure Capability = "backpressure" // a full input stalls the pipeline instead of dropping
// Stream capabilities // Stream capabilities
CapBidirectional Capability = "bidirectional" CapBidirectional Capability = "bidirectional"
CapCompression Capability = "compression" CapCompression Capability = "compression"
+1 -1
View File
@@ -4,7 +4,7 @@ import (
"net" "net"
"testing" "testing"
"logwisp/internal/testutil" "github.com/lixenwraith/logwisp/internal/testutil"
) )
// The host literal picks the family, strictly: an IPv4 literal or the empty // The host literal picks the family, strictly: an IPv4 literal or the empty
+2 -2
View File
@@ -4,8 +4,8 @@ import (
"fmt" "fmt"
"sync/atomic" "sync/atomic"
"logwisp/internal/config" "github.com/lixenwraith/logwisp/internal/config"
"logwisp/internal/core" "github.com/lixenwraith/logwisp/internal/core"
"github.com/lixenwraith/log" "github.com/lixenwraith/log"
) )
+2 -2
View File
@@ -6,8 +6,8 @@ import (
"sync" "sync"
"sync/atomic" "sync/atomic"
"logwisp/internal/config" "github.com/lixenwraith/logwisp/internal/config"
"logwisp/internal/core" "github.com/lixenwraith/logwisp/internal/core"
lconfig "github.com/lixenwraith/config" lconfig "github.com/lixenwraith/config"
"github.com/lixenwraith/log" "github.com/lixenwraith/log"
+4 -4
View File
@@ -5,10 +5,10 @@ import (
"fmt" "fmt"
"sync/atomic" "sync/atomic"
"logwisp/internal/config" "github.com/lixenwraith/logwisp/internal/config"
"logwisp/internal/core" "github.com/lixenwraith/logwisp/internal/core"
"logwisp/internal/filter" "github.com/lixenwraith/logwisp/internal/filter"
"logwisp/internal/format" "github.com/lixenwraith/logwisp/internal/format"
"github.com/lixenwraith/log" "github.com/lixenwraith/log"
) )
+3 -3
View File
@@ -8,9 +8,9 @@ import (
"sync/atomic" "sync/atomic"
"time" "time"
"logwisp/internal/config" "github.com/lixenwraith/logwisp/internal/config"
"logwisp/internal/core" "github.com/lixenwraith/logwisp/internal/core"
"logwisp/internal/format" "github.com/lixenwraith/logwisp/internal/format"
lconfig "github.com/lixenwraith/config" lconfig "github.com/lixenwraith/config"
"github.com/lixenwraith/log" "github.com/lixenwraith/log"
+2 -2
View File
@@ -5,8 +5,8 @@ import (
"testing" "testing"
"time" "time"
"logwisp/internal/config" "github.com/lixenwraith/logwisp/internal/config"
"logwisp/internal/format" "github.com/lixenwraith/logwisp/internal/format"
) )
// include_timestamp = false drops the timestamp the formatter shows by default // include_timestamp = false drops the timestamp the formatter shows by default
+3 -3
View File
@@ -5,9 +5,9 @@ import (
"strings" "strings"
"sync/atomic" "sync/atomic"
"logwisp/internal/config" "github.com/lixenwraith/logwisp/internal/config"
"logwisp/internal/core" "github.com/lixenwraith/logwisp/internal/core"
"logwisp/internal/tokenbucket" "github.com/lixenwraith/logwisp/internal/tokenbucket"
lconfig "github.com/lixenwraith/config" lconfig "github.com/lixenwraith/config"
"github.com/lixenwraith/log" "github.com/lixenwraith/log"
+12 -6
View File
@@ -6,8 +6,8 @@ import (
"fmt" "fmt"
"sync" "sync"
"logwisp/internal/config" "github.com/lixenwraith/logwisp/internal/config"
"logwisp/internal/core" "github.com/lixenwraith/logwisp/internal/core"
lconfig "github.com/lixenwraith/config" lconfig "github.com/lixenwraith/config"
"github.com/lixenwraith/log/formatter" "github.com/lixenwraith/log/formatter"
@@ -98,15 +98,21 @@ func (a *FormatterAdapter) FormatWithFlags(entry core.LogEntry, customFlags int6
return a.serialize(entry, customFlags), nil return a.serialize(entry, customFlags), nil
} }
// serialize renders an entry under the given flags. The returned slice is a // serialize renders an entry under the given flags as one record ending in
// copy: the underlying formatter reuses one buffer and sinks retain payloads. // one newline, which raw output lacks unless the message carried it. The
// result is a copy: the formatter reuses one buffer and sinks keep payloads.
func (a *FormatterAdapter) serialize(entry core.LogEntry, flags int64) []byte { func (a *FormatterAdapter) serialize(entry core.LogEntry, flags int64) []byte {
args, flags := formatArgs(entry, flags) args, flags := formatArgs(entry, flags)
a.mu.Lock() a.mu.Lock()
out := bytes.Clone(a.formatter.Format(flags, entry.Time, mapLevel(entry.Level), sourceLabel(entry), args)) out := a.formatter.Format(flags, entry.Time, mapLevel(entry.Level), sourceLabel(entry), args)
record := make([]byte, len(out), len(out)+1)
copy(record, out)
a.mu.Unlock() a.mu.Unlock()
return out if !bytes.HasSuffix(record, []byte{'\n'}) {
record = append(record, '\n')
}
return record
} }
// formatArgs pairs the entry with its flags. FlagRaw keeps the fields JSON // formatArgs pairs the entry with its flags. FlagRaw keeps the fields JSON
+34
View File
@@ -0,0 +1,34 @@
package format
import (
"bytes"
"testing"
"time"
"github.com/lixenwraith/logwisp/internal/config"
"github.com/lixenwraith/logwisp/internal/core"
)
// Every payload is one record ending in exactly one newline, whatever the
// type and whether or not the message carried its own.
func TestEveryRecordEndsInOneNewline(t *testing.T) {
entries := []core.LogEntry{
{Message: "plain"},
{Message: "carried\n"},
{Fields: []byte(`{"k":1}`)},
{Message: "with", Fields: []byte(`{"k":1}`)},
}
for _, typ := range []string{"raw", "txt", "json"} {
f, err := NewFormatter(&config.FormatConfig{Type: typ})
if err != nil {
t.Fatal(err)
}
for _, e := range entries {
e.Time = time.Now()
out, _ := f.Format(e)
if !bytes.HasSuffix(out, []byte("\n")) || bytes.HasSuffix(out, []byte("\n\n")) {
t.Errorf("%s %+v: %q", typ, e, out)
}
}
}
}
+2 -2
View File
@@ -1,8 +1,8 @@
package format package format
import ( import (
"logwisp/internal/config" "github.com/lixenwraith/logwisp/internal/config"
"logwisp/internal/core" "github.com/lixenwraith/logwisp/internal/core"
) )
// Formatter defines the interface for transforming a LogEntry into a byte slice // Formatter defines the interface for transforming a LogEntry into a byte slice
+58 -29
View File
@@ -7,12 +7,12 @@ import (
"sync/atomic" "sync/atomic"
"time" "time"
"logwisp/internal/config" "github.com/lixenwraith/logwisp/internal/config"
"logwisp/internal/core" "github.com/lixenwraith/logwisp/internal/core"
"logwisp/internal/flow" "github.com/lixenwraith/logwisp/internal/flow"
"logwisp/internal/session" "github.com/lixenwraith/logwisp/internal/session"
"logwisp/internal/sink" "github.com/lixenwraith/logwisp/internal/sink"
"logwisp/internal/source" "github.com/lixenwraith/logwisp/internal/source"
"github.com/lixenwraith/log" "github.com/lixenwraith/log"
) )
@@ -32,11 +32,17 @@ type Pipeline struct {
Stats *PipelineStats Stats *PipelineStats
logger *log.Logger logger *log.Logger
// Sinks that stall dispatch rather than drop (core.CapBackpressure)
backpressure map[string]bool
// Runtime // Runtime
ctx context.Context ctx context.Context
cancel context.CancelFunc cancel context.CancelFunc
wg sync.WaitGroup wg sync.WaitGroup
running atomic.Bool running atomic.Bool
halt chan struct{} // closed by Stop: backpressure sinks drop again
finished chan struct{} // closed when every source has ended
finishedOnce sync.Once
} }
// PipelineStats contains runtime statistics for a pipeline // PipelineStats contains runtime statistics for a pipeline
@@ -70,6 +76,9 @@ func NewPipeline(
logger: logger, logger: logger,
ctx: pipelineCtx, ctx: pipelineCtx,
cancel: pipelineCancel, cancel: pipelineCancel,
backpressure: make(map[string]bool),
finished: make(chan struct{}),
} }
// Create flow processor // Create flow processor
@@ -199,6 +208,9 @@ func (p *Pipeline) initSinkCapabilities(s sink.Sink, cfg config.PluginSinkConfig
var hasTLS, hasAuth bool var hasTLS, hasAuth bool
for _, c := range s.Capabilities() { for _, c := range s.Capabilities() {
switch c { switch c {
case core.CapBackpressure:
p.backpressure[cfg.ID] = true
// Network capabilities // Network capabilities
case core.CapNetLimit: case core.CapNetLimit:
continue // No-op for now, placeholder continue // No-op for now, placeholder
@@ -224,31 +236,31 @@ func (p *Pipeline) initSinkCapabilities(s sink.Sink, cfg config.PluginSinkConfig
return nil return nil
} }
// run is the central processing loop that connects sources, flow, and sinks // run connects the subscribed sources through the flow to the sinks. It
func (p *Pipeline) run() { // returns once every source channel has closed, at Stop or at end of input.
func (p *Pipeline) run(subscriptions []<-chan core.LogEntry) {
defer p.wg.Done() defer p.wg.Done()
defer p.finish()
defer p.logger.Info("msg", "Pipeline processing loop stopped", "pipeline", p.Config.Name) defer p.logger.Info("msg", "Pipeline processing loop stopped", "pipeline", p.Config.Name)
var componentWg sync.WaitGroup var componentWg sync.WaitGroup
// Start a goroutine for each source to fan-in data for _, ch := range subscriptions {
for _, src := range p.Sources {
componentWg.Add(1) componentWg.Add(1)
go func(s source.Source) { go func() {
defer componentWg.Done() defer componentWg.Done()
ch := s.Subscribe() // Range drains in-flight entries once the source closes the channel
// Range allows in-flight data to drain cleanly once Source.Stop() closes the channel
for entry := range ch { for entry := range ch {
if event, passed := p.Flow.Process(entry); passed { if event, passed := p.Flow.Process(entry); passed {
// Use non-blocking dispatcher
p.dispatch(event) p.dispatch(event)
} }
} }
}(src) }()
} }
// Sinks keep p.ctx until Stop, so the heartbeat ends on its own context
hbCtx, hbCancel := context.WithCancel(p.ctx)
var hbWg sync.WaitGroup var hbWg sync.WaitGroup
// Start heartbeat generator if enabled if heartbeatCh := p.Flow.StartHeartbeat(hbCtx); heartbeatCh != nil {
if heartbeatCh := p.Flow.StartHeartbeat(p.ctx); heartbeatCh != nil {
hbWg.Add(1) hbWg.Add(1)
go func() { go func() {
defer hbWg.Done() defer hbWg.Done()
@@ -258,9 +270,8 @@ func (p *Pipeline) run() {
if !ok { if !ok {
return return
} }
// Use non-blocking dispatcher
p.dispatch(event) p.dispatch(event)
case <-p.ctx.Done(): case <-hbCtx.Done():
return return
} }
} }
@@ -268,25 +279,36 @@ func (p *Pipeline) run() {
} }
componentWg.Wait() componentWg.Wait()
hbCancel()
// Terminate internal contexts (heartbeat) once flow is complete
p.cancel()
hbWg.Wait() hbWg.Wait()
} }
// dispatch performs a non-blocking send to all sinks. // dispatch sends to every sink. A full sink drops the event so it cannot
// A full/stalled sink must never block the run loop or starve sibling sinks. // stall its siblings, except a backpressure sink, which waits until Stop.
func (p *Pipeline) dispatch(event core.TransportEvent) { func (p *Pipeline) dispatch(event core.TransportEvent) {
for _, snk := range p.Sinks { for id, snk := range p.Sinks {
if p.backpressure[id] {
select {
case snk.Input() <- event:
case <-p.halt:
p.Stats.TotalEntriesDroppedBySink.Add(1)
}
continue
}
select { select {
case snk.Input() <- event: case snk.Input() <- event:
default: default:
// Buffer full - drop to avoid deadlocking the pipeline
p.Stats.TotalEntriesDroppedBySink.Add(1) p.Stats.TotalEntriesDroppedBySink.Add(1)
} }
} }
} }
func (p *Pipeline) finish() { p.finishedOnce.Do(func() { close(p.finished) }) }
// Finished is closed when every source has ended, or the pipeline shut down.
// Only a finite source (console at end of input) ends without Stop.
func (p *Pipeline) Finished() <-chan struct{} { return p.finished }
// Start starts the pipeline operation and all its components including flow, sources, and sinks // Start starts the pipeline operation and all its components including flow, sources, and sinks
func (p *Pipeline) Start() error { func (p *Pipeline) Start() error {
if !p.running.CompareAndSwap(false, true) { if !p.running.CompareAndSwap(false, true) {
@@ -295,6 +317,7 @@ func (p *Pipeline) Start() error {
p.logger.Info("msg", "Starting pipeline", "pipeline", p.Config.Name) p.logger.Info("msg", "Starting pipeline", "pipeline", p.Config.Name)
p.ctx, p.cancel = context.WithCancel(context.Background()) p.ctx, p.cancel = context.WithCancel(context.Background())
p.halt = make(chan struct{})
// Start all sinks // Start all sinks
for id, s := range p.Sinks { for id, s := range p.Sinks {
@@ -303,7 +326,11 @@ func (p *Pipeline) Start() error {
} }
} }
// Start all sources // Subscribed before Start: a source publishes from its first entry on
subscriptions := make([]<-chan core.LogEntry, 0, len(p.Sources))
for _, src := range p.Sources {
subscriptions = append(subscriptions, src.Subscribe())
}
for id, src := range p.Sources { for id, src := range p.Sources {
if err := src.Start(); err != nil { if err := src.Start(); err != nil {
return fmt.Errorf("failed to start source %s: %w", id, err) return fmt.Errorf("failed to start source %s: %w", id, err)
@@ -313,7 +340,7 @@ func (p *Pipeline) Start() error {
// Start the central processing loop // Start the central processing loop
p.Stats.StartTime = time.Now() p.Stats.StartTime = time.Now()
p.wg.Add(1) p.wg.Add(1)
go p.run() go p.run(subscriptions)
return nil return nil
} }
@@ -325,6 +352,7 @@ func (p *Pipeline) Stop() error {
} }
p.logger.Info("msg", "Stopping pipeline", "pipeline", p.Config.Name) p.logger.Info("msg", "Stopping pipeline", "pipeline", p.Config.Name)
close(p.halt)
// 1. Stop all sources concurrently to halt new data ingress and close their channels // 1. Stop all sources concurrently to halt new data ingress and close their channels
var sourceWg sync.WaitGroup var sourceWg sync.WaitGroup
@@ -338,7 +366,6 @@ func (p *Pipeline) Stop() error {
sourceWg.Wait() sourceWg.Wait()
// 2. Wait for the run loop to finish processing and sending all in-flight data // 2. Wait for the run loop to finish processing and sending all in-flight data
// run() inherently calls p.cancel() when the source channels are empty
p.wg.Wait() p.wg.Wait()
// 3. Stop all sinks concurrently now that no new data will be sent // 3. Stop all sinks concurrently now that no new data will be sent
@@ -351,6 +378,7 @@ func (p *Pipeline) Stop() error {
}(s) }(s)
} }
sinkWg.Wait() sinkWg.Wait()
p.cancel()
p.logger.Info("msg", "Pipeline stopped", "pipeline", p.Config.Name) p.logger.Info("msg", "Pipeline stopped", "pipeline", p.Config.Name)
return nil return nil
@@ -373,6 +401,7 @@ func (p *Pipeline) Shutdown() {
if p.Sessions != nil { if p.Sessions != nil {
p.Sessions.Stop() p.Sessions.Stop()
} }
p.finish() // a pipeline that never started has no run loop to close it
p.logger.Info("msg", "Pipeline shutdown complete", p.logger.Info("msg", "Pipeline shutdown complete",
"component", "pipeline", "component", "pipeline",
+132
View File
@@ -0,0 +1,132 @@
package pipeline
import (
"context"
"fmt"
"strconv"
"testing"
"time"
"github.com/lixenwraith/logwisp/internal/config"
"github.com/lixenwraith/logwisp/internal/core"
"github.com/lixenwraith/logwisp/internal/plugin"
"github.com/lixenwraith/logwisp/internal/session"
"github.com/lixenwraith/logwisp/internal/sink"
"github.com/lixenwraith/logwisp/internal/source"
"github.com/lixenwraith/log"
)
// finiteSource publishes n entries as soon as it starts, then ends
type finiteSource struct {
n int
subs []chan core.LogEntry
}
func (s *finiteSource) Capabilities() []core.Capability { return nil }
func (s *finiteSource) Subscribe() <-chan core.LogEntry {
ch := make(chan core.LogEntry, 1)
s.subs = append(s.subs, ch)
return ch
}
func (s *finiteSource) Start() error {
go func() {
for i := range s.n {
for _, ch := range s.subs {
ch <- core.LogEntry{Time: time.Now(), Message: strconv.Itoa(i)}
}
}
for _, ch := range s.subs {
close(ch)
}
}()
return nil
}
func (s *finiteSource) Stop() {}
func (s *finiteSource) GetStats() source.SourceStats { return source.SourceStats{} }
// slowSink applies backpressure; a nil out never reads at all
type slowSink struct {
in chan core.TransportEvent
out chan string
}
func (s *slowSink) Capabilities() []core.Capability { return []core.Capability{core.CapBackpressure} }
func (s *slowSink) Input() chan<- core.TransportEvent { return s.in }
func (s *slowSink) Start(context.Context) error {
if s.out != nil {
go func() {
for e := range s.in {
time.Sleep(10 * time.Microsecond)
s.out <- e.Entry.Message
}
}()
}
return nil
}
func (s *slowSink) Stop() {}
func (s *slowSink) GetStats() sink.SinkStats { return sink.SinkStats{} }
var testSinks = map[string]*slowSink{}
func init() {
plugin.RegisterSource("test_finite", func(id string, cfg map[string]any, _ *log.Logger, _ *session.Proxy) (source.Source, error) {
n, _ := strconv.Atoi(fmt.Sprint(cfg["n"]))
return &finiteSource{n: n}, nil
})
plugin.RegisterSink("test_slow", func(id string, _ map[string]any, _ *log.Logger, _ *session.Proxy) (sink.Sink, error) {
return testSinks[id], nil
})
}
func newTestPipeline(t *testing.T, n int, s *slowSink) *Pipeline {
t.Helper()
testSinks[t.Name()] = s
p, err := NewPipeline(&config.PipelineConfig{
Name: t.Name(),
PluginSources: []config.PluginSourceConfig{{ID: "src", Type: "test_finite", Config: map[string]any{"n": n}}},
PluginSinks: []config.PluginSinkConfig{{ID: t.Name(), Type: "test_slow"}},
}, log.NewLogger())
if err != nil {
t.Fatal(err)
}
return p
}
// A source that publishes from its first moment loses nothing to a slow sink,
// and the pipeline reports Finished when its input ends.
func TestFiniteInputReachesBackpressureSinkWhole(t *testing.T) {
const n = 2000
s := &slowSink{in: make(chan core.TransportEvent, 1), out: make(chan string, n)}
p := newTestPipeline(t, n, s)
if err := p.Start(); err != nil {
t.Fatal(err)
}
select {
case <-p.Finished():
case <-time.After(10 * time.Second):
t.Fatal("pipeline did not finish at the end of its input")
}
for i := range n {
if got := <-s.out; got != strconv.Itoa(i) {
t.Fatalf("entry %d = %s", i, got)
}
}
p.Shutdown()
}
// Stop releases a dispatch blocked on a sink that never reads
func TestStopReleasesStalledBackpressureSink(t *testing.T) {
p := newTestPipeline(t, 100, &slowSink{in: make(chan core.TransportEvent, 1)})
if err := p.Start(); err != nil {
t.Fatal(err)
}
time.Sleep(50 * time.Millisecond)
stopped := make(chan struct{})
go func() { p.Shutdown(); close(stopped) }()
select {
case <-stopped:
case <-time.After(5 * time.Second):
t.Fatal("Stop blocked behind a stalled backpressure sink")
}
}
+4 -4
View File
@@ -4,10 +4,10 @@ import (
"fmt" "fmt"
"sync" "sync"
"logwisp/internal/plugin" "github.com/lixenwraith/logwisp/internal/plugin"
"logwisp/internal/session" "github.com/lixenwraith/logwisp/internal/session"
"logwisp/internal/sink" "github.com/lixenwraith/logwisp/internal/sink"
"logwisp/internal/source" "github.com/lixenwraith/logwisp/internal/source"
"github.com/lixenwraith/log" "github.com/lixenwraith/log"
) )
+4 -4
View File
@@ -4,10 +4,10 @@ import (
"fmt" "fmt"
"sync" "sync"
"logwisp/internal/core" "github.com/lixenwraith/logwisp/internal/core"
"logwisp/internal/session" "github.com/lixenwraith/logwisp/internal/session"
"logwisp/internal/sink" "github.com/lixenwraith/logwisp/internal/sink"
"logwisp/internal/source" "github.com/lixenwraith/logwisp/internal/source"
"github.com/lixenwraith/log" "github.com/lixenwraith/log"
) )
-70
View File
@@ -1,70 +0,0 @@
package sanitize
import (
"encoding/hex"
"strconv"
"strings"
"unicode/utf8"
)
// String sanitizes a string by replacing non-printable characters with hex encoding
// Non-printable characters are encoded as <hex> (e.g., newline becomes <0a>)
func String(data string) string {
// Fast path: check if sanitization is needed
needsSanitization := false
for _, r := range data {
if !strconv.IsPrint(r) {
needsSanitization = true
break
}
}
if !needsSanitization {
return data
}
// Pre-allocate builder for efficiency
var builder strings.Builder
builder.Grow(len(data))
for _, r := range data {
if strconv.IsPrint(r) {
builder.WriteRune(r)
} else {
// Encode non-printable rune as <hex>
var runeBytes [utf8.UTFMax]byte
n := utf8.EncodeRune(runeBytes[:], r)
builder.WriteByte('<')
builder.WriteString(hex.EncodeToString(runeBytes[:n]))
builder.WriteByte('>')
}
}
return builder.String()
}
// Bytes sanitizes a byte slice by converting to string and sanitizing
func Bytes(data []byte) []byte {
return []byte(String(string(data)))
}
// Rune sanitizes a single rune, returning its string representation
func Rune(r rune) string {
if strconv.IsPrint(r) {
return string(r)
}
var runeBytes [utf8.UTFMax]byte
n := utf8.EncodeRune(runeBytes[:], r)
return "<" + hex.EncodeToString(runeBytes[:n]) + ">"
}
// IsSafe checks if a string contains only printable characters
func IsSafe(data string) bool {
for _, r := range data {
if !strconv.IsPrint(r) {
return false
}
}
return true
}
+21 -2
View File
@@ -6,8 +6,8 @@ import (
"fmt" "fmt"
"sync" "sync"
"logwisp/internal/config" "github.com/lixenwraith/logwisp/internal/config"
"logwisp/internal/pipeline" "github.com/lixenwraith/logwisp/internal/pipeline"
"github.com/lixenwraith/log" "github.com/lixenwraith/log"
) )
@@ -21,6 +21,8 @@ type Service struct {
wg sync.WaitGroup wg sync.WaitGroup
logger *log.Logger logger *log.Logger
shutdownOnce sync.Once shutdownOnce sync.Once
watchOnce sync.Once
done chan struct{} // closed when every pipeline has finished
} }
// NewService creates a new, empty service // NewService creates a new, empty service
@@ -31,6 +33,7 @@ func NewService(ctx context.Context, cfg *config.Config, logger *log.Logger) (*S
ctx: serviceCtx, ctx: serviceCtx,
cancel: cancel, cancel: cancel,
logger: logger, logger: logger,
done: make(chan struct{}),
} }
var errs error var errs error
@@ -89,10 +92,26 @@ func (svc *Service) Start(names ...string) error {
} }
svc.logger.Debug("msg", "Finished starting pipeline(s)", "pipelines", names) svc.logger.Debug("msg", "Finished starting pipeline(s)", "pipelines", names)
svc.watchOnce.Do(func() {
pipelines := make([]*pipeline.Pipeline, 0, len(svc.pipelines))
for _, p := range svc.pipelines {
pipelines = append(pipelines, p)
}
go func() {
for _, p := range pipelines {
<-p.Finished()
}
close(svc.done)
}()
})
return errs return errs
} }
// Done is closed once every pipeline has finished: all their sources reached
// the end of their input (stdin), or the service shut down.
func (svc *Service) Done() <-chan struct{} { return svc.done }
// Stop stops all or specific pipeline // Stop stops all or specific pipeline
func (svc *Service) Stop(names ...string) error { func (svc *Service) Stop(names ...string) error {
svc.mu.RLock() svc.mu.RLock()
+1 -1
View File
@@ -8,7 +8,7 @@ import (
"sync" "sync"
"time" "time"
"logwisp/internal/core" "github.com/lixenwraith/logwisp/internal/core"
) )
// Session represents a connection session // Session represents a connection session
+108 -32
View File
@@ -1,21 +1,26 @@
package console package console
import ( import (
"bytes"
"context" "context"
"encoding/hex"
"fmt" "fmt"
"io" "io"
"os" "os"
"sync"
"sync/atomic" "sync/atomic"
"time" "time"
"unicode/utf8"
"logwisp/internal/config" "github.com/lixenwraith/logwisp/internal/config"
"logwisp/internal/core" "github.com/lixenwraith/logwisp/internal/core"
"logwisp/internal/plugin" "github.com/lixenwraith/logwisp/internal/plugin"
"logwisp/internal/session" "github.com/lixenwraith/logwisp/internal/session"
"logwisp/internal/sink" "github.com/lixenwraith/logwisp/internal/sink"
lconfig "github.com/lixenwraith/config" lconfig "github.com/lixenwraith/config"
"github.com/lixenwraith/log" "github.com/lixenwraith/log"
"golang.org/x/term"
) )
// init registers the component in plugin factory // init registers the component in plugin factory
@@ -25,7 +30,7 @@ func init() {
} }
} }
// ConsoleSink writes log entries to the console (stdout/stderr) using an dedicated logger instance // ConsoleSink writes formatted entries to stdout or stderr
type ConsoleSink struct { type ConsoleSink struct {
// Plugin identity and session management // Plugin identity and session management
id string id string
@@ -34,6 +39,7 @@ type ConsoleSink struct {
// Configuration // Configuration
config *config.ConsoleSinkOptions config *config.ConsoleSinkOptions
escape bool
// Application // Application
input chan core.TransportEvent input chan core.TransportEvent
@@ -42,6 +48,9 @@ type ConsoleSink struct {
// Runtime // Runtime
done chan struct{} done chan struct{}
exited chan struct{}
started atomic.Bool
stopOnce sync.Once
startTime time.Time startTime time.Time
// Statistics // Statistics
@@ -53,6 +62,7 @@ const (
// Defaults // Defaults
DefaultConsoleTarget = "stdout" DefaultConsoleTarget = "stdout"
DefaultConsoleBufferSize = 1000 DefaultConsoleBufferSize = 1000
DefaultConsoleEscape = "auto"
) )
// NewConsoleSinkPlugin creates a console sink through plugin factory // NewConsoleSinkPlugin creates a console sink through plugin factory
@@ -62,28 +72,22 @@ func NewConsoleSinkPlugin(
logger *log.Logger, logger *log.Logger,
proxy *session.Proxy, proxy *session.Proxy,
) (sink.Sink, error) { ) (sink.Sink, error) {
opts := &config.ConsoleSinkOptions{} opts := &config.ConsoleSinkOptions{Target: DefaultConsoleTarget, Escape: DefaultConsoleEscape}
// Scan config map into struct // Scan config map into struct
if err := config.Scan(configMap, opts); err != nil { if err := config.Scan(configMap, opts); err != nil {
return nil, fmt.Errorf("failed to parse config: %w", err) return nil, fmt.Errorf("failed to parse config: %w", err)
} }
// Validate and apply defaults if err := lconfig.OneOf("stdout", "stderr")(opts.Target); err != nil {
if opts.Target == "" {
opts.Target = DefaultConsoleTarget
} else {
validateTarget := lconfig.OneOf("stdout", "stderr")
if err := validateTarget(opts.Target); err != nil {
return nil, fmt.Errorf("target: %w", err) return nil, fmt.Errorf("target: %w", err)
} }
if err := lconfig.OneOf("auto", "always", "never")(opts.Escape); err != nil {
return nil, fmt.Errorf("escape: %w", err)
} }
var output io.Writer output := os.Stdout
switch opts.Target { if opts.Target == "stderr" {
case "stdout":
output = os.Stdout
case "stderr":
output = os.Stderr output = os.Stderr
} }
@@ -96,9 +100,11 @@ func NewConsoleSinkPlugin(
id: id, id: id,
proxy: proxy, proxy: proxy,
config: opts, config: opts,
escape: opts.Escape == "always" || opts.Escape == "auto" && term.IsTerminal(int(output.Fd())),
input: make(chan core.TransportEvent, opts.BufferSize), input: make(chan core.TransportEvent, opts.BufferSize),
output: output, output: output,
done: make(chan struct{}), done: make(chan struct{}),
exited: make(chan struct{}),
logger: logger, logger: logger,
} }
cs.lastProcessed.Store(time.Time{}) cs.lastProcessed.Store(time.Time{})
@@ -117,6 +123,7 @@ func NewConsoleSinkPlugin(
"component", "console_sink", "component", "console_sink",
"instance_id", id, "instance_id", id,
"target", opts.Target, "target", opts.Target,
"escape", cs.escape,
) )
return cs, nil return cs, nil
@@ -126,6 +133,7 @@ func NewConsoleSinkPlugin(
func (cs *ConsoleSink) Capabilities() []core.Capability { func (cs *ConsoleSink) Capabilities() []core.Capability {
return []core.Capability{ return []core.Capability{
core.CapSessionAware, // Single output session core.CapSessionAware, // Single output session
core.CapBackpressure, // a slow reader slows the pipeline, as with any filter
} }
} }
@@ -137,6 +145,7 @@ func (cs *ConsoleSink) Input() chan<- core.TransportEvent {
// Start begins the processing loop // Start begins the processing loop
func (cs *ConsoleSink) Start(ctx context.Context) error { func (cs *ConsoleSink) Start(ctx context.Context) error {
cs.startTime = time.Now() cs.startTime = time.Now()
cs.started.Store(true)
go cs.processLoop(ctx) go cs.processLoop(ctx)
cs.logger.Info("msg", "Console sink started", cs.logger.Info("msg", "Console sink started",
"component", "console_sink", "component", "console_sink",
@@ -144,7 +153,7 @@ func (cs *ConsoleSink) Start(ctx context.Context) error {
return nil return nil
} }
// Stop gracefully shuts down the sink // Stop writes what is queued, then returns
func (cs *ConsoleSink) Stop() { func (cs *ConsoleSink) Stop() {
cs.logger.Info("msg", "Stopping console sink", "target", cs.config.Target) cs.logger.Info("msg", "Stopping console sink", "target", cs.config.Target)
@@ -153,12 +162,14 @@ func (cs *ConsoleSink) Stop() {
cs.proxy.RemoveSession(cs.session.ID) cs.proxy.RemoveSession(cs.session.ID)
} }
close(cs.done) cs.stopOnce.Do(func() { close(cs.done) })
if cs.started.Load() {
<-cs.exited
}
cs.logger.Info("msg", "Console sink stopped", cs.logger.Info("msg", "Console sink stopped",
"instance_id", cs.id, "instance_id", cs.id,
"target", cs.config.Target, "target", cs.config.Target,
"instance_id", cs.id,
) )
} }
@@ -175,35 +186,100 @@ func (cs *ConsoleSink) GetStats() sink.SinkStats {
Details: map[string]any{ Details: map[string]any{
"target": cs.config.Target, "target": cs.config.Target,
"buffer_size": cs.config.BufferSize, "buffer_size": cs.config.BufferSize,
"escape": cs.escape,
}, },
} }
} }
// processLoop reads transport events and writes to console // processLoop writes transport events until stopped, then what is queued
func (cs *ConsoleSink) processLoop(ctx context.Context) { func (cs *ConsoleSink) processLoop(ctx context.Context) {
defer close(cs.exited)
for { for {
select { select {
case event, ok := <-cs.input: case event := <-cs.input:
if !ok { cs.write(event)
case <-ctx.Done():
cs.drain()
return
case <-cs.done:
cs.drain()
return return
} }
}
}
// Write pre-formatted payload directly to output func (cs *ConsoleSink) drain() {
if _, err := cs.output.Write(event.Payload); err != nil { for {
select {
case event := <-cs.input:
cs.write(event)
default:
return
}
}
}
func (cs *ConsoleSink) write(event core.TransportEvent) {
payload := event.Payload
if cs.escape {
payload = escapeControls(payload)
}
if _, err := cs.output.Write(payload); err != nil {
cs.logger.Error("msg", "Failed to write to console", cs.logger.Error("msg", "Failed to write to console",
"component", "console_sink", "component", "console_sink",
"target", cs.config.Target, "target", cs.config.Target,
"error", err) "error", err)
continue return
} }
cs.totalProcessed.Add(1) cs.totalProcessed.Add(1)
cs.lastProcessed.Store(time.Now()) cs.lastProcessed.Store(time.Now())
}
case <-ctx.Done(): // escapeControls writes what a terminal would act on or reorder (C0 and C1
return // controls but tab, DEL, bidi controls, line separators, invalid UTF-8) as
case <-cs.done: // <hex>, keeping the final newline: a log line cannot drive the terminal.
return // Other invisible characters stay: emoji and Indic scripts need ZWJ.
func escapeControls(p []byte) []byte {
body, newline := bytes.CutSuffix(p, []byte{'\n'})
if printableASCII(body) {
return p
}
out := make([]byte, 0, len(p)+16)
for len(body) > 0 {
r, n := utf8.DecodeRune(body)
if !terminalControl(r, n) {
out = append(out, body[:n]...)
} else {
out = append(out, '<')
out = hex.AppendEncode(out, body[:n])
out = append(out, '>')
}
body = body[n:]
}
if newline {
out = append(out, '\n')
}
return out
}
func terminalControl(r rune, size int) bool {
switch {
case r == utf8.RuneError && size == 1:
return true
case r < 0x20:
return r != '\t'
case r >= 0x7f && r <= 0x9f, r == 0x061c, r == 0x200e, r == 0x200f,
r >= 0x2028 && r <= 0x202e, r >= 0x2066 && r <= 0x2069:
return true
}
return false
}
func printableASCII(b []byte) bool {
for _, c := range b {
if (c < 0x20 || c > 0x7e) && c != '\t' {
return false
} }
} }
return true
} }
+25
View File
@@ -0,0 +1,25 @@
package console
import "testing"
// Everything a terminal would act on is escaped; tabs and the final newline,
// which it only lays out, are kept.
func TestEscapeControlsKeepsOnlyLayout(t *testing.T) {
for in, want := range map[string]string{
"plain\ttext\n": "plain\ttext\n",
"a\x1b[2Jb\n": "a<1b>[2Jb\n",
"bell\x07 del\x7f\n": "bell<07> del<7f>\n",
"inner\nnewline\n": "inner<0a>newline\n",
"c1 \u009b31m\n": "c1 <c29b>31m\n",
"bidi \u202eevil\n": "bidi <e280ae>evil\n",
"isolate \u2066x\u2069\n": "isolate <e281a6>x<e281a9>\n",
"line\u2028sep\n": "line<e280a8>sep\n",
"zwj \U0001f468\u200d\U0001f469\n": "zwj \U0001f468\u200d\U0001f469\n",
"bad \xff utf8": "bad <ff> utf8",
"ünïcødé and \ufffd kept\n": "ünïcødé and \ufffd kept\n",
} {
if got := string(escapeControls([]byte(in))); got != want {
t.Errorf("%q: got %q, want %q", in, got, want)
}
}
}
+37 -19
View File
@@ -6,11 +6,11 @@ import (
"sync/atomic" "sync/atomic"
"time" "time"
"logwisp/internal/config" "github.com/lixenwraith/logwisp/internal/config"
"logwisp/internal/core" "github.com/lixenwraith/logwisp/internal/core"
"logwisp/internal/plugin" "github.com/lixenwraith/logwisp/internal/plugin"
"logwisp/internal/session" "github.com/lixenwraith/logwisp/internal/session"
"logwisp/internal/sink" "github.com/lixenwraith/logwisp/internal/sink"
lconfig "github.com/lixenwraith/config" lconfig "github.com/lixenwraith/config"
"github.com/lixenwraith/log" "github.com/lixenwraith/log"
@@ -41,6 +41,8 @@ type FileSink struct {
// Runtime // Runtime
done chan struct{} done chan struct{}
exited chan struct{}
started atomic.Bool
startTime time.Time startTime time.Time
// Statistics // Statistics
@@ -132,6 +134,7 @@ func NewFileSinkPlugin(
writer: log.NewLogger(), writer: log.NewLogger(),
writerConfig: writerConfig, writerConfig: writerConfig,
done: make(chan struct{}), done: make(chan struct{}),
exited: make(chan struct{}),
logger: logger, logger: logger,
} }
fs.lastProcessed.Store(time.Time{}) fs.lastProcessed.Store(time.Time{})
@@ -178,6 +181,7 @@ func (fs *FileSink) Start(ctx context.Context) error {
} }
fs.startTime = time.Now() fs.startTime = time.Now()
fs.started.Store(true)
go fs.processLoop(ctx) go fs.processLoop(ctx)
fs.logger.Info("msg", "File sink started", fs.logger.Info("msg", "File sink started",
@@ -205,7 +209,11 @@ func (fs *FileSink) Stop() {
"directory", fs.config.Directory, "directory", fs.config.Directory,
"name", fs.config.Name) "name", fs.config.Name)
// The loop writes what is queued before the writer shuts down
close(fs.done) close(fs.done)
if fs.started.Load() {
<-fs.exited
}
// Remove session // Remove session
if fs.session != nil { if fs.session != nil {
@@ -240,27 +248,37 @@ func (fs *FileSink) GetStats() sink.SinkStats {
} }
} }
// processLoop reads transport events and writes to file // processLoop writes transport events until stopped, then what is queued
func (fs *FileSink) processLoop(ctx context.Context) { func (fs *FileSink) processLoop(ctx context.Context) {
defer close(fs.exited)
for { for {
select { select {
case event, ok := <-fs.input: case event := <-fs.input:
if !ok { fs.write(event)
case <-ctx.Done():
fs.drain()
return
case <-fs.done:
fs.drain()
return return
} }
}
}
// Write the pre-formatted payload directly func (fs *FileSink) drain() {
// The writer handles rotation automatically based on configuration for {
select {
case event := <-fs.input:
fs.write(event)
default:
return
}
}
}
// write hands the formatted payload to the writer, which rotates files
func (fs *FileSink) write(event core.TransportEvent) {
fs.writer.Write(string(event.Payload)) fs.writer.Write(string(event.Payload))
fs.totalProcessed.Add(1) fs.totalProcessed.Add(1)
fs.lastProcessed.Store(time.Now()) fs.lastProcessed.Store(time.Now())
case <-ctx.Done():
return
case <-fs.done:
return
}
}
} }
+9 -9
View File
@@ -16,15 +16,15 @@ import (
"sync/atomic" "sync/atomic"
"time" "time"
"logwisp/internal/authz" "github.com/lixenwraith/logwisp/internal/authz"
"logwisp/internal/chain" "github.com/lixenwraith/logwisp/internal/chain"
"logwisp/internal/config" "github.com/lixenwraith/logwisp/internal/config"
"logwisp/internal/core" "github.com/lixenwraith/logwisp/internal/core"
"logwisp/internal/plugin" "github.com/lixenwraith/logwisp/internal/plugin"
"logwisp/internal/session" "github.com/lixenwraith/logwisp/internal/session"
"logwisp/internal/sink" "github.com/lixenwraith/logwisp/internal/sink"
"logwisp/internal/tlsx" "github.com/lixenwraith/logwisp/internal/tlsx"
"logwisp/internal/version" "github.com/lixenwraith/logwisp/internal/version"
lconfig "github.com/lixenwraith/config" lconfig "github.com/lixenwraith/config"
"github.com/lixenwraith/log" "github.com/lixenwraith/log"
+3 -3
View File
@@ -16,9 +16,9 @@ import (
"testing" "testing"
"time" "time"
"logwisp/internal/core" "github.com/lixenwraith/logwisp/internal/core"
"logwisp/internal/sink" "github.com/lixenwraith/logwisp/internal/sink"
"logwisp/internal/testutil" "github.com/lixenwraith/logwisp/internal/testutil"
"github.com/lixenwraith/auth" "github.com/lixenwraith/auth"
"github.com/lixenwraith/log" "github.com/lixenwraith/log"
+3 -3
View File
@@ -9,9 +9,9 @@ import (
"testing" "testing"
"time" "time"
"logwisp/internal/authz" "github.com/lixenwraith/logwisp/internal/authz"
"logwisp/internal/session" "github.com/lixenwraith/logwisp/internal/session"
"logwisp/internal/testutil" "github.com/lixenwraith/logwisp/internal/testutil"
"github.com/lixenwraith/auth" "github.com/lixenwraith/auth"
"github.com/lixenwraith/log" "github.com/lixenwraith/log"
+3 -3
View File
@@ -9,9 +9,9 @@ import (
"path" "path"
"strings" "strings"
"logwisp/internal/authz" "github.com/lixenwraith/logwisp/internal/authz"
"logwisp/internal/chain" "github.com/lixenwraith/logwisp/internal/chain"
"logwisp/internal/config" "github.com/lixenwraith/logwisp/internal/config"
) )
//go:embed web/*.js web/*.html web/*.css //go:embed web/*.js web/*.html web/*.css
+8 -8
View File
@@ -17,14 +17,14 @@ import (
"sync/atomic" "sync/atomic"
"time" "time"
"logwisp/internal/authz" "github.com/lixenwraith/logwisp/internal/authz"
"logwisp/internal/chain" "github.com/lixenwraith/logwisp/internal/chain"
"logwisp/internal/config" "github.com/lixenwraith/logwisp/internal/config"
"logwisp/internal/core" "github.com/lixenwraith/logwisp/internal/core"
"logwisp/internal/plugin" "github.com/lixenwraith/logwisp/internal/plugin"
"logwisp/internal/session" "github.com/lixenwraith/logwisp/internal/session"
"logwisp/internal/sink" "github.com/lixenwraith/logwisp/internal/sink"
"logwisp/internal/tlsx" "github.com/lixenwraith/logwisp/internal/tlsx"
lconfig "github.com/lixenwraith/config" lconfig "github.com/lixenwraith/config"
"github.com/lixenwraith/log" "github.com/lixenwraith/log"
+7 -7
View File
@@ -12,13 +12,13 @@ import (
"testing" "testing"
"time" "time"
"logwisp/internal/authz" "github.com/lixenwraith/logwisp/internal/authz"
"logwisp/internal/chain" "github.com/lixenwraith/logwisp/internal/chain"
"logwisp/internal/config" "github.com/lixenwraith/logwisp/internal/config"
"logwisp/internal/core" "github.com/lixenwraith/logwisp/internal/core"
"logwisp/internal/session" "github.com/lixenwraith/logwisp/internal/session"
ingest "logwisp/internal/source/httpchain" ingest "github.com/lixenwraith/logwisp/internal/source/httpchain"
"logwisp/internal/testutil" "github.com/lixenwraith/logwisp/internal/testutil"
"github.com/lixenwraith/auth" "github.com/lixenwraith/auth"
"github.com/lixenwraith/log" "github.com/lixenwraith/log"
+5 -5
View File
@@ -6,11 +6,11 @@ import (
"sync/atomic" "sync/atomic"
"time" "time"
"logwisp/internal/config" "github.com/lixenwraith/logwisp/internal/config"
"logwisp/internal/core" "github.com/lixenwraith/logwisp/internal/core"
"logwisp/internal/plugin" "github.com/lixenwraith/logwisp/internal/plugin"
"logwisp/internal/session" "github.com/lixenwraith/logwisp/internal/session"
"logwisp/internal/sink" "github.com/lixenwraith/logwisp/internal/sink"
"github.com/lixenwraith/log" "github.com/lixenwraith/log"
) )
+1 -1
View File
@@ -4,7 +4,7 @@ import (
"context" "context"
"time" "time"
"logwisp/internal/core" "github.com/lixenwraith/logwisp/internal/core"
) )
// Sink represents an output data stream. // Sink represents an output data stream.
+7 -7
View File
@@ -12,13 +12,13 @@ import (
"sync/atomic" "sync/atomic"
"time" "time"
"logwisp/internal/authz" "github.com/lixenwraith/logwisp/internal/authz"
"logwisp/internal/config" "github.com/lixenwraith/logwisp/internal/config"
"logwisp/internal/core" "github.com/lixenwraith/logwisp/internal/core"
"logwisp/internal/plugin" "github.com/lixenwraith/logwisp/internal/plugin"
"logwisp/internal/session" "github.com/lixenwraith/logwisp/internal/session"
"logwisp/internal/sink" "github.com/lixenwraith/logwisp/internal/sink"
"logwisp/internal/tlsx" "github.com/lixenwraith/logwisp/internal/tlsx"
lconfig "github.com/lixenwraith/config" lconfig "github.com/lixenwraith/config"
"github.com/lixenwraith/log" "github.com/lixenwraith/log"
+2 -2
View File
@@ -6,8 +6,8 @@ import (
"testing" "testing"
"time" "time"
"logwisp/internal/session" "github.com/lixenwraith/logwisp/internal/session"
"logwisp/internal/testutil" "github.com/lixenwraith/logwisp/internal/testutil"
"github.com/lixenwraith/log" "github.com/lixenwraith/log"
) )
+8 -8
View File
@@ -14,14 +14,14 @@ import (
"sync/atomic" "sync/atomic"
"time" "time"
"logwisp/internal/authz" "github.com/lixenwraith/logwisp/internal/authz"
"logwisp/internal/chain" "github.com/lixenwraith/logwisp/internal/chain"
"logwisp/internal/config" "github.com/lixenwraith/logwisp/internal/config"
"logwisp/internal/core" "github.com/lixenwraith/logwisp/internal/core"
"logwisp/internal/plugin" "github.com/lixenwraith/logwisp/internal/plugin"
"logwisp/internal/session" "github.com/lixenwraith/logwisp/internal/session"
"logwisp/internal/sink" "github.com/lixenwraith/logwisp/internal/sink"
"logwisp/internal/tlsx" "github.com/lixenwraith/logwisp/internal/tlsx"
lconfig "github.com/lixenwraith/config" lconfig "github.com/lixenwraith/config"
"github.com/lixenwraith/log" "github.com/lixenwraith/log"
+2 -2
View File
@@ -8,8 +8,8 @@ import (
"testing" "testing"
"time" "time"
"logwisp/internal/core" "github.com/lixenwraith/logwisp/internal/core"
"logwisp/internal/session" "github.com/lixenwraith/logwisp/internal/session"
"github.com/lixenwraith/log" "github.com/lixenwraith/log"
) )
+113 -50
View File
@@ -2,16 +2,19 @@ package console
import ( import (
"bufio" "bufio"
"errors"
"fmt" "fmt"
"io"
"os" "os"
"sync"
"sync/atomic" "sync/atomic"
"time" "time"
"logwisp/internal/config" "github.com/lixenwraith/logwisp/internal/config"
"logwisp/internal/core" "github.com/lixenwraith/logwisp/internal/core"
"logwisp/internal/plugin" "github.com/lixenwraith/logwisp/internal/plugin"
"logwisp/internal/session" "github.com/lixenwraith/logwisp/internal/session"
"logwisp/internal/source" "github.com/lixenwraith/logwisp/internal/source"
"github.com/lixenwraith/log" "github.com/lixenwraith/log"
) )
@@ -31,6 +34,61 @@ func init() {
} }
} }
// One reader serves the process: after a reload the new source continues
// where the old one stopped, and no line is read twice. The channel is
// unbuffered, so a busy pipeline stops the reading of stdin, not its lines.
var (
stdinOnce sync.Once
stdinLines chan string
stdinErr error // set before stdinLines closes
)
func lines() <-chan string {
stdinOnce.Do(func() {
stdinLines = make(chan string)
go func() {
stdinErr = readLines(os.Stdin, stdinLines)
close(stdinLines)
}()
})
return stdinLines
}
// readLines sends r's lines without their terminator (\n or \r\n), the last
// one too when unterminated, until the end of input. A line longer than
// core.MaxLogEntryBytes continues in the next one.
func readLines(r io.Reader, out chan<- string) error {
br := bufio.NewReaderSize(r, 64*1024)
var line []byte
for {
chunk, err := br.ReadSlice('\n')
line = append(line, chunk...)
for len(line) > core.MaxLogEntryBytes {
out <- string(line[:core.MaxLogEntryBytes])
line = append(line[:0], line[core.MaxLogEntryBytes:]...)
}
switch {
case errors.Is(err, bufio.ErrBufferFull):
continue
case err == nil:
line = line[:len(line)-1]
if n := len(line); n > 0 && line[n-1] == '\r' {
line = line[:n-1]
}
}
if len(line) > 0 || err == nil {
out <- string(line)
}
line = line[:0]
if err != nil {
if errors.Is(err, io.EOF) {
return nil
}
return err
}
}
}
// ConsoleSource reads log entries from the standard input stream // ConsoleSource reads log entries from the standard input stream
type ConsoleSource struct { type ConsoleSource struct {
// Plugin identity and session management // Plugin identity and session management
@@ -47,12 +105,13 @@ type ConsoleSource struct {
// Runtime // Runtime
done chan struct{} done chan struct{}
stopOnce sync.Once
// Statistics // Statistics
totalEntries atomic.Uint64 totalEntries atomic.Uint64
droppedEntries atomic.Uint64
startTime time.Time startTime time.Time
lastEntryTime atomic.Value // time.Time lastEntryTime atomic.Value // time.Time
ended atomic.Bool // standard input reached its end
} }
const ( const (
@@ -122,7 +181,7 @@ func (s *ConsoleSource) Subscribe() <-chan core.LogEntry {
// Start begins reading from the standard input. // Start begins reading from the standard input.
func (s *ConsoleSource) Start() error { func (s *ConsoleSource) Start() error {
s.startTime = time.Now() s.startTime = time.Now()
go s.readLoop() go s.readLoop(lines())
// Update session activity // Update session activity
s.proxy.UpdateActivity(s.session.ID) s.proxy.UpdateActivity(s.session.ID)
@@ -133,20 +192,15 @@ func (s *ConsoleSource) Start() error {
return nil return nil
} }
// Stop signals the source to stop reading. // Stop signals the source to stop reading; readLoop closes the subscribers.
func (s *ConsoleSource) Stop() { func (s *ConsoleSource) Stop() {
close(s.done) s.stopOnce.Do(func() { close(s.done) })
// Remove session // Remove session
if s.session != nil { if s.session != nil {
s.proxy.RemoveSession(s.session.ID) s.proxy.RemoveSession(s.session.ID)
} }
// Close subscriber channels
for _, ch := range s.subscribers {
close(ch)
}
s.logger.Info("msg", "Console source stopped", s.logger.Info("msg", "Console source stopped",
"component", "console_source", "component", "console_source",
"instance_id", s.id) "instance_id", s.id)
@@ -157,67 +211,76 @@ func (s *ConsoleSource) GetStats() source.SourceStats {
lastEntry, _ := s.lastEntryTime.Load().(time.Time) lastEntry, _ := s.lastEntryTime.Load().(time.Time)
return source.SourceStats{ return source.SourceStats{
ID: s.id,
Type: "console", Type: "console",
TotalEntries: s.totalEntries.Load(), TotalEntries: s.totalEntries.Load(),
DroppedEntries: s.droppedEntries.Load(),
StartTime: s.startTime, StartTime: s.startTime,
LastEntryTime: lastEntry, LastEntryTime: lastEntry,
Details: map[string]any{}, Details: map[string]any{"ended": s.ended.Load()},
} }
} }
// readLoop continuously reads lines from stdin and publishes them // readLoop publishes stdin's lines until Stop or the end of input, then
func (s *ConsoleSource) readLoop() { // closes the subscriber channels: it is their only sender.
scanner := bufio.NewScanner(os.Stdin) func (s *ConsoleSource) readLoop(lines <-chan string) {
for scanner.Scan() { defer func() {
for _, ch := range s.subscribers {
close(ch)
}
}()
for {
select { select {
case <-s.done: case <-s.done:
return return
default: case line, ok := <-lines:
// Update session activity on each read if !ok {
s.proxy.UpdateActivity(s.session.ID) s.ended.Store(true)
if stdinErr != nil {
// Get raw line s.logger.Error("msg", "Failed to read standard input",
lineBytes := scanner.Bytes() "component", "console_source",
if len(lineBytes) == 0 { "instance_id", s.id,
"error", stdinErr)
}
s.logger.Info("msg", "Standard input ended",
"component", "console_source",
"instance_id", s.id)
return
}
if line == "" {
continue continue
} }
s.proxy.UpdateActivity(s.session.ID)
// Add newline back (scanner strips it)
lineWithNewline := append(lineBytes, '\n')
entry := core.LogEntry{ entry := core.LogEntry{
Time: time.Now(), Time: time.Now(),
Source: "console", Source: "console",
Message: string(lineWithNewline), // Keep newline Message: line,
Level: source.ExtractLogLevel(string(lineBytes)), Level: source.ExtractLogLevel(line),
RawSize: int64(len(lineWithNewline)), RawSize: int64(len(line)),
}
if !s.publish(entry) {
return
}
} }
s.publish(entry)
} }
} }
if err := scanner.Err(); err != nil { // publish waits for every subscriber: stdin is pulled, so a full pipeline
s.logger.Error("msg", "Scanner error reading stdin", // slows the reading instead of losing lines. False when stopped meanwhile.
"component", "console_source", func (s *ConsoleSource) publish(entry core.LogEntry) bool {
"instance_id", s.id,
"error", err)
}
}
// publish sends a log entry to all subscribers
func (s *ConsoleSource) publish(entry core.LogEntry) {
s.totalEntries.Add(1) s.totalEntries.Add(1)
s.lastEntryTime.Store(entry.Time) s.lastEntryTime.Store(entry.Time)
for _, ch := range s.subscribers { for _, ch := range s.subscribers {
select { select {
case ch <- entry: case ch <- entry: // first: a select with both ready picks at random
continue
default: default:
s.droppedEntries.Add(1) }
s.logger.Debug("msg", "Dropped log entry - subscriber buffer full", select {
"component", "console_source") case ch <- entry:
case <-s.done:
return false
} }
} }
return true
} }
+5 -5
View File
@@ -13,11 +13,11 @@ import (
"syscall" "syscall"
"time" "time"
"logwisp/internal/config" "github.com/lixenwraith/logwisp/internal/config"
"logwisp/internal/core" "github.com/lixenwraith/logwisp/internal/core"
"logwisp/internal/plugin" "github.com/lixenwraith/logwisp/internal/plugin"
"logwisp/internal/session" "github.com/lixenwraith/logwisp/internal/session"
"logwisp/internal/source" "github.com/lixenwraith/logwisp/internal/source"
lconfig "github.com/lixenwraith/config" lconfig "github.com/lixenwraith/config"
"github.com/lixenwraith/log" "github.com/lixenwraith/log"
+2 -2
View File
@@ -8,8 +8,8 @@ import (
"testing" "testing"
"time" "time"
"logwisp/internal/core" "github.com/lixenwraith/logwisp/internal/core"
"logwisp/internal/testutil" "github.com/lixenwraith/logwisp/internal/testutil"
) )
func TestStoppedWatcherReturnsNormally(t *testing.T) { func TestStoppedWatcherReturnsNormally(t *testing.T) {
+3 -4
View File
@@ -13,8 +13,8 @@ import (
"syscall" "syscall"
"time" "time"
"logwisp/internal/core" "github.com/lixenwraith/logwisp/internal/core"
"logwisp/internal/source" "github.com/lixenwraith/logwisp/internal/source"
"github.com/lixenwraith/log" "github.com/lixenwraith/log"
) )
@@ -379,12 +379,11 @@ func (w *fileWatcher) isStopped() bool {
// parseLine converts a line into an entry, as JSON when nothing would be lost // parseLine converts a line into an entry, as JSON when nothing would be lost
func (w *fileWatcher) parseLine(line string) core.LogEntry { func (w *fileWatcher) parseLine(line string) core.LogEntry {
if w.raw { if w.raw {
// Newline restored: sinks write the payload as it stands
return core.LogEntry{ return core.LogEntry{
Time: time.Now(), Time: time.Now(),
Source: filepath.Base(w.directory), Source: filepath.Base(w.directory),
Level: source.ExtractLogLevel(line), Level: source.ExtractLogLevel(line),
Message: line + "\n", Message: line,
} }
} }
+8 -8
View File
@@ -15,14 +15,14 @@ import (
"sync/atomic" "sync/atomic"
"time" "time"
"logwisp/internal/authz" "github.com/lixenwraith/logwisp/internal/authz"
"logwisp/internal/chain" "github.com/lixenwraith/logwisp/internal/chain"
"logwisp/internal/config" "github.com/lixenwraith/logwisp/internal/config"
"logwisp/internal/core" "github.com/lixenwraith/logwisp/internal/core"
"logwisp/internal/plugin" "github.com/lixenwraith/logwisp/internal/plugin"
"logwisp/internal/session" "github.com/lixenwraith/logwisp/internal/session"
"logwisp/internal/source" "github.com/lixenwraith/logwisp/internal/source"
"logwisp/internal/tlsx" "github.com/lixenwraith/logwisp/internal/tlsx"
lconfig "github.com/lixenwraith/config" lconfig "github.com/lixenwraith/config"
"github.com/lixenwraith/log" "github.com/lixenwraith/log"
+5 -5
View File
@@ -5,11 +5,11 @@ import (
"sync/atomic" "sync/atomic"
"time" "time"
"logwisp/internal/config" "github.com/lixenwraith/logwisp/internal/config"
"logwisp/internal/core" "github.com/lixenwraith/logwisp/internal/core"
"logwisp/internal/plugin" "github.com/lixenwraith/logwisp/internal/plugin"
"logwisp/internal/session" "github.com/lixenwraith/logwisp/internal/session"
"logwisp/internal/source" "github.com/lixenwraith/logwisp/internal/source"
"github.com/lixenwraith/log" "github.com/lixenwraith/log"
) )
+5 -5
View File
@@ -8,11 +8,11 @@ import (
"sync/atomic" "sync/atomic"
"time" "time"
"logwisp/internal/config" "github.com/lixenwraith/logwisp/internal/config"
"logwisp/internal/core" "github.com/lixenwraith/logwisp/internal/core"
"logwisp/internal/plugin" "github.com/lixenwraith/logwisp/internal/plugin"
"logwisp/internal/session" "github.com/lixenwraith/logwisp/internal/session"
"logwisp/internal/source" "github.com/lixenwraith/logwisp/internal/source"
lconfig "github.com/lixenwraith/config" lconfig "github.com/lixenwraith/config"
"github.com/lixenwraith/log" "github.com/lixenwraith/log"
+1 -1
View File
@@ -4,7 +4,7 @@ import (
"strings" "strings"
"time" "time"
"logwisp/internal/core" "github.com/lixenwraith/logwisp/internal/core"
) )
// Source represents an input data stream for log entries // Source represents an input data stream for log entries
+8 -8
View File
@@ -13,14 +13,14 @@ import (
"sync/atomic" "sync/atomic"
"time" "time"
"logwisp/internal/authz" "github.com/lixenwraith/logwisp/internal/authz"
"logwisp/internal/chain" "github.com/lixenwraith/logwisp/internal/chain"
"logwisp/internal/config" "github.com/lixenwraith/logwisp/internal/config"
"logwisp/internal/core" "github.com/lixenwraith/logwisp/internal/core"
"logwisp/internal/plugin" "github.com/lixenwraith/logwisp/internal/plugin"
"logwisp/internal/session" "github.com/lixenwraith/logwisp/internal/session"
"logwisp/internal/source" "github.com/lixenwraith/logwisp/internal/source"
"logwisp/internal/tlsx" "github.com/lixenwraith/logwisp/internal/tlsx"
lconfig "github.com/lixenwraith/config" lconfig "github.com/lixenwraith/config"
"github.com/lixenwraith/log" "github.com/lixenwraith/log"
+1 -1
View File
@@ -14,7 +14,7 @@ import (
"sync" "sync"
"time" "time"
"logwisp/internal/config" "github.com/lixenwraith/logwisp/internal/config"
"github.com/lixenwraith/log" "github.com/lixenwraith/log"
) )
+1 -1
View File
@@ -7,7 +7,7 @@ import (
"testing" "testing"
"time" "time"
"logwisp/internal/config" "github.com/lixenwraith/logwisp/internal/config"
) )
// A dialer verifies an IPv6 target by its address: the zone names a local // A dialer verifies an IPv6 target by its address: the zone names a local
+24 -4
View File
@@ -1,6 +1,9 @@
package version package version
import "fmt" import (
"fmt"
"runtime/debug"
)
var ( var (
// Version is the application version, set at compile time via -ldflags // Version is the application version, set at compile time via -ldflags
@@ -11,11 +14,28 @@ var (
BuildTime = "unknown" BuildTime = "unknown"
) )
// init fills what -ldflags left unset from the build info: `go install
// module@version` stamps the module version, a build in a checkout the commit.
func init() {
info, ok := debug.ReadBuildInfo()
if !ok {
return
}
if v := info.Main.Version; Version == "dev" && v != "" && v != "(devel)" {
Version = v
}
for _, s := range info.Settings {
switch {
case s.Key == "vcs.revision" && GitCommit == "unknown":
GitCommit = s.Value[:min(len(s.Value), 12)]
case s.Key == "vcs.time" && BuildTime == "unknown":
BuildTime = s.Value
}
}
}
// String returns a detailed, formatted version string including commit and build time // String returns a detailed, formatted version string including commit and build time
func String() string { func String() string {
if Version == "dev" {
return fmt.Sprintf("dev (commit: %s, built: %s)", GitCommit, BuildTime)
}
return fmt.Sprintf("%s (commit: %s, built: %s)", Version, GitCommit, BuildTime) return fmt.Sprintf("%s (commit: %s, built: %s)", Version, GitCommit, BuildTime)
} }
+13 -9
View File
@@ -18,6 +18,9 @@ if [[ ${FORCE_COLOR:-0} != 0 ]] || [[ -t 1 && -z ${NO_COLOR:-} ]]; then
else else
C_BOLD='' C_DIM='' C_RED='' C_GREEN='' C_YELLOW='' C_CYAN='' C_OFF='' C_BOLD='' C_DIM='' C_RED='' C_GREEN='' C_YELLOW='' C_CYAN='' C_OFF=''
fi fi
# Rules fit a narrower terminal; wider ones keep 64 columns
COLS=${COLUMNS:-$(stty size <&2 2>/dev/null | cut -d' ' -f2)}
[[ $COLS =~ ^[0-9]+$ ]] && ((COLS >= 20 && COLS < 64)) || COLS=64
usage() { usage() {
sed -n '2,/^[^#]/s/^# \{0,1\}//p' "$0" sed -n '2,/^[^#]/s/^# \{0,1\}//p' "$0"
@@ -55,9 +58,9 @@ e2e_init() { # "$@" of the script
# --- output --- # --- output ---
rule() { local s='' n=$1; while ((n-- > 0)); do s+='─'; done; printf '%s' "$s"; } rule() { local s='' n=$1; while ((n-- > 0)); do s+=${2:-─}; done; printf '%s' "$s"; } # N [CHAR]
short() { if [[ $1 == "$PWD"/* ]]; then printf '%s' "${1#"$PWD"/}"; else printf '%s' "$1"; fi; } short() { if [[ $1 == "$PWD"/* ]]; then printf '%s' "${1#"$PWD"/}"; else printf '%s' "$1"; fi; }
section() { printf '\n%s── %s %s%s\n' "$C_BOLD$C_CYAN" "$1" "$(rule $((60 - ${#1})))" "$C_OFF"; } section() { printf '\n%s── %s %s%s\n' "$C_BOLD$C_CYAN" "$1" "$(rule $((COLS - 4 - ${#1})))" "$C_OFF"; }
info() { printf ' %s·%s %s\n' "$C_DIM" "$C_OFF" "$*"; } info() { printf ' %s·%s %s\n' "$C_DIM" "$C_OFF" "$*"; }
pass() { E2E_PASSED=$((E2E_PASSED + 1)); printf ' %sPASS%s %s\n' "$C_BOLD$C_GREEN" "$C_OFF" "$1"; } pass() { E2E_PASSED=$((E2E_PASSED + 1)); printf ' %sPASS%s %s\n' "$C_BOLD$C_GREEN" "$C_OFF" "$1"; }
@@ -67,20 +70,21 @@ check() { if (($2)); then pass "$1"; else fail "$1"; fi; } # LABEL 1|0
is() { [[ $1 == "$2" ]] && echo 1 || echo 0; } # ACTUAL EXPECTED is() { [[ $1 == "$2" ]] && echo 1 || echo 0; } # ACTUAL EXPECTED
is_set() { [[ -n $1 ]] && echo 1 || echo 0; } is_set() { [[ -n $1 ]] && echo 1 || echo 0; }
# guide TITLE, the body on stdin: "> " lines are commands, " NNNNN " lines # guide TITLE, the body on stdin, between two rules and without a side border,
# ports, lines ending in ":" headings. # so lines wrapped by a narrow terminal stay readable: "> " lines are commands,
# " NNNNN " lines ports, lines ending in ":" headings.
guide() { guide() {
local line local line
printf '\n%s┌── %s %s%s\n' "$C_BOLD" "$1" "$(rule $((59 - ${#1})))" "$C_OFF" printf '\n%s══ %s %s%s\n' "$C_BOLD" "$1" "$(rule $((COLS - 4 - ${#1})) ═)" "$C_OFF"
while IFS= read -r line; do while IFS= read -r line; do
case $line in case $line in
'> '*) line=" $C_CYAN${line#> }$C_OFF" ;; '> '*) line=" $C_CYAN${line#> }$C_OFF" ;;
' '[0-9][0-9][0-9][0-9][0-9]' '*) line=" $C_YELLOW${line:2:5}$C_OFF${line:7}" ;; ' '[0-9][0-9][0-9][0-9][0-9]' '*) line=" $C_YELLOW${line:2:5}$C_OFF${line:7}" ;;
*:) line="$C_BOLD$line$C_OFF" ;; *:) line="$C_BOLD$line$C_OFF" ;;
esac esac
printf '%s│%s %s\n' "$C_BOLD" "$C_OFF" "$line" printf '%s\n' "$line"
done done
printf '%s└%s%s\n' "$C_BOLD" "$(rule 63)" "$C_OFF" printf '%s%s%s\n' "$C_BOLD" "$(rule "$COLS" ═)" "$C_OFF"
} }
# write_env NAME=VALUE...: $RUN/env, which the guide's commands source # write_env NAME=VALUE...: $RUN/env, which the guide's commands source
@@ -119,7 +123,7 @@ summary() {
[[ $name == "${ABORTED:-}" ]] || excerpt "$name" '^([^ ]+ ERROR |Error: )' [[ $name == "${ABORTED:-}" ]] || excerpt "$name" '^([^ ]+ ERROR |Error: )'
done done
fi fi
printf '%s%s%s\n' "$C_DIM" "$(rule 64)" "$C_OFF" printf '%s%s%s\n' "$C_DIM" "$(rule "$COLS")" "$C_OFF"
if ((E2E_FAILED)); then if ((E2E_FAILED)); then
printf '%sRESULT: FAILURES%s (%s); logs in %s/\n' "$C_BOLD$C_RED" "$C_OFF" "$counts" "$(short "$LOG")" printf '%sRESULT: FAILURES%s (%s); logs in %s/\n' "$C_BOLD$C_RED" "$C_OFF" "$counts" "$(short "$LOG")"
exit 1 exit 1
@@ -134,7 +138,7 @@ summary() {
skip_all() { # REASON: nothing in the script applies to this host skip_all() { # REASON: nothing in the script applies to this host
skip "$1" skip "$1"
printf '%s%s%s\n%sRESULT: SKIPPED%s\n' "$C_DIM" "$(rule 64)" "$C_OFF" "$C_BOLD$C_YELLOW" "$C_OFF" printf '%s%s%s\n%sRESULT: SKIPPED%s\n' "$C_DIM" "$(rule "$COLS")" "$C_OFF" "$C_BOLD$C_YELLOW" "$C_OFF"
exit 77 exit 77
} }
+74
View File
@@ -0,0 +1,74 @@
#!/usr/bin/env bash
# logwisp pipe test: without a configuration file lw is a filter, stdin to
# stdout line for line, without loss, quiet on stderr, exiting 0 at the end of
# input. One-shot: both modes run the checks; no ports.
# Requires: bash 5+, coreutils (timeout, seq, head, cmp, od). Linux dev host only.
set -u
. "$(dirname -- "${BASH_SOURCE[0]}")/lib.sh"
RUN=$E2E_DIR/run/pipe
e2e_init "$@"
section "Setup"
need
rm -rf "$RUN"
mkdir -p "$RUN/bare" "$RUN/withfile" "$LOG"
printf '[logging]\noutput = "stderr"\n' >"$RUN/withfile/logwisp.toml"
# lw DIR ARGS...: in DIR, with no user configuration or LOGWISP_ variable
lw() {
(
cd "$RUN/$1" || exit 1
for v in $(compgen -e LOGWISP_); do unset "$v"; done
HOME=$RUN timeout 30 "$BIN" "${@:2}"
)
}
info "runs lw in $(short "$RUN")/bare (no file) and $(short "$RUN")/withfile"
section "Checks"
# 1. line for line, without loss, and nothing on stderr
seq 1 200000 >"$RUN/seq.txt"
lw bare <"$RUN/seq.txt" >"$RUN/seq.out" 2>"$LOG/seq.err"
rc=$?
check "200000 lines pass unchanged, exit $rc" $((rc == 0))
check "output identical to input" "$(cmp -s "$RUN/seq.txt" "$RUN/seq.out" && echo 1 || echo 0)"
check "nothing on stderr without a configuration file" "$( [[ -s $LOG/seq.err ]] && echo 0 || echo 1)"
# 2. sources are subscribed before they start: the first line is not lost
check "a single line arrives" "$(is "$(printf 'first\n' | lw bare)" first)"
# 3. terminators: CRLF stripped, blank lines skipped, last line unterminated
check "\\r\\n, blank and unterminated lines" "$(is "$(printf 'a\r\nb\n\nc' | lw bare | od -An -c | tr -s ' ')" " a \n b \n c \n")"
# 4. a line over the 1 MiB entry cap continues in the next entry, no byte lost
head -c 2500000 /dev/zero | tr '\0' x | lw bare >"$RUN/long.out"
check "2.5 MB line: entries of $(awk '{printf "%s ", length($0)}' "$RUN/long.out")" \
"$(is "$(awk '{n += length($0)} END {print NR, n}' "$RUN/long.out")" "3 2500000")"
# 5. a slow reader slows lw down instead of losing lines
n=$(seq 1 50000 | lw bare | { sleep 1; wc -l; })
check "slow reader receives all 50000 lines ($n)" "$((n == 50000))"
# 6. a reader that leaves ends lw as it ends cat: SIGPIPE
seq 1 5000000 | lw bare | head -1 >/dev/null
rc=${PIPESTATUS[1]}
check "reader gone: lw exits on SIGPIPE ($rc)" "$((rc == 141))"
# 7. filters and formats compose with stdin and stdout
check "--filter keeps matching lines" "$(is "$(printf 'x INFO\ny ERROR\n' | lw bare --filter include,patterns=ERROR)" "y ERROR")"
# 8. control characters pass to a pipe unchanged and are escaped on request
check "control bytes reach a pipe unchanged" "$(is "$(printf 'a\033[31mb\n' | lw bare | od -An -tx1 | tr -d ' ')" 611b5b33316d620a)"
check "escape=always writes them as <hex>" "$(is "$(printf 'a\033[31mb\n' | lw bare --sink console,escape=always)" 'a<1b>[31mb')"
# 9. at the end of input the file sink writes its queue before lw exits
seq 1 5000 | lw bare --sink "file,directory=$RUN/fsink,name=out,buffer_size=10000"
n=$(cat "$RUN"/fsink/out*.log 2>/dev/null | wc -l)
check "file sink holds all 5000 lines at exit ($n)" "$((n == 5000))"
# 10. a configuration file makes lw a service: info on stderr, data on stdout
out=$(printf 'x\n' | lw withfile 2>"$LOG/withfile.err")
check "with a file: data still on stdout" "$(is "$out" x)"
check "with a file: info logging on stderr" "$(grep -q 'LogWisp starting' "$LOG/withfile.err" && echo 1 || echo 0)"
summary