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