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
+772 -390

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.version="${VERSION}" \
org.opencontainers.image.licenses="BSD-3-Clause" 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 /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt
COPY --from=build /out/lw /lw 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 `console` (level names in color and control characters escaped on a
terminal), `file` (rotating with terminal), `file` (rotating with
retention), `http` (Server-Sent Events plus a retention), `http` (Server-Sent Events, a JSON status endpoint and a browser
JSON status endpoint), `tcp` (broadcast server), `null`, and the chain viewer), `tcp` (broadcast server), `null`, and the chain
forwarders `tcp_chain` and `http_chain`. forwarders `tcp_chain` and `http_chain`.
### Processing ### Processing
@@ -116,7 +116,7 @@ authentication designs for the rationale and what is deliberately left out.
- [Operations](doc/operations.md): running, monitoring, tuning, - [Operations](doc/operations.md): running, monitoring, tuning,
troubleshooting troubleshooting
- [To Do](doc/todo.md): planned work in priority order: network access - [To Do](doc/todo.md): planned work in priority order: network access
control and the PROXY protocol, packaging control and the PROXY protocol, packaging, smaller follow-ups
A fully annotated configuration covering every option ships as A fully annotated configuration covering every option ships as
[`config/logwisp.toml`](config/logwisp.toml). [`config/logwisp.toml`](config/logwisp.toml).
@@ -135,7 +135,8 @@ one without `--sink` writes stdout:
./bin/lw < app.log > copy.log ./bin/lw < app.log > copy.log
tail -F app.log | ./bin/lw --filter include,patterns=ERROR,patterns=WARN 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 journalctl -f | ./bin/lw --sink http,host=127.0.0.1,port=8080
# tail a directory instead of stdin # 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)" const reloadHint = "send SIGHUP to logwisp to apply (auto_reload does not watch the credentials file)"
var authCommands = []subcommand{ 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", "Add a user to a credentials file, or replace its password",
[]string{"credentials", "user"}, defineAddUser}, []string{"credentials", "user"}, defineAddUser},
{"remove-user", "-credentials FILE -user NAME", {"remove-user", "--credentials FILE -u NAME",
"Remove a user from a credentials file", "Remove a user from a credentials file",
[]string{"credentials", "user"}, defineRemoveUser}, []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", "Log in to an http sink or http_chain source and print a bearer token",
[]string{"url", "user", "password-file"}, defineToken}, []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", "Log in to a tcp sink and copy its stream to stdout until interrupted",
[]string{"addr", "user", "password-file"}, defineStream}, []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") path := fs.String("credentials", "", "credentials `file`, created when missing")
user := fs.String("user", "", "user `name`") 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") 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 { return func(stdout, stderr io.Writer) error {
creds, err := authz.LoadCredentials(*path) creds, err := authz.LoadCredentials(*path)
// An empty file is one created beforehand to choose its owner // 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 == "" generated := password == ""
if generated && i >= 0 && !*generate { if generated && i >= 0 && !*generate {
// A mistyped -password-file must not replace a working password // 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) return fmt.Errorf("user %q exists: replacing its password needs an existing --password-file or --generate", *user)
} }
if generated { if generated {
password = rand.Text() password = rand.Text()
@@ -105,7 +105,7 @@ func defineAddUser(fs *flag.FlagSet) func(stdout, stderr io.Writer) error {
} }
if generated && *passwordFile != "" { if generated && *passwordFile != "" {
// First, so a failure leaves the verifiers untouched and a rerun // 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 { if err := writeAtomic(*passwordFile, []byte(password+"\n")); err != nil {
return err 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 { 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`") user := fs.String("user", "", "user `name`")
passwordFile := fs.String("password-file", "", "`file` holding the password") 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)") 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 { return func(stdout, _ io.Writer) error {
u, err := url.Parse(*rawURL) u, err := url.Parse(*rawURL)
if err != nil || u.Scheme != "https" || u.Host == "" || u.RawQuery != "" || u.Fragment != "" || u.User != nil { 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 { if u.Path = strings.TrimSuffix(u.Path, "/"); u.Path != "" && !*unbound {
// TLS passthrough cannot route by path: only a terminating proxy can // 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()) network, err := core.Network(u.Hostname())
if err != nil { if err != nil {
return usageError("-url: " + err.Error()) return usageError("--url: " + err.Error())
} }
tlsCfg, policy, err := dialPolicy(tlsOpts, u.Hostname(), *user, *passwordFile, authz.HTTP) tlsCfg, policy, err := dialPolicy(tlsOpts, u.Hostname(), *user, *passwordFile, authz.HTTP)
if err != nil { 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 { 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`") user := fs.String("user", "", "user `name`")
passwordFile := fs.String("password-file", "", "`file` holding the password") passwordFile := fs.String("password-file", "", "`file` holding the password")
tlsOpts := tlsFlags(fs) tlsOpts := tlsFlags(fs)
@@ -211,7 +211,7 @@ func defineStream(fs *flag.FlagSet) func(stdout, stderr io.Writer) error {
host, _, err := net.SplitHostPort(*addr) host, _, err := net.SplitHostPort(*addr)
network, nerr := core.Network(host) network, nerr := core.Network(host)
if err != nil || nerr != nil || 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) tlsCfg, policy, err := dialPolicy(tlsOpts, host, *user, *passwordFile, authz.TCP)
if err != nil { 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 { func tlsFlags(fs *flag.FlagSet) *config.TLSOptions {
o := &config.TLSOptions{Enabled: true} o := &config.TLSOptions{Enabled: true}
fs.StringVar(&o.CAFile, "ca-file", "", "CA `file` that verifies the server (default: system roots)") 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.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.CertFile, "cert-file", "", "client certificate `file`, for listeners with tls.client_auth")
fs.StringVar(&o.KeyFile, "key-file", "", "client key `file`") 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) { func TestAddUserCreatesPrivateFilesWithAMatchingVerifier(t *testing.T) {
dir := t.TempDir() dir := t.TempDir()
creds, pass := filepath.Join(dir, "users.toml"), filepath.Join(dir, "edge-01.pass") 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 != "" { if code != 0 || stdout != "" {
t.Fatalf("exit %d, stdout %q, stderr: %s", code, stdout, stderr) 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 // 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 // its zone escaped, not turned into one that no longer parses
func TestTokenLogsInAtTheURLGiven(t *testing.T) { func TestTokenLogsInAtTheURLGiven(t *testing.T) {
+64 -13
View File
@@ -8,6 +8,7 @@ import (
"io" "io"
"slices" "slices"
"strings" "strings"
"unicode"
"github.com/lixenwraith/logwisp/internal/config" "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()}, "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 // usageError is a command-line mistake: exit status 2 rather than 1
type usageError string type usageError string
@@ -63,9 +75,14 @@ func (c *command) run(args []string, stdout, stderr io.Writer) int {
fs.SetOutput(stderr) fs.SetOutput(stderr)
fs.Usage = func() { fs.Usage = func() {
fmt.Fprintf(stderr, "Usage: %s %s\n\n%s.\n\n", fs.Name(), s.synopsis, s.summary) 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) 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 err := fs.Parse(args[1:]); err != nil {
if errors.Is(err, flag.ErrHelp) { if errors.Is(err, flag.ErrHelp) {
return 0 return 0
@@ -88,21 +105,48 @@ func (c *command) run(args []string, stdout, stderr io.Writer) int {
} }
func (c *command) usage(w io.Writer) { 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 { for _, s := range c.subcommands {
fmt.Fprintf(w, " %-12s %s\n", s.name, s.summary) 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) "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 { func checkArgs(fs *flag.FlagSet, required []string) error {
if fs.NArg() > 0 { if fs.NArg() > 0 {
return usageError(fmt.Sprintf("unexpected argument %q", fs.Arg(0))) return usageError(fmt.Sprintf("unexpected argument %q", fs.Arg(0)))
} }
for _, name := range required { for _, name := range required {
if fs.Lookup(name).Value.String() == "" { if fs.Lookup(name).Value.String() == "" {
return usageError("-" + name + " is required") return usageError("--" + name + " is required")
} }
} }
return nil return nil
@@ -117,10 +161,10 @@ type invocation struct {
load config.Args load config.Args
} }
// parseCommandLine reads lw's own flags; anything else is a --path=value // parseCommandLine reads lw's own flags, a short one as its long one; any
// setting for lixenwraith/config, which reports what it does not know. A // other --flag is a --path=value setting for lixenwraith/config, which reports
// pipeline flag takes the next argument unless it starts with '-'. A help // what it does not know. A pipeline flag takes the next argument unless it
// request wins over a malformed flag. // starts with '-'. A help request wins over a malformed flag.
func parseCommandLine(argv []string) (inv invocation, err error) { func parseCommandLine(argv []string) (inv invocation, err error) {
defer func() { defer func() {
if inv.help { if inv.help {
@@ -144,18 +188,22 @@ func parseCommandLine(argv []string) (inv invocation, err error) {
for i := 0; i < len(argv); i++ { for i := 0; i < len(argv); i++ {
arg := argv[i] arg := argv[i]
name, value, inline := strings.Cut(arg, "=") 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 { switch {
case arg == "--": case arg == "--":
inv.load.Overrides = append(inv.load.Overrides, argv[i:]...) inv.load.Overrides = append(inv.load.Overrides, argv[i:]...)
return inv, err return inv, err
case arg == "-h" || arg == "--help": case arg == "--help":
inv.help = true inv.help = true
case name == "-c" || name == "--config": case name == "--config":
if !inline { if !inline {
value = next(&i) value = next(&i)
} }
if value == "" { 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 inv.load.File = value
case arg == "--color": case arg == "--color":
@@ -166,6 +214,9 @@ func parseCommandLine(argv []string) (inv invocation, err error) {
value = next(&i) value = next(&i)
} }
inv.load.Specs = append(inv.load.Specs, config.Spec{Flag: name[2:], Value: value}) 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: default:
inv.load.Overrides = append(inv.load.Overrides, arg) inv.load.Overrides = append(inv.load.Overrides, arg)
} }
@@ -182,10 +233,10 @@ func presetCommands() []subcommand {
for _, k := range p.Params { for _, k := range p.Params {
if k.Required { if k.Required {
s.required = append(s.required, k.Name) 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 { s.define = func(fs *flag.FlagSet) func(stdout, stderr io.Writer) error {
values := map[string]*string{} values := map[string]*string{}
for _, k := range p.Params { for _, k := range p.Params {
+40 -5
View File
@@ -2,9 +2,11 @@ package main
import ( import (
"bytes" "bytes"
"flag"
"os" "os"
"path/filepath" "path/filepath"
"reflect" "reflect"
"regexp"
"strings" "strings"
"testing" "testing"
@@ -13,9 +15,10 @@ import (
"github.com/lixenwraith/logwisp/internal/tlsx" "github.com/lixenwraith/logwisp/internal/tlsx"
) )
// lw's own flags: the last -c wins, a pipeline flag or --color takes the next // lw's own flags: a short is its long flag, an unknown single-dash one fails,
// argument unless it starts with '-', -- ends lw's flags, and a command is // the last -c wins, a pipeline flag or --color takes the next argument unless
// only the first argument. Everything else is a setting for config. // 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) { func TestCommandLineGrammar(t *testing.T) {
for _, c := range []struct { for _, c := range []struct {
argv []string argv []string
@@ -33,15 +36,47 @@ func TestCommandLineGrammar(t *testing.T) {
{[]string{"help"}, invocation{help: true, load: config.Args{Overrides: []string{"help"}}}}, {[]string{"help"}, invocation{help: true, load: config.Args{Overrides: []string{"help"}}}},
{[]string{"tls", "ca", "-h"}, invocation{command: &commands[1], args: []string{"ca", "-h"}}}, {[]string{"tls", "ca", "-h"}, invocation{command: &commands[1], args: []string{"ca", "-h"}}},
{[]string{"--quiet", "tls"}, invocation{load: config.Args{Overrides: []string{"--quiet", "tls"}}}}, {[]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) got, err := parseCommandLine(c.argv)
if err != nil || !reflect.DeepEqual(got, c.want) { if err != nil || !reflect.DeepEqual(got, c.want) {
t.Errorf("%q:\n got %+v %v\nwant %+v", c.argv, got, err, 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 { 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)
}
} }
} }
} }
+44 -32
View File
@@ -13,55 +13,66 @@ const helpText = `LogWisp %s - log collection, processing, and distribution
Usage: Usage:
lw [options] [< input] No file, no pipeline options: stdin to stdout, lw [options] [< input] No file, no pipeline options: stdin to stdout,
line for line, like cat; exits at end of input line for line, like cat; exits at end of input
lw -c <file> [options] Run the file's pipelines lw -c|--config FILE [options] Run the file's pipelines
lw --check [options] Build every pipeline and plugin, report, exit 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 --dump [options] Print the effective configuration as TOML, exit
lw help | -h | --help lw help | -h | --help
lw --version lw -V | --version
Commands (lw <command> -h lists its subcommands): Commands (lw COMMAND -h lists its subcommands):
%s %s
Scalar configuration keys are settable as flags using their TOML paths: Options: a short option is its long one; a value follows as the next argument
--<path>=<value> e.g. --logging.level=debug or after '=' (-c FILE, --config=FILE), one that starts with '-' after '=';
short options do not combine.
Common options: -c, --config FILE Configuration file (default:
-c, --config <path> Configuration file (default: ./logwisp.toml) ~/.config/logwisp/logwisp.toml if it exists,
--quiet Suppress console output else ./logwisp.toml)
--color [<when>] Level names in color on console sinks: -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), auto (a terminal, NO_COLOR unset; default),
always (bare --color) or never always (bare --color) or never
--status_reporter=<bool> Periodic status logging (default: true with -V, --version Print the version and exit
a configuration file) -h, --help Print this help and exit
--auto_reload=<bool> Config hot reload on file change (default: false) -- End the options
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 Logging (lw's own log goes to stderr, at info with a configuration file and
at warn without one): at warn without one):
--logging.output=<mode> file|stdout|stderr|split|all|none --logging.output=MODE file|stdout|stderr|split|all|none
--logging.level=<level> debug|info|warn|error --logging.level=LEVEL debug|info|warn|error
--logging.file.directory=<path> --logging.file.directory=DIR
Pipelines (replace the file's pipelines; see doc/cli.md): Pipelines (replace the file's pipelines; see doc/cli.md):
--preset <name>[,k=v...] Start a pipeline with a preset: pipe, tail, -p, --preset NAME[,KEY=VALUE...]
serve, edge, aggregator; lw preset <name> -h Start a pipeline with a preset: pipe, tail,
lists its keys, lw preset <name> prints it serve, edge, aggregator; lw preset NAME -h
--pipeline <name> Start a pipeline; earlier specs go to "cli" lists its keys, lw preset NAME prints it
--source <spec> TYPE[,key=value...], repeatable --pipeline NAME Start a pipeline; earlier specs go to "cli"
--sink <spec> e.g. http,host=0.0.0.0,port=8080, repeatable --source SPEC TYPE[,KEY=VALUE...], repeatable
--filter <spec> include|exclude,patterns=RE, repeatable --sink SPEC e.g. http,host=0.0.0.0,port=8080, repeatable
--format <spec> json|txt|raw[,key=value...] --filter SPEC include|exclude,patterns=RE, repeatable
--rate-limit <spec> rate=N[,burst=N,policy=drop|pass] --format SPEC json|txt|raw[,KEY=VALUE...]
--heartbeat <spec> interval_ms=N[,include_stats=true,...] --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. A pipeline without --source reads stdin, one without --sink writes stdout.
Keys nest with '.' (tls.cert_file=...), a repeated key makes a list, and Keys nest with '.' (tls.cert_file=...), a repeated key makes a list, and
'\' escapes ',' '=' '\' in values. -- ends option parsing. '\' escapes ',' '=' '\' in values.
Examples: Examples:
lw < app.log > copy.log Copy, line for line lw < app.log > copy.log Copy, line for line
tail -F app.log | lw --filter include,patterns=ERROR,patterns=WARN tail -F app.log | lw --filter include,patterns=ERROR,patterns=WARN
journalctl -f | lw --sink http,host=127.0.0.1,port=8080 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 --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 lw --preset serve,path=/var/log/app,tls=self,users=users.toml
HTTPS stream, SCRAM logins HTTPS stream, SCRAM logins
lw --preset edge,path=/var/log/app,to=agg:9000,ca=ca.crt,user=edge-01,\ lw --preset edge,path=/var/log/app,to=agg:9000,ca=ca.crt,user=edge-01,\
@@ -83,14 +94,15 @@ Signals:
Exit codes: Exit codes:
0 success, including the end of input 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 2 configuration file not found
` `
func printHelp() { func printHelp() {
var list strings.Builder var list strings.Builder
for _, c := range commands { 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()) fmt.Printf(helpText, version.Short(), list.String())
} }
+12 -12
View File
@@ -20,21 +20,21 @@ import (
const maxDays = 36500 const maxDays = 36500
var tlsCommands = []subcommand{ 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", "Create a certificate authority, DIR/ca.crt and DIR/ca.key, that signs certificates only",
[]string{"dir"}, defineCA}, []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", "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}, []string{"ca-dir", "name"}, defineCert},
} }
func defineCA(fs *flag.FlagSet) func(stdout, stderr io.Writer) error { 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`") 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 { return func(_, stderr io.Writer) error {
if *days < 1 || *days > maxDays { 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) cert, key, err := tlsx.NewCA(*name, time.Duration(*days)*24*time.Hour)
if err != nil { 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 { 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") 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)") server := fs.Bool("server", false, "for listeners (TLS server authentication)")
client := fs.Bool("client", false, "for dialers presenting a certificate (TLS client 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") days := fs.Int("days", int(tlsx.LeafValidity/(24*time.Hour)), "validity in `n` days, at most the CA's")
out := fs.String("out", "", "`directory` to write in (default: -ca-dir)") out := fs.String("out", "", "`dir`ectory to write in (default: --ca-dir)")
return func(_, stderr io.Writer) error { return func(_, stderr io.Writer) error {
switch { switch {
case !*server && !*client: case !*server && !*client:
return usageError("-server, -client or both is required") return usageError("--server, --client or both is required")
case *days < 1 || *days > maxDays: 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, "."): 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")) issuer, signer, err := tlsx.LoadIssuer(filepath.Join(*caDir, "ca.crt"), filepath.Join(*caDir, "ca.key"))
if err != nil { if err != nil {
+5 -4
View File
@@ -293,14 +293,15 @@ target = "stdout" # stdout|stderr ("split" NOT supported)
# [pipelines.plugin_sinks.config] # [pipelines.plugin_sinks.config]
# host = "0.0.0.0" # or "::" for IPv6 (IPv6 only) # host = "0.0.0.0" # or "::" for IPv6 (IPv6 only)
# port = 8081 # Required # port = 8081 # Required
# stream_path = "/stream" # Must start with "/" # stream_path = "/stream" # Exact; starts with "/", clean, none of { } % ? #
# status_path = "/status" # Must differ from stream_path # status_path = "/status" # Same rules; differs from stream_path
# buffer_size = 1000 # Sink input queue # buffer_size = 1000 # Sink input queue
# client_buffer_size = 256 # Per-client send queue # client_buffer_size = 256 # Per-client send queue
# write_timeout_ms = 0 # Per-event deadline, 0 = none # write_timeout_ms = 0 # Per-event deadline, 0 = none
# max_connections = 0 # 0 = unlimited # max_connections = 0 # 0 = unlimited
# login_page = false # GET /auth/login; needs auth.trusted_proxies # login_page = false # GET /auth/login; scram behind auth.trusted_proxies
# viewer_page = false # GET /auth/view; needs login_page # 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] # [pipelines.plugin_sinks.config.tls]
# enabled = true # example # enabled = true # example
# cert_file = "/etc/logwisp/tls/server.crt" # cert_file = "/etc/logwisp/tls/server.crt"
+1 -1
View File
@@ -1257,7 +1257,7 @@ next_steps() {
note " status: curl $_c$_u/status" note " status: curl $_c$_u/status"
;; ;;
mtls) note " status: curl --cacert CA --cert CERT --key KEY $_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 esac
note " stream: $_u/stream" note " stream: $_u/stream"
fi fi
+1 -1
View File
@@ -31,7 +31,7 @@ streams, or downstream LogWisp nodes.
manual, [`lw.1`](lw.1) manual, [`lw.1`](lw.1)
- [Operations](operations.md): running, monitoring, tuning, troubleshooting - [Operations](operations.md): running, monitoring, tuning, troubleshooting
- [To Do](todo.md): planned work in priority order: network access control - [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 A fully annotated configuration covering every option lives at
[`config/logwisp.toml`](../config/logwisp.toml). [`config/logwisp.toml`](../config/logwisp.toml).
+100 -73
View File
@@ -2,15 +2,15 @@
``` ```
lw [options] [< input] lw [options] [< input]
lw -c <file> [options] lw -c|--config FILE [options]
lw --check [options] lw -p|--preset NAME[,KEY=VALUE...] [options]
lw -t|--check [options]
lw --dump [options] lw --dump [options]
lw --preset NAME[,key=value...] [options] lw auth COMMAND [flags]
lw auth <command> [flags] lw tls COMMAND [flags]
lw tls <command> [flags] lw preset NAME [flags]
lw preset <name> [flags]
lw help | -h | --help lw help | -h | --help
lw --version lw -V | --version
``` ```
Without a configuration file or pipeline options, lw is a filter: stdin to 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 ## 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; Any scalar configuration key is settable as a flag using its TOML path;
pipelines have [their own flags](#pipelines): pipelines have [their own flags](#pipelines):
@@ -36,27 +53,27 @@ pipelines have [their own flags](#pipelines):
### Common ### Common
- `-c <path>`, `--config <path>`, `--config=<path>`: the configuration file; - `-c FILE`, `--config FILE`: the configuration file; unnamed,
unnamed, `~/.config/logwisp/logwisp.toml` if it exists, else `~/.config/logwisp/logwisp.toml` if it exists, else `./logwisp.toml`
`./logwisp.toml` ([Configuration](configuration.md#file-location)) ([Configuration](configuration.md#file-location))
- `--check`: build every pipeline and plugin, bind nothing, report and exit; - `-t`, `--check`: build every pipeline and plugin, bind nothing, report and
see [Usage Patterns](#usage-patterns) exit; see [Usage Patterns](#usage-patterns)
- `--dump`: print the effective configuration, flags, environment and presets - `--dump`: print the effective configuration, flags, environment and presets
resolved into it, as TOML that `-c` reads back, and exit resolved into it, as TOML that `-c` reads back, and exit
- `--color [auto|always|never]`: level names in color on console sinks, - `--color [WHEN]`, WHEN `auto`, `always` or `never`: level names in color on
default `auto` (a terminal, `NO_COLOR` unset, `TERM` not `dumb`); bare, console sinks, default `auto` (a terminal, `NO_COLOR` unset, `TERM` not
`always`. A console sink's own `color` wins ([console sink](sinks.md#console)) `dumb`); bare, `always`. A console sink's own `color` wins
- `--quiet`: silence lw's own log and notices, default `false`; pipeline ([console sink](sinks.md#console))
output still flows - `-q`, `--quiet`: silence lw's own log and notices, default `false`;
- `--status_reporter=<bool>`: periodic status logging, default `true` with a pipeline output still flows
- `--status_reporter=BOOL`: periodic status logging, default `true` with a
configuration file, `false` without configuration file, `false` without
- `--auto_reload=<bool>`: reload when the configuration file changes, default - `--auto_reload=BOOL`: reload when the configuration file changes, default
`false` `false`
- `--version`: print the version and exit - `-V`, `--version`: print the version and exit
- `-h`, `--help`, `help`: print usage and exit - `-h`, `--help`, `help`: print usage and exit
The last file-selection flag wins. `-c=<path>` also works. A missing or empty The last `-c` wins; a missing or empty path is an error.
path returns an error, and `--` ends option parsing.
### Logging ### 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 NAME` starts a pipeline; specs before the first one go to a
pipeline named `cli`, or after the preset that starts them pipeline named `cli`, or after the preset that starts them
- `--preset SPEC` starts a pipeline with a [preset](#presets); later flags add - `-p SPEC`, `--preset SPEC` starts a pipeline with a [preset](#presets);
sources, sinks and filters to it, or replace its format, rate limit and later flags add sources, sinks and filters to it, or replace its format,
heartbeat rate limit and heartbeat
- `--source SPEC`, `--sink SPEC` and `--filter SPEC` add a stage, repeatable - `--source SPEC`, `--sink SPEC` and `--filter SPEC` add a stage, repeatable
- `--format SPEC`, `--rate-limit SPEC` and `--heartbeat SPEC`, once per pipeline - `--format SPEC`, `--rate-limit SPEC` and `--heartbeat SPEC`, once per pipeline
- both `--flag SPEC` and `--flag=SPEC` work; `--` ends options
- a pipeline without `--source` reads stdin (console source `stdin`), one - a pipeline without `--source` reads stdin (console source `stdin`), one
without `--sink` writes stdout (console sink `stdout`) without `--sink` writes stdout (console sink `stdout`)
- stdin has one reader, so the whole configuration holds at most one console - 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"`. opens: `failed to create sink http: ... unknown key "tls.enabeld"`.
```bash ```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' \ lw --source 'file,directory=/var/log/app,pattern=*.log' \
--sink http,host=0.0.0.0,port=8080 --sink http,host=0.0.0.0,port=8080
@@ -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 `--preset NAME,key=value,...` takes the [SPEC](#pipelines) syntax. A list
key (`hosts`, `proxy`) takes several values, repeated (`hosts=a,hosts=b`) or key (`hosts`, `proxy`) takes several values, repeated (`hosts=a,hosts=b`) or
`,`-separated; any other key takes one. `lw preset NAME [-key value ...]` `,`-separated; any other key takes one. `lw preset NAME [--key value ...]`
prints the pipeline a preset expands to, ready for a configuration file; 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 `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 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 (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`) - `format` (`raw`)
- `tail`: follow files to stdout, like `tail -F` - `tail`: follow files to stdout, like `tail -F`
- `path` (required), `from`, `format` (`raw`) - `path` (required), `from`, `format` (`raw`)
- `serve`: a live 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`) - `path`, `from`, `format` (`json`), `listen` (`127.0.0.1:8080`)
- `tls`: `off` (default), `self` (a self-signed certificate made at - `tls`: `off` (default), `self` (a self-signed certificate made at
startup), `issuer` (one signed by `issuer_cert` and `issuer_key`), or startup), `issuer` (one signed by `issuer_cert` and `issuer_key`), or
`files` (`cert` and `key`); `hosts` adds names to a made certificate `files` (`cert` and `key`); `hosts` adds names to a made certificate
- `users`: a credentials file; readers then log in with SCRAM - without `users` the viewer needs no login, over plain http too
- `proxy`, `viewer=true`: the built-in login and viewer pages, which work - `users`: a credentials file; readers then log in with SCRAM, and browsers
only behind the TLS-terminating proxies `proxy` names (a browser cannot need `proxy` and `viewer=true`: the login page and viewer behind the
bind its login to the TLS channel); there is no standalone viewer 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` - `edge`: forward to an aggregator over TLS (`tcp_chain`, or `http_chain`
with `transport=http`) with `transport=http`)
- `to` (required), `path`, `from`, `transport` (`tcp`), `node` - `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 # follow a directory, level names in color on a terminal
lw --preset tail,path=/var/log/app 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 # 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 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` / - `0`: the end of input, a clean shutdown (`SIGINT`, `SIGTERM`), `--version` /
`--help`, `--dump`, or a valid `--check` `--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 included), a logger init failure, a service bootstrap failure
- `2`: an explicitly requested configuration file is not found - `2`: an explicitly requested configuration file is not found
- killed by `SIGPIPE` (shell status `141`): the reader of stdout went away, as - 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). [Password Authentication](security.md#password-authentication-scram).
``` ```
lw auth add-user -credentials FILE -user NAME [-password-file FILE] [-generate] lw auth add-user --credentials FILE -u NAME [--password-file FILE] [--generate]
lw auth remove-user -credentials FILE -user NAME lw auth remove-user --credentials FILE -u NAME
lw auth token -url https://HOST:PORT[/PATH] -user NAME -password-file FILE lw auth token --url https://HOST:PORT[/PATH] -u NAME --password-file FILE
[-unbound] [TLS flags] [--unbound] [TLS flags]
lw auth stream -addr HOST:PORT -user NAME -password-file FILE [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 - `add-user`: adds a user, or replaces its password
- `remove-user`: removes a user; refuses the last one - `remove-user`: removes a user; refuses the last one
- `token`: logs in to an `http` sink or `http_chain` source and prints a - `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 - `stream`: logs in to a `tcp` sink and copies its stream to stdout until
interrupted 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. success, `1` on failure and `2` on a usage error.
**`add-user`** creates the credentials file, with a fresh `decoy_key`, when it **`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 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 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 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 password needs an existing `--password-file` or `--generate`, so a mistyped path
cannot replace a working password; `-generate` always generates, overwriting cannot replace a working password; `--generate` always generates, overwriting
`-password-file`. New users take the file's existing Argon2 profile. `--password-file`. New users take the file's existing Argon2 profile.
Both file commands rewrite atomically (a temporary file in the same directory 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 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. watch the credentials file.
**`token`** and **`stream`** build the same TLS and SCRAM client as a chain **`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 sink. TLS flags: `--ca-file` (default: system roots) or `--pin-sha256` (the
`tls.pin_sha256` a self-signed listener logs), `-server-name` (default: 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`. 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 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 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` proxy mounts LogWisp at. `stream` exits `0` on `SIGINT` or `SIGTERM`, and `1`
when the server ends the stream (a reload or shutdown). when the server ends the stream (a reload or shutdown).
Addresses, as for the plugins ([Networking](networking.md#address-family)): Addresses, as for the plugins ([Networking](networking.md#address-family)):
- An IPv6 address goes in brackets: `-addr [::1]:PORT`, - An IPv6 address goes in brackets: `--addr [::1]:PORT`,
`-url https://[::1]:PORT`; in a URL a link-local zone is escaped, `--url https://[::1]:PORT`; in a URL a link-local zone is escaped,
`https://[fe80::1%25eth0]:PORT`. `https://[fe80::1%25eth0]:PORT`.
- The dial keeps to the address's family; a hostname resolves. - 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. must then be among the certificate's IP SANs.
```bash ```bash
# listener host: a file the service user can read, users, then apply # 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 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 \ lw auth add-user --credentials /etc/logwisp/users.toml --user edge-01 \
-password-file /etc/logwisp/edge-01.pass --password-file /etc/logwisp/edge-01.pass
lw auth add-user -credentials /etc/logwisp/users.toml -user viewer \ lw auth add-user --credentials /etc/logwisp/users.toml --user viewer \
-password-file viewer.pass --password-file viewer.pass
kill -HUP $(pidof lw) kill -HUP $(pidof lw)
# rotate: new password into the file; SIGHUP, deploy the file, SIGHUP the edge # 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 \ lw auth add-user --credentials /etc/logwisp/users.toml --user edge-01 \
-password-file /etc/logwisp/edge-01.pass -generate --password-file /etc/logwisp/edge-01.pass --generate
# http sink status, with the token kept out of curl's argv # http sink status, with the token kept out of curl's argv
curl --cacert ca.crt -H @<(printf 'Authorization: Bearer %s\n' \ curl --cacert ca.crt -H @<(printf 'Authorization: Bearer %s\n' \
"$(lw auth token -url https://HOST:PORT -user viewer \ "$(lw auth token --url https://HOST:PORT --user viewer \
-password-file viewer.pass -ca-file ca.crt)") https://HOST:PORT/status --password-file viewer.pass --ca-file ca.crt)") https://HOST:PORT/status
# follow a tcp sink # follow a tcp sink
lw auth stream -addr HOST:PORT -user viewer -password-file viewer.pass \ lw auth stream --addr HOST:PORT --user viewer --password-file viewer.pass \
-ca-file ca.crt --ca-file ca.crt
``` ```
## `lw tls` ## `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`. (remove one to replace it) and writes keys `0600`, certificates `0644`.
``` ```
lw tls ca -dir DIR [-name NAME] [-days 3650] lw tls ca --dir DIR [--name NAME] [--days 3650]
lw tls cert -ca-dir DIR -name NAME [-host NAME,...] [-server] [-client] lw tls cert --ca-dir DIR --name NAME [--host NAME,...] [--server] [--client]
[-days 397] [-out DIR] [--days 397] [--out DIR]
``` ```
- `ca`: `DIR/ca.crt` and `DIR/ca.key`, a CA that signs leaves only (path - `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 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.client_ca_file`; with `ca.key` it is a listener's
`tls.issuer_cert_file` and `tls.issuer_key_file`. `tls.issuer_cert_file` and `tls.issuer_key_file`.
- `cert`: `NAME.crt` and `NAME.key`, in `-out` (default: `-ca-dir`). - `cert`: `NAME.crt` and `NAME.key`, in `--out` (default: `--ca-dir`).
- `-server` for a listener: `-host` lists the DNS names and IP addresses it - `--server` for a listener: `--host` lists the DNS names and IP addresses it
carries, default `NAME`; `-client` for a dialer presenting a certificate; carries, default `NAME`; `--client` for a dialer presenting a certificate;
both may be given. both may be given.
- `NAME` is the subject CN, the identity `auth.type = "mtls"` matches. - `NAME` is the subject CN, the identity `auth.type = "mtls"` matches.
- Valid 397 days (the longest browsers accept), never past the CA. It prints - Valid 397 days (the longest browsers accept), never past the CA. It prints
the certificate's `pin_sha256`. the certificate's `pin_sha256`.
```bash ```bash
lw tls ca -dir /etc/logwisp/pki 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 agg.example.org --server
lw tls cert -ca-dir /etc/logwisp/pki -name edge-01 -client 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 A bare flag is accepted only for a boolean key: `--logging.file.directory
--quiet` fails with `--logging.file.directory needs a value`. Unrecognized flags, --quiet` fails with `--logging.file.directory needs a value`. Unrecognized `--`
positional words and single-dash arguments are reported on stderr before the flags and positional words are reported on stderr before the logger exists and
logger exists and are then ignored: 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 An empty `LOGWISP_<PATH>` variable counts as unset, as the pipeline variables
do. do.
Array-indexed paths such as `--pipelines.0.name=x` are unrecognized; whole An array-indexed path such as `--pipelines.0.name=x` is an error (exit 1);
pipelines have [their own flags](cli.md#pipelines). whole pipelines have [their own flags](cli.md#pipelines).
## Validation ## 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 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 `/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 `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)). ~/logwisp-secrets/ops.pass --ca-file ca.crt` ([CLI](cli.md#lw-auth)).
Variants: Variants:
+3 -3
View File
@@ -116,8 +116,8 @@ both:
```bash ```bash
sudo install -m 0600 -o logwisp -g logwisp /dev/null /etc/logwisp/users.toml 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> \ sudo lw auth add-user --credentials /etc/logwisp/users.toml --user <name> \
-password-file <file> --password-file <file>
sudo systemctl reload logwisp sudo systemctl reload logwisp
``` ```
@@ -230,7 +230,7 @@ docker run -d --name logwisp \
```bash ```bash
docker run --rm --read-only --cap-drop ALL --network none \ docker run --rm --read-only --cap-drop ALL --network none \
-v /srv/logwisp/secrets:/work -v /srv/logwisp/passwords:/pw logwisp:<tag> \ -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 ## Packaging Status
+66 -36
View File
@@ -6,12 +6,12 @@
.Nd LogWisp log transport .Nd LogWisp log transport
.Sh SYNOPSIS .Sh SYNOPSIS
.Nm .Nm
.Op Fl \-check | Fl \-dump .Op Fl t | Fl \-dump
.Op Fl q
.Op Fl c Ar file .Op Fl c Ar file
.Op Fl \-quiet
.Op Fl \-color Op Ar when .Op Fl \-color Op Ar when
.Op Fl \- Ns Ar key Ns = Ns Ar value ... .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 \-pipeline Ar name
.Op Fl \-source Ar spec ... .Op Fl \-source Ar spec ...
.Op Fl \-sink Ar spec ... .Op Fl \-sink Ar spec ...
@@ -24,7 +24,7 @@
.Ar command .Ar command
.Op Ar flags .Op Ar flags
.Nm .Nm
.Fl \-version .Fl V | Fl \-version
.Nm .Nm
.Cm help | Fl h | Fl \-help .Cm help | Fl h | Fl \-help
.Sh DESCRIPTION .Sh DESCRIPTION
@@ -107,6 +107,27 @@ service under a supervisor such as
or or
.Xr daemon 8 . .Xr daemon 8 .
.Pp .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: The options are as follows:
.Bl -tag -width Ds .Bl -tag -width Ds
.It Fl c Ar file , Fl \-config Ar file , Fl \-config= Ns Ar file .It Fl c Ar file , Fl \-config Ar file , Fl \-config= Ns Ar file
@@ -136,7 +157,7 @@ program parses keeps
.Cm auto .Cm auto
or or
.Cm never . .Cm never .
.It Fl \-quiet .It Fl q , Fl \-quiet
Turn off the log of Turn off the log of
.Nm .Nm
and the terminal reminder; console sinks still write. and the terminal reminder; console sinks still write.
@@ -170,7 +191,7 @@ the default is
with a configuration file and with a configuration file and
.Cm warn .Cm warn
without. without.
.It Fl \-check .It Fl t , Fl \-check
Build every pipeline and plugin as a start would, loading options, TLS files Build every pipeline and plugin as a start would, loading options, TLS files
and credentials files and printing startup warnings, without binding a port and credentials files and printing startup warnings, without binding a port
or opening, reading or creating a log file; print 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 presets resolved into it, as a file
.Fl c .Fl c
reads back, and exit. reads back, and exit.
.It Fl \-version .It Fl V , Fl \-version
Print the version, commit and build time, and exit. Print the version, commit and build time, and exit.
.It Fl h , Fl \-help , Cm help .It Fl h , Fl \-help , Cm help
Print usage and exit. Print usage and exit.
@@ -210,7 +231,7 @@ Specifications before the first
belong to a pipeline named belong to a pipeline named
.Dq cli , .Dq cli ,
or after the preset that starts them. 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 Start the pipeline with a preset, described in
.Sx Presets ; .Sx Presets ;
later options add sources, sinks and filters to it, or replace its format. 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 ... , .Fl \-preset Ar name Ns Op , Ns Ar key Ns = Ns Ar value ... ,
in the syntax of the other specifications; in the syntax of the other specifications;
.Nm .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 prints the pipeline it expands to, and
.Fl h .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 A
.Ar path .Ar path
is a file, a directory, whose files are followed, or a glob; without one, a 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 ; .Pq Cm cert No and Cm key ;
.Cm users .Cm users
names a credentials file, so readers log in with SCRAM. names a credentials file, so readers log in with SCRAM.
The built-in login and viewer pages, A browser at the root gets a live viewer; with
.Cm viewer=true , .Cm users ,
work only behind a TLS-terminating proxy named by only behind a TLS-terminating proxy named by
.Cm proxy , .Cm proxy ,
with
.Cm viewer=true ,
since a browser cannot bind its login to the TLS channel. 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 .It Cm edge to Oo Cm path , transport , ca | pin , user , password_file , cert , key , node Oc
Forward to an aggregator at 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 makes ECDSA P-256 certificates; it never replaces a file, and writes keys
with mode 0600. with mode 0600.
.Bl -tag -width Ds .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, Create a certificate authority,
.Pa dir/ca.crt .Pa dir/ca.crt
and and
.Pa dir/ca.key , .Pa dir/ca.key ,
valid 3650 days. 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 Issue
.Pa name.crt .Pa name.crt
and and
.Pa name.key .Pa name.key
from that authority, valid 397 days and never past it. from that authority, valid 397 days and never past it.
.Fl host .Fl \-host
lists the DNS names and addresses a server certificate carries lists the DNS names and addresses a server certificate carries
.Pq default: Ar name ; .Pq default: Ar name ;
.Ar name .Ar name
@@ -378,33 +405,36 @@ restarts, so a fleet uses an issuer instead.
.Nm .Nm
.Cm auth .Cm auth
manages SCRAM credentials files and logs in to SCRAM listeners. 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 Changes to a credentials file apply when the server receives
.Dv SIGHUP . .Dv SIGHUP .
.Bl -tag -width Ds .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. Add a user, or replace its password.
The password is read from the The password is read from the
.Fl password-file .Fl \-password\-file
when it exists; otherwise one is generated and written there, or printed when it exists; otherwise one is generated and written there, or printed
once to standard output. once to standard output.
.Fl generate .Fl \-generate
replaces an existing password without reading one. replaces an existing password without reading one.
A missing or empty credentials file is created with mode 0600; an existing A missing or empty credentials file is created with mode 0600; an existing
one keeps its owner and mode. 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. Remove a user.
The last user cannot be removed. 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 Log in to an
.Cm http .Cm http
sink or sink or
.Cm http_chain .Cm http_chain
source and print a bearer token. source and print a bearer token.
.Fl unbound .Fl \-unbound
logs in without channel binding, to an logs in without channel binding, to an
.Cm http .Cm http
sink behind a TLS-terminating proxy; the URL may then carry the proxy's path. 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 Log in to a
.Cm tcp .Cm tcp
sink and copy its stream to standard output until interrupted. 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 The
.Ar tls-flags .Ar tls-flags
are are
.Fl ca-file Ar file .Fl \-ca\-file Ar file
.Pq default: the system roots .Pq default: the system roots
or or
.Fl pin-sha256 Ar pin , .Fl \-pin\-sha256 Ar pin ,
.Fl server-name Ar name , .Fl \-server\-name Ar name ,
and and
.Fl cert-file Ar file .Fl \-cert\-file Ar file
with with
.Fl key-file Ar file .Fl \-key\-file Ar file
for a listener that requires client certificates. for a listener that requires client certificates.
There is no flag to skip server verification. There is no flag to skip server verification.
An IPv6 An IPv6
@@ -507,8 +537,8 @@ The service's working directory on Linux and on
.Nm .Nm
exits 0 at the end of input, after a clean shutdown, exits 0 at the end of input, after a clean shutdown,
.Fl \-version .Fl \-version
or help; 1 on a configuration, logger or startup error; and 2 when a or help; 1 on a command-line, configuration, logger or startup error; and 2
configuration file named by when a configuration file named by
.Fl c .Fl c
or or
.Ev LOGWISP_CONFIG_* .Ev LOGWISP_CONFIG_*
@@ -591,10 +621,10 @@ the authority, its certificate and a user for the edge; copy
.Pa ca.crt .Pa ca.crt
and the password file to the edge; then run both: and the password file to the edge; then run both:
.Bd -literal -offset indent .Bd -literal -offset indent
# lw tls ca \-dir /etc/logwisp/pki # 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 agg.example.org \-\-server
# lw auth add-user \-credentials /etc/logwisp/users.toml \e # lw auth add-user \-\-credentials /etc/logwisp/users.toml \e
\-user edge-01 \-password-file /root/edge-01.pass \-\-user edge-01 \-\-password-file /root/edge-01.pass
agg# lw \-\-preset aggregator,tls=files,users=/etc/logwisp/users.toml,\e agg# lw \-\-preset aggregator,tls=files,users=/etc/logwisp/users.toml,\e
cert=/etc/logwisp/pki/agg.example.org.crt,\e cert=/etc/logwisp/pki/agg.example.org.crt,\e
key=/etc/logwisp/pki/agg.example.org.key,out=/var/log/edges 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 .Bd -literal -offset indent
# install \-m 0600 \-o logwisp \-g logwisp \e # install \-m 0600 \-o logwisp \-g logwisp \e
/dev/null /etc/logwisp/users.toml /dev/null /etc/logwisp/users.toml
# lw auth add-user \-credentials /etc/logwisp/users.toml \e # lw auth add-user \-\-credentials /etc/logwisp/users.toml \e
\-user alice \-password-file /root/alice.pass \-\-user alice \-\-password-file /root/alice.pass
# systemctl reload logwisp # systemctl reload logwisp
.Ed .Ed
.Sh SEE ALSO .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 - `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. 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` - 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. 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 - A link-local address carries its zone, `fe80::1%eth0`, escaped in URLs as
`https://[fe80::1%25eth0]:8443`. `https://[fe80::1%25eth0]:8443`.
@@ -46,7 +46,8 @@ Every listener and dialer keeps strictly to the family of its `host`:
Listeners: Listeners:
- `tcp` sink: raw stream; broadcasts formatted payloads to clients. - `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. - `tcp_chain` source: chain v1; ingests a persistent NDJSON stream.
- `http_chain` source: chain v1; ingests NDJSON batches over POST. - `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 between the peers terminates TLS. Pass TLS through to LogWisp; the login
cannot work otherwise, except on an `http` sink in proxy mode cannot work otherwise, except on an `http` sink in proxy mode
(`auth.trusted_proxies`), where `the client bound its proof to the proxy's (`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, `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 `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), the certificate changed after the login: a rotation (the retry binds anew),
+18 -16
View File
@@ -204,16 +204,16 @@ closed.
## CLI ## CLI
``` ```
lw auth add-user -credentials F -user U [-password-file P] [-generate] lw auth add-user --credentials F --user U [--password-file P] [--generate]
lw auth remove-user -credentials F -user U 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 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 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 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 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 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, own parser, and the rewrite (temp file beside the file or a symlink's target,
rename) keeps the existing mode and owner, or fails. 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. credentials file.
- `token` prints a bearer token for curl (`-H @<(...)` keeps it out of argv); - `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 `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. followed.
- TLS flags: `-ca-file`, `-server-name`, `-cert-file`, `-key-file`. There is no - TLS flags: `--ca-file` or `--pin-sha256`, `--server-name`, `--cert-file`,
insecure flag. `--key-file`. There is no insecure flag.
- Exit status: 0 success, 1 failure, 2 usage error. - Exit status: 0 success, 1 failure, 2 usage error.
## Rollout and Rotation ## 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 - 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, without a gap, upgrade binaries, add a second SCRAM listener, move dialers,
then remove the old listener. 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 password file, `SIGHUP` the dialer. The dialer retries under backoff in
between. between.
- Revocation: `remove-user`, `SIGHUP`. All tokens die with the reload. - 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 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 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 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 **Session.** The proof step may ask for a cookie (`"session": "cookie"`, proxy
mode only). The answer sets `logwisp_session` (`HttpOnly; Secure; 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. pages is relative, so a proxy prefix works unchanged.
- `POST /auth`: SCRAM hello, proof or logout; unbound in proxy mode. - `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/scram.js`, `style.css`: the client library and the pages' style,
- `GET /auth/login`, `login.js`, `style.css`: the login page (`login_page`). 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 - `GET /auth/view`, `view.js`: a minimal live viewer (`viewer_page`, needs
`login_page`). `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 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. is the contract: a site may implement its own client.
**CLI.** `lw auth token -unbound` logs in through the proxy, still pinning **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 its certificate across the two requests; only then may `--url` carry the mount
path. path.
## Verification ## 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 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 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 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. and plaintext-forwarded requests `403`. The existing scripts keep passing.
## Not Implemented ## 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. to that certificate.
```bash ```bash
lw tls ca -dir /etc/logwisp/pki # ca.crt, ca.key (0600) 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 agg.example.org --server
lw tls cert -ca-dir /etc/logwisp/pki -name edge-01 -client lw tls cert --ca-dir /etc/logwisp/pki --name edge-01 --client
``` ```
## Enabling mTLS ## Enabling mTLS
@@ -594,8 +594,8 @@ different users.
- **Rollout.** Turning `scram` on cuts off every dialer of that listener that - **Rollout.** Turning `scram` on cuts off every dialer of that listener that
has no credentials. Upgrade every binary first, then add a second listener 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. 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 rotation.** `lw auth add-user --generate` with the user's
`-password-file` replaces verifier and password together. `SIGHUP` the `--password-file` replaces verifier and password together. `SIGHUP` the
listener, deploy the password file, `SIGHUP` the dialer. Logins fail in listener, deploy the password file, `SIGHUP` the dialer. Logins fail in
between and the dialer retries under backoff, holding its current entry or 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 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 `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 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 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 cookie and revokes the token until it expires. `/auth` takes only
`application/json`, which a cross-origin page cannot send without a preflight. `application/json`, which a cross-origin page cannot send without a preflight.
- **Private windows.** Private or incognito windows keep cookies in memory, - **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 - `cookiesUsable()` tells which applies: false when the session cookie would
not stick. `loginUnavailable()`, asked first, names why neither can run not stick. `loginUnavailable()`, asked first, names why neither can run
(no secure context), or is empty. (no secure context), or is empty.
- **Pages.** Under `/auth/` the sink serves `scram.js` always, and with - **Pages.** Under `/auth/` the sink serves `scram.js` and `style.css` always,
`login_page` / `viewer_page` the login page and a minimal live viewer with and with `login_page` / `viewer_page` the login page and a minimal live
their script and style, under `default-src 'none'; script-src 'self'; 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 connect-src 'self'; style-src 'self'; form-action 'self'; frame-ancestors
'none'; base-uri 'none'`. '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 `listen`s with `proxy_protocol` and proxy_passes to LogWisp without it; that
hop consumes the header. hop consumes the header.
- Either way dialers must send the name: a `host` that is one, or - 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. - With HAProxy, leave `send-proxy` off the LogWisp backends.
```nginx ```nginx
@@ -864,6 +865,10 @@ What each exposes when `auth.type = "none"`:
header is omitted once an auth policy is set). header is omitted once an auth policy is set).
- `http` sink `status_path`: host, port, TLS flag, uptime, client counts, - `http` sink `status_path`: host, port, TLS flag, uptime, client counts,
throughput counters. 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` sink: the full log stream to any client that connects.
- `tcp_chain` and `http_chain` sources: ingest from any peer that can connect - `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 (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 ## http
Server-Sent Events stream plus a JSON status endpoint. Server-Sent Events stream, a JSON status endpoint, and a browser viewer.
```toml ```toml
[[pipelines.plugin_sinks]] [[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 - `host` (string, `0.0.0.0`): bind address, IPv4 or IPv6 (`::`); the listener
keeps to its family ([Networking](networking.md#address-family)). keeps to its family ([Networking](networking.md#address-family)).
- `port` (int, **required**): listen port. - `port` (int, **required**): listen port.
- `stream_path` (string, `/stream`): SSE endpoint; must start with `/`. - `stream_path` (string, `/stream`): SSE endpoint, matched exactly (`/logs/`
- `status_path` (string, `/status`): status endpoint; must start with `/` and does not answer `/logs/x`); it starts with `/`, is clean (no `//`, `.` or
differ from `stream_path`. `..` 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. - `buffer_size` (int, `1000`): sink input queue depth.
- `client_buffer_size` (int, `256`): per-client send queue depth. - `client_buffer_size` (int, `256`): per-client send queue depth.
- `write_timeout_ms` (int, `0`): per-event write deadline; `0` = none. - `write_timeout_ms` (int, `0`): per-event write deadline; `0` = none.
- `max_connections` (int, `0`): concurrent stream cap; `0` = unlimited. - `max_connections` (int, `0`): concurrent stream cap; `0` = unlimited.
- `login_page` (bool, `false`): serve the browser login page at `/auth/login`; - `login_page` (bool, `false`): `scram` in proxy mode only (an error
needs `auth.trusted_proxies`. elsewhere): the browser login page at `/auth/login`.
- `viewer_page` (bool, `false`): serve a minimal live viewer at `/auth/view`; - `viewer_page` (bool, `false`): `scram` in proxy mode only (an error
needs `login_page`. 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). - `tls` (table): listener TLS; see [Security](security.md).
- `auth` (table): client authentication (`mtls` or `scram`); see - `auth` (table): client authentication (`mtls` or `scram`); see
[Security](security.md#the-auth-block). [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 through `/auth/login` or the site's own copy of `/auth/scram.js`, and stream
and status also accept the `logwisp_session` cookie. See and status also accept the `logwisp_session` cookie. See
[Browsers behind a TLS-terminating proxy](security.md#browsers-behind-a-tls-terminating-proxy). [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 - Refusals are logged at WARN and counted in `auth_rejected`. The authorized
identity is recorded in the client's session as `auth_method` / identity is recorded in the client's session as `auth_method` /
`auth_identity`. `auth_identity`.
+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`. 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 6. A packaging CI job: build the AUR package in an Arch container and the port
in a FreeBSD VM, run `lw --version` and `make image-check`. in a FreeBSD VM, run `lw --version` and `make image-check`.
## 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 go 1.27.1
require ( 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/color v0.1.3
github.com/lixenwraith/config v0.2.2 github.com/lixenwraith/config v0.2.2
github.com/lixenwraith/log v0.1.11 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 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= 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.5.1 h1:5/eKRyEO4af/vpSLsUgAXaFiECNbCKtQ0EEXnO/oPF4=
github.com/lixenwraith/auth v0.0.0-20261002191639-3ed0e995807c/go.mod h1:411JUqnbQgy9jgoFV+fH91TfVUEfzYA4dyPv7f4c264= 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 h1:vSuvtU4zh1mIZS/7UGHmMdsTr+a7eCjUYGbou7kzFn4=
github.com/lixenwraith/color v0.1.3/go.mod h1:L/I3Vxp6pqslAIzWyZGgKCm2lyStHCA4vKaUoMUm7bU= github.com/lixenwraith/color v0.1.3/go.mod h1:L/I3Vxp6pqslAIzWyZGgKCm2lyStHCA4vKaUoMUm7bU=
github.com/lixenwraith/config v0.2.2 h1:X+l3Tf8Rbk49cipYBJU7/pfdw5wDwD4hRxtFH7rZgOc= 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 // Enabled reports whether a policy is in force
func (p *Policy) Enabled() bool { return p != nil } 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 // Unrestricted reports whether an mtls policy admits any identity the CA
// vouches for. Under scram the credentials file is the allow list. // vouches for. Under scram the credentials file is the allow list.
func (p *Policy) Unrestricted() bool { 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 // 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) { func TestOnlyUnboundLoginsBehindProxy(t *testing.T) {
f := newFixture(t) f := newFixture(t)
l := f.listener(t, config.AuthOptions{TrustedProxies: []string{"127.0.0.1"}}, f.serverTLS, RoleListener, HTTP) 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) p.rejected.Add(1)
switch { switch {
case step.Binding != "" && l.proxy != nil: 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: 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): case step.Binding != "" && step.Binding != base64.StdEncoding.EncodeToString(l.cb):
l.bindingMismatch.Add(1) l.bindingMismatch.Add(1)
err = fmt.Errorf("%w; the client saw another server certificate: TLS interception or a terminating proxy", err) 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"]} p.Flow.Format = &FormatConfig{Type: v["format"]}
return nil 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"), append([]PresetParam{pathParam, fromParam, formatParam("json"),
{Name: "listen", Default: "127.0.0.1:8080", Help: "HOST:PORT; [IPV6]:PORT"}, {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: "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: "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: "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...), }, tlsParams...),
func(p *PipelineConfig, v map[string]string) error { func(p *PipelineConfig, v map[string]string) error {
p.PluginSources = pathOrStdin(v) p.PluginSources = pathOrStdin(v)
@@ -85,6 +85,9 @@ var presets = []Preset{
} }
switch v["viewer"] { switch v["viewer"] {
case "true": 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 sink["login_page"], sink["viewer_page"] = true, true
case "false": case "false":
default: default:
+1
View File
@@ -49,6 +49,7 @@ func TestPresetKeysAreChecked(t *testing.T) {
"serve,tls=files,cert=c": "tls=files needs key", "serve,tls=files,cert=c": "tls=files needs key",
"serve,viewer=yes": `viewer "yes"`, "serve,viewer=yes": `viewer "yes"`,
"serve,proxy=127.0.0.1": "proxy needs users", "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": "it never sends unauthenticated",
"edge,to=agg:9000,user=u": "user and password_file go together", "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"`, "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, sessionProxy,
) )
if err != nil { 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 // Check and inject capabilities using core interfaces
@@ -144,7 +144,7 @@ func (p *Pipeline) initializeComponents() error {
sessionProxy, sessionProxy,
) )
if err != nil { 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 // Check and inject capabilities using core interfaces
+36 -25
View File
@@ -10,6 +10,7 @@ import (
"maps" "maps"
"net" "net"
"net/http" "net/http"
"path"
"strconv" "strconv"
"strings" "strings"
"sync" "sync"
@@ -77,7 +78,7 @@ type HTTPSink struct {
// Authorization // Authorization
auth *authz.Policy 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 // Runtime. Stop closes flush; the broker then moves what remains of its
// input to tail and closes flushed; each stream writes its queue, tail // input to tail and closes flushed; each stream writes its queue, tail
@@ -128,22 +129,21 @@ func NewHTTPSinkPlugin(
} }
if opts.StreamPath == "" { if opts.StreamPath == "" {
opts.StreamPath = DefaultHTTPStreamPath opts.StreamPath = DefaultHTTPStreamPath
} else if !strings.HasPrefix(opts.StreamPath, "/") {
return nil, fmt.Errorf("stream_path: must start with '/'")
} }
if opts.StatusPath == "" { if opts.StatusPath == "" {
opts.StatusPath = DefaultHTTPStatusPath 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 { if opts.StreamPath == opts.StatusPath {
return nil, fmt.Errorf("stream_path and status_path must differ") 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 { if opts.BufferSize <= 0 {
opts.BufferSize = DefaultHTTPBufferSize opts.BufferSize = DefaultHTTPBufferSize
} }
@@ -160,16 +160,14 @@ func NewHTTPSinkPlugin(
} }
switch { switch {
case (opts.LoginPage || opts.ViewerPage) && !authPolicy.BehindProxy(): 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: case opts.ViewerPage && !opts.LoginPage:
return nil, errors.New("viewer_page needs login_page, where it sends a signed-out viewer") return nil, errors.New("viewer_page needs login_page, where it sends a signed-out viewer")
} }
var web map[string]http.Handler web, err := webHandlers(opts, authPolicy)
if authPolicy.BehindProxy() { if err != nil {
if web, err = webHandlers(opts); err != nil {
return nil, err return nil, err
} }
}
h := &HTTPSink{ h := &HTTPSink{
id: id, id: id,
@@ -202,7 +200,7 @@ func NewHTTPSinkPlugin(
"mtls", tlsCfg != nil && tlsCfg.ClientAuth == tls.RequireAndVerifyClientCert, "mtls", tlsCfg != nil && tlsCfg.ClientAuth == tls.RequireAndVerifyClientCert,
"auth", authPolicy.Describe(), "auth", authPolicy.Describe(),
"login_page", opts.LoginPage, "login_page", opts.LoginPage,
"viewer_page", opts.ViewerPage) "viewer_page", web[chain.AuthPath+"/view"] != nil)
tlsx.LogWarnings(logger, "http_sink", id, opts.TLS, true) tlsx.LogWarnings(logger, "http_sink", id, opts.TLS, true)
authPolicy.LogStartup(logger, "http_sink", id, false) authPolicy.LogStartup(logger, "http_sink", id, false)
return h, nil return h, nil
@@ -248,29 +246,27 @@ func (h *HTTPSink) serve(ctx context.Context, ln net.Listener) error {
} }
mux := http.NewServeMux() mux := http.NewServeMux()
// Method-scoped patterns: mux answers 405 with Allow header on non-GET // 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+" "+exact(h.config.StreamPath), h.handleStream)
mux.HandleFunc(http.MethodGet+" "+h.config.StatusPath, h.handleStatus) mux.HandleFunc(http.MethodGet+" "+exact(h.config.StatusPath), h.handleStatus)
// A GET pattern also serves HEAD, and a HEAD stream is a registered client // 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 // 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. // 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 // One wrapper covers stream and status, and keeps the handlers themselves
// unaware of authorization. Login and its browser files sit outside it; // unaware of authorization; a nil policy admits every request. Login, the
// in proxy mode everything sits behind the proxy gate. // browser files and the root sit outside it; in proxy mode everything sits
var handler http.Handler = mux // behind the proxy gate.
if h.auth.Enabled() {
outer := http.NewServeMux() outer := http.NewServeMux()
outer.HandleFunc(http.MethodPost+" "+chain.AuthPath, h.handleAuth) outer.HandleFunc(http.MethodPost+" "+chain.AuthPath, h.handleAuth)
for p, file := range h.web { for p, file := range h.web {
outer.Handle(http.MethodGet+" "+p, file) outer.Handle(http.MethodGet+" "+p, file)
} }
outer.Handle("/", h.authMiddleware(mux)) outer.Handle("/", h.authMiddleware(mux))
handler = outer var handler http.Handler = outer
if h.auth.BehindProxy() { if h.auth.BehindProxy() {
handler = h.proxyGate(outer) handler = h.proxyGate(outer)
} }
}
h.server = &http.Server{ h.server = &http.Server{
Handler: handler, 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 // streamHeadNotAllowed refuses a body-less read of a stream that is only a body
func streamHeadNotAllowed(w http.ResponseWriter, _ *http.Request) { func streamHeadNotAllowed(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Allow", http.MethodGet) w.Header().Set("Allow", http.MethodGet)
+92 -29
View File
@@ -4,14 +4,12 @@ import (
"bufio" "bufio"
"bytes" "bytes"
"crypto/tls" "crypto/tls"
"crypto/x509"
"encoding/json" "encoding/json"
"fmt" "fmt"
"io" "io"
"maps" "maps"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"os"
"slices" "slices"
"strings" "strings"
"testing" "testing"
@@ -138,17 +136,6 @@ func TestHeadOnStreamPathIsRefused(t *testing.T) {
// open stream keeps the wildcard so browser dashboards still work. // open stream keeps the wildcard so browser dashboards still work.
func TestWildcardCORSOnlyWithoutAuth(t *testing.T) { func TestWildcardCORSOnlyWithoutAuth(t *testing.T) {
pki := testutil.NewPKI(t, "viewer-01") 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) open, _ := newTestHTTPSink(t, nil)
gated, _ := newTestHTTPSink(t, map[string]any{ gated, _ := newTestHTTPSink(t, map[string]any{
"tls": map[string]any{"enabled": true, "cert_file": pki.ServerCert, "key_file": pki.ServerKey, "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, ""}} { }{{"open", open, "*"}, {"gated", gated, ""}} {
client, baseURL := serveTestHTTPSink(t, tc.sink) client, baseURL := serveTestHTTPSink(t, tc.sink)
if tc.sink.tlsConfig != nil { if tc.sink.tlsConfig != nil {
client.Transport = &http.Transport{TLSClientConfig: &tls.Config{RootCAs: roots, Certificates: []tls.Certificate{clientCert}}} baseURL = trustPKI(t, client, baseURL, pki, true)
baseURL = "https" + strings.TrimPrefix(baseURL, "http")
} }
resp, err := client.Get(baseURL + "/stream") resp, err := client.Get(baseURL + "/stream")
if err != nil { if err != nil {
@@ -189,14 +175,7 @@ func TestLoginEndpointBypassesTheGate(t *testing.T) {
"auth": map[string]any{"type": "scram", "credentials_file": creds}, "auth": map[string]any{"type": "scram", "credentials_file": creds},
}) })
client, baseURL := serveTestHTTPSink(t, gated) client, baseURL := serveTestHTTPSink(t, gated)
caPEM, err := os.ReadFile(pki.CA) baseURL = trustPKI(t, client, baseURL, pki, false)
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")
hello := `{"logwisp":1,"scram":{"username":"viewer-01","client_nonce":"abcdefgh"}}` hello := `{"logwisp":1,"scram":{"username":"viewer-01","client_nonce":"abcdefgh"}}`
resp, err := client.Post(baseURL+"/auth", "application/json", strings.NewReader(hello)) 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 // Behind a proxy nothing answers a peer outside trusted_proxies, not even the
// login page or the challenge. // login page or the challenge.
func TestProxyModeRefusesDirectPeers(t *testing.T) { 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 // The constructor refuses what it cannot serve: the login pages outside
// needs the login page; /auth and the paths under it are reserved. // scram's proxy mode, a viewer without the login page, a path under /auth, and
func TestPagesNeedProxyMode(t *testing.T) { // 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)} scram := map[string]any{"type": "scram", "credentials_file": scramCredentials(t)}
pki := testutil.NewPKI(t, "viewer-01") pki := testutil.NewPKI(t, "viewer-01")
tlsOn := map[string]any{"enabled": true, "cert_file": pki.ServerCert, "key_file": pki.ServerKey} 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}, "login page without proxies": {"tls": tlsOn, "auth": scram, "login_page": true},
"viewer without login": {"auth": proxy, "viewer_page": true}, "viewer without login": {"auth": proxy, "viewer_page": true},
"stream under /auth": {"auth": proxy, "stream_path": "/auth/stream"}, "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)}) maps.Copy(opts, map[string]any{"host": "127.0.0.1", "port": int64(8081)})
if _, err := NewHTTPSinkPlugin("stream", opts, log.NewLogger(), nil); err == nil { 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. // stopped reading does; Stop returns at the flush bound.
func TestStopWritesQueuedEventsToReadingStreams(t *testing.T) { func TestStopWritesQueuedEventsToReadingStreams(t *testing.T) {
const n = 2000 // of 8 KiB: more than a silent peer's socket buffers hold 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) client, baseURL := serveTestHTTPSink(t, httpSink)
var scanners []*bufio.Scanner var scanners []*bufio.Scanner
for range 2 { for range 2 {
@@ -383,8 +446,8 @@ func TestStopWritesQueuedEventsToReadingStreams(t *testing.T) {
}() }()
time.Sleep(100 * time.Millisecond) time.Sleep(100 * time.Millisecond)
httpSink.clientsMu.Unlock() httpSink.clientsMu.Unlock()
if d := <-stopped; d > 1500*time.Millisecond { if d := <-stopped; d > 3500*time.Millisecond {
t.Errorf("Stop took %v past a 500 ms flush bound", d) t.Errorf("Stop took %v past a 2 s flush bound", d)
} }
if r := <-done; r.lines != n || !r.disconnect { if r := <-done; r.lines != n || !r.disconnect {
t.Fatalf("reading stream received %d of %d lines, disconnect %v", r.lines, n, r.disconnect) t.Fatalf("reading stream received %d of %d lines, disconnect %v", r.lines, n, r.disconnect)
+25
View File
@@ -2,10 +2,14 @@ package http
import ( import (
"context" "context"
"crypto/tls"
"crypto/x509"
"maps" "maps"
"net" "net"
"net/http" "net/http"
"os"
"path/filepath" "path/filepath"
"strings"
"testing" "testing"
"time" "time"
@@ -51,6 +55,27 @@ func serveTestHTTPSink(t *testing.T, h *HTTPSink) (*http.Client, string) {
return client, "http://" + listener.Addr().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 // scramCredentials writes a credentials file holding viewer-01 under a cheap
// Argon2 profile // Argon2 profile
func scramCredentials(t *testing.T) string { 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", ".html": "text/html; charset=utf-8",
} }
func loginOn(o *config.HTTPSinkOptions) bool { return o.LoginPage } // webRoutes are the browser files under /auth/, each with the page that needs
func viewerOn(o *config.HTTPSinkOptions) bool { return o.ViewerPage } // 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
// webRoutes are served under /auth/ in proxy mode: the client library always,
// so a site can load it, and the pages when enabled
var webRoutes = []struct { var webRoutes = []struct {
name, file string name, file, page string
on func(*config.HTTPSinkOptions) bool
}{ }{
{"scram.js", "scram.js", func(*config.HTTPSinkOptions) bool { return true }}, {"scram.js", "scram.js", ""},
{"style.css", "style.css", loginOn}, {"style.css", "style.css", ""},
{"login", "login.html", loginOn}, {"login", "login.html", "login"},
{"login.js", "login.js", loginOn}, {"login.js", "login.js", "login"},
{"view", "view.html", viewerOn}, {"view", "view.html", "view"},
{"view.js", "view.js", viewerOn}, {"view.js", "view.js", "view"},
} }
// webHandlers maps each enabled GET path to its file. The viewer learns a // webHandlers maps each GET path a browser uses to its handler. Without a
// custom status path from a meta tag, as the CSP allows no inline script. // login (no auth, or mtls: the certificate is the login) the viewer is always
func webHandlers(o *config.HTTPSinkOptions) (map[string]http.Handler, error) { // 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) handlers := make(map[string]http.Handler)
for _, r := range webRoutes { for _, r := range webRoutes {
if !r.on(o) { if !on[r.page] {
continue continue
} }
data, err := webFiles.ReadFile("web/" + r.file) 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" { if r.file == "view.html" {
status := html.EscapeString(strings.TrimPrefix(o.StatusPath, "/")) 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)]) 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 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 { func serveWebFile(data []byte, contentType string) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
hd := w.Header() hd := w.Header()
+1
View File
@@ -5,6 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>LogWisp stream</title> <title>LogWisp stream</title>
<meta name="logwisp-status" content="status"> <meta name="logwisp-status" content="status">
<meta name="logwisp-login" content="scram">
<link rel="stylesheet" href="style.css"> <link rel="stylesheet" href="style.css">
<script type="module" src="view.js"></script> <script type="module" src="view.js"></script>
</head> </head>
+11 -5
View File
@@ -1,8 +1,11 @@
import { cookiesUsable, login, loginUnavailable, logout, stream } from "./scram.js"; import { cookiesUsable, login, loginUnavailable, logout, stream } from "./scram.js";
const base = new URL("..", location.href); const base = new URL("..", location.href);
// The server writes its status path here, relative to the mount // The server writes its status path here, relative to the mount, and "none"
const statusPath = document.querySelector('meta[name="logwisp-status"]').content; // 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 MAX_LINES = 5000;
const log = document.getElementById("log"); const log = document.getElementById("log");
const state = document.getElementById("state"); const state = document.getElementById("state");
@@ -11,10 +14,11 @@ const form = document.getElementById("login");
const progress = document.getElementById("progress"); const progress = document.getElementById("progress");
const status = document.getElementById("status"); 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, // 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 // 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; let token = null;
// The login page comes back with this fragment: a 401 now means the browser // The login page comes back with this fragment: a 401 now means the browser
// dropped the session cookie // dropped the session cookie
@@ -45,7 +49,8 @@ async function connect() {
} catch { } catch {
return retry("server unreachable"); 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; fresh = false;
const path = res.ok ? (await res.json().catch(() => ({})))?.endpoints?.stream : null; const path = res.ok ? (await res.json().catch(() => ({})))?.endpoints?.stream : null;
if (typeof path !== "string" || !path.startsWith("/")) { if (typeof path !== "string" || !path.startsWith("/")) {
@@ -168,5 +173,6 @@ if (unavailable) {
state.textContent = unavailable; state.textContent = unavailable;
signOut.hidden = true; signOut.hidden = true;
} else { } else {
signOut.hidden = open;
connect(); connect();
} }
+3 -3
View File
@@ -80,7 +80,7 @@ func TestStopWritesQueuedEventsToReadingClients(t *testing.T) {
t.Cleanup(manager.Stop) t.Cleanup(manager.Stop)
created, err := NewTCPSinkPlugin("out", map[string]any{ created, err := NewTCPSinkPlugin("out", map[string]any{
"host": "127.0.0.1", "port": p, "buffer_size": int64(n), "client_buffer_size": int64(64), "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")) }, log.NewLogger(), session.NewProxy(manager, "out"))
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
@@ -132,8 +132,8 @@ func TestStopWritesQueuedEventsToReadingClients(t *testing.T) {
}() }()
time.Sleep(100 * time.Millisecond) time.Sleep(100 * time.Millisecond)
sink.clientsMu.Unlock() sink.clientsMu.Unlock()
if d := <-stopped; d > 1500*time.Millisecond { if d := <-stopped; d > 3500*time.Millisecond {
t.Errorf("Stop took %v past a 500 ms flush bound", d) t.Errorf("Stop took %v past a 2 s flush bound", d)
} }
if got := <-lines; got != n { if got := <-lines; got != n {
t.Fatalf("reading client received %d of %d 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"> <svg viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">
<!-- <!-- LogWisp: five colored wisps interlock directly at the center.
LogWisp Logo - Vibrant Tech Stream No central core; each wing meets the others at (50,50). -->
- 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.
-->
<defs> <defs>
<radialGradient id="vibrant-glow" cx="50%" cy="50%" r="50%" fx="50%" fy="50%"> <path id="wisp" d="
<stop offset="0%" stop-color="#E0FFFF" /> <!-- Bright, almost white cyan at the core --> M 50,50
<stop offset="100%" stop-color="#00B0B0" /> <!-- Deeper teal/cyan at the tips --> C 50,38.5 59.2,28.8 70.8,27.8
</radialGradient> 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> </defs>
<style>
.wisp-path {
fill: none;
stroke: url(#vibrant-glow);
stroke-width: 1.5;
stroke-linecap: round;
}
</style>
<g id="wisps"> <g id="wisps">
<!-- A single path shape is defined, then rotated 5 times around the center (50, 50) --> <use href="#wisp" fill="#E0409A"/>
<!-- The path is a cubic Bezier curve: M(start) C(control1, control2, end) --> <use href="#wisp" fill="#D4640A" transform="rotate(72 50 50)"/>
<path class="wisp-path" d="M50,50 C58,30 85,25 95,50" transform="rotate(0, 50, 50)" /> <use href="#wisp" fill="#169B58" transform="rotate(144 50 50)"/>
<path class="wisp-path" d="M50,50 C58,30 85,25 95,50" transform="rotate(72, 50, 50)" /> <use href="#wisp" fill="#0891B2" transform="rotate(216 50 50)"/>
<path class="wisp-path" d="M50,50 C58,30 85,25 95,50" transform="rotate(144, 50, 50)" /> <use href="#wisp" fill="#8E5CF0" transform="rotate(288 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)" />
</g> </g>
</svg> </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 > curl -N http://127.0.0.1:$PORT_HTTP/stream
> echo hello >>\$APP/app.log > echo hello >>\$APP/app.log
The secured sink (bearer keeps the token off argv): 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 > curl -N --cacert \$CA -H @<(bearer) https://127.0.0.1:$PORT_S_HTTP/stream
Logs: $(short "$LOG")/ Logs: $(short "$LOG")/
EOF 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): Shell setup (LW, CA, PW: viewer-01's password file):
> . $(short "$RUN")/env > . $(short "$RUN")/env
Read the tcp sink as viewer-01: 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): 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 > curl -gN --cacert \$CA -H @<(bearer) https://[::1]:$PORT_HTTP_SINK/stream
Which listener answers on $PORT_FAMILY: Which listener answers on $PORT_FAMILY:
> curl -s http://127.0.0.1:$PORT_FAMILY/status; curl -gs http://[::1]:$PORT_FAMILY/status > 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 # 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 \ 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")" -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" \ 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)" -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" \ -password-file "$AUTH/viewer-01.pass" -ca-file "$PKI/ca.crt" \
>"$RUN/stream.out" 2>"$LOG/stream.err" >"$RUN/stream.out" 2>"$LOG/stream.err"
n=$(grep -c 'edge-01/' "$RUN/stream.out") 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" section "Scenario 2: each listener keeps to its address family"
+12 -2
View File
@@ -1,7 +1,8 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# logwisp preset test: colors, presets, --dump, lw tls, listeners that make # logwisp preset test: colors, presets, --dump, lw tls, listeners that make
# their certificate (self-signed and pinned, or issued), and the http and tcp # their certificate (self-signed and pinned, or issued), the serve preset's
# sinks writing a finite input whole. One-shot: both modes run the checks. # 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. # Ports: 15890-15896 on 127.0.0.1.
# Requires: bash 5+, coreutils, curl, openssl. Linux dev host only. # 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 "$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 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 "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" section "Flush at exit"
# feed SECONDS PORT: the lines, once a client had time to connect # 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): Shell setup (LW, CA, PW: viewer-01's password file, USERS, RELAY: its pid):
> . $(short "$RUN")/env > . $(short "$RUN")/env
Read the tcp sink as viewer-01: 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): 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 > curl -N --cacert \$CA -H @<(bearer) https://127.0.0.1:$PORT_HTTP_SINK/stream
Revoke viewer-01: 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")/ Ingested entries (node label forced to the username): $(short "$OUT")/
Passwords: $(short "$AUTH")/ Logs: $(short "$LOG")/ Passwords: $(short "$AUTH")/ Logs: $(short "$LOG")/
EOF EOF
+44 -11
View File
@@ -1,7 +1,8 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# logwisp browser login test: an http sink in proxy mode behind a TLS-terminating # 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 # 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 # Requires: bash 5+, go, openssl, curl; node with playwright and both builds
# `playwright install chromium` fetches, or the browser checks skip. # `playwright install chromium` fetches, or the browser checks skip.
@@ -15,13 +16,14 @@ PROXY_SRC=$RUN/proxy
USERS=$AUTH/users.toml USERS=$AUTH/users.toml
PORT_PROXY=15831 # the site: TLS ends here, logwisp is under /logs/ 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_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 PROXY_ADDR=127.0.0.2
SITE="https://127.0.0.1:$PORT_PROXY" SITE="https://127.0.0.1:$PORT_PROXY"
e2e_init "$@" e2e_init "$@"
section "Setup" section "Setup"
need go openssl curl 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 # The launch each scenario makes: the module resolves without its browsers, and
# chromium.executablePath() names full Chromium, not the default headless shell # 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; } 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" type = "scram"
credentials_file = "$USERS" credentials_file = "$USERS"
trusted_proxies = ["$PROXY_ADDR"] trusted_proxies = ["$PROXY_ADDR"]
[[pipelines.plugin_sinks]]
id = "open"
type = "http"
[pipelines.plugin_sinks.config]
host = "127.0.0.1"
port = $PORT_OPEN
EOF EOF
cat >"$RUN/browser.cjs" <<'EOF' cat >"$RUN/browser.cjs" <<'EOF'
@@ -147,7 +156,7 @@ const inlineLogin = (page) => page.waitForFunction(() =>
const scenarios = { const scenarios = {
async cookie(context, page) { 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 }); await page.waitForURL(/\/auth\/login\?next=view%23signed-in$/, { timeout: 10000 });
out.redirected_to_login = 1; out.redirected_to_login = 1;
await signIn(page, "not-the-password"); await signIn(page, "not-the-password");
@@ -235,6 +244,16 @@ const scenarios = {
await inlineLogin(page); await inlineLogin(page);
out.expired_asks_again = 1; 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 () => { (async () => {
@@ -244,7 +263,8 @@ const scenarios = {
// Full Chromium: the headless shell ignores a profile's Preferences // Full Chromium: the headless shell ignores a profile's Preferences
context = await chromium.launchPersistentContext(profile, { ignoreHTTPSErrors: true, channel: "chromium" }); context = await chromium.launchPersistentContext(profile, { ignoreHTTPSErrors: true, channel: "chromium" });
} else { } 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(); const page = await context.newPage();
page.on("console", (m) => { if (/Content Security Policy/i.test(m.text())) out.csp_violations++; }); 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" "$PKI/site.crt" "$PKI/site.key"
wait_port $PORT_SINK || abort "logwisp port $PORT_SINK is not listening" logwisp 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_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 daemons_up
write_env LW="$BIN" CA="$PKI/ca.crt" PW="$AUTH/viewer-01.pass" SITE="$SITE" 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: Ports:
$PORT_PROXY the site: TLS-terminating reverse proxy, logwisp under /logs/ $PORT_PROXY the site: TLS-terminating reverse proxy, logwisp under /logs/
$PORT_SINK logwisp http sink, plaintext, trusted_proxies = ["$PROXY_ADDR"] $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 In a browser trusting $(short "$PKI/ca.crt"), sign in as viewer-01 with the
password in $(short "$AUTH/viewer-01.pass"): password in $(short "$AUTH/viewer-01.pass"):
> $SITE/logs/auth/view > $SITE/logs/auth/view
Shell setup (LW, CA, PW: viewer-01's password file, SITE): Shell setup (LW, CA, PW: viewer-01's password file, SITE):
> . $(short "$RUN")/env > . $(short "$RUN")/env
The CLI, unbound through the proxy (bearer keeps the token off argv): 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 > curl -N --cacert \$CA -H @<(bearer) \$SITE/logs/stream
Logs: $(short "$LOG")/ Logs: $(short "$LOG")/
EOF EOF
@@ -294,9 +316,9 @@ code_of() { # url [token] -> http_code; the header goes through a pipe, never ar
fi 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)" 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" "$RUN/profile" "${PIDS[0]}" >"$LOG/browser-$1.out" 2>"$LOG/browser-$1.err"
browser_out="$(cat "$LOG/browser-$1.out")" browser_out="$(cat "$LOG/browser-$1.out")"
[[ -z $(val error) ]] || info "${C_RED}browser error ($1): $(val error)$C_OFF" [[ -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" section "Scenario 1: a person in a browser"
if ((BROWSER)); then if ((BROWSER)); then
browser cookie 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: 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)") check "browser: login (Argon2 in the page, $(val login_ms) ms) opened the viewer" $(is_set "$(val login_ms)")
n=$(val events) n=$(val events)
@@ -360,7 +382,7 @@ fi
section "Scenario 3: the CLI through the proxy" 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" \ 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")" -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")" code="$(code_of "$SITE/logs/status" "$token")"
check "cli: /logs/status served with the token (HTTP $code)" $(is "$code" 200) check "cli: /logs/status served with the token (HTTP $code)" $(is "$code" 200)
sse="$(timeout 3 curl -sN --noproxy '*' --cacert "$PKI/ca.crt" \ 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" \ "$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" -ca-file "$PKI/ca.crt" >/dev/null 2>>"$LOG/auth-cli.out"
rc=$? 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")" 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) 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")" code="$(code_of "http://127.0.0.1:$PORT_SINK/status" "$token")"