v0.24.0 cli long and short options, http sink viewer and new logo
Test / test (push) Canceled after 0s
Test / e2e (push) Canceled after 0s

This commit is contained in:
lixen committed 2026-10-04 18:28:47 -04:00
1 parent bf410e1225
commit 9e86d7999d
42 files changed
+774 -392

No files matched your search

+1 -1
View File
@@ -39,7 +39,7 @@ LABEL org.opencontainers.image.title="logwisp" \
org.opencontainers.image.version="${VERSION}" \
org.opencontainers.image.licenses="BSD-3-Clause"
# Dialers without tls.ca_file, and `lw auth` without -ca-file, verify against system roots.
# Dialers without tls.ca_file, and `lw auth` without --ca-file, verify against system roots.
COPY --from=build /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt
COPY --from=build /out/lw /lw
+5 -4
View File
@@ -44,8 +44,8 @@ streams, or downstream LogWisp nodes.
`console` (level names in color and 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
retention), `http` (Server-Sent Events, a JSON status endpoint and a browser
viewer), `tcp` (broadcast server), `null`, and the chain
forwarders `tcp_chain` and `http_chain`.
### Processing
@@ -116,7 +116,7 @@ authentication designs for the rationale and what is deliberately left out.
- [Operations](doc/operations.md): running, monitoring, tuning,
troubleshooting
- [To Do](doc/todo.md): planned work in priority order: network access
control and the PROXY protocol, packaging
control and the PROXY protocol, packaging, smaller follow-ups
A fully annotated configuration covering every option ships as
[`config/logwisp.toml`](config/logwisp.toml).
@@ -135,7 +135,8 @@ one without `--sink` writes stdout:
./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
# stdin as a live SSE stream, with a browser viewer at http://127.0.0.1:8080/;
# 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
+15 -15
View File
@@ -29,16 +29,16 @@ import (
const reloadHint = "send SIGHUP to logwisp to apply (auto_reload does not watch the credentials file)"
var authCommands = []subcommand{
{"add-user", "-credentials FILE -user NAME [-password-file FILE] [-generate]",
{"add-user", "--credentials FILE -u NAME [--password-file FILE] [--generate]",
"Add a user to a credentials file, or replace its password",
[]string{"credentials", "user"}, defineAddUser},
{"remove-user", "-credentials FILE -user NAME",
{"remove-user", "--credentials FILE -u NAME",
"Remove a user from a credentials file",
[]string{"credentials", "user"}, defineRemoveUser},
{"token", "-url https://HOST:PORT -user NAME -password-file FILE [TLS flags]",
{"token", "--url https://HOST:PORT -u NAME --password-file FILE [--unbound] [TLS flags]",
"Log in to an http sink or http_chain source and print a bearer token",
[]string{"url", "user", "password-file"}, defineToken},
{"stream", "-addr HOST:PORT -user NAME -password-file FILE [TLS flags]",
{"stream", "--addr HOST:PORT -u NAME --password-file FILE [TLS flags]",
"Log in to a tcp sink and copy its stream to stdout until interrupted",
[]string{"addr", "user", "password-file"}, defineStream},
}
@@ -47,7 +47,7 @@ func defineAddUser(fs *flag.FlagSet) func(stdout, stderr io.Writer) error {
path := fs.String("credentials", "", "credentials `file`, created when missing")
user := fs.String("user", "", "user `name`")
passwordFile := fs.String("password-file", "", "read the password from `file` when it exists, else write a generated one there")
generate := fs.Bool("generate", false, "generate a new password, replacing any -password-file; needed to rotate without one")
generate := fs.Bool("generate", false, "generate a new password, replacing any --password-file; needed to rotate without one")
return func(stdout, stderr io.Writer) error {
creds, err := authz.LoadCredentials(*path)
// An empty file is one created beforehand to choose its owner
@@ -72,8 +72,8 @@ func defineAddUser(fs *flag.FlagSet) func(stdout, stderr io.Writer) error {
}
generated := password == ""
if generated && i >= 0 && !*generate {
// A mistyped -password-file must not replace a working password
return fmt.Errorf("user %q exists: replacing its password needs an existing -password-file or -generate", *user)
// A mistyped --password-file must not replace a working password
return fmt.Errorf("user %q exists: replacing its password needs an existing --password-file or --generate", *user)
}
if generated {
password = rand.Text()
@@ -105,7 +105,7 @@ func defineAddUser(fs *flag.FlagSet) func(stdout, stderr io.Writer) error {
}
if generated && *passwordFile != "" {
// First, so a failure leaves the verifiers untouched and a rerun
// without -generate completes the change
// without --generate completes the change
if err := writeAtomic(*passwordFile, []byte(password+"\n")); err != nil {
return err
}
@@ -153,7 +153,7 @@ func defineRemoveUser(fs *flag.FlagSet) func(stdout, stderr io.Writer) error {
}
func defineToken(fs *flag.FlagSet) func(stdout, stderr io.Writer) error {
rawURL := fs.String("url", "", "listener `URL`, https://HOST:PORT or https://[IPV6]:PORT (with -unbound, a proxy's mount URL may carry a path)")
rawURL := fs.String("url", "", "listener `URL`, https://HOST:PORT or https://[IPV6]:PORT (with --unbound, a proxy's mount URL may carry a path)")
user := fs.String("user", "", "user `name`")
passwordFile := fs.String("password-file", "", "`file` holding the password")
unbound := fs.Bool("unbound", false, "log in without channel binding, to an http sink behind a TLS-terminating proxy (auth.trusted_proxies)")
@@ -161,15 +161,15 @@ func defineToken(fs *flag.FlagSet) func(stdout, stderr io.Writer) error {
return func(stdout, _ io.Writer) error {
u, err := url.Parse(*rawURL)
if err != nil || u.Scheme != "https" || u.Host == "" || u.RawQuery != "" || u.Fragment != "" || u.User != nil {
return usageError("-url must be https://HOST:PORT")
return usageError("--url must be https://HOST:PORT")
}
if u.Path = strings.TrimSuffix(u.Path, "/"); u.Path != "" && !*unbound {
// TLS passthrough cannot route by path: only a terminating proxy can
return usageError("-url takes a path only with -unbound")
return usageError("--url takes a path only with --unbound")
}
network, err := core.Network(u.Hostname())
if err != nil {
return usageError("-url: " + err.Error())
return usageError("--url: " + err.Error())
}
tlsCfg, policy, err := dialPolicy(tlsOpts, u.Hostname(), *user, *passwordFile, authz.HTTP)
if err != nil {
@@ -203,7 +203,7 @@ func defineToken(fs *flag.FlagSet) func(stdout, stderr io.Writer) error {
}
func defineStream(fs *flag.FlagSet) func(stdout, stderr io.Writer) error {
addr := fs.String("addr", "", "tcp sink `address`, HOST:PORT or [IPV6]:PORT")
addr := fs.String("addr", "", "tcp sink address, `HOST:PORT` or [IPV6]:PORT")
user := fs.String("user", "", "user `name`")
passwordFile := fs.String("password-file", "", "`file` holding the password")
tlsOpts := tlsFlags(fs)
@@ -211,7 +211,7 @@ func defineStream(fs *flag.FlagSet) func(stdout, stderr io.Writer) error {
host, _, err := net.SplitHostPort(*addr)
network, nerr := core.Network(host)
if err != nil || nerr != nil || host == "" {
return usageError("-addr must be HOST:PORT, with an IPv6 address in brackets")
return usageError("--addr must be HOST:PORT, with an IPv6 address in brackets")
}
tlsCfg, policy, err := dialPolicy(tlsOpts, host, *user, *passwordFile, authz.TCP)
if err != nil {
@@ -244,7 +244,7 @@ func defineStream(fs *flag.FlagSet) func(stdout, stderr io.Writer) error {
func tlsFlags(fs *flag.FlagSet) *config.TLSOptions {
o := &config.TLSOptions{Enabled: true}
fs.StringVar(&o.CAFile, "ca-file", "", "CA `file` that verifies the server (default: system roots)")
fs.StringVar(&o.PinSHA256, "pin-sha256", "", "sha256//BASE64 `pin` of the server key, in place of -ca-file (a self_signed listener logs it)")
fs.StringVar(&o.PinSHA256, "pin-sha256", "", "sha256//BASE64 `pin` of the server key, in place of --ca-file (a self_signed listener logs it)")
fs.StringVar(&o.ServerName, "server-name", "", "`name` the server certificate must carry (default: the host)")
fs.StringVar(&o.CertFile, "cert-file", "", "client certificate `file`, for listeners with tls.client_auth")
fs.StringVar(&o.KeyFile, "key-file", "", "client key `file`")
+1 -8
View File
@@ -42,7 +42,7 @@ func writeCredentials(t *testing.T, path string, users ...string) *authz.Credent
func TestAddUserCreatesPrivateFilesWithAMatchingVerifier(t *testing.T) {
dir := t.TempDir()
creds, pass := filepath.Join(dir, "users.toml"), filepath.Join(dir, "edge-01.pass")
code, stdout, stderr := runAuthTest(t, "add-user", "-credentials", creds, "-user", "edge-01", "-password-file", pass)
code, stdout, stderr := runAuthTest(t, "add-user", "--credentials", creds, "-u", "edge-01", "--password-file", pass)
if code != 0 || stdout != "" {
t.Fatalf("exit %d, stdout %q, stderr: %s", code, stdout, stderr)
}
@@ -185,13 +185,6 @@ func TestRemoveUserRefusesTheLastUser(t *testing.T) {
}
}
func TestAuthCommandHelpPrintsAuthUsage(t *testing.T) {
code, _, stderr := runAuthTest(t, "add-user", "-h")
if code != 0 || !strings.Contains(stderr, "Usage: lw auth add-user -credentials FILE") {
t.Fatalf("exit %d, stderr: %s", code, stderr)
}
}
// The login is posted to the -url given plus /auth: a link-local URL keeps
// its zone escaped, not turned into one that no longer parses
func TestTokenLogsInAtTheURLGiven(t *testing.T) {
+64 -13
View File
@@ -8,6 +8,7 @@ import (
"io"
"slices"
"strings"
"unicode"
"github.com/lixenwraith/logwisp/internal/config"
@@ -37,6 +38,17 @@ var commands = []command{
"lw --preset NAME,key=value runs one; flags here are its keys.", presetCommands()},
}
// shorts are lw's one-letter flags, each its long flag in every command that
// defines that flag; lw's own command line takes the top ones
var shorts = map[string]struct {
long string
top bool
}{
"c": {"config", true}, "h": {"help", true}, "p": {"preset", true},
"q": {"quiet", true}, "t": {"check", true}, "V": {"version", true},
"u": {"user", false},
}
// usageError is a command-line mistake: exit status 2 rather than 1
type usageError string
@@ -63,9 +75,14 @@ func (c *command) run(args []string, stdout, stderr io.Writer) int {
fs.SetOutput(stderr)
fs.Usage = func() {
fmt.Fprintf(stderr, "Usage: %s %s\n\n%s.\n\n", fs.Name(), s.synopsis, s.summary)
fs.PrintDefaults()
printFlags(stderr, fs)
}
work := s.define(fs)
for letter, short := range shorts {
if f := fs.Lookup(short.long); f != nil {
fs.Var(f.Value, letter, "") // no usage: printFlags shows it with its long flag
}
}
if err := fs.Parse(args[1:]); err != nil {
if errors.Is(err, flag.ErrHelp) {
return 0
@@ -88,21 +105,48 @@ func (c *command) run(args []string, stdout, stderr io.Writer) int {
}
func (c *command) usage(w io.Writer) {
fmt.Fprintf(w, "Usage: lw %s <command> [flags]\n\n", c.name)
fmt.Fprintf(w, "Usage: lw %s COMMAND [flags]\n\n", c.name)
for _, s := range c.subcommands {
fmt.Fprintf(w, " %-12s %s\n", s.name, s.summary)
}
fmt.Fprintf(w, "\nRun lw %s <command> -h for its flags. %s\n"+
fmt.Fprintf(w, "\nRun lw %s COMMAND -h for its flags. %s\n"+
"Exit status: 0 success, 1 failure, 2 usage error.\n", c.name, c.footer)
}
// printFlags lists fs's flags as " -u, --user NAME" or " --credentials FILE"
func printFlags(w io.Writer, fs *flag.FlagSet) {
fs.VisitAll(func(f *flag.Flag) {
if f.Usage == "" {
return
}
head := " "
for letter, short := range shorts {
if short.long == f.Name {
head = "-" + letter + ", "
}
}
arg, usage := flag.UnquoteUsage(f)
// Only a bool flag has no argument; its "false" default goes unsaid
if f.DefValue != "" && (arg != "" || f.DefValue != "false") {
usage += " (default: " + f.DefValue + ")"
}
if arg == "string" {
arg = "value"
}
if arg != "" {
arg = " " + strings.ToUpper(arg)
}
fmt.Fprintf(w, " %s--%s%s\n %s\n", head, f.Name, arg, usage)
})
}
func checkArgs(fs *flag.FlagSet, required []string) error {
if fs.NArg() > 0 {
return usageError(fmt.Sprintf("unexpected argument %q", fs.Arg(0)))
}
for _, name := range required {
if fs.Lookup(name).Value.String() == "" {
return usageError("-" + name + " is required")
return usageError("--" + name + " is required")
}
}
return nil
@@ -117,10 +161,10 @@ type invocation struct {
load config.Args
}
// parseCommandLine reads lw's own flags; anything else is a --path=value
// setting for lixenwraith/config, which reports what it does not know. A
// pipeline flag takes the next argument unless it starts with '-'. A help
// request wins over a malformed flag.
// parseCommandLine reads lw's own flags, a short one as its long one; any
// other --flag is a --path=value setting for lixenwraith/config, which reports
// what it does not know. A pipeline flag takes the next argument unless it
// starts with '-'. A help request wins over a malformed flag.
func parseCommandLine(argv []string) (inv invocation, err error) {
defer func() {
if inv.help {
@@ -144,18 +188,22 @@ func parseCommandLine(argv []string) (inv invocation, err error) {
for i := 0; i < len(argv); i++ {
arg := argv[i]
name, value, inline := strings.Cut(arg, "=")
typed := name
if short, ok := shorts[strings.TrimPrefix(name, "-")]; ok && short.top && len(name) == 2 {
arg, name = "--"+short.long+arg[len(name):], "--"+short.long
}
switch {
case arg == "--":
inv.load.Overrides = append(inv.load.Overrides, argv[i:]...)
return inv, err
case arg == "-h" || arg == "--help":
case arg == "--help":
inv.help = true
case name == "-c" || name == "--config":
case name == "--config":
if !inline {
value = next(&i)
}
if value == "" {
err = cmp.Or(err, fmt.Errorf("%s requires a configuration file path", name))
err = cmp.Or(err, fmt.Errorf("%s requires a configuration file path", typed))
}
inv.load.File = value
case arg == "--color":
@@ -166,6 +214,9 @@ func parseCommandLine(argv []string) (inv invocation, err error) {
value = next(&i)
}
inv.load.Specs = append(inv.load.Specs, config.Spec{Flag: name[2:], Value: value})
case len(name) > 1 && name[0] == '-' && unicode.IsLetter(rune(name[1])):
// config reads only --flags; a negative number stays a value
err = cmp.Or(err, fmt.Errorf("unknown option %s: long options take two dashes, and a value that starts with '-' follows '=' (lw --help lists them)", typed))
default:
inv.load.Overrides = append(inv.load.Overrides, arg)
}
@@ -182,10 +233,10 @@ func presetCommands() []subcommand {
for _, k := range p.Params {
if k.Required {
s.required = append(s.required, k.Name)
synopsis = append(synopsis, "-"+k.Name+" VALUE")
synopsis = append(synopsis, "--"+k.Name+" VALUE")
}
}
s.synopsis = strings.Join(append(synopsis, "[-KEY VALUE...]"), " ")
s.synopsis = strings.Join(append(synopsis, "[--KEY VALUE...]"), " ")
s.define = func(fs *flag.FlagSet) func(stdout, stderr io.Writer) error {
values := map[string]*string{}
for _, k := range p.Params {
+40 -5
View File
@@ -2,9 +2,11 @@ package main
import (
"bytes"
"flag"
"os"
"path/filepath"
"reflect"
"regexp"
"strings"
"testing"
@@ -13,9 +15,10 @@ import (
"github.com/lixenwraith/logwisp/internal/tlsx"
)
// lw's own flags: the last -c wins, a pipeline flag or --color takes the next
// argument unless it starts with '-', -- ends lw's flags, and a command is
// only the first argument. Everything else is a setting for config.
// lw's own flags: a short is its long flag, an unknown single-dash one fails,
// the last -c wins, a pipeline flag or --color takes the next argument unless
// it starts with '-', -- ends lw's flags, and a command is only the first
// argument. Everything else is a setting for config.
func TestCommandLineGrammar(t *testing.T) {
for _, c := range []struct {
argv []string
@@ -33,15 +36,47 @@ func TestCommandLineGrammar(t *testing.T) {
{[]string{"help"}, invocation{help: true, load: config.Args{Overrides: []string{"help"}}}},
{[]string{"tls", "ca", "-h"}, invocation{command: &commands[1], args: []string{"ca", "-h"}}},
{[]string{"--quiet", "tls"}, invocation{load: config.Args{Overrides: []string{"--quiet", "tls"}}}},
{[]string{"-q", "-t", "-V", "-p", "tail,path=x", "-c=a.toml", "--logging.file.retention_hours", "-1"},
invocation{load: config.Args{File: "a.toml", Specs: []config.Spec{{Flag: "preset", Value: "tail,path=x"}},
Overrides: []string{"--quiet", "--check", "--version", "--logging.file.retention_hours", "-1"}}}},
} {
got, err := parseCommandLine(c.argv)
if err != nil || !reflect.DeepEqual(got, c.want) {
t.Errorf("%q:\n got %+v %v\nwant %+v", c.argv, got, err, c.want)
}
}
for _, argv := range [][]string{{"-c"}, {"--config"}, {"--config="}, {"-c="}, {"-c", "--quiet"}} {
for _, argv := range [][]string{{"-c"}, {"--config"}, {"--config="}, {"-c="}, {"-c", "--quiet"},
{"-v"}, {"-qt"}, {"-u", "x"}, {"-config", "x"}} {
if _, err := parseCommandLine(argv); err == nil {
t.Errorf("%q: missing path accepted", argv)
t.Errorf("%q accepted", argv)
}
}
}
// Every subcommand's -h lists each flag once, GNU style, with its short form
// beside it: " -u, --user NAME" or " --credentials FILE"
func TestUsageListsEachFlagOnce(t *testing.T) {
for _, c := range commands {
for _, s := range c.subcommands {
code, _, stderr := runCommand(t, c.name, s.name, "-h")
fs := flag.NewFlagSet("", flag.ContinueOnError)
s.define(fs)
want := 0
fs.VisitAll(func(f *flag.Flag) {
want++
head := " --" + f.Name
for letter, short := range shorts {
if short.long == f.Name {
head = " -" + letter + ", --" + f.Name
}
}
if n := len(regexp.MustCompile(`(?m)^`+regexp.QuoteMeta(head)+`( |$)`).FindAllString(stderr, -1)); n != 1 {
t.Errorf("lw %s %s -h lists %q %d times:\n%s", c.name, s.name, head, n, stderr)
}
})
if heads := regexp.MustCompile(`(?m)^ (-., | )--`).FindAllString(stderr, -1); code != 0 || len(heads) != want {
t.Errorf("lw %s %s -h: exit %d, %d flags listed, want %d", c.name, s.name, code, len(heads), want)
}
}
}
}
+46 -34
View File
@@ -13,55 +13,66 @@ const helpText = `LogWisp %s - log collection, processing, and distribution
Usage:
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 -c|--config FILE [options] Run the file's pipelines
lw -p|--preset SPEC [options] Run a preset's pipeline
lw -t|--check [options] Build every pipeline and plugin, report, exit
lw --dump [options] Print the effective configuration as TOML, exit
lw help | -h | --help
lw --version
lw -V | --version
Commands (lw <command> -h lists its subcommands):
Commands (lw COMMAND -h lists its subcommands):
%s
Scalar configuration keys are settable as flags using their TOML paths:
--<path>=<value> e.g. --logging.level=debug
Common options:
-c, --config <path> Configuration file (default: ./logwisp.toml)
--quiet Suppress console output
--color [<when>] Level names in color on console sinks:
Options: a short option is its long one; a value follows as the next argument
or after '=' (-c FILE, --config=FILE), one that starts with '-' after '=';
short options do not combine.
-c, --config FILE Configuration file (default:
~/.config/logwisp/logwisp.toml if it exists,
else ./logwisp.toml)
-t, --check Build every pipeline and plugin, report, exit
--dump Print the effective configuration, exit
-q, --quiet Silence lw's own log and notices; pipeline
output still flows
--color [WHEN] Level names in color on console sinks:
auto (a terminal, NO_COLOR unset; default),
always (bare --color) or never
--status_reporter=<bool> Periodic status logging (default: true with
a configuration file)
--auto_reload=<bool> Config hot reload on file change (default: false)
-V, --version Print the version and exit
-h, --help Print this help and exit
-- End the options
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.level=<level> debug|info|warn|error
--logging.file.directory=<path>
Configuration keys are flags by their TOML path, '_' and '.' kept:
--KEY=VALUE e.g. --logging.level=debug
--status_reporter=BOOL Periodic status logging (default: true with
a configuration file)
--auto_reload=BOOL Reload when the file changes (default: false)
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.level=LEVEL debug|info|warn|error
--logging.file.directory=DIR
Pipelines (replace the file's pipelines; see doc/cli.md):
--preset <name>[,k=v...] Start a pipeline with a preset: pipe, tail,
serve, edge, aggregator; lw preset <name> -h
lists its keys, lw preset <name> prints it
--pipeline <name> Start a pipeline; earlier specs go to "cli"
--source <spec> TYPE[,key=value...], repeatable
--sink <spec> e.g. http,host=0.0.0.0,port=8080, repeatable
--filter <spec> include|exclude,patterns=RE, repeatable
--format <spec> json|txt|raw[,key=value...]
--rate-limit <spec> rate=N[,burst=N,policy=drop|pass]
--heartbeat <spec> interval_ms=N[,include_stats=true,...]
-p, --preset NAME[,KEY=VALUE...]
Start a pipeline with a preset: pipe, tail,
serve, edge, aggregator; lw preset NAME -h
lists its keys, lw preset NAME prints it
--pipeline NAME Start a pipeline; earlier specs go to "cli"
--source SPEC TYPE[,KEY=VALUE...], repeatable
--sink SPEC e.g. http,host=0.0.0.0,port=8080, repeatable
--filter SPEC include|exclude,patterns=RE, repeatable
--format SPEC json|txt|raw[,KEY=VALUE...]
--rate-limit SPEC rate=N[,burst=N,policy=drop|pass]
--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
'\' escapes ',' '=' '\' in values. -- ends option parsing.
'\' escapes ',' '=' '\' in values.
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
Serve stdin live; browse http://127.0.0.1:8080/
lw --source file,directory=/var/log/app,pattern='*.log' --format txt
lw --preset tail,path=/var/log/app Follow a directory's files
lw -p tail,path=/var/log/app Follow a directory's files
lw --preset serve,path=/var/log/app,tls=self,users=users.toml
HTTPS stream, SCRAM logins
lw --preset edge,path=/var/log/app,to=agg:9000,ca=ca.crt,user=edge-01,\
@@ -83,14 +94,15 @@ Signals:
Exit codes:
0 success, including the end of input
1 general error
1 general error, including an error in lw's own options (lw COMMAND
exits 2 on a usage error)
2 configuration file not found
`
func printHelp() {
var list strings.Builder
for _, c := range commands {
fmt.Fprintf(&list, " lw %-26s %s\n", c.name+" <command>", c.summary)
fmt.Fprintf(&list, " lw %-26s %s\n", c.name+" COMMAND", c.summary)
}
fmt.Printf(helpText, version.Short(), list.String())
}
+12 -12
View File
@@ -20,21 +20,21 @@ import (
const maxDays = 36500
var tlsCommands = []subcommand{
{"ca", "-dir DIR [-name NAME] [-days N]",
{"ca", "--dir DIR [--name NAME] [--days N]",
"Create a certificate authority, DIR/ca.crt and DIR/ca.key, that signs certificates only",
[]string{"dir"}, defineCA},
{"cert", "-ca-dir DIR -name NAME [-host NAME,...] [-server] [-client] [-days N] [-out DIR]",
{"cert", "--ca-dir DIR --name NAME [--host NAME,...] [--server] [--client] [--days N] [--out DIR]",
"Issue a certificate and key, OUT/NAME.crt and OUT/NAME.key, from DIR/ca.crt and DIR/ca.key",
[]string{"ca-dir", "name"}, defineCert},
}
func defineCA(fs *flag.FlagSet) func(stdout, stderr io.Writer) error {
dir := fs.String("dir", "", "`directory` to write ca.crt and ca.key in, created when missing")
dir := fs.String("dir", "", "`dir`ectory to write ca.crt and ca.key in, created when missing")
name := fs.String("name", "logwisp CA", "subject common `name`")
days := fs.Int("days", int(tlsx.CAValidity/(24*time.Hour)), "validity in `days`")
days := fs.Int("days", int(tlsx.CAValidity/(24*time.Hour)), "validity in `n` days")
return func(_, stderr io.Writer) error {
if *days < 1 || *days > maxDays {
return usageError(fmt.Sprintf("-days must be 1 to %d", maxDays))
return usageError(fmt.Sprintf("--days must be 1 to %d", maxDays))
}
cert, key, err := tlsx.NewCA(*name, time.Duration(*days)*24*time.Hour)
if err != nil {
@@ -51,21 +51,21 @@ func defineCA(fs *flag.FlagSet) func(stdout, stderr io.Writer) error {
}
func defineCert(fs *flag.FlagSet) func(stdout, stderr io.Writer) error {
caDir := fs.String("ca-dir", "", "`directory` holding ca.crt and ca.key (lw tls ca)")
caDir := fs.String("ca-dir", "", "`dir`ectory holding ca.crt and ca.key (lw tls ca)")
name := fs.String("name", "", "subject common `name`, the mtls identity, and the file names")
hosts := fs.String("host", "", "DNS `names` and IP addresses, ',' between them (default with -server: -name)")
hosts := fs.String("host", "", "DNS `names` and IP addresses, ',' between them (default with --server: --name)")
server := fs.Bool("server", false, "for listeners (TLS server authentication)")
client := fs.Bool("client", false, "for dialers presenting a certificate (TLS client authentication)")
days := fs.Int("days", int(tlsx.LeafValidity/(24*time.Hour)), "validity in `days`, at most the CA's")
out := fs.String("out", "", "`directory` to write in (default: -ca-dir)")
days := fs.Int("days", int(tlsx.LeafValidity/(24*time.Hour)), "validity in `n` days, at most the CA's")
out := fs.String("out", "", "`dir`ectory to write in (default: --ca-dir)")
return func(_, stderr io.Writer) error {
switch {
case !*server && !*client:
return usageError("-server, -client or both is required")
return usageError("--server, --client or both is required")
case *days < 1 || *days > maxDays:
return usageError(fmt.Sprintf("-days must be 1 to %d", maxDays))
return usageError(fmt.Sprintf("--days must be 1 to %d", maxDays))
case strings.ContainsAny(*name, `/\`) || *name == "ca" || strings.HasPrefix(*name, "."):
return usageError(fmt.Sprintf("-name %q cannot name the files", *name))
return usageError(fmt.Sprintf("--name %q cannot name the files", *name))
}
issuer, signer, err := tlsx.LoadIssuer(filepath.Join(*caDir, "ca.crt"), filepath.Join(*caDir, "ca.key"))
if err != nil {
+5 -4
View File
@@ -293,14 +293,15 @@ target = "stdout" # stdout|stderr ("split" NOT supported)
# [pipelines.plugin_sinks.config]
# host = "0.0.0.0" # or "::" for IPv6 (IPv6 only)
# port = 8081 # Required
# stream_path = "/stream" # Must start with "/"
# status_path = "/status" # Must differ from stream_path
# stream_path = "/stream" # Exact; starts with "/", clean, none of { } % ? #
# status_path = "/status" # Same rules; differs from stream_path
# buffer_size = 1000 # Sink input queue
# client_buffer_size = 256 # Per-client send queue
# write_timeout_ms = 0 # Per-event deadline, 0 = none
# max_connections = 0 # 0 = unlimited
# login_page = false # GET /auth/login; needs auth.trusted_proxies
# viewer_page = false # GET /auth/view; needs login_page
# login_page = false # GET /auth/login; scram behind auth.trusted_proxies
# viewer_page = false # GET /auth/view, needs login_page; scram + trusted_proxies only
# (without auth or under mtls the viewer is always on)
# [pipelines.plugin_sinks.config.tls]
# enabled = true # example
# cert_file = "/etc/logwisp/tls/server.crt"
+1 -1
View File
@@ -1257,7 +1257,7 @@ next_steps() {
note " status: curl $_c$_u/status"
;;
mtls) note " status: curl --cacert CA --cert CERT --key KEY $_u/status" ;;
scram) note " status: curl with a bearer token from: lw auth token -url $_u -user USER -password-file FILE -ca-file CA (doc/cli.md)" ;;
scram) note " status: curl with a bearer token from: lw auth token --url $_u --user USER --password-file FILE --ca-file CA (doc/cli.md)" ;;
esac
note " stream: $_u/stream"
fi
+1 -1
View File
@@ -31,7 +31,7 @@ streams, or downstream LogWisp nodes.
manual, [`lw.1`](lw.1)
- [Operations](operations.md): running, monitoring, tuning, troubleshooting
- [To Do](todo.md): planned work in priority order: network access control
and the PROXY protocol, packaging
and the PROXY protocol, packaging, smaller follow-ups
A fully annotated configuration covering every option lives at
[`config/logwisp.toml`](../config/logwisp.toml).
+100 -73
View File
@@ -2,15 +2,15 @@
```
lw [options] [< input]
lw -c <file> [options]
lw --check [options]
lw -c|--config FILE [options]
lw -p|--preset NAME[,KEY=VALUE...] [options]
lw -t|--check [options]
lw --dump [options]
lw --preset NAME[,key=value...] [options]
lw auth <command> [flags]
lw tls <command> [flags]
lw preset <name> [flags]
lw auth COMMAND [flags]
lw tls COMMAND [flags]
lw preset NAME [flags]
lw help | -h | --help
lw --version
lw -V | --version
```
Without a configuration file or pipeline options, lw is a filter: stdin to
@@ -25,6 +25,23 @@ manages SCRAM credentials and logs in to `scram` listeners
## Options
- lw's own options have a long form (`--config`), and the frequent ones a
one-letter short form that stands for it in every command that has it:
- `-c` `--config`, `-p` `--preset`, `-q` `--quiet`, `-t` `--check`,
`-V` `--version`, `-h` `--help`
- `-u` `--user` in `lw auth` and `lw preset edge`
- A value follows as the next argument or after `=` (`-c FILE`, `-c=FILE`,
`--config FILE`, `--config=FILE`); a value that starts with `-` follows `=`,
and `--` ends the options.
- Short options do not combine (`-q -t`, not `-qt`) or take an attached value
(`-cFILE`); an unknown single-dash option is an error.
- lw's multiword options join words with `-` (`--rate-limit`,
`--password-file`); configuration keys, spec keys and preset keys keep
TOML's `_` and `.` (`--status_reporter`, `--sink http,tls.cert_file=F`,
`lw preset edge --password_file F`).
- `lw auth`, `lw tls` and `lw preset` also take a long option after one dash
(`-user NAME`), as earlier releases documented.
Any scalar configuration key is settable as a flag using its TOML path;
pipelines have [their own flags](#pipelines):
@@ -36,27 +53,27 @@ pipelines have [their own flags](#pipelines):
### Common
- `-c <path>`, `--config <path>`, `--config=<path>`: the configuration file;
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;
see [Usage Patterns](#usage-patterns)
- `-c FILE`, `--config FILE`: the configuration file; unnamed,
`~/.config/logwisp/logwisp.toml` if it exists, else `./logwisp.toml`
([Configuration](configuration.md#file-location))
- `-t`, `--check`: build every pipeline and plugin, bind nothing, report and
exit; see [Usage Patterns](#usage-patterns)
- `--dump`: print the effective configuration, flags, environment and presets
resolved into it, as TOML that `-c` reads back, and exit
- `--color [auto|always|never]`: level names in color on console sinks,
default `auto` (a terminal, `NO_COLOR` unset, `TERM` not `dumb`); bare,
`always`. A console sink's own `color` wins ([console sink](sinks.md#console))
- `--quiet`: silence lw's own log and notices, default `false`; pipeline
output still flows
- `--status_reporter=<bool>`: periodic status logging, default `true` with a
- `--color [WHEN]`, WHEN `auto`, `always` or `never`: level names in color on
console sinks, default `auto` (a terminal, `NO_COLOR` unset, `TERM` not
`dumb`); bare, `always`. A console sink's own `color` wins
([console sink](sinks.md#console))
- `-q`, `--quiet`: silence lw's own log and notices, default `false`;
pipeline 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`
- `--version`: print the version and exit
- `-V`, `--version`: print the version and exit
- `-h`, `--help`, `help`: print usage and exit
The last file-selection flag wins. `-c=<path>` also works. A missing or empty
path returns an error, and `--` ends option parsing.
The last `-c` wins; a missing or empty path is an error.
### Logging
@@ -81,12 +98,11 @@ pipelines and the built-in default; every other key keeps its precedence.
- `--pipeline NAME` starts a pipeline; specs before the first one go to a
pipeline named `cli`, or after the preset that starts them
- `--preset SPEC` starts a pipeline with a [preset](#presets); later flags add
sources, sinks and filters to it, or replace its format, rate limit and
heartbeat
- `-p SPEC`, `--preset SPEC` starts a pipeline with a [preset](#presets);
later flags add sources, sinks and filters to it, or replace its format,
rate limit and heartbeat
- `--source SPEC`, `--sink SPEC` and `--filter SPEC` add a stage, repeatable
- `--format SPEC`, `--rate-limit SPEC` and `--heartbeat SPEC`, once per pipeline
- 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
@@ -124,7 +140,8 @@ A misspelled plugin key fails when the plugin is built, before any listener
opens: `failed to create sink http: ... unknown key "tls.enabeld"`.
```bash
# tail a directory, serve it over SSE on every interface
# tail a directory, serve it over SSE on every interface; a browser at
# http://HOST:8080/ shows it
lw --source 'file,directory=/var/log/app,pattern=*.log' \
--sink http,host=0.0.0.0,port=8080
@@ -146,8 +163,10 @@ Pipelines over stdin and stdout are under [Usage Patterns](#usage-patterns).
`--preset NAME,key=value,...` takes the [SPEC](#pipelines) syntax. A list
key (`hosts`, `proxy`) takes several values, repeated (`hosts=a,hosts=b`) or
`,`-separated; any other key takes one. `lw preset NAME [-key value ...]`
prints the pipeline a preset expands to, ready for a configuration file;
`,`-separated; any other key takes one. `lw preset NAME [--key value ...]`
prints the pipeline a preset expands to, ready for a configuration file; its
flags are the keys, spelled as in the SPEC (`--password_file`), and `-u` is
`--user`;
`lw preset NAME -h` lists its keys and defaults, `lw preset` the presets. An
unknown key fails and lists the valid ones. A `path` is a file, a directory
(its files) or a glob; a preset that takes one reads stdin without it, and
@@ -157,15 +176,17 @@ unknown key fails and lists the valid ones. A `path` is a file, a directory
- `format` (`raw`)
- `tail`: follow files to stdout, like `tail -F`
- `path` (required), `from`, `format` (`raw`)
- `serve`: a live HTTP stream (SSE), an [http sink](sinks.md#http)
- `serve`: a live stream, an [http sink](sinks.md#http): a browser at
`listen` gets the viewer, `/stream` is SSE
- `path`, `from`, `format` (`json`), `listen` (`127.0.0.1:8080`)
- `tls`: `off` (default), `self` (a self-signed certificate made at
startup), `issuer` (one signed by `issuer_cert` and `issuer_key`), or
`files` (`cert` and `key`); `hosts` adds names to a made certificate
- `users`: a credentials file; readers then log in with SCRAM
- `proxy`, `viewer=true`: the built-in login and viewer pages, which work
only behind the TLS-terminating proxies `proxy` names (a browser cannot
bind its login to the TLS channel); there is no standalone viewer
- without `users` the viewer needs no login, over plain http too
- `users`: a credentials file; readers then log in with SCRAM, and browsers
need `proxy` and `viewer=true`: the login page and viewer behind the
TLS-terminating proxies `proxy` names, since a browser cannot bind its
login to the TLS channel
- `edge`: forward to an aggregator over TLS (`tcp_chain`, or `http_chain`
with `transport=http`)
- `to` (required), `path`, `from`, `transport` (`tcp`), `node`
@@ -183,6 +204,9 @@ unknown key fails and lists the valid ones. A `path` is a file, a directory
# follow a directory, level names in color on a terminal
lw --preset tail,path=/var/log/app
# serve it to a browser at http://127.0.0.1:8080/
lw --preset serve,path=/var/log/app
# serve it over HTTPS, self-signed, to SCRAM users; keep the result as a file
lw --preset serve,path=/var/log/app,tls=self,users=/etc/logwisp/users.toml --dump > serve.toml
@@ -281,7 +305,8 @@ service untouched; see [Configuration](configuration.md#hot-reload).
- `0`: the end of input, a clean shutdown (`SIGINT`, `SIGTERM`), `--version` /
`--help`, `--dump`, or a valid `--check`
- `1`: general error: a configuration load or validation failure (`--check`
- `1`: general error: a command-line error (an unknown option, a missing
`-c` path), a configuration load or validation failure (`--check`
included), a logger init failure, a service bootstrap failure
- `2`: an explicitly requested configuration file is not found
- killed by `SIGPIPE` (shell status `141`): the reader of stdout went away, as
@@ -395,13 +420,15 @@ them as a viewer. See
[Password Authentication](security.md#password-authentication-scram).
```
lw auth add-user -credentials FILE -user NAME [-password-file FILE] [-generate]
lw auth remove-user -credentials FILE -user NAME
lw auth token -url https://HOST:PORT[/PATH] -user NAME -password-file FILE
[-unbound] [TLS flags]
lw auth stream -addr HOST:PORT -user NAME -password-file FILE [TLS flags]
lw auth add-user --credentials FILE -u NAME [--password-file FILE] [--generate]
lw auth remove-user --credentials FILE -u NAME
lw auth token --url https://HOST:PORT[/PATH] -u NAME --password-file FILE
[--unbound] [TLS flags]
lw auth stream --addr HOST:PORT -u NAME --password-file FILE [TLS flags]
```
`-u` is short for `--user`.
- `add-user`: adds a user, or replaces its password
- `remove-user`: removes a user; refuses the last one
- `token`: logs in to an `http` sink or `http_chain` source and prints a
@@ -409,18 +436,18 @@ lw auth stream -addr HOST:PORT -user NAME -password-file FILE [TLS flags]
- `stream`: logs in to a `tcp` sink and copies its stream to stdout until
interrupted
`lw auth <command> -h` lists a command's flags. The exit status is `0` on
`lw auth COMMAND -h` lists a command's flags. The exit status is `0` on
success, `1` on failure and `2` on a usage error.
**`add-user`** creates the credentials file, with a fresh `decoy_key`, when it
does not exist or is empty (create it empty first to choose its owner). The
password comes from `-password-file` when that file exists (at least 8 bytes;
password comes from `--password-file` when that file exists (at least 8 bytes;
one trailing line break is trimmed). Otherwise a random
26-character password (130 bits) is generated and written to `-password-file`,
26-character password (130 bits) is generated and written to `--password-file`,
or printed once to stdout when there is none. Replacing an existing user's
password needs an existing `-password-file` or `-generate`, so a mistyped path
cannot replace a working password; `-generate` always generates, overwriting
`-password-file`. New users take the file's existing Argon2 profile.
password needs an existing `--password-file` or `--generate`, so a mistyped path
cannot replace a working password; `--generate` always generates, overwriting
`--password-file`. New users take the file's existing Argon2 profile.
Both file commands rewrite atomically (a temporary file in the same directory
as the file, or a symlink's target, then a rename), create files `0600`, keep an
@@ -431,46 +458,46 @@ Neither touches a running LogWisp: send `SIGHUP`, since `auto_reload` does not
watch the credentials file.
**`token`** and **`stream`** build the same TLS and SCRAM client as a chain
sink. TLS flags: `-ca-file` (default: system roots) or `-pin-sha256` (the
`tls.pin_sha256` a self-signed listener logs), `-server-name` (default: the
host), and `-cert-file` / `-key-file` for a listener with `tls.client_auth`.
sink. TLS flags: `--ca-file` (default: system roots) or `--pin-sha256` (the
`tls.pin_sha256` a self-signed listener logs), `--server-name` (default: the
host), and `--cert-file` / `--key-file` for a listener with `tls.client_auth`.
There is no flag to skip verification: an unverified server could relay the
login. Redirects are not followed. `-unbound` logs in to an `http` sink behind
login. Redirects are not followed. `--unbound` logs in to an `http` sink behind
a TLS-terminating proxy (`auth.trusted_proxies`), still pinning the proxy's
certificate across the two requests; only then may `-url` carry the path the
certificate across the two requests; only then may `--url` carry the path the
proxy mounts LogWisp at. `stream` exits `0` on `SIGINT` or `SIGTERM`, and `1`
when the server ends the stream (a reload or shutdown).
Addresses, as for the plugins ([Networking](networking.md#address-family)):
- An IPv6 address goes in brackets: `-addr [::1]:PORT`,
`-url https://[::1]:PORT`; in a URL a link-local zone is escaped,
- An IPv6 address goes in brackets: `--addr [::1]:PORT`,
`--url https://[::1]:PORT`; in a URL a link-local zone is escaped,
`https://[fe80::1%25eth0]:PORT`.
- The dial keeps to the address's family; a hostname resolves.
- `-server-name` defaults to the host; an address, without brackets or zone,
- `--server-name` defaults to the host; an address, without brackets or zone,
must then be among the certificate's IP SANs.
```bash
# listener host: a file the service user can read, users, then apply
install -m 0640 -o root -g logwisp /dev/null /etc/logwisp/users.toml
lw auth add-user -credentials /etc/logwisp/users.toml -user edge-01 \
-password-file /etc/logwisp/edge-01.pass
lw auth add-user -credentials /etc/logwisp/users.toml -user viewer \
-password-file viewer.pass
lw auth add-user --credentials /etc/logwisp/users.toml --user edge-01 \
--password-file /etc/logwisp/edge-01.pass
lw auth add-user --credentials /etc/logwisp/users.toml --user viewer \
--password-file viewer.pass
kill -HUP $(pidof lw)
# rotate: new password into the file; SIGHUP, deploy the file, SIGHUP the edge
lw auth add-user -credentials /etc/logwisp/users.toml -user edge-01 \
-password-file /etc/logwisp/edge-01.pass -generate
lw auth add-user --credentials /etc/logwisp/users.toml --user edge-01 \
--password-file /etc/logwisp/edge-01.pass --generate
# http sink status, with the token kept out of curl's argv
curl --cacert ca.crt -H @<(printf 'Authorization: Bearer %s\n' \
"$(lw auth token -url https://HOST:PORT -user viewer \
-password-file viewer.pass -ca-file ca.crt)") https://HOST:PORT/status
"$(lw auth token --url https://HOST:PORT --user viewer \
--password-file viewer.pass --ca-file ca.crt)") https://HOST:PORT/status
# follow a tcp sink
lw auth stream -addr HOST:PORT -user viewer -password-file viewer.pass \
-ca-file ca.crt
lw auth stream --addr HOST:PORT --user viewer --password-file viewer.pass \
--ca-file ca.crt
```
## `lw tls`
@@ -479,25 +506,25 @@ Makes ECDSA P-256 certificates for `tls` blocks. It never replaces a file
(remove one to replace it) and writes keys `0600`, certificates `0644`.
```
lw tls ca -dir DIR [-name NAME] [-days 3650]
lw tls cert -ca-dir DIR -name NAME [-host NAME,...] [-server] [-client]
[-days 397] [-out DIR]
lw tls ca --dir DIR [--name NAME] [--days 3650]
lw tls cert --ca-dir DIR --name NAME [--host NAME,...] [--server] [--client]
[--days 397] [--out DIR]
```
- `ca`: `DIR/ca.crt` and `DIR/ca.key`, a CA that signs leaves only (path
length 0). `ca.crt` is what dialers set as `tls.ca_file` and listeners as
`tls.client_ca_file`; with `ca.key` it is a listener's
`tls.issuer_cert_file` and `tls.issuer_key_file`.
- `cert`: `NAME.crt` and `NAME.key`, in `-out` (default: `-ca-dir`).
- `-server` for a listener: `-host` lists the DNS names and IP addresses it
carries, default `NAME`; `-client` for a dialer presenting a certificate;
- `cert`: `NAME.crt` and `NAME.key`, in `--out` (default: `--ca-dir`).
- `--server` for a listener: `--host` lists the DNS names and IP addresses it
carries, default `NAME`; `--client` for a dialer presenting a certificate;
both may be given.
- `NAME` is the subject CN, the identity `auth.type = "mtls"` matches.
- Valid 397 days (the longest browsers accept), never past the CA. It prints
the certificate's `pin_sha256`.
```bash
lw tls ca -dir /etc/logwisp/pki
lw tls cert -ca-dir /etc/logwisp/pki -name agg.example.org -server
lw tls cert -ca-dir /etc/logwisp/pki -name edge-01 -client
lw tls ca --dir /etc/logwisp/pki
lw tls cert --ca-dir /etc/logwisp/pki --name agg.example.org --server
lw tls cert --ca-dir /etc/logwisp/pki --name edge-01 --client
```
+9 -6
View File
@@ -235,19 +235,22 @@ lw --quiet # bare flag means true
```
A bare flag is accepted only for a boolean key: `--logging.file.directory
--quiet` fails with `--logging.file.directory needs a value`. Unrecognized flags,
positional words and single-dash arguments are reported on stderr before the
logger exists and are then ignored:
--quiet` fails with `--logging.file.directory needs a value`. Unrecognized `--`
flags and positional words are reported on stderr before the logger exists and
are then ignored:
```
Warning: unrecognized flags ignored: [pipelines.0.name -q]
Warning: unrecognized flags ignored: [nosuch.key]
```
Single-dash options are lw's own ([CLI](cli.md#options)); an unknown one is
an error.
An empty `LOGWISP_<PATH>` variable counts as unset, as the pipeline variables
do.
Array-indexed paths such as `--pipelines.0.name=x` are unrecognized; whole
pipelines have [their own flags](cli.md#pipelines).
An array-indexed path such as `--pipelines.0.name=x` is an error (exit 1);
whole pipelines have [their own flags](cli.md#pipelines).
## Validation
+2 -2
View File
@@ -147,8 +147,8 @@ already, and the `logs` jail has an address the others reach (`AGG_ADDR`).
4. Check: `jexec logs service logwisp status`, the files in the `logs` jail's
`/var/log/logwisp`, and `https://AGG_ADDR:8080/status` with a token from
`lw auth token -url https://AGG_ADDR:8080 -user ops -password-file
~/logwisp-secrets/ops.pass -ca-file ca.crt` ([CLI](cli.md#lw-auth)).
`lw auth token --url https://AGG_ADDR:8080 --user ops --password-file
~/logwisp-secrets/ops.pass --ca-file ca.crt` ([CLI](cli.md#lw-auth)).
Variants:
+3 -3
View File
@@ -116,8 +116,8 @@ both:
```bash
sudo install -m 0600 -o logwisp -g logwisp /dev/null /etc/logwisp/users.toml
sudo lw auth add-user -credentials /etc/logwisp/users.toml -user <name> \
-password-file <file>
sudo lw auth add-user --credentials /etc/logwisp/users.toml --user <name> \
--password-file <file>
sudo systemctl reload logwisp
```
@@ -230,7 +230,7 @@ docker run -d --name logwisp \
```bash
docker run --rm --read-only --cap-drop ALL --network none \
-v /srv/logwisp/secrets:/work -v /srv/logwisp/passwords:/pw logwisp:<tag> \
auth add-user -credentials /work/users.toml -user <name> -password-file /pw/<name>.pass
auth add-user --credentials /work/users.toml --user <name> --password-file /pw/<name>.pass
```
## Packaging Status
+66 -36
View File
@@ -6,12 +6,12 @@
.Nd LogWisp log transport
.Sh SYNOPSIS
.Nm
.Op Fl \-check | Fl \-dump
.Op Fl t | Fl \-dump
.Op Fl q
.Op Fl c Ar file
.Op Fl \-quiet
.Op Fl \-color Op Ar when
.Op Fl \- Ns Ar key Ns = Ns Ar value ...
.Op Fl \-preset Ar spec
.Op Fl p Ar spec
.Op Fl \-pipeline Ar name
.Op Fl \-source Ar spec ...
.Op Fl \-sink Ar spec ...
@@ -24,7 +24,7 @@
.Ar command
.Op Ar flags
.Nm
.Fl \-version
.Fl V | Fl \-version
.Nm
.Cm help | Fl h | Fl \-help
.Sh DESCRIPTION
@@ -107,6 +107,27 @@ service under a supervisor such as
or
.Xr daemon 8 .
.Pp
Every option has a long form, and the frequent ones a one-letter short form
that stands for it in every command that has it.
A value follows as the next argument or after
.Ql = ;
a value that starts with
.Ql -
follows
.Ql = .
Short options do not combine, and an unknown one is an error.
The options of
.Nm
join words with
.Ql - ;
a configuration or preset key keeps its TOML spelling.
The
.Cm auth ,
.Cm tls
and
.Cm preset
commands also take a long option after a single dash.
.Pp
The options are as follows:
.Bl -tag -width Ds
.It Fl c Ar file , Fl \-config Ar file , Fl \-config= Ns Ar file
@@ -136,7 +157,7 @@ program parses keeps
.Cm auto
or
.Cm never .
.It Fl \-quiet
.It Fl q , Fl \-quiet
Turn off the log of
.Nm
and the terminal reminder; console sinks still write.
@@ -170,7 +191,7 @@ the default is
with a configuration file and
.Cm warn
without.
.It Fl \-check
.It Fl t , Fl \-check
Build every pipeline and plugin as a start would, loading options, TLS files
and credentials files and printing startup warnings, without binding a port
or opening, reading or creating a log file; print
@@ -183,7 +204,7 @@ Print the effective configuration, with the options, environment and
presets resolved into it, as a file
.Fl c
reads back, and exit.
.It Fl \-version
.It Fl V , Fl \-version
Print the version, commit and build time, and exit.
.It Fl h , Fl \-help , Cm help
Print usage and exit.
@@ -210,7 +231,7 @@ Specifications before the first
belong to a pipeline named
.Dq cli ,
or after the preset that starts them.
.It Fl \-preset Ar spec
.It Fl p Ar spec , Fl \-preset Ar spec
Start the pipeline with a preset, described in
.Sx Presets ;
later options add sources, sinks and filters to it, or replace its format.
@@ -260,10 +281,14 @@ A preset builds a common pipeline from a few keys:
.Fl \-preset Ar name Ns Op , Ns Ar key Ns = Ns Ar value ... ,
in the syntax of the other specifications;
.Nm
.Cm preset Ar name Op Fl Ns Ar key Ar value ...
.Cm preset Ar name Op Fl \- Ns Ar key Ar value ...
prints the pipeline it expands to, and
.Fl h
there lists its keys and defaults.
there lists its keys and defaults; its flags are the keys, spelled as in the
specification, and
.Fl u
is
.Fl \-user .
A
.Ar path
is a file, a directory, whose files are followed, or a glob; without one, a
@@ -292,10 +317,12 @@ or
.Pq Cm cert No and Cm key ;
.Cm users
names a credentials file, so readers log in with SCRAM.
The built-in login and viewer pages,
.Cm viewer=true ,
work only behind a TLS-terminating proxy named by
A browser at the root gets a live viewer; with
.Cm users ,
only behind a TLS-terminating proxy named by
.Cm proxy ,
with
.Cm viewer=true ,
since a browser cannot bind its login to the TLS channel.
.It Cm edge to Oo Cm path , transport , ca | pin , user , password_file , cert , key , node Oc
Forward to an aggregator at
@@ -332,19 +359,19 @@ vouches for, and write to standard output or to files in
makes ECDSA P-256 certificates; it never replaces a file, and writes keys
with mode 0600.
.Bl -tag -width Ds
.It Cm ca Fl dir Ar dir Oo Fl name Ar name Oc Op Fl days Ar n
.It Cm ca Fl \-dir Ar dir Oo Fl \-name Ar name Oc Op Fl \-days Ar n
Create a certificate authority,
.Pa dir/ca.crt
and
.Pa dir/ca.key ,
valid 3650 days.
.It Cm cert Fl ca-dir Ar dir Fl name Ar name Oo Fl host Ar names Oc Oo Fl server Oc Oo Fl client Oc Oo Fl days Ar n Oc Op Fl out Ar dir
.It Cm cert Fl \-ca\-dir Ar dir Fl \-name Ar name Oo Fl \-host Ar names Oc Oo Fl \-server Oc Oo Fl \-client Oc Oo Fl \-days Ar n Oc Op Fl \-out Ar dir
Issue
.Pa name.crt
and
.Pa name.key
from that authority, valid 397 days and never past it.
.Fl host
.Fl \-host
lists the DNS names and addresses a server certificate carries
.Pq default: Ar name ;
.Ar name
@@ -378,33 +405,36 @@ restarts, so a fleet uses an issuer instead.
.Nm
.Cm auth
manages SCRAM credentials files and logs in to SCRAM listeners.
.Fl u
is short for
.Fl \-user .
Changes to a credentials file apply when the server receives
.Dv SIGHUP .
.Bl -tag -width Ds
.It Cm add-user Fl credentials Ar file Fl user Ar name Oo Fl password-file Ar file Oc Op Fl generate
.It Cm add-user Fl \-credentials Ar file Fl u Ar name Oo Fl \-password\-file Ar file Oc Op Fl \-generate
Add a user, or replace its password.
The password is read from the
.Fl password-file
.Fl \-password\-file
when it exists; otherwise one is generated and written there, or printed
once to standard output.
.Fl generate
.Fl \-generate
replaces an existing password without reading one.
A missing or empty credentials file is created with mode 0600; an existing
one keeps its owner and mode.
.It Cm remove-user Fl credentials Ar file Fl user Ar name
.It Cm remove-user Fl \-credentials Ar file Fl u Ar name
Remove a user.
The last user cannot be removed.
.It Cm token Fl url Ar https://host:port Fl user Ar name Fl password-file Ar file Oo Ar tls-flags Oc Op Fl unbound
.It Cm token Fl \-url Ar https://host:port Fl u Ar name Fl \-password\-file Ar file Oo Ar tls-flags Oc Op Fl \-unbound
Log in to an
.Cm http
sink or
.Cm http_chain
source and print a bearer token.
.Fl unbound
.Fl \-unbound
logs in without channel binding, to an
.Cm http
sink behind a TLS-terminating proxy; the URL may then carry the proxy's path.
.It Cm stream Fl addr Ar host:port Fl user Ar name Fl password-file Ar file Op Ar tls-flags
.It Cm stream Fl \-addr Ar host:port Fl u Ar name Fl \-password\-file Ar file Op Ar tls-flags
Log in to a
.Cm tcp
sink and copy its stream to standard output until interrupted.
@@ -413,15 +443,15 @@ sink and copy its stream to standard output until interrupted.
The
.Ar tls-flags
are
.Fl ca-file Ar file
.Fl \-ca\-file Ar file
.Pq default: the system roots
or
.Fl pin-sha256 Ar pin ,
.Fl server-name Ar name ,
.Fl \-pin\-sha256 Ar pin ,
.Fl \-server\-name Ar name ,
and
.Fl cert-file Ar file
.Fl \-cert\-file Ar file
with
.Fl key-file Ar file
.Fl \-key\-file Ar file
for a listener that requires client certificates.
There is no flag to skip server verification.
An IPv6
@@ -507,8 +537,8 @@ The service's working directory on Linux and on
.Nm
exits 0 at the end of input, after a clean shutdown,
.Fl \-version
or help; 1 on a configuration, logger or startup error; and 2 when a
configuration file named by
or help; 1 on a command-line, configuration, logger or startup error; and 2
when a configuration file named by
.Fl c
or
.Ev LOGWISP_CONFIG_*
@@ -591,10 +621,10 @@ the authority, its certificate and a user for the edge; copy
.Pa ca.crt
and the password file to the edge; then run both:
.Bd -literal -offset indent
# lw tls ca \-dir /etc/logwisp/pki
# lw tls cert \-ca-dir /etc/logwisp/pki \-name agg.example.org \-server
# lw auth add-user \-credentials /etc/logwisp/users.toml \e
\-user edge-01 \-password-file /root/edge-01.pass
# lw tls ca \-\-dir /etc/logwisp/pki
# lw tls cert \-\-ca-dir /etc/logwisp/pki \-\-name agg.example.org \-\-server
# lw auth add-user \-\-credentials /etc/logwisp/users.toml \e
\-\-user edge-01 \-\-password-file /root/edge-01.pass
agg# lw \-\-preset aggregator,tls=files,users=/etc/logwisp/users.toml,\e
cert=/etc/logwisp/pki/agg.example.org.crt,\e
key=/etc/logwisp/pki/agg.example.org.key,out=/var/log/edges
@@ -619,8 +649,8 @@ generate the password into a file, then reload the server:
.Bd -literal -offset indent
# install \-m 0600 \-o logwisp \-g logwisp \e
/dev/null /etc/logwisp/users.toml
# lw auth add-user \-credentials /etc/logwisp/users.toml \e
\-user alice \-password-file /root/alice.pass
# lw auth add-user \-\-credentials /etc/logwisp/users.toml \e
\-\-user alice \-\-password-file /root/alice.pass
# systemctl reload logwisp
.Ed
.Sh SEE ALSO
+4 -3
View File
@@ -25,7 +25,7 @@ Every listener and dialer keeps strictly to the family of its `host`:
- `host` takes the bare address: `host = "::1"`. A bracketed host, one
with a port, or an IPv4-mapped one (`::ffff:10.0.0.1`) fails at load.
- Addresses and URLs bracket it: logs print `[::1]:8443`, and `lw auth`
takes `-addr [::1]:8443` and `-url https://[::1]:8443`. Older `curl`
takes `--addr [::1]:8443` and `--url https://[::1]:8443`. Older `curl`
releases read the brackets as a glob; `-g` stops that and is harmless.
- A link-local address carries its zone, `fe80::1%eth0`, escaped in URLs as
`https://[fe80::1%25eth0]:8443`.
@@ -46,7 +46,8 @@ Every listener and dialer keeps strictly to the family of its `host`:
Listeners:
- `tcp` sink: raw stream; broadcasts formatted payloads to clients.
- `http` sink: HTTP SSE; a browser-friendly live stream plus status JSON.
- `http` sink: HTTP SSE; a browser viewer at `/`, the live stream and status
JSON.
- `tcp_chain` source: chain v1; ingests a persistent NDJSON stream.
- `http_chain` source: chain v1; ingests NDJSON batches over POST.
@@ -249,7 +250,7 @@ headers, and entry encoding.
between the peers terminates TLS. Pass TLS through to LogWisp; the login
cannot work otherwise, except on an `http` sink in proxy mode
(`auth.trusted_proxies`), where `the client bound its proof to the proxy's
certificate` means a client that needs `lw auth token -unbound`, and
certificate` means a client that needs `lw auth token --unbound`, and
`the client sent an unbound proof` the reverse. On an `http_chain` sink,
`server certificate differs from the one the SCRAM login was bound to` means
the certificate changed after the login: a rotation (the retry binds anew),
+18 -16
View File
@@ -204,16 +204,16 @@ closed.
## CLI
```
lw auth add-user -credentials F -user U [-password-file P] [-generate]
lw auth remove-user -credentials F -user U
lw auth token -url https://host:port[/path] -user U -password-file P [-unbound] [TLS flags]
lw auth stream -addr host:port -user U -password-file P [TLS flags]
lw auth add-user --credentials F --user U [--password-file P] [--generate]
lw auth remove-user --credentials F --user U
lw auth token --url https://host:port[/path] --user U --password-file P [--unbound] [TLS flags]
lw auth stream --addr host:port --user U --password-file P [TLS flags]
```
- `add-user` takes the password from `-password-file` when it exists (at least
- `add-user` takes the password from `--password-file` when it exists (at least
8 bytes); a new user without one gets a generated 130-bit password, written to
that file (0600) or printed once. Rotating an existing user needs the file or
`-generate`, so a mistyped path cannot replace a password. The file's KDF
`--generate`, so a mistyped path cannot replace a password. The file's KDF
profile and decoy key are reused, the result is validated with the daemon's
own parser, and the rewrite (temp file beside the file or a symlink's target,
rename) keeps the existing mode and owner, or fails.
@@ -222,10 +222,10 @@ lw auth stream -addr host:port -user U -password-file P [TLS flags]
credentials file.
- `token` prints a bearer token for curl (`-H @<(...)` keeps it out of argv);
`stream` authenticates to a `tcp` sink and copies the stream to stdout until
interrupted. `-url` takes a path only with `-unbound`, and redirects are not
interrupted. `--url` takes a path only with `--unbound`, and redirects are not
followed.
- TLS flags: `-ca-file`, `-server-name`, `-cert-file`, `-key-file`. There is no
insecure flag.
- TLS flags: `--ca-file` or `--pin-sha256`, `--server-name`, `--cert-file`,
`--key-file`. There is no insecure flag.
- Exit status: 0 success, 1 failure, 2 usage error.
## Rollout and Rotation
@@ -233,7 +233,7 @@ lw auth stream -addr host:port -user U -password-file P [TLS flags]
- Turning SCRAM on is per listener and cuts off unconfigured dialers. To migrate
without a gap, upgrade binaries, add a second SCRAM listener, move dialers,
then remove the old listener.
- Password rotation: `add-user -generate`, `SIGHUP` the listener, deploy the new
- Password rotation: `add-user --generate`, `SIGHUP` the listener, deploy the new
password file, `SIGHUP` the dialer. The dialer retries under backoff in
between.
- Revocation: `remove-user`, `SIGHUP`. All tokens die with the reload.
@@ -261,7 +261,7 @@ sink into proxy mode; it is refused on every other plugin and with `identity`.
proxy-to-LogWisp hop could relay a login, hence the address restriction and a
startup warning for a plaintext hop to a proxy off this host. `tls` may stay
off. Outside proxy mode nothing changes: proofs stay bound, and a bound proof
in proxy mode fails with a hint naming `-unbound`.
in proxy mode fails with a hint naming `--unbound`.
**Session.** The proof step may ask for a cookie (`"session": "cookie"`, proxy
mode only). The answer sets `logwisp_session` (`HttpOnly; Secure;
@@ -289,8 +289,10 @@ token in a page variable only: it lasts no longer than the page or its lifetime.
pages is relative, so a proxy prefix works unchanged.
- `POST /auth`: SCRAM hello, proof or logout; unbound in proxy mode.
- `GET /auth/scram.js`: the client library, always served in proxy mode.
- `GET /auth/login`, `login.js`, `style.css`: the login page (`login_page`).
- `GET /auth/scram.js`, `style.css`: the client library and the pages' style,
always served in proxy mode, and with the viewer.
- `GET /`: `303` to `auth/view`, else `auth/login`, when either is served.
- `GET /auth/login`, `login.js`: the login page (`login_page`).
- `GET /auth/view`, `view.js`: a minimal live viewer (`viewer_page`, needs
`login_page`).
@@ -316,8 +318,8 @@ refuses redirects, and reports success only after verifying the server's
signature. A 64 MiB login takes about 2 s on a desktop. The wire protocol above
is the contract: a site may implement its own client.
**CLI.** `lw auth token -unbound` logs in through the proxy, still pinning
its certificate across the two requests; only then may `-url` carry the mount
**CLI.** `lw auth token --unbound` logs in through the proxy, still pinning
its certificate across the two requests; only then may `--url` carry the mount
path.
## Verification
@@ -367,7 +369,7 @@ by sign-out, and no CSP violations; a cookie the browser drops leading to the
viewer's own form, not a loop, its token kept out of storage and the URL; a
profile blocking every cookie signing in on the viewer, its proofs asking for
no cookie, streaming with a bearer, sign-out revoking the token, and a reload's
`401` asking again; `token -unbound` and curl through the proxy; direct peers
`401` asking again; `token --unbound` and curl through the proxy; direct peers
and plaintext-forwarded requests `403`. The existing scripts keep passing.
## Not Implemented
+15 -10
View File
@@ -360,9 +360,9 @@ process and, at startup and every reload, a certificate for it valid 397 days
to that certificate.
```bash
lw tls ca -dir /etc/logwisp/pki # ca.crt, ca.key (0600)
lw tls cert -ca-dir /etc/logwisp/pki -name agg.example.org -server
lw tls cert -ca-dir /etc/logwisp/pki -name edge-01 -client
lw tls ca --dir /etc/logwisp/pki # ca.crt, ca.key (0600)
lw tls cert --ca-dir /etc/logwisp/pki --name agg.example.org --server
lw tls cert --ca-dir /etc/logwisp/pki --name edge-01 --client
```
## Enabling mTLS
@@ -594,8 +594,8 @@ different users.
- **Rollout.** Turning `scram` on cuts off every dialer of that listener that
has no credentials. Upgrade every binary first, then add a second listener
with `scram` on another port, move the dialers to it, and remove the old one.
- **Password rotation.** `lw auth add-user -generate` with the user's
`-password-file` replaces verifier and password together. `SIGHUP` the
- **Password rotation.** `lw auth add-user --generate` with the user's
`--password-file` replaces verifier and password together. `SIGHUP` the
listener, deploy the password file, `SIGHUP` the dialer. Logins fail in
between and the dialer retries under backoff, holding its current entry or
batch while its input queue fills. To avoid the gap, add a second user, move
@@ -664,7 +664,7 @@ location /logs/ {
`Path`, so it scopes itself to the mount (`/logs` above). Stream and status
accept it or a bearer token, so `new EventSource("/logs/stream")` works on any
page of the site, beside the site's own Basic auth too, and `lw auth token
-unbound` keeps working. `POST /auth` with `{"logout": true}` clears the
--unbound` keeps working. `POST /auth` with `{"logout": true}` clears the
cookie and revokes the token until it expires. `/auth` takes only
`application/json`, which a cross-origin page cannot send without a preflight.
- **Private windows.** Private or incognito windows keep cookies in memory,
@@ -698,9 +698,10 @@ location /logs/ {
- `cookiesUsable()` tells which applies: false when the session cookie would
not stick. `loginUnavailable()`, asked first, names why neither can run
(no secure context), or is empty.
- **Pages.** Under `/auth/` the sink serves `scram.js` always, and with
`login_page` / `viewer_page` the login page and a minimal live viewer with
their script and style, under `default-src 'none'; script-src 'self';
- **Pages.** Under `/auth/` the sink serves `scram.js` and `style.css` always,
and with `login_page` / `viewer_page` the login page and a minimal live
viewer with their scripts; `GET /` leads to the viewer, else the login page.
All are served under `default-src 'none'; script-src 'self';
connect-src 'self'; style-src 'self'; form-action 'self'; frame-ancestors
'none'; base-uri 'none'`.
@@ -771,7 +772,7 @@ sets `proxy_protocol` per stream `server`, so route LogWisp SNIs without it:
`listen`s with `proxy_protocol` and proxy_passes to LogWisp without it; that
hop consumes the header.
- Either way dialers must send the name: a `host` that is one, or
`tls.server_name` (`-server-name` for `lw auth`); an IP literal sends no SNI.
`tls.server_name` (`--server-name` for `lw auth`); an IP literal sends no SNI.
- With HAProxy, leave `send-proxy` off the LogWisp backends.
```nginx
@@ -864,6 +865,10 @@ What each exposes when `auth.type = "none"`:
header is omitted once an auth policy is set).
- `http` sink `status_path`: host, port, TLS flag, uptime, client counts,
throughput counters.
- `http` sink `GET /` (`303` to `/auth/view`) and the viewer's files: static
pages that read the two endpoints above under the CSP and expose nothing
beyond them. Under `mtls` they are served before the allow list, while the
stream and status stay gated.
- `tcp` sink: the full log stream to any client that connects.
- `tcp_chain` and `http_chain` sources: ingest from any peer that can connect
(with `client_auth`, any the CA vouches for), under any node label it
+24 -8
View File
@@ -128,7 +128,7 @@ No options. The input queue is fixed at 1000.
## http
Server-Sent Events stream plus a JSON status endpoint.
Server-Sent Events stream, a JSON status endpoint, and a browser viewer.
```toml
[[pipelines.plugin_sinks]]
@@ -161,17 +161,20 @@ Options, each as type and default:
- `host` (string, `0.0.0.0`): bind address, IPv4 or IPv6 (`::`); the listener
keeps to its family ([Networking](networking.md#address-family)).
- `port` (int, **required**): listen port.
- `stream_path` (string, `/stream`): SSE endpoint; must start with `/`.
- `status_path` (string, `/status`): status endpoint; must start with `/` and
differ from `stream_path`.
- `stream_path` (string, `/stream`): SSE endpoint, matched exactly (`/logs/`
does not answer `/logs/x`); it starts with `/`, is clean (no `//`, `.` or
`..` segment) and holds none of `{`, `}`, `%`, `?`, `#`.
- `status_path` (string, `/status`): status endpoint, under the same rules;
differs from `stream_path`.
- `buffer_size` (int, `1000`): sink input queue depth.
- `client_buffer_size` (int, `256`): per-client send queue depth.
- `write_timeout_ms` (int, `0`): per-event write deadline; `0` = none.
- `max_connections` (int, `0`): concurrent stream cap; `0` = unlimited.
- `login_page` (bool, `false`): serve the browser login page at `/auth/login`;
needs `auth.trusted_proxies`.
- `viewer_page` (bool, `false`): serve a minimal live viewer at `/auth/view`;
needs `login_page`.
- `login_page` (bool, `false`): `scram` in proxy mode only (an error
elsewhere): the browser login page at `/auth/login`.
- `viewer_page` (bool, `false`): `scram` in proxy mode only (an error
elsewhere): the live viewer at `/auth/view`; needs `login_page`. Without
auth, or under `mtls`, the viewer is always served.
- `tls` (table): listener TLS; see [Security](security.md).
- `auth` (table): client authentication (`mtls` or `scram`); see
[Security](security.md#the-auth-block).
@@ -194,6 +197,19 @@ Options, each as type and default:
through `/auth/login` or the site's own copy of `/auth/scram.js`, and stream
and status also accept the `logwisp_session` cookie. See
[Browsers behind a TLS-terminating proxy](security.md#browsers-behind-a-tls-terminating-proxy).
- `GET /` answers `303` to `auth/view`, a relative `Location` that a proxy
prefix keeps, wherever a browser can read the stream:
- without auth, over plain http too: the viewer needs no secure context, so
`http://ADDRESS:PORT/` works;
- under `mtls`, with the browser's client certificate (`openssl pkcs12
-export -in NAME.crt -inkey NAME.key -out NAME.p12` imports it);
- in proxy mode with `viewer_page`, or to `auth/login` with `login_page`
alone;
- never under `scram` on the sink's own TLS, which a browser cannot log in
to: `/` is refused like any other path;
- an endpoint at `/` keeps the root, and the viewer stays at `/auth/view`.
The viewer shows entries from when it connects (the sink keeps no backlog)
and holds one stream against `max_connections`.
- Refusals are logged at WARN and counted in `auth_rejected`. The authorized
identity is recorded in the client's session as `auth_method` /
`auth_identity`.
+37
View File
@@ -178,3 +178,40 @@ identity, and both skeletons download the tagged source.
5. Shell completion for bash, zsh and fish, installed by `make install`.
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`.
## 3. Follow-ups
Smaller items found along the way; each is independent of the ACL.
1. Chain sinks at the end of a finite input. The `tcp_chain` sink's `Stop`
closes its loop without draining its queue, and the `http_chain` sink makes
one best-effort flush, so `seq 1 N | lw --preset edge,...` may lose the
tail. Verify with a finite stdin into each and an aggregator counting lines;
give both a bounded drain like `sink.FlushBound`.
2. A late browser sees no backlog: the `http` sink keeps none, so the viewer
shows entries from when it connects. A bounded replay (a `replay_lines`
option, sent after `event: connected`) would fill it. The viewer also hides
a `503` from `max_connections` behind "retrying"; the status JSON it already
fetches could name it.
3. Level detection. `source.ExtractLogLevel` needs a delimiter after the name
(`ERROR:`, `[WARN]`, ` INFO `), so `warning disk full` or `DBG x` get no
level, hence no color. Match the names as words, as the console sink's
painter does, with one table for both.
4. Command line:
- a positional word at the top level (`lw tail`, `lw -t FILE`) still reaches
config with an unclear message; name it ("unexpected argument X; the file
is -c FILE");
- Go's flag errors keep single dashes ("flag needs an argument: -u");
- an unknown `--key` only warns and then runs the stdin pipe; consider an
error when stdin is a terminal;
- `lw tls cert --host` could become `--hosts` (keeping `--host`), to match
`tls.hosts` and the preset key;
- top-level usage errors exit 1, subcommand ones 2.
5. `GET /favicon.ico` answers 404; the logo could serve as the icon once the
pages' CSP allows `img-src 'self'`.
6. TLS: the generated-certificate key is one per process, so a pin taken from a
`self_signed` listener also matches an `issuer` listener of the same
process; document it or key per listener. Pins are checked in
`VerifyPeerCertificate`, which Go skips on resumption: guard it if a dialer
ever keeps a `ClientSessionCache`.
7. `core.ShutdownTimeout` is unused.
+1 -1
View File
@@ -3,7 +3,7 @@ module github.com/lixenwraith/logwisp
go 1.27.1
require (
github.com/lixenwraith/auth v0.0.0-20261002191639-3ed0e995807c
github.com/lixenwraith/auth v0.5.1
github.com/lixenwraith/color v0.1.3
github.com/lixenwraith/config v0.2.2
github.com/lixenwraith/log v0.1.11
+2 -2
View File
@@ -1,7 +1,7 @@
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/lixenwraith/auth v0.0.0-20261002191639-3ed0e995807c h1:zwpVa85g267vo13UCzCao4lfVgUPaU79CmxPW9OdF8w=
github.com/lixenwraith/auth v0.0.0-20261002191639-3ed0e995807c/go.mod h1:411JUqnbQgy9jgoFV+fH91TfVUEfzYA4dyPv7f4c264=
github.com/lixenwraith/auth v0.5.1 h1:5/eKRyEO4af/vpSLsUgAXaFiECNbCKtQ0EEXnO/oPF4=
github.com/lixenwraith/auth v0.5.1/go.mod h1:411JUqnbQgy9jgoFV+fH91TfVUEfzYA4dyPv7f4c264=
github.com/lixenwraith/color v0.1.3 h1:vSuvtU4zh1mIZS/7UGHmMdsTr+a7eCjUYGbou7kzFn4=
github.com/lixenwraith/color v0.1.3/go.mod h1:L/I3Vxp6pqslAIzWyZGgKCm2lyStHCA4vKaUoMUm7bU=
github.com/lixenwraith/config v0.2.2 h1:X+l3Tf8Rbk49cipYBJU7/pfdw5wDwD4hRxtFH7rZgOc=
+4
View File
@@ -532,6 +532,10 @@ func (p *Policy) NodeBinding() string {
// Enabled reports whether a policy is in force
func (p *Policy) Enabled() bool { return p != nil }
// NeedsLogin reports a scram listener: its clients log in at /auth before any
// protected request, where mtls admits by the certificate alone
func (p *Policy) NeedsLogin() bool { return p != nil && p.listener != nil }
// Unrestricted reports whether an mtls policy admits any identity the CA
// vouches for. Under scram the credentials file is the allow list.
func (p *Policy) Unrestricted() bool {
+1 -1
View File
@@ -177,7 +177,7 @@ func TestBrowserSessionBehindProxy(t *testing.T) {
}
// Behind a proxy logwisp never sees the certificate a client binds to, so
// only unbound proofs (lw auth token -unbound) log in.
// only unbound proofs (lw auth token --unbound) log in.
func TestOnlyUnboundLoginsBehindProxy(t *testing.T) {
f := newFixture(t)
l := f.listener(t, config.AuthOptions{TrustedProxies: []string{"127.0.0.1"}}, f.serverTLS, RoleListener, HTTP)
+2 -2
View File
@@ -355,9 +355,9 @@ func (p *Policy) finish(ip, nonce string, step authStep, cs *tls.ConnectionState
p.rejected.Add(1)
switch {
case step.Binding != "" && l.proxy != nil:
err = fmt.Errorf("%w; the client bound its proof to the proxy's certificate: behind trusted_proxies, log in unbound (lw auth token -unbound)", err)
err = fmt.Errorf("%w; the client bound its proof to the proxy's certificate: behind trusted_proxies, log in unbound (lw auth token --unbound)", err)
case step.Binding == "" && l.cb != nil:
err = fmt.Errorf("%w; the client sent an unbound proof (-unbound), but this listener binds logins to its certificate", err)
err = fmt.Errorf("%w; the client sent an unbound proof (--unbound), but this listener binds logins to its certificate", err)
case step.Binding != "" && step.Binding != base64.StdEncoding.EncodeToString(l.cb):
l.bindingMismatch.Add(1)
err = fmt.Errorf("%w; the client saw another server certificate: TLS interception or a terminating proxy", err)
+5 -2
View File
@@ -59,13 +59,13 @@ var presets = []Preset{
p.Flow.Format = &FormatConfig{Type: v["format"]}
return nil
}},
{"serve", "Serve files or standard input as a live HTTP stream (SSE)",
{"serve", "Serve files or standard input live: a browser viewer at /, SSE at /stream",
append([]PresetParam{pathParam, fromParam, formatParam("json"),
{Name: "listen", Default: "127.0.0.1:8080", Help: "HOST:PORT; [IPV6]:PORT"},
{Name: "tls", Default: "off", Help: "off|self|issuer|files: a certificate made at startup (self-signed or from issuer_*), or files"},
{Name: "users", Help: "credentials file (lw auth add-user): readers log in with SCRAM"},
{Name: "proxy", List: true, Help: "addresses or CIDRs of the TLS-terminating proxies browsers come through; ',' between them"},
{Name: "viewer", Default: "false", Help: "true: the built-in login and viewer pages, behind proxy"},
{Name: "viewer", Default: "false", Help: "true: the login page and viewer for users, behind proxy; without users the viewer is always on"},
}, tlsParams...),
func(p *PipelineConfig, v map[string]string) error {
p.PluginSources = pathOrStdin(v)
@@ -85,6 +85,9 @@ var presets = []Preset{
}
switch v["viewer"] {
case "true":
if v["proxy"] == "" {
return fmt.Errorf("viewer=true needs proxy, behind which browsers log in; without users the viewer is always on")
}
sink["login_page"], sink["viewer_page"] = true, true
case "false":
default:
+1
View File
@@ -49,6 +49,7 @@ func TestPresetKeysAreChecked(t *testing.T) {
"serve,tls=files,cert=c": "tls=files needs key",
"serve,viewer=yes": `viewer "yes"`,
"serve,proxy=127.0.0.1": "proxy needs users",
"serve,viewer=true": "viewer=true needs proxy",
"edge,to=agg:9000": "it never sends unauthenticated",
"edge,to=agg:9000,user=u": "user and password_file go together",
"edge,to=agg:9000,user=u,password_file=p,transport=udp": `transport "udp"`,
+2 -2
View File
@@ -116,7 +116,7 @@ func (p *Pipeline) initializeComponents() error {
sessionProxy,
)
if err != nil {
return fmt.Errorf("failed to create source %s: %w", srcCfg.ID, err)
return err // the registry names the plugin
}
// Check and inject capabilities using core interfaces
@@ -144,7 +144,7 @@ func (p *Pipeline) initializeComponents() error {
sessionProxy,
)
if err != nil {
return fmt.Errorf("failed to create sink %s: %w", sinkCfg.ID, err)
return err // the registry names the plugin
}
// Check and inject capabilities using core interfaces
+36 -25
View File
@@ -10,6 +10,7 @@ import (
"maps"
"net"
"net/http"
"path"
"strconv"
"strings"
"sync"
@@ -77,7 +78,7 @@ type HTTPSink struct {
// Authorization
auth *authz.Policy
web map[string]http.Handler // proxy mode: GET /auth/... browser files
web map[string]http.Handler // GET paths of the browser files and the root
// Runtime. Stop closes flush; the broker then moves what remains of its
// input to tail and closes flushed; each stream writes its queue, tail
@@ -128,22 +129,21 @@ func NewHTTPSinkPlugin(
}
if opts.StreamPath == "" {
opts.StreamPath = DefaultHTTPStreamPath
} else if !strings.HasPrefix(opts.StreamPath, "/") {
return nil, fmt.Errorf("stream_path: must start with '/'")
}
if opts.StatusPath == "" {
opts.StatusPath = DefaultHTTPStatusPath
} else if !strings.HasPrefix(opts.StatusPath, "/") {
return nil, fmt.Errorf("status_path: must start with '/'")
}
for _, o := range []struct{ name, p string }{{"stream_path", opts.StreamPath}, {"status_path", opts.StatusPath}} {
if !routable(o.p) {
return nil, fmt.Errorf("%s %q: must start with '/', hold no '//', '.' or '..' segment, and none of '{', '}', '%%', '?', '#'", o.name, o.p)
}
if p := o.p; p == chain.AuthPath || strings.HasPrefix(p, chain.AuthPath+"/") {
return nil, fmt.Errorf("%s and the paths under it are reserved for authentication", chain.AuthPath)
}
}
if opts.StreamPath == opts.StatusPath {
return nil, fmt.Errorf("stream_path and status_path must differ")
}
for _, p := range []string{opts.StreamPath, opts.StatusPath} {
if p == chain.AuthPath || strings.HasPrefix(p, chain.AuthPath+"/") {
return nil, fmt.Errorf("%s and the paths under it are reserved for authentication", chain.AuthPath)
}
}
if opts.BufferSize <= 0 {
opts.BufferSize = DefaultHTTPBufferSize
}
@@ -160,16 +160,14 @@ func NewHTTPSinkPlugin(
}
switch {
case (opts.LoginPage || opts.ViewerPage) && !authPolicy.BehindProxy():
return nil, errors.New("login_page and viewer_page need auth.trusted_proxies: browsers log in only behind a TLS-terminating proxy")
return nil, errors.New("login_page and viewer_page apply to scram behind auth.trusted_proxies, where browsers log in; without auth or under mtls the viewer is always served")
case opts.ViewerPage && !opts.LoginPage:
return nil, errors.New("viewer_page needs login_page, where it sends a signed-out viewer")
}
var web map[string]http.Handler
if authPolicy.BehindProxy() {
if web, err = webHandlers(opts); err != nil {
web, err := webHandlers(opts, authPolicy)
if err != nil {
return nil, err
}
}
h := &HTTPSink{
id: id,
@@ -202,7 +200,7 @@ func NewHTTPSinkPlugin(
"mtls", tlsCfg != nil && tlsCfg.ClientAuth == tls.RequireAndVerifyClientCert,
"auth", authPolicy.Describe(),
"login_page", opts.LoginPage,
"viewer_page", opts.ViewerPage)
"viewer_page", web[chain.AuthPath+"/view"] != nil)
tlsx.LogWarnings(logger, "http_sink", id, opts.TLS, true)
authPolicy.LogStartup(logger, "http_sink", id, false)
return h, nil
@@ -248,29 +246,27 @@ func (h *HTTPSink) serve(ctx context.Context, ln net.Listener) error {
}
mux := http.NewServeMux()
// Method-scoped patterns: mux answers 405 with Allow header on non-GET
mux.HandleFunc(http.MethodGet+" "+h.config.StreamPath, h.handleStream)
mux.HandleFunc(http.MethodGet+" "+h.config.StatusPath, h.handleStatus)
mux.HandleFunc(http.MethodGet+" "+exact(h.config.StreamPath), h.handleStream)
mux.HandleFunc(http.MethodGet+" "+exact(h.config.StatusPath), h.handleStatus)
// A GET pattern also serves HEAD, and a HEAD stream is a registered client
// whose body writes are discarded: it never reads, so nothing but the peer
// closing the connection ends it. The status path answers one either way.
mux.HandleFunc(http.MethodHead+" "+h.config.StreamPath, streamHeadNotAllowed)
mux.HandleFunc(http.MethodHead+" "+exact(h.config.StreamPath), streamHeadNotAllowed)
// One wrapper covers stream and status, and keeps the handlers themselves
// unaware of authorization. Login and its browser files sit outside it;
// in proxy mode everything sits behind the proxy gate.
var handler http.Handler = mux
if h.auth.Enabled() {
// unaware of authorization; a nil policy admits every request. Login, the
// browser files and the root sit outside it; in proxy mode everything sits
// behind the proxy gate.
outer := http.NewServeMux()
outer.HandleFunc(http.MethodPost+" "+chain.AuthPath, h.handleAuth)
for p, file := range h.web {
outer.Handle(http.MethodGet+" "+p, file)
}
outer.Handle("/", h.authMiddleware(mux))
handler = outer
var handler http.Handler = outer
if h.auth.BehindProxy() {
handler = h.proxyGate(outer)
}
}
h.server = &http.Server{
Handler: handler,
@@ -671,6 +667,21 @@ func (h *HTTPSink) handleAuth(w http.ResponseWriter, r *http.Request) {
}
}
// routable reports a path ServeMux and a URL take literally: clean, a trailing
// '/' aside, without the braces of wildcards, an escape, a query or a fragment
func routable(p string) bool {
c := path.Clean(p)
return strings.HasPrefix(p, "/") && !strings.ContainsAny(p, "{}%?#") && (c == p || c != "/" && c+"/" == p)
}
// exact keeps a path ending in '/' from matching every path beneath it
func exact(p string) string {
if strings.HasSuffix(p, "/") {
return p + "{$}"
}
return p
}
// streamHeadNotAllowed refuses a body-less read of a stream that is only a body
func streamHeadNotAllowed(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Allow", http.MethodGet)
+92 -29
View File
@@ -4,14 +4,12 @@ import (
"bufio"
"bytes"
"crypto/tls"
"crypto/x509"
"encoding/json"
"fmt"
"io"
"maps"
"net/http"
"net/http/httptest"
"os"
"slices"
"strings"
"testing"
@@ -138,17 +136,6 @@ func TestHeadOnStreamPathIsRefused(t *testing.T) {
// open stream keeps the wildcard so browser dashboards still work.
func TestWildcardCORSOnlyWithoutAuth(t *testing.T) {
pki := testutil.NewPKI(t, "viewer-01")
caPEM, err := os.ReadFile(pki.CA)
if err != nil {
t.Fatal(err)
}
roots := x509.NewCertPool()
roots.AppendCertsFromPEM(caPEM)
clientCert, err := tls.LoadX509KeyPair(pki.ClientCert, pki.ClientKey)
if err != nil {
t.Fatal(err)
}
open, _ := newTestHTTPSink(t, nil)
gated, _ := newTestHTTPSink(t, map[string]any{
"tls": map[string]any{"enabled": true, "cert_file": pki.ServerCert, "key_file": pki.ServerKey,
@@ -162,8 +149,7 @@ func TestWildcardCORSOnlyWithoutAuth(t *testing.T) {
}{{"open", open, "*"}, {"gated", gated, ""}} {
client, baseURL := serveTestHTTPSink(t, tc.sink)
if tc.sink.tlsConfig != nil {
client.Transport = &http.Transport{TLSClientConfig: &tls.Config{RootCAs: roots, Certificates: []tls.Certificate{clientCert}}}
baseURL = "https" + strings.TrimPrefix(baseURL, "http")
baseURL = trustPKI(t, client, baseURL, pki, true)
}
resp, err := client.Get(baseURL + "/stream")
if err != nil {
@@ -189,14 +175,7 @@ func TestLoginEndpointBypassesTheGate(t *testing.T) {
"auth": map[string]any{"type": "scram", "credentials_file": creds},
})
client, baseURL := serveTestHTTPSink(t, gated)
caPEM, err := os.ReadFile(pki.CA)
if err != nil {
t.Fatal(err)
}
roots := x509.NewCertPool()
roots.AppendCertsFromPEM(caPEM)
client.Transport = &http.Transport{TLSClientConfig: &tls.Config{RootCAs: roots}}
baseURL = "https" + strings.TrimPrefix(baseURL, "http")
baseURL = trustPKI(t, client, baseURL, pki, false)
hello := `{"logwisp":1,"scram":{"username":"viewer-01","client_nonce":"abcdefgh"}}`
resp, err := client.Post(baseURL+"/auth", "application/json", strings.NewReader(hello))
@@ -268,6 +247,85 @@ func TestProxyModeServesBrowserFiles(t *testing.T) {
}
}
// The root leads to the page a browser can use: the viewer, needing no login
// without auth and under mtls; in proxy mode the viewer, or else the login
// page; nothing under direct scram, which a browser cannot log in to; and an
// endpoint at "/" keeps the root.
func TestRootLeadsToThePageABrowserCanUse(t *testing.T) {
pki := testutil.NewPKI(t, "viewer-01")
tlsOn := map[string]any{"enabled": true, "cert_file": pki.ServerCert, "key_file": pki.ServerKey}
mtlsOn := map[string]any{"enabled": true, "cert_file": pki.ServerCert, "key_file": pki.ServerKey,
"client_auth": true, "client_ca_file": pki.CA}
creds := scramCredentials(t)
proxy := map[string]any{"type": "scram", "credentials_file": creds, "trusted_proxies": []any{"127.0.0.1"}}
for _, tc := range []struct {
name string
opts map[string]any
code int
target, mode string // the root's Location, and that page's logwisp-login
}{
{"no auth", nil, http.StatusSeeOther, "auth/view", "none"},
{"mtls", map[string]any{"tls": mtlsOn, "auth": map[string]any{"type": "mtls"}}, http.StatusSeeOther, "auth/view", "none"},
{"proxy, viewer", map[string]any{"auth": proxy, "login_page": true, "viewer_page": true}, http.StatusSeeOther, "auth/view", "scram"},
{"proxy, login page", map[string]any{"auth": proxy, "login_page": true}, http.StatusSeeOther, "auth/login", ""},
{"direct scram", map[string]any{"tls": tlsOn, "auth": map[string]any{"type": "scram", "credentials_file": creds}}, http.StatusUnauthorized, "", ""},
{"status at the root", map[string]any{"status_path": "/"}, http.StatusOK, "", ""},
} {
h, _ := newTestHTTPSink(t, tc.opts)
client, baseURL := serveTestHTTPSink(t, h)
client.CheckRedirect = func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }
if h.tlsConfig != nil {
baseURL = trustPKI(t, client, baseURL, pki, h.tlsConfig.ClientAuth == tls.RequireAndVerifyClientCert)
}
get := func(path string) (*http.Response, string) {
req, _ := http.NewRequest(http.MethodGet, baseURL+path, nil)
resp, err := client.Do(proxied(req))
if err != nil {
t.Fatalf("%s: %v", tc.name, err)
}
body, _ := io.ReadAll(resp.Body)
resp.Body.Close()
return resp, string(body)
}
resp, _ := get("/")
if resp.StatusCode != tc.code || resp.Header.Get("Location") != tc.target {
t.Errorf("%s: GET / = %d to %q, want %d to %q", tc.name, resp.StatusCode, resp.Header.Get("Location"), tc.code, tc.target)
}
for _, file := range []string{tc.target + ".js", "auth/scram.js", "auth/style.css"} {
if resp, _ := get("/" + file); tc.target != "" && resp.StatusCode != http.StatusOK {
t.Errorf("%s: /%s = %d, which its page loads", tc.name, file, resp.StatusCode)
}
}
if tc.mode == "" {
continue
}
resp, page := get("/" + tc.target)
if want := `name="logwisp-login" content="` + tc.mode + `"`; resp.StatusCode != http.StatusOK || !strings.Contains(page, want) {
t.Errorf("%s: /%s = %d, without %s", tc.name, tc.target, resp.StatusCode, want)
}
}
}
// A path ending in "/" matches itself only: a stream at the root does not
// answer every other path, a browser's favicon request included
func TestEndpointPathsMatchExactly(t *testing.T) {
h, manager := newTestHTTPSink(t, map[string]any{"stream_path": "/"})
client, baseURL := serveTestHTTPSink(t, h)
for path, want := range map[string]int{"/favicon.ico": http.StatusNotFound, "/auth/view": http.StatusOK} {
resp, err := client.Get(baseURL + path)
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
if resp.StatusCode != want {
t.Errorf("GET %s = %d, want %d", path, resp.StatusCode, want)
}
}
if n := manager.GetSessionCount(); n != 0 {
t.Errorf("sessions = %d, want 0", n)
}
}
// Behind a proxy nothing answers a peer outside trusted_proxies, not even the
// login page or the challenge.
func TestProxyModeRefusesDirectPeers(t *testing.T) {
@@ -289,9 +347,10 @@ func TestProxyModeRefusesDirectPeers(t *testing.T) {
}
}
// Pages exist only behind a proxy, where browsers can log in; the viewer
// needs the login page; /auth and the paths under it are reserved.
func TestPagesNeedProxyMode(t *testing.T) {
// The constructor refuses what it cannot serve: the login pages outside
// scram's proxy mode, a viewer without the login page, a path under /auth, and
// a path ServeMux or a URL would not take literally, which panicked at Start.
func TestUnservableOptionsAreRefused(t *testing.T) {
scram := map[string]any{"type": "scram", "credentials_file": scramCredentials(t)}
pki := testutil.NewPKI(t, "viewer-01")
tlsOn := map[string]any{"enabled": true, "cert_file": pki.ServerCert, "key_file": pki.ServerKey}
@@ -300,6 +359,10 @@ func TestPagesNeedProxyMode(t *testing.T) {
"login page without proxies": {"tls": tlsOn, "auth": scram, "login_page": true},
"viewer without login": {"auth": proxy, "viewer_page": true},
"stream under /auth": {"auth": proxy, "stream_path": "/auth/stream"},
"wildcard brace in a path": {"stream_path": "/a{b"},
"unclean path": {"status_path": "//status"},
"escape aliasing a path": {"stream_path": "/x", "status_path": "/%78"},
"query in a path": {"status_path": "/a?b"},
} {
maps.Copy(opts, map[string]any{"host": "127.0.0.1", "port": int64(8081)})
if _, err := NewHTTPSinkPlugin("stream", opts, log.NewLogger(), nil); err == nil {
@@ -326,7 +389,7 @@ func TestSSEFramesEveryLineAsData(t *testing.T) {
// stopped reading does; Stop returns at the flush bound.
func TestStopWritesQueuedEventsToReadingStreams(t *testing.T) {
const n = 2000 // of 8 KiB: more than a silent peer's socket buffers hold
httpSink, _ := newTestHTTPSink(t, map[string]any{"buffer_size": int64(n), "client_buffer_size": int64(64), "write_timeout_ms": int64(500)})
httpSink, _ := newTestHTTPSink(t, map[string]any{"buffer_size": int64(n), "client_buffer_size": int64(64), "write_timeout_ms": int64(2000)})
client, baseURL := serveTestHTTPSink(t, httpSink)
var scanners []*bufio.Scanner
for range 2 {
@@ -383,8 +446,8 @@ func TestStopWritesQueuedEventsToReadingStreams(t *testing.T) {
}()
time.Sleep(100 * time.Millisecond)
httpSink.clientsMu.Unlock()
if d := <-stopped; d > 1500*time.Millisecond {
t.Errorf("Stop took %v past a 500 ms flush bound", d)
if d := <-stopped; d > 3500*time.Millisecond {
t.Errorf("Stop took %v past a 2 s flush bound", d)
}
if r := <-done; r.lines != n || !r.disconnect {
t.Fatalf("reading stream received %d of %d lines, disconnect %v", r.lines, n, r.disconnect)
+25
View File
@@ -2,10 +2,14 @@ package http
import (
"context"
"crypto/tls"
"crypto/x509"
"maps"
"net"
"net/http"
"os"
"path/filepath"
"strings"
"testing"
"time"
@@ -51,6 +55,27 @@ func serveTestHTTPSink(t *testing.T, h *HTTPSink) (*http.Client, string) {
return client, "http://" + listener.Addr().String()
}
// trustPKI points client at https, trusting pki's CA and, with cert,
// presenting its client certificate
func trustPKI(t *testing.T, client *http.Client, baseURL string, pki *testutil.PKI, cert bool) string {
t.Helper()
caPEM, err := os.ReadFile(pki.CA)
if err != nil {
t.Fatal(err)
}
cfg := &tls.Config{RootCAs: x509.NewCertPool()}
cfg.RootCAs.AppendCertsFromPEM(caPEM)
if cert {
pair, err := tls.LoadX509KeyPair(pki.ClientCert, pki.ClientKey)
if err != nil {
t.Fatal(err)
}
cfg.Certificates = []tls.Certificate{pair}
}
client.Transport = &http.Transport{TLSClientConfig: cfg}
return "https" + strings.TrimPrefix(baseURL, "http")
}
// scramCredentials writes a credentials file holding viewer-01 under a cheap
// Argon2 profile
func scramCredentials(t *testing.T) string {
+38 -18
View File
@@ -28,29 +28,30 @@ var webTypes = map[string]string{
".html": "text/html; charset=utf-8",
}
func loginOn(o *config.HTTPSinkOptions) bool { return o.LoginPage }
func viewerOn(o *config.HTTPSinkOptions) bool { return o.ViewerPage }
// webRoutes are served under /auth/ in proxy mode: the client library always,
// so a site can load it, and the pages when enabled
// webRoutes are the browser files under /auth/, each with the page that needs
// it; "" marks a shared file, served with any page and always in proxy mode,
// where a site may load the client library on its own
var webRoutes = []struct {
name, file string
on func(*config.HTTPSinkOptions) bool
name, file, page string
}{
{"scram.js", "scram.js", func(*config.HTTPSinkOptions) bool { return true }},
{"style.css", "style.css", loginOn},
{"login", "login.html", loginOn},
{"login.js", "login.js", loginOn},
{"view", "view.html", viewerOn},
{"view.js", "view.js", viewerOn},
{"scram.js", "scram.js", ""},
{"style.css", "style.css", ""},
{"login", "login.html", "login"},
{"login.js", "login.js", "login"},
{"view", "view.html", "view"},
{"view.js", "view.js", "view"},
}
// webHandlers maps each enabled GET path to its file. The viewer learns a
// custom status path from a meta tag, as the CSP allows no inline script.
func webHandlers(o *config.HTTPSinkOptions) (map[string]http.Handler, error) {
// webHandlers maps each GET path a browser uses to its handler. Without a
// login (no auth, or mtls: the certificate is the login) the viewer is always
// served; under scram the pages need proxy mode. The viewer learns the status
// path and whether it logs in from meta tags, as the CSP allows no inline script.
func webHandlers(o *config.HTTPSinkOptions, p *authz.Policy) (map[string]http.Handler, error) {
open := !p.NeedsLogin()
on := map[string]bool{"": open || p.BehindProxy(), "login": o.LoginPage, "view": open || o.ViewerPage}
handlers := make(map[string]http.Handler)
for _, r := range webRoutes {
if !r.on(o) {
if !on[r.page] {
continue
}
data, err := webFiles.ReadFile("web/" + r.file)
@@ -59,13 +60,32 @@ func webHandlers(o *config.HTTPSinkOptions) (map[string]http.Handler, error) {
}
if r.file == "view.html" {
status := html.EscapeString(strings.TrimPrefix(o.StatusPath, "/"))
data = bytes.Replace(data, []byte(`content="status"`), []byte(`content="`+status+`"`), 1)
data = bytes.Replace(data, []byte(`name="logwisp-status" content="status"`), []byte(`name="logwisp-status" content="`+status+`"`), 1)
if open {
data = bytes.Replace(data, []byte(`name="logwisp-login" content="scram"`), []byte(`name="logwisp-login" content="none"`), 1)
}
}
handlers[chain.AuthPath+"/"+r.name] = serveWebFile(data, webTypes[path.Ext(r.file)])
}
switch {
case o.StreamPath == "/" || o.StatusPath == "/": // the endpoint owns the root
case on["view"]:
handlers["/{$}"] = seeOther("auth/view")
case on["login"]:
handlers["/{$}"] = seeOther("auth/login")
}
return handlers, nil
}
// seeOther redirects to a relative target, which a proxy prefix keeps intact;
// http.Redirect would resolve it against the backend's path
func seeOther(target string) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Location", target)
w.WriteHeader(http.StatusSeeOther)
})
}
func serveWebFile(data []byte, contentType string) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
hd := w.Header()
+1
View File
@@ -5,6 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>LogWisp stream</title>
<meta name="logwisp-status" content="status">
<meta name="logwisp-login" content="scram">
<link rel="stylesheet" href="style.css">
<script type="module" src="view.js"></script>
</head>
+11 -5
View File
@@ -1,8 +1,11 @@
import { cookiesUsable, login, loginUnavailable, logout, stream } from "./scram.js";
const base = new URL("..", location.href);
// The server writes its status path here, relative to the mount
const statusPath = document.querySelector('meta[name="logwisp-status"]').content;
// The server writes its status path here, relative to the mount, and "none"
// as the login when the browser needs none: no auth, or its mTLS certificate
const meta = (name) => document.querySelector(`meta[name="logwisp-${name}"]`).content;
const statusPath = meta("status");
const open = meta("login") === "none";
const MAX_LINES = 5000;
const log = document.getElementById("log");
const state = document.getElementById("state");
@@ -11,10 +14,11 @@ const form = document.getElementById("login");
const progress = document.getElementById("progress");
const status = document.getElementById("status");
const unavailable = loginUnavailable();
// A page without a login needs no WebCrypto, so plain http works too
const unavailable = open ? "" : loginUnavailable();
// Without cookies the session is a token in this variable only: never stored,
// so it ends with the page, or earlier with its lifetime or sign-out
let tokenMode = !unavailable && !cookiesUsable();
let tokenMode = !open && !unavailable && !cookiesUsable();
let token = null;
// The login page comes back with this fragment: a 401 now means the browser
// dropped the session cookie
@@ -45,7 +49,8 @@ async function connect() {
} catch {
return retry("server unreachable");
}
if (res.status === 401) return sessionEnded();
// An open page has no session to end: a 401 there is a proxy's, so it retries
if (res.status === 401 && !open) return sessionEnded();
fresh = false;
const path = res.ok ? (await res.json().catch(() => ({})))?.endpoints?.stream : null;
if (typeof path !== "string" || !path.startsWith("/")) {
@@ -168,5 +173,6 @@ if (unavailable) {
state.textContent = unavailable;
signOut.hidden = true;
} else {
signOut.hidden = open;
connect();
}
+3 -3
View File
@@ -80,7 +80,7 @@ func TestStopWritesQueuedEventsToReadingClients(t *testing.T) {
t.Cleanup(manager.Stop)
created, err := NewTCPSinkPlugin("out", map[string]any{
"host": "127.0.0.1", "port": p, "buffer_size": int64(n), "client_buffer_size": int64(64),
"write_timeout_ms": int64(500),
"write_timeout_ms": int64(2000),
}, log.NewLogger(), session.NewProxy(manager, "out"))
if err != nil {
t.Fatal(err)
@@ -132,8 +132,8 @@ func TestStopWritesQueuedEventsToReadingClients(t *testing.T) {
}()
time.Sleep(100 * time.Millisecond)
sink.clientsMu.Unlock()
if d := <-stopped; d > 1500*time.Millisecond {
t.Errorf("Stop took %v past a 500 ms flush bound", d)
if d := <-stopped; d > 3500*time.Millisecond {
t.Errorf("Stop took %v past a 2 s flush bound", d)
}
if got := <-lines; got != n {
t.Fatalf("reading client received %d of %d lines", got, n)
+17 -26
View File
@@ -1,33 +1,24 @@
<svg viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">
<!--
LogWisp Logo - Vibrant Tech Stream
- Shape: 5 swirling streams emanating from a center point.
- Style: Wiry, energetic, and modern, suggesting data flow or a galaxy.
- Color: Radiant gradient from a bright core to a vibrant teal.
-->
<!-- LogWisp: five colored wisps interlock directly at the center.
No central core; each wing meets the others at (50,50). -->
<defs>
<radialGradient id="vibrant-glow" cx="50%" cy="50%" r="50%" fx="50%" fy="50%">
<stop offset="0%" stop-color="#E0FFFF" /> <!-- Bright, almost white cyan at the core -->
<stop offset="100%" stop-color="#00B0B0" /> <!-- Deeper teal/cyan at the tips -->
</radialGradient>
<path id="wisp" d="
M 50,50
C 50,38.5 59.2,28.8 70.8,27.8
C 82.9,26.7 91.2,35.4 96.7,49.3
A 1.8,1.8 0 0 1 93.3,50.7
C 89.1,42.8 82.0,38.2 74.1,38.4
C 64.8,38.7 56.5,43.7 50,50
Z
"/>
</defs>
<style>
.wisp-path {
fill: none;
stroke: url(#vibrant-glow);
stroke-width: 1.5;
stroke-linecap: round;
}
</style>
<g id="wisps">
<!-- A single path shape is defined, then rotated 5 times around the center (50, 50) -->
<!-- The path is a cubic Bezier curve: M(start) C(control1, control2, end) -->
<path class="wisp-path" d="M50,50 C58,30 85,25 95,50" transform="rotate(0, 50, 50)" />
<path class="wisp-path" d="M50,50 C58,30 85,25 95,50" transform="rotate(72, 50, 50)" />
<path class="wisp-path" d="M50,50 C58,30 85,25 95,50" transform="rotate(144, 50, 50)" />
<path class="wisp-path" d="M50,50 C58,30 85,25 95,50" transform="rotate(216, 50, 50)" />
<path class="wisp-path" d="M50,50 C58,30 85,25 95,50" transform="rotate(288, 50, 50)" />
<use href="#wisp" fill="#E0409A"/>
<use href="#wisp" fill="#D4640A" transform="rotate(72 50 50)"/>
<use href="#wisp" fill="#169B58" transform="rotate(144 50 50)"/>
<use href="#wisp" fill="#0891B2" transform="rotate(216 50 50)"/>
<use href="#wisp" fill="#8E5CF0" transform="rotate(288 50 50)"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 797 B

+1 -1
View File
@@ -97,7 +97,7 @@ Append a line and watch it arrive:
> curl -N http://127.0.0.1:$PORT_HTTP/stream
> echo hello >>\$APP/app.log
The secured sink (bearer keeps the token off argv):
> token=\$(\$LW auth token -url https://127.0.0.1:$PORT_S_HTTP -user viewer-01 -password-file \$PW -ca-file \$CA)
> token=\$(\$LW auth token --url https://127.0.0.1:$PORT_S_HTTP --user viewer-01 --password-file \$PW --ca-file \$CA)
> curl -N --cacert \$CA -H @<(bearer) https://127.0.0.1:$PORT_S_HTTP/stream
Logs: $(short "$LOG")/
EOF
+4 -4
View File
@@ -168,9 +168,9 @@ Ports (relay on ::1, TLS with SAN IP ::1, scram):
Shell setup (LW, CA, PW: viewer-01's password file):
> . $(short "$RUN")/env
Read the tcp sink as viewer-01:
> \$LW auth stream -addr [::1]:$PORT_TCP_SINK -user viewer-01 -password-file \$PW -ca-file \$CA
> \$LW auth stream --addr [::1]:$PORT_TCP_SINK --user viewer-01 --password-file \$PW --ca-file \$CA
Read the http sink (bearer keeps the token off argv; -g leaves the brackets to the URL):
> token=\$(\$LW auth token -url https://[::1]:$PORT_HTTP_SINK -user viewer-01 -password-file \$PW -ca-file \$CA)
> token=\$(\$LW auth token --url https://[::1]:$PORT_HTTP_SINK --user viewer-01 --password-file \$PW --ca-file \$CA)
> curl -gN --cacert \$CA -H @<(bearer) https://[::1]:$PORT_HTTP_SINK/stream
Which listener answers on $PORT_FAMILY:
> curl -s http://127.0.0.1:$PORT_FAMILY/status; curl -gs http://[::1]:$PORT_FAMILY/status
@@ -203,7 +203,7 @@ check "relay logged its listener as [::1]:$PORT_TCP_CHAIN ($n)" $((n >= 1))
# 2. HTTP sink: lw auth token at an IPv6 URL, the token carried by curl -g
token="$("$BIN" auth token -url "https://[::1]:$PORT_HTTP_SINK" -user viewer-01 \
-password-file "$AUTH/viewer-01.pass" -ca-file "$PKI/ca.crt" 2>>"$LOG/auth-cli.out")"
check "http sink: lw auth token -url https://[::1]:$PORT_HTTP_SINK issued a token" $(is_set "$token")
check "http sink: lw auth token --url https://[::1]:$PORT_HTTP_SINK issued a token" $(is_set "$token")
code="$(curl "${curl_args[@]}" -o /dev/null -w '%{http_code}' --cacert "$PKI/ca.crt" \
-H @<(printf 'Authorization: Bearer %s\n' "$token") "https://[::1]:$PORT_HTTP_SINK/status" 2>/dev/null || true)"
@@ -220,7 +220,7 @@ timeout 4 "$BIN" auth stream -addr "[::1]:$PORT_TCP_SINK" -user viewer-01 \
-password-file "$AUTH/viewer-01.pass" -ca-file "$PKI/ca.crt" \
>"$RUN/stream.out" 2>"$LOG/stream.err"
n=$(grep -c 'edge-01/' "$RUN/stream.out")
check "tcp sink: lw auth stream -addr [::1]:$PORT_TCP_SINK streamed entries ($n lines)" $((n >= 1))
check "tcp sink: lw auth stream --addr [::1]:$PORT_TCP_SINK streamed entries ($n lines)" $((n >= 1))
section "Scenario 2: each listener keeps to its address family"
+12 -2
View File
@@ -1,7 +1,8 @@
#!/usr/bin/env bash
# logwisp preset test: colors, presets, --dump, lw tls, listeners that make
# their certificate (self-signed and pinned, or issued), and the http and tcp
# sinks writing a finite input whole. One-shot: both modes run the checks.
# their certificate (self-signed and pinned, or issued), the serve preset's
# browser viewer, and the http and tcp sinks writing a finite input whole.
# One-shot: both modes run the checks.
# Ports: 15890-15896 on 127.0.0.1.
# Requires: bash 5+, coreutils, curl, openssl. Linux dev host only.
@@ -85,6 +86,15 @@ check "curl --pinnedpubkey reads a self-signed stream's status" \
"$(curl -sk --pinnedpubkey "$SERVE_PIN" https://127.0.0.1:15895/status | grep -q '"tls":true' && echo 1 || echo 0)"
curl -sk --pinnedpubkey "$PIN" -o /dev/null https://127.0.0.1:15895/status
check "curl with another listener's pin is refused" "$(( $? != 0 ))"
check "a browser at the self-signed root lands on the viewer" \
"$(curl -sLk --pinnedpubkey "$SERVE_PIN" https://127.0.0.1:15895/ | grep -q 'name="logwisp-login" content="none"' && echo 1 || echo 0)"
section "Viewer"
spawn serve-plain "$BIN" --preset "serve,path=$RUN/edge.log,listen=127.0.0.1:15896"
wait_port 15896 || abort "the plain serve preset did not listen" serve-plain
root=$(curl -s -o /dev/null -w '%{http_code} %{redirect_url}' http://127.0.0.1:15896/)
check "GET / sends a browser to the viewer, which needs no login over plain http ($root)" \
"$( [[ $root == '303 http://127.0.0.1:15896/auth/view' ]] && curl -sL http://127.0.0.1:15896/ | grep -q 'name="logwisp-login" content="none"' && echo 1 || echo 0)"
section "Flush at exit"
# feed SECONDS PORT: the lines, once a client had time to connect
+3 -3
View File
@@ -235,12 +235,12 @@ edge_unknown and edge_noauth are refused on $PORT_TCP_CHAIN. On $PORT_BOUND edge
Shell setup (LW, CA, PW: viewer-01's password file, USERS, RELAY: its pid):
> . $(short "$RUN")/env
Read the tcp sink as viewer-01:
> \$LW auth stream -addr 127.0.0.1:$PORT_TCP_SINK -user viewer-01 -password-file \$PW -ca-file \$CA
> \$LW auth stream --addr 127.0.0.1:$PORT_TCP_SINK --user viewer-01 --password-file \$PW --ca-file \$CA
Read the http sink (bearer keeps the token off argv):
> token=\$(\$LW auth token -url https://127.0.0.1:$PORT_HTTP_SINK -user viewer-01 -password-file \$PW -ca-file \$CA)
> token=\$(\$LW auth token --url https://127.0.0.1:$PORT_HTTP_SINK --user viewer-01 --password-file \$PW --ca-file \$CA)
> curl -N --cacert \$CA -H @<(bearer) https://127.0.0.1:$PORT_HTTP_SINK/stream
Revoke viewer-01:
> \$LW auth remove-user -credentials \$USERS -user viewer-01 && kill -HUP \$RELAY
> \$LW auth remove-user --credentials \$USERS --user viewer-01 && kill -HUP \$RELAY
Ingested entries (node label forced to the username): $(short "$OUT")/
Passwords: $(short "$AUTH")/ Logs: $(short "$LOG")/
EOF
+44 -11
View File
@@ -1,7 +1,8 @@
#!/usr/bin/env bash
# logwisp browser login test: an http sink in proxy mode behind a TLS-terminating
# reverse proxy that mounts it under /logs/; headless Chromium signs in through
# the shipped pages, with cookies and without (token mode); the CLI unbound.
# the shipped pages, with cookies and without (token mode); the CLI unbound; an
# open sink's viewer streams with no login on a page the browser holds insecure.
# Requires: bash 5+, go, openssl, curl; node with playwright and both builds
# `playwright install chromium` fetches, or the browser checks skip.
@@ -15,13 +16,14 @@ PROXY_SRC=$RUN/proxy
USERS=$AUTH/users.toml
PORT_PROXY=15831 # the site: TLS ends here, logwisp is under /logs/
PORT_SINK=15832 # logwisp http sink, plaintext, only for 127.0.0.2
PORT_OPEN=15833 # logwisp http sink without auth
PROXY_ADDR=127.0.0.2
SITE="https://127.0.0.1:$PORT_PROXY"
e2e_init "$@"
section "Setup"
need go openssl curl
ports_free $PORT_PROXY $PORT_SINK
ports_free $PORT_PROXY $PORT_SINK $PORT_OPEN
# The launch each scenario makes: the module resolves without its browsers, and
# chromium.executablePath() names full Chromium, not the default headless shell
launches() { timeout 60 node -e 'require("playwright").chromium.launch(JSON.parse(process.argv[1])).then((b) => b.close())' "$1" >/dev/null 2>&1; }
@@ -118,6 +120,13 @@ viewer_page = true
type = "scram"
credentials_file = "$USERS"
trusted_proxies = ["$PROXY_ADDR"]
[[pipelines.plugin_sinks]]
id = "open"
type = "http"
[pipelines.plugin_sinks.config]
host = "127.0.0.1"
port = $PORT_OPEN
EOF
cat >"$RUN/browser.cjs" <<'EOF'
@@ -147,7 +156,7 @@ const inlineLogin = (page) => page.waitForFunction(() =>
const scenarios = {
async cookie(context, page) {
await page.goto(base + "auth/view");
await page.goto(base);
await page.waitForURL(/\/auth\/login\?next=view%23signed-in$/, { timeout: 10000 });
out.redirected_to_login = 1;
await signIn(page, "not-the-password");
@@ -235,6 +244,16 @@ const scenarios = {
await inlineLogin(page);
out.expired_asks_again = 1;
},
// base names 127.0.0.1 as viewer.test, which the browser holds insecure
async open(context, page) {
await page.goto(base);
await page.waitForURL(/\/auth\/view$/, { timeout: 10000 });
await linesAtLeast(page, 3);
out.events = await lines(page);
out.secure = await page.evaluate(() => isSecureContext);
out.sign_in_ui = await page.evaluate(() => !document.getElementById("logout").hidden || !document.getElementById("login").hidden);
},
};
(async () => {
@@ -244,7 +263,8 @@ const scenarios = {
// Full Chromium: the headless shell ignores a profile's Preferences
context = await chromium.launchPersistentContext(profile, { ignoreHTTPSErrors: true, channel: "chromium" });
} else {
context = await (await chromium.launch()).newContext({ ignoreHTTPSErrors: true });
const args = mode === "open" ? ["--host-resolver-rules=MAP viewer.test 127.0.0.1"] : [];
context = await (await chromium.launch({ args })).newContext({ ignoreHTTPSErrors: true });
}
const page = await context.newPage();
page.on("console", (m) => { if (/Content Security Policy/i.test(m.text())) out.csp_violations++; });
@@ -266,6 +286,7 @@ spawn proxy "$PROXY_SRC/proxy" "127.0.0.1:$PORT_PROXY" "http://127.0.0.1:$PORT_S
"$PKI/site.crt" "$PKI/site.key"
wait_port $PORT_SINK || abort "logwisp port $PORT_SINK is not listening" logwisp
wait_port $PORT_PROXY || abort "proxy port $PORT_PROXY is not listening" proxy
wait_port $PORT_OPEN || abort "logwisp port $PORT_OPEN is not listening" logwisp
daemons_up
write_env LW="$BIN" CA="$PKI/ca.crt" PW="$AUTH/viewer-01.pass" SITE="$SITE"
@@ -273,13 +294,14 @@ guide "logwisp browser login test" <<EOF
Ports:
$PORT_PROXY the site: TLS-terminating reverse proxy, logwisp under /logs/
$PORT_SINK logwisp http sink, plaintext, trusted_proxies = ["$PROXY_ADDR"]
$PORT_OPEN logwisp http sink without auth: its viewer at the root
In a browser trusting $(short "$PKI/ca.crt"), sign in as viewer-01 with the
password in $(short "$AUTH/viewer-01.pass"):
> $SITE/logs/auth/view
Shell setup (LW, CA, PW: viewer-01's password file, SITE):
> . $(short "$RUN")/env
The CLI, unbound through the proxy (bearer keeps the token off argv):
> token=\$(\$LW auth token -unbound -url \$SITE/logs -user viewer-01 -password-file \$PW -ca-file \$CA)
> token=\$(\$LW auth token --unbound --url \$SITE/logs --user viewer-01 --password-file \$PW --ca-file \$CA)
> curl -N --cacert \$CA -H @<(bearer) \$SITE/logs/stream
Logs: $(short "$LOG")/
EOF
@@ -294,9 +316,9 @@ code_of() { # url [token] -> http_code; the header goes through a pipe, never ar
fi
}
browser() { # mode: runs browser.cjs, whose key=value lines val reads
browser() { # mode [base]: runs browser.cjs, whose key=value lines val reads
info "browser: $1 (output in $(short "$LOG")/browser-$1.out)"
timeout 120 node "$RUN/browser.cjs" "$1" "$SITE/logs/" viewer-01 "$AUTH/viewer-01.pass" \
timeout 120 node "$RUN/browser.cjs" "$1" "${2:-$SITE/logs/}" viewer-01 "$AUTH/viewer-01.pass" \
"$RUN/profile" "${PIDS[0]}" >"$LOG/browser-$1.out" 2>"$LOG/browser-$1.err"
browser_out="$(cat "$LOG/browser-$1.out")"
[[ -z $(val error) ]] || info "${C_RED}browser error ($1): $(val error)$C_OFF"
@@ -308,7 +330,7 @@ csp_clean() { echo $(($(is "$(val error)" "") && $(is "$(val csp_violations)" 0)
section "Scenario 1: a person in a browser"
if ((BROWSER)); then
browser cookie
check "browser: an unauthenticated viewer is sent to the login page" $(is "$(val redirected_to_login)" 1)
check "browser: the mount sends an unauthenticated visitor to the viewer, then the login page" $(is "$(val redirected_to_login)" 1)
check "browser: a wrong password is refused on the page" $(is "$(val wrong_password_refused)" 1)
check "browser: login (Argon2 in the page, $(val login_ms) ms) opened the viewer" $(is_set "$(val login_ms)")
n=$(val events)
@@ -360,7 +382,7 @@ fi
section "Scenario 3: the CLI through the proxy"
token="$("$BIN" auth token -unbound -url "$SITE/logs" -user viewer-01 -password-file "$AUTH/viewer-01.pass" \
-ca-file "$PKI/ca.crt" 2>>"$LOG/auth-cli.out")"
check "cli: lw auth token -unbound logged in through the proxy" $(is_set "$token")
check "cli: lw auth token --unbound logged in through the proxy" $(is_set "$token")
code="$(code_of "$SITE/logs/status" "$token")"
check "cli: /logs/status served with the token (HTTP $code)" $(is "$code" 200)
sse="$(timeout 3 curl -sN --noproxy '*' --cacert "$PKI/ca.crt" \
@@ -372,9 +394,20 @@ check "cli: /logs/status refused without a token (HTTP $code)" $(is "$code" 401)
"$BIN" auth token -url "$SITE/logs" -user viewer-01 -password-file "$AUTH/viewer-01.pass" \
-ca-file "$PKI/ca.crt" >/dev/null 2>>"$LOG/auth-cli.out"
rc=$?
check "cli: a path without -unbound is a usage error (exit $rc)" $((rc == 2))
check "cli: a path without --unbound is a usage error (exit $rc)" $((rc == 2))
section "Scenario 4: around the proxy"
section "Scenario 4: an open sink"
if ((BROWSER)); then
browser open "http://viewer.test:$PORT_OPEN/"
n=$(val events)
check "open: the root led to the viewer, which streamed with no login on an insecure page (${n:-0} lines, secure $(val secure))" \
$((${n:-0} >= 3 && $(is "$(val secure)" false) && $(is "$(val sign_in_ui)" false)))
check "open: no CSP violations ($(val csp_violations))" $(csp_clean)
else
skip "browser checks: node, playwright or its Chromium headless shell missing"
fi
section "Scenario 5: around the proxy"
code="$(code_of "http://127.0.0.1:$PORT_SINK/auth/login")"
check "direct: the login page refuses a peer that is not the proxy (HTTP $code)" $(is "$code" 403)
code="$(code_of "http://127.0.0.1:$PORT_SINK/status" "$token")"