v0.24.0 cli long and short options, http sink viewer and new logo
This commit is contained in:
1 parent
bf410e1225
commit
9e86d7999d
42 files changed
+774
-392
No files matched your search
+1
-1
@@ -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
|
||||
|
||||
|
||||
@@ -44,8 +44,8 @@ streams, or downstream LogWisp nodes.
|
||||
|
||||
`console` (level names in color and control characters escaped on a
|
||||
terminal), `file` (rotating with
|
||||
retention), `http` (Server-Sent Events plus a
|
||||
JSON status endpoint), `tcp` (broadcast server), `null`, and the chain
|
||||
retention), `http` (Server-Sent Events, a JSON status endpoint and a browser
|
||||
viewer), `tcp` (broadcast server), `null`, and the chain
|
||||
forwarders `tcp_chain` and `http_chain`.
|
||||
|
||||
### Processing
|
||||
@@ -116,7 +116,7 @@ authentication designs for the rationale and what is deliberately left out.
|
||||
- [Operations](doc/operations.md): running, monitoring, tuning,
|
||||
troubleshooting
|
||||
- [To Do](doc/todo.md): planned work in priority order: network access
|
||||
control and the PROXY protocol, packaging
|
||||
control and the PROXY protocol, packaging, smaller follow-ups
|
||||
|
||||
A fully annotated configuration covering every option ships as
|
||||
[`config/logwisp.toml`](config/logwisp.toml).
|
||||
@@ -135,7 +135,8 @@ one without `--sink` writes stdout:
|
||||
./bin/lw < app.log > copy.log
|
||||
tail -F app.log | ./bin/lw --filter include,patterns=ERROR,patterns=WARN
|
||||
|
||||
# stdin as a live SSE stream; the http sink binds 0.0.0.0 unless told
|
||||
# stdin as a live SSE stream, with a browser viewer at http://127.0.0.1:8080/;
|
||||
# the http sink binds 0.0.0.0 unless told
|
||||
journalctl -f | ./bin/lw --sink http,host=127.0.0.1,port=8080
|
||||
|
||||
# tail a directory instead of stdin
|
||||
|
||||
+15
-15
@@ -29,16 +29,16 @@ import (
|
||||
const reloadHint = "send SIGHUP to logwisp to apply (auto_reload does not watch the credentials file)"
|
||||
|
||||
var authCommands = []subcommand{
|
||||
{"add-user", "-credentials FILE -user NAME [-password-file FILE] [-generate]",
|
||||
{"add-user", "--credentials FILE -u NAME [--password-file FILE] [--generate]",
|
||||
"Add a user to a credentials file, or replace its password",
|
||||
[]string{"credentials", "user"}, defineAddUser},
|
||||
{"remove-user", "-credentials FILE -user NAME",
|
||||
{"remove-user", "--credentials FILE -u NAME",
|
||||
"Remove a user from a credentials file",
|
||||
[]string{"credentials", "user"}, defineRemoveUser},
|
||||
{"token", "-url https://HOST:PORT -user NAME -password-file FILE [TLS flags]",
|
||||
{"token", "--url https://HOST:PORT -u NAME --password-file FILE [--unbound] [TLS flags]",
|
||||
"Log in to an http sink or http_chain source and print a bearer token",
|
||||
[]string{"url", "user", "password-file"}, defineToken},
|
||||
{"stream", "-addr HOST:PORT -user NAME -password-file FILE [TLS flags]",
|
||||
{"stream", "--addr HOST:PORT -u NAME --password-file FILE [TLS flags]",
|
||||
"Log in to a tcp sink and copy its stream to stdout until interrupted",
|
||||
[]string{"addr", "user", "password-file"}, defineStream},
|
||||
}
|
||||
@@ -47,7 +47,7 @@ func defineAddUser(fs *flag.FlagSet) func(stdout, stderr io.Writer) error {
|
||||
path := fs.String("credentials", "", "credentials `file`, created when missing")
|
||||
user := fs.String("user", "", "user `name`")
|
||||
passwordFile := fs.String("password-file", "", "read the password from `file` when it exists, else write a generated one there")
|
||||
generate := fs.Bool("generate", false, "generate a new password, replacing any -password-file; needed to rotate without one")
|
||||
generate := fs.Bool("generate", false, "generate a new password, replacing any --password-file; needed to rotate without one")
|
||||
return func(stdout, stderr io.Writer) error {
|
||||
creds, err := authz.LoadCredentials(*path)
|
||||
// An empty file is one created beforehand to choose its owner
|
||||
@@ -72,8 +72,8 @@ func defineAddUser(fs *flag.FlagSet) func(stdout, stderr io.Writer) error {
|
||||
}
|
||||
generated := password == ""
|
||||
if generated && i >= 0 && !*generate {
|
||||
// A mistyped -password-file must not replace a working password
|
||||
return fmt.Errorf("user %q exists: replacing its password needs an existing -password-file or -generate", *user)
|
||||
// A mistyped --password-file must not replace a working password
|
||||
return fmt.Errorf("user %q exists: replacing its password needs an existing --password-file or --generate", *user)
|
||||
}
|
||||
if generated {
|
||||
password = rand.Text()
|
||||
@@ -105,7 +105,7 @@ func defineAddUser(fs *flag.FlagSet) func(stdout, stderr io.Writer) error {
|
||||
}
|
||||
if generated && *passwordFile != "" {
|
||||
// First, so a failure leaves the verifiers untouched and a rerun
|
||||
// without -generate completes the change
|
||||
// without --generate completes the change
|
||||
if err := writeAtomic(*passwordFile, []byte(password+"\n")); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -153,7 +153,7 @@ func defineRemoveUser(fs *flag.FlagSet) func(stdout, stderr io.Writer) error {
|
||||
}
|
||||
|
||||
func defineToken(fs *flag.FlagSet) func(stdout, stderr io.Writer) error {
|
||||
rawURL := fs.String("url", "", "listener `URL`, https://HOST:PORT or https://[IPV6]:PORT (with -unbound, a proxy's mount URL may carry a path)")
|
||||
rawURL := fs.String("url", "", "listener `URL`, https://HOST:PORT or https://[IPV6]:PORT (with --unbound, a proxy's mount URL may carry a path)")
|
||||
user := fs.String("user", "", "user `name`")
|
||||
passwordFile := fs.String("password-file", "", "`file` holding the password")
|
||||
unbound := fs.Bool("unbound", false, "log in without channel binding, to an http sink behind a TLS-terminating proxy (auth.trusted_proxies)")
|
||||
@@ -161,15 +161,15 @@ func defineToken(fs *flag.FlagSet) func(stdout, stderr io.Writer) error {
|
||||
return func(stdout, _ io.Writer) error {
|
||||
u, err := url.Parse(*rawURL)
|
||||
if err != nil || u.Scheme != "https" || u.Host == "" || u.RawQuery != "" || u.Fragment != "" || u.User != nil {
|
||||
return usageError("-url must be https://HOST:PORT")
|
||||
return usageError("--url must be https://HOST:PORT")
|
||||
}
|
||||
if u.Path = strings.TrimSuffix(u.Path, "/"); u.Path != "" && !*unbound {
|
||||
// TLS passthrough cannot route by path: only a terminating proxy can
|
||||
return usageError("-url takes a path only with -unbound")
|
||||
return usageError("--url takes a path only with --unbound")
|
||||
}
|
||||
network, err := core.Network(u.Hostname())
|
||||
if err != nil {
|
||||
return usageError("-url: " + err.Error())
|
||||
return usageError("--url: " + err.Error())
|
||||
}
|
||||
tlsCfg, policy, err := dialPolicy(tlsOpts, u.Hostname(), *user, *passwordFile, authz.HTTP)
|
||||
if err != nil {
|
||||
@@ -203,7 +203,7 @@ func defineToken(fs *flag.FlagSet) func(stdout, stderr io.Writer) error {
|
||||
}
|
||||
|
||||
func defineStream(fs *flag.FlagSet) func(stdout, stderr io.Writer) error {
|
||||
addr := fs.String("addr", "", "tcp sink `address`, HOST:PORT or [IPV6]:PORT")
|
||||
addr := fs.String("addr", "", "tcp sink address, `HOST:PORT` or [IPV6]:PORT")
|
||||
user := fs.String("user", "", "user `name`")
|
||||
passwordFile := fs.String("password-file", "", "`file` holding the password")
|
||||
tlsOpts := tlsFlags(fs)
|
||||
@@ -211,7 +211,7 @@ func defineStream(fs *flag.FlagSet) func(stdout, stderr io.Writer) error {
|
||||
host, _, err := net.SplitHostPort(*addr)
|
||||
network, nerr := core.Network(host)
|
||||
if err != nil || nerr != nil || host == "" {
|
||||
return usageError("-addr must be HOST:PORT, with an IPv6 address in brackets")
|
||||
return usageError("--addr must be HOST:PORT, with an IPv6 address in brackets")
|
||||
}
|
||||
tlsCfg, policy, err := dialPolicy(tlsOpts, host, *user, *passwordFile, authz.TCP)
|
||||
if err != nil {
|
||||
@@ -244,7 +244,7 @@ func defineStream(fs *flag.FlagSet) func(stdout, stderr io.Writer) error {
|
||||
func tlsFlags(fs *flag.FlagSet) *config.TLSOptions {
|
||||
o := &config.TLSOptions{Enabled: true}
|
||||
fs.StringVar(&o.CAFile, "ca-file", "", "CA `file` that verifies the server (default: system roots)")
|
||||
fs.StringVar(&o.PinSHA256, "pin-sha256", "", "sha256//BASE64 `pin` of the server key, in place of -ca-file (a self_signed listener logs it)")
|
||||
fs.StringVar(&o.PinSHA256, "pin-sha256", "", "sha256//BASE64 `pin` of the server key, in place of --ca-file (a self_signed listener logs it)")
|
||||
fs.StringVar(&o.ServerName, "server-name", "", "`name` the server certificate must carry (default: the host)")
|
||||
fs.StringVar(&o.CertFile, "cert-file", "", "client certificate `file`, for listeners with tls.client_auth")
|
||||
fs.StringVar(&o.KeyFile, "key-file", "", "client key `file`")
|
||||
|
||||
+1
-8
@@ -42,7 +42,7 @@ func writeCredentials(t *testing.T, path string, users ...string) *authz.Credent
|
||||
func TestAddUserCreatesPrivateFilesWithAMatchingVerifier(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
creds, pass := filepath.Join(dir, "users.toml"), filepath.Join(dir, "edge-01.pass")
|
||||
code, stdout, stderr := runAuthTest(t, "add-user", "-credentials", creds, "-user", "edge-01", "-password-file", pass)
|
||||
code, stdout, stderr := runAuthTest(t, "add-user", "--credentials", creds, "-u", "edge-01", "--password-file", pass)
|
||||
if code != 0 || stdout != "" {
|
||||
t.Fatalf("exit %d, stdout %q, stderr: %s", code, stdout, stderr)
|
||||
}
|
||||
@@ -185,13 +185,6 @@ func TestRemoveUserRefusesTheLastUser(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthCommandHelpPrintsAuthUsage(t *testing.T) {
|
||||
code, _, stderr := runAuthTest(t, "add-user", "-h")
|
||||
if code != 0 || !strings.Contains(stderr, "Usage: lw auth add-user -credentials FILE") {
|
||||
t.Fatalf("exit %d, stderr: %s", code, stderr)
|
||||
}
|
||||
}
|
||||
|
||||
// The login is posted to the -url given plus /auth: a link-local URL keeps
|
||||
// its zone escaped, not turned into one that no longer parses
|
||||
func TestTokenLogsInAtTheURLGiven(t *testing.T) {
|
||||
|
||||
+64
-13
@@ -8,6 +8,7 @@ import (
|
||||
"io"
|
||||
"slices"
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"github.com/lixenwraith/logwisp/internal/config"
|
||||
|
||||
@@ -37,6 +38,17 @@ var commands = []command{
|
||||
"lw --preset NAME,key=value runs one; flags here are its keys.", presetCommands()},
|
||||
}
|
||||
|
||||
// shorts are lw's one-letter flags, each its long flag in every command that
|
||||
// defines that flag; lw's own command line takes the top ones
|
||||
var shorts = map[string]struct {
|
||||
long string
|
||||
top bool
|
||||
}{
|
||||
"c": {"config", true}, "h": {"help", true}, "p": {"preset", true},
|
||||
"q": {"quiet", true}, "t": {"check", true}, "V": {"version", true},
|
||||
"u": {"user", false},
|
||||
}
|
||||
|
||||
// usageError is a command-line mistake: exit status 2 rather than 1
|
||||
type usageError string
|
||||
|
||||
@@ -63,9 +75,14 @@ func (c *command) run(args []string, stdout, stderr io.Writer) int {
|
||||
fs.SetOutput(stderr)
|
||||
fs.Usage = func() {
|
||||
fmt.Fprintf(stderr, "Usage: %s %s\n\n%s.\n\n", fs.Name(), s.synopsis, s.summary)
|
||||
fs.PrintDefaults()
|
||||
printFlags(stderr, fs)
|
||||
}
|
||||
work := s.define(fs)
|
||||
for letter, short := range shorts {
|
||||
if f := fs.Lookup(short.long); f != nil {
|
||||
fs.Var(f.Value, letter, "") // no usage: printFlags shows it with its long flag
|
||||
}
|
||||
}
|
||||
if err := fs.Parse(args[1:]); err != nil {
|
||||
if errors.Is(err, flag.ErrHelp) {
|
||||
return 0
|
||||
@@ -88,21 +105,48 @@ func (c *command) run(args []string, stdout, stderr io.Writer) int {
|
||||
}
|
||||
|
||||
func (c *command) usage(w io.Writer) {
|
||||
fmt.Fprintf(w, "Usage: lw %s <command> [flags]\n\n", c.name)
|
||||
fmt.Fprintf(w, "Usage: lw %s COMMAND [flags]\n\n", c.name)
|
||||
for _, s := range c.subcommands {
|
||||
fmt.Fprintf(w, " %-12s %s\n", s.name, s.summary)
|
||||
}
|
||||
fmt.Fprintf(w, "\nRun lw %s <command> -h for its flags. %s\n"+
|
||||
fmt.Fprintf(w, "\nRun lw %s COMMAND -h for its flags. %s\n"+
|
||||
"Exit status: 0 success, 1 failure, 2 usage error.\n", c.name, c.footer)
|
||||
}
|
||||
|
||||
// printFlags lists fs's flags as " -u, --user NAME" or " --credentials FILE"
|
||||
func printFlags(w io.Writer, fs *flag.FlagSet) {
|
||||
fs.VisitAll(func(f *flag.Flag) {
|
||||
if f.Usage == "" {
|
||||
return
|
||||
}
|
||||
head := " "
|
||||
for letter, short := range shorts {
|
||||
if short.long == f.Name {
|
||||
head = "-" + letter + ", "
|
||||
}
|
||||
}
|
||||
arg, usage := flag.UnquoteUsage(f)
|
||||
// Only a bool flag has no argument; its "false" default goes unsaid
|
||||
if f.DefValue != "" && (arg != "" || f.DefValue != "false") {
|
||||
usage += " (default: " + f.DefValue + ")"
|
||||
}
|
||||
if arg == "string" {
|
||||
arg = "value"
|
||||
}
|
||||
if arg != "" {
|
||||
arg = " " + strings.ToUpper(arg)
|
||||
}
|
||||
fmt.Fprintf(w, " %s--%s%s\n %s\n", head, f.Name, arg, usage)
|
||||
})
|
||||
}
|
||||
|
||||
func checkArgs(fs *flag.FlagSet, required []string) error {
|
||||
if fs.NArg() > 0 {
|
||||
return usageError(fmt.Sprintf("unexpected argument %q", fs.Arg(0)))
|
||||
}
|
||||
for _, name := range required {
|
||||
if fs.Lookup(name).Value.String() == "" {
|
||||
return usageError("-" + name + " is required")
|
||||
return usageError("--" + name + " is required")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
@@ -117,10 +161,10 @@ type invocation struct {
|
||||
load config.Args
|
||||
}
|
||||
|
||||
// parseCommandLine reads lw's own flags; anything else is a --path=value
|
||||
// setting for lixenwraith/config, which reports what it does not know. A
|
||||
// pipeline flag takes the next argument unless it starts with '-'. A help
|
||||
// request wins over a malformed flag.
|
||||
// parseCommandLine reads lw's own flags, a short one as its long one; any
|
||||
// other --flag is a --path=value setting for lixenwraith/config, which reports
|
||||
// what it does not know. A pipeline flag takes the next argument unless it
|
||||
// starts with '-'. A help request wins over a malformed flag.
|
||||
func parseCommandLine(argv []string) (inv invocation, err error) {
|
||||
defer func() {
|
||||
if inv.help {
|
||||
@@ -144,18 +188,22 @@ func parseCommandLine(argv []string) (inv invocation, err error) {
|
||||
for i := 0; i < len(argv); i++ {
|
||||
arg := argv[i]
|
||||
name, value, inline := strings.Cut(arg, "=")
|
||||
typed := name
|
||||
if short, ok := shorts[strings.TrimPrefix(name, "-")]; ok && short.top && len(name) == 2 {
|
||||
arg, name = "--"+short.long+arg[len(name):], "--"+short.long
|
||||
}
|
||||
switch {
|
||||
case arg == "--":
|
||||
inv.load.Overrides = append(inv.load.Overrides, argv[i:]...)
|
||||
return inv, err
|
||||
case arg == "-h" || arg == "--help":
|
||||
case arg == "--help":
|
||||
inv.help = true
|
||||
case name == "-c" || name == "--config":
|
||||
case name == "--config":
|
||||
if !inline {
|
||||
value = next(&i)
|
||||
}
|
||||
if value == "" {
|
||||
err = cmp.Or(err, fmt.Errorf("%s requires a configuration file path", name))
|
||||
err = cmp.Or(err, fmt.Errorf("%s requires a configuration file path", typed))
|
||||
}
|
||||
inv.load.File = value
|
||||
case arg == "--color":
|
||||
@@ -166,6 +214,9 @@ func parseCommandLine(argv []string) (inv invocation, err error) {
|
||||
value = next(&i)
|
||||
}
|
||||
inv.load.Specs = append(inv.load.Specs, config.Spec{Flag: name[2:], Value: value})
|
||||
case len(name) > 1 && name[0] == '-' && unicode.IsLetter(rune(name[1])):
|
||||
// config reads only --flags; a negative number stays a value
|
||||
err = cmp.Or(err, fmt.Errorf("unknown option %s: long options take two dashes, and a value that starts with '-' follows '=' (lw --help lists them)", typed))
|
||||
default:
|
||||
inv.load.Overrides = append(inv.load.Overrides, arg)
|
||||
}
|
||||
@@ -182,10 +233,10 @@ func presetCommands() []subcommand {
|
||||
for _, k := range p.Params {
|
||||
if k.Required {
|
||||
s.required = append(s.required, k.Name)
|
||||
synopsis = append(synopsis, "-"+k.Name+" VALUE")
|
||||
synopsis = append(synopsis, "--"+k.Name+" VALUE")
|
||||
}
|
||||
}
|
||||
s.synopsis = strings.Join(append(synopsis, "[-KEY VALUE...]"), " ")
|
||||
s.synopsis = strings.Join(append(synopsis, "[--KEY VALUE...]"), " ")
|
||||
s.define = func(fs *flag.FlagSet) func(stdout, stderr io.Writer) error {
|
||||
values := map[string]*string{}
|
||||
for _, k := range p.Params {
|
||||
|
||||
+40
-5
@@ -2,9 +2,11 @@ package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"flag"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -13,9 +15,10 @@ import (
|
||||
"github.com/lixenwraith/logwisp/internal/tlsx"
|
||||
)
|
||||
|
||||
// lw's own flags: the last -c wins, a pipeline flag or --color takes the next
|
||||
// argument unless it starts with '-', -- ends lw's flags, and a command is
|
||||
// only the first argument. Everything else is a setting for config.
|
||||
// lw's own flags: a short is its long flag, an unknown single-dash one fails,
|
||||
// the last -c wins, a pipeline flag or --color takes the next argument unless
|
||||
// it starts with '-', -- ends lw's flags, and a command is only the first
|
||||
// argument. Everything else is a setting for config.
|
||||
func TestCommandLineGrammar(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
argv []string
|
||||
@@ -33,15 +36,47 @@ func TestCommandLineGrammar(t *testing.T) {
|
||||
{[]string{"help"}, invocation{help: true, load: config.Args{Overrides: []string{"help"}}}},
|
||||
{[]string{"tls", "ca", "-h"}, invocation{command: &commands[1], args: []string{"ca", "-h"}}},
|
||||
{[]string{"--quiet", "tls"}, invocation{load: config.Args{Overrides: []string{"--quiet", "tls"}}}},
|
||||
{[]string{"-q", "-t", "-V", "-p", "tail,path=x", "-c=a.toml", "--logging.file.retention_hours", "-1"},
|
||||
invocation{load: config.Args{File: "a.toml", Specs: []config.Spec{{Flag: "preset", Value: "tail,path=x"}},
|
||||
Overrides: []string{"--quiet", "--check", "--version", "--logging.file.retention_hours", "-1"}}}},
|
||||
} {
|
||||
got, err := parseCommandLine(c.argv)
|
||||
if err != nil || !reflect.DeepEqual(got, c.want) {
|
||||
t.Errorf("%q:\n got %+v %v\nwant %+v", c.argv, got, err, c.want)
|
||||
}
|
||||
}
|
||||
for _, argv := range [][]string{{"-c"}, {"--config"}, {"--config="}, {"-c="}, {"-c", "--quiet"}} {
|
||||
for _, argv := range [][]string{{"-c"}, {"--config"}, {"--config="}, {"-c="}, {"-c", "--quiet"},
|
||||
{"-v"}, {"-qt"}, {"-u", "x"}, {"-config", "x"}} {
|
||||
if _, err := parseCommandLine(argv); err == nil {
|
||||
t.Errorf("%q: missing path accepted", argv)
|
||||
t.Errorf("%q accepted", argv)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Every subcommand's -h lists each flag once, GNU style, with its short form
|
||||
// beside it: " -u, --user NAME" or " --credentials FILE"
|
||||
func TestUsageListsEachFlagOnce(t *testing.T) {
|
||||
for _, c := range commands {
|
||||
for _, s := range c.subcommands {
|
||||
code, _, stderr := runCommand(t, c.name, s.name, "-h")
|
||||
fs := flag.NewFlagSet("", flag.ContinueOnError)
|
||||
s.define(fs)
|
||||
want := 0
|
||||
fs.VisitAll(func(f *flag.Flag) {
|
||||
want++
|
||||
head := " --" + f.Name
|
||||
for letter, short := range shorts {
|
||||
if short.long == f.Name {
|
||||
head = " -" + letter + ", --" + f.Name
|
||||
}
|
||||
}
|
||||
if n := len(regexp.MustCompile(`(?m)^`+regexp.QuoteMeta(head)+`( |$)`).FindAllString(stderr, -1)); n != 1 {
|
||||
t.Errorf("lw %s %s -h lists %q %d times:\n%s", c.name, s.name, head, n, stderr)
|
||||
}
|
||||
})
|
||||
if heads := regexp.MustCompile(`(?m)^ (-., | )--`).FindAllString(stderr, -1); code != 0 || len(heads) != want {
|
||||
t.Errorf("lw %s %s -h: exit %d, %d flags listed, want %d", c.name, s.name, code, len(heads), want)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+46
-34
@@ -13,55 +13,66 @@ const helpText = `LogWisp %s - log collection, processing, and distribution
|
||||
Usage:
|
||||
lw [options] [< input] No file, no pipeline options: stdin to stdout,
|
||||
line for line, like cat; exits at end of input
|
||||
lw -c <file> [options] Run the file's pipelines
|
||||
lw --check [options] Build every pipeline and plugin, report, exit
|
||||
lw -c|--config FILE [options] Run the file's pipelines
|
||||
lw -p|--preset SPEC [options] Run a preset's pipeline
|
||||
lw -t|--check [options] Build every pipeline and plugin, report, exit
|
||||
lw --dump [options] Print the effective configuration as TOML, exit
|
||||
lw help | -h | --help
|
||||
lw --version
|
||||
lw -V | --version
|
||||
|
||||
Commands (lw <command> -h lists its subcommands):
|
||||
Commands (lw COMMAND -h lists its subcommands):
|
||||
%s
|
||||
Scalar configuration keys are settable as flags using their TOML paths:
|
||||
--<path>=<value> e.g. --logging.level=debug
|
||||
|
||||
Common options:
|
||||
-c, --config <path> Configuration file (default: ./logwisp.toml)
|
||||
--quiet Suppress console output
|
||||
--color [<when>] Level names in color on console sinks:
|
||||
Options: a short option is its long one; a value follows as the next argument
|
||||
or after '=' (-c FILE, --config=FILE), one that starts with '-' after '=';
|
||||
short options do not combine.
|
||||
-c, --config FILE Configuration file (default:
|
||||
~/.config/logwisp/logwisp.toml if it exists,
|
||||
else ./logwisp.toml)
|
||||
-t, --check Build every pipeline and plugin, report, exit
|
||||
--dump Print the effective configuration, exit
|
||||
-q, --quiet Silence lw's own log and notices; pipeline
|
||||
output still flows
|
||||
--color [WHEN] Level names in color on console sinks:
|
||||
auto (a terminal, NO_COLOR unset; default),
|
||||
always (bare --color) or never
|
||||
--status_reporter=<bool> Periodic status logging (default: true with
|
||||
a configuration file)
|
||||
--auto_reload=<bool> Config hot reload on file change (default: false)
|
||||
-V, --version Print the version and exit
|
||||
-h, --help Print this help and exit
|
||||
-- End the options
|
||||
|
||||
Logging (lw's own log goes to stderr, at info with a configuration file and
|
||||
at warn without one):
|
||||
--logging.output=<mode> file|stdout|stderr|split|all|none
|
||||
--logging.level=<level> debug|info|warn|error
|
||||
--logging.file.directory=<path>
|
||||
Configuration keys are flags by their TOML path, '_' and '.' kept:
|
||||
--KEY=VALUE e.g. --logging.level=debug
|
||||
--status_reporter=BOOL Periodic status logging (default: true with
|
||||
a configuration file)
|
||||
--auto_reload=BOOL Reload when the file changes (default: false)
|
||||
Logging (lw's own log goes to stderr, at info with a configuration file and
|
||||
at warn without one):
|
||||
--logging.output=MODE file|stdout|stderr|split|all|none
|
||||
--logging.level=LEVEL debug|info|warn|error
|
||||
--logging.file.directory=DIR
|
||||
|
||||
Pipelines (replace the file's pipelines; see doc/cli.md):
|
||||
--preset <name>[,k=v...] Start a pipeline with a preset: pipe, tail,
|
||||
serve, edge, aggregator; lw preset <name> -h
|
||||
lists its keys, lw preset <name> prints it
|
||||
--pipeline <name> Start a pipeline; earlier specs go to "cli"
|
||||
--source <spec> TYPE[,key=value...], repeatable
|
||||
--sink <spec> e.g. http,host=0.0.0.0,port=8080, repeatable
|
||||
--filter <spec> include|exclude,patterns=RE, repeatable
|
||||
--format <spec> json|txt|raw[,key=value...]
|
||||
--rate-limit <spec> rate=N[,burst=N,policy=drop|pass]
|
||||
--heartbeat <spec> interval_ms=N[,include_stats=true,...]
|
||||
-p, --preset NAME[,KEY=VALUE...]
|
||||
Start a pipeline with a preset: pipe, tail,
|
||||
serve, edge, aggregator; lw preset NAME -h
|
||||
lists its keys, lw preset NAME prints it
|
||||
--pipeline NAME Start a pipeline; earlier specs go to "cli"
|
||||
--source SPEC TYPE[,KEY=VALUE...], repeatable
|
||||
--sink SPEC e.g. http,host=0.0.0.0,port=8080, repeatable
|
||||
--filter SPEC include|exclude,patterns=RE, repeatable
|
||||
--format SPEC json|txt|raw[,KEY=VALUE...]
|
||||
--rate-limit SPEC rate=N[,burst=N,policy=drop|pass]
|
||||
--heartbeat SPEC interval_ms=N[,include_stats=true,...]
|
||||
A pipeline without --source reads stdin, one without --sink writes stdout.
|
||||
Keys nest with '.' (tls.cert_file=...), a repeated key makes a list, and
|
||||
'\' escapes ',' '=' '\' in values. -- ends option parsing.
|
||||
'\' escapes ',' '=' '\' in values.
|
||||
|
||||
Examples:
|
||||
lw < app.log > copy.log Copy, line for line
|
||||
tail -F app.log | lw --filter include,patterns=ERROR,patterns=WARN
|
||||
journalctl -f | lw --sink http,host=127.0.0.1,port=8080
|
||||
Serve stdin as a live stream
|
||||
Serve stdin live; browse http://127.0.0.1:8080/
|
||||
lw --source file,directory=/var/log/app,pattern='*.log' --format txt
|
||||
lw --preset tail,path=/var/log/app Follow a directory's files
|
||||
lw -p tail,path=/var/log/app Follow a directory's files
|
||||
lw --preset serve,path=/var/log/app,tls=self,users=users.toml
|
||||
HTTPS stream, SCRAM logins
|
||||
lw --preset edge,path=/var/log/app,to=agg:9000,ca=ca.crt,user=edge-01,\
|
||||
@@ -83,14 +94,15 @@ Signals:
|
||||
|
||||
Exit codes:
|
||||
0 success, including the end of input
|
||||
1 general error
|
||||
1 general error, including an error in lw's own options (lw COMMAND
|
||||
exits 2 on a usage error)
|
||||
2 configuration file not found
|
||||
`
|
||||
|
||||
func printHelp() {
|
||||
var list strings.Builder
|
||||
for _, c := range commands {
|
||||
fmt.Fprintf(&list, " lw %-26s %s\n", c.name+" <command>", c.summary)
|
||||
fmt.Fprintf(&list, " lw %-26s %s\n", c.name+" COMMAND", c.summary)
|
||||
}
|
||||
fmt.Printf(helpText, version.Short(), list.String())
|
||||
}
|
||||
+12
-12
@@ -20,21 +20,21 @@ import (
|
||||
const maxDays = 36500
|
||||
|
||||
var tlsCommands = []subcommand{
|
||||
{"ca", "-dir DIR [-name NAME] [-days N]",
|
||||
{"ca", "--dir DIR [--name NAME] [--days N]",
|
||||
"Create a certificate authority, DIR/ca.crt and DIR/ca.key, that signs certificates only",
|
||||
[]string{"dir"}, defineCA},
|
||||
{"cert", "-ca-dir DIR -name NAME [-host NAME,...] [-server] [-client] [-days N] [-out DIR]",
|
||||
{"cert", "--ca-dir DIR --name NAME [--host NAME,...] [--server] [--client] [--days N] [--out DIR]",
|
||||
"Issue a certificate and key, OUT/NAME.crt and OUT/NAME.key, from DIR/ca.crt and DIR/ca.key",
|
||||
[]string{"ca-dir", "name"}, defineCert},
|
||||
}
|
||||
|
||||
func defineCA(fs *flag.FlagSet) func(stdout, stderr io.Writer) error {
|
||||
dir := fs.String("dir", "", "`directory` to write ca.crt and ca.key in, created when missing")
|
||||
dir := fs.String("dir", "", "`dir`ectory to write ca.crt and ca.key in, created when missing")
|
||||
name := fs.String("name", "logwisp CA", "subject common `name`")
|
||||
days := fs.Int("days", int(tlsx.CAValidity/(24*time.Hour)), "validity in `days`")
|
||||
days := fs.Int("days", int(tlsx.CAValidity/(24*time.Hour)), "validity in `n` days")
|
||||
return func(_, stderr io.Writer) error {
|
||||
if *days < 1 || *days > maxDays {
|
||||
return usageError(fmt.Sprintf("-days must be 1 to %d", maxDays))
|
||||
return usageError(fmt.Sprintf("--days must be 1 to %d", maxDays))
|
||||
}
|
||||
cert, key, err := tlsx.NewCA(*name, time.Duration(*days)*24*time.Hour)
|
||||
if err != nil {
|
||||
@@ -51,21 +51,21 @@ func defineCA(fs *flag.FlagSet) func(stdout, stderr io.Writer) error {
|
||||
}
|
||||
|
||||
func defineCert(fs *flag.FlagSet) func(stdout, stderr io.Writer) error {
|
||||
caDir := fs.String("ca-dir", "", "`directory` holding ca.crt and ca.key (lw tls ca)")
|
||||
caDir := fs.String("ca-dir", "", "`dir`ectory holding ca.crt and ca.key (lw tls ca)")
|
||||
name := fs.String("name", "", "subject common `name`, the mtls identity, and the file names")
|
||||
hosts := fs.String("host", "", "DNS `names` and IP addresses, ',' between them (default with -server: -name)")
|
||||
hosts := fs.String("host", "", "DNS `names` and IP addresses, ',' between them (default with --server: --name)")
|
||||
server := fs.Bool("server", false, "for listeners (TLS server authentication)")
|
||||
client := fs.Bool("client", false, "for dialers presenting a certificate (TLS client authentication)")
|
||||
days := fs.Int("days", int(tlsx.LeafValidity/(24*time.Hour)), "validity in `days`, at most the CA's")
|
||||
out := fs.String("out", "", "`directory` to write in (default: -ca-dir)")
|
||||
days := fs.Int("days", int(tlsx.LeafValidity/(24*time.Hour)), "validity in `n` days, at most the CA's")
|
||||
out := fs.String("out", "", "`dir`ectory to write in (default: --ca-dir)")
|
||||
return func(_, stderr io.Writer) error {
|
||||
switch {
|
||||
case !*server && !*client:
|
||||
return usageError("-server, -client or both is required")
|
||||
return usageError("--server, --client or both is required")
|
||||
case *days < 1 || *days > maxDays:
|
||||
return usageError(fmt.Sprintf("-days must be 1 to %d", maxDays))
|
||||
return usageError(fmt.Sprintf("--days must be 1 to %d", maxDays))
|
||||
case strings.ContainsAny(*name, `/\`) || *name == "ca" || strings.HasPrefix(*name, "."):
|
||||
return usageError(fmt.Sprintf("-name %q cannot name the files", *name))
|
||||
return usageError(fmt.Sprintf("--name %q cannot name the files", *name))
|
||||
}
|
||||
issuer, signer, err := tlsx.LoadIssuer(filepath.Join(*caDir, "ca.crt"), filepath.Join(*caDir, "ca.key"))
|
||||
if err != nil {
|
||||
|
||||
+5
-4
@@ -293,14 +293,15 @@ target = "stdout" # stdout|stderr ("split" NOT supported)
|
||||
# [pipelines.plugin_sinks.config]
|
||||
# host = "0.0.0.0" # or "::" for IPv6 (IPv6 only)
|
||||
# port = 8081 # Required
|
||||
# stream_path = "/stream" # Must start with "/"
|
||||
# status_path = "/status" # Must differ from stream_path
|
||||
# stream_path = "/stream" # Exact; starts with "/", clean, none of { } % ? #
|
||||
# status_path = "/status" # Same rules; differs from stream_path
|
||||
# buffer_size = 1000 # Sink input queue
|
||||
# client_buffer_size = 256 # Per-client send queue
|
||||
# write_timeout_ms = 0 # Per-event deadline, 0 = none
|
||||
# max_connections = 0 # 0 = unlimited
|
||||
# login_page = false # GET /auth/login; needs auth.trusted_proxies
|
||||
# viewer_page = false # GET /auth/view; needs login_page
|
||||
# login_page = false # GET /auth/login; scram behind auth.trusted_proxies
|
||||
# viewer_page = false # GET /auth/view, needs login_page; scram + trusted_proxies only
|
||||
# (without auth or under mtls the viewer is always on)
|
||||
# [pipelines.plugin_sinks.config.tls]
|
||||
# enabled = true # example
|
||||
# cert_file = "/etc/logwisp/tls/server.crt"
|
||||
|
||||
+1
-1
@@ -1257,7 +1257,7 @@ next_steps() {
|
||||
note " status: curl $_c$_u/status"
|
||||
;;
|
||||
mtls) note " status: curl --cacert CA --cert CERT --key KEY $_u/status" ;;
|
||||
scram) note " status: curl with a bearer token from: lw auth token -url $_u -user USER -password-file FILE -ca-file CA (doc/cli.md)" ;;
|
||||
scram) note " status: curl with a bearer token from: lw auth token --url $_u --user USER --password-file FILE --ca-file CA (doc/cli.md)" ;;
|
||||
esac
|
||||
note " stream: $_u/stream"
|
||||
fi
|
||||
|
||||
+1
-1
@@ -31,7 +31,7 @@ streams, or downstream LogWisp nodes.
|
||||
manual, [`lw.1`](lw.1)
|
||||
- [Operations](operations.md): running, monitoring, tuning, troubleshooting
|
||||
- [To Do](todo.md): planned work in priority order: network access control
|
||||
and the PROXY protocol, packaging
|
||||
and the PROXY protocol, packaging, smaller follow-ups
|
||||
|
||||
A fully annotated configuration covering every option lives at
|
||||
[`config/logwisp.toml`](../config/logwisp.toml).
|
||||
|
||||
+100
-73
@@ -2,15 +2,15 @@
|
||||
|
||||
```
|
||||
lw [options] [< input]
|
||||
lw -c <file> [options]
|
||||
lw --check [options]
|
||||
lw -c|--config FILE [options]
|
||||
lw -p|--preset NAME[,KEY=VALUE...] [options]
|
||||
lw -t|--check [options]
|
||||
lw --dump [options]
|
||||
lw --preset NAME[,key=value...] [options]
|
||||
lw auth <command> [flags]
|
||||
lw tls <command> [flags]
|
||||
lw preset <name> [flags]
|
||||
lw auth COMMAND [flags]
|
||||
lw tls COMMAND [flags]
|
||||
lw preset NAME [flags]
|
||||
lw help | -h | --help
|
||||
lw --version
|
||||
lw -V | --version
|
||||
```
|
||||
|
||||
Without a configuration file or pipeline options, lw is a filter: stdin to
|
||||
@@ -25,6 +25,23 @@ manages SCRAM credentials and logs in to `scram` listeners
|
||||
|
||||
## Options
|
||||
|
||||
- lw's own options have a long form (`--config`), and the frequent ones a
|
||||
one-letter short form that stands for it in every command that has it:
|
||||
- `-c` `--config`, `-p` `--preset`, `-q` `--quiet`, `-t` `--check`,
|
||||
`-V` `--version`, `-h` `--help`
|
||||
- `-u` `--user` in `lw auth` and `lw preset edge`
|
||||
- A value follows as the next argument or after `=` (`-c FILE`, `-c=FILE`,
|
||||
`--config FILE`, `--config=FILE`); a value that starts with `-` follows `=`,
|
||||
and `--` ends the options.
|
||||
- Short options do not combine (`-q -t`, not `-qt`) or take an attached value
|
||||
(`-cFILE`); an unknown single-dash option is an error.
|
||||
- lw's multiword options join words with `-` (`--rate-limit`,
|
||||
`--password-file`); configuration keys, spec keys and preset keys keep
|
||||
TOML's `_` and `.` (`--status_reporter`, `--sink http,tls.cert_file=F`,
|
||||
`lw preset edge --password_file F`).
|
||||
- `lw auth`, `lw tls` and `lw preset` also take a long option after one dash
|
||||
(`-user NAME`), as earlier releases documented.
|
||||
|
||||
Any scalar configuration key is settable as a flag using its TOML path;
|
||||
pipelines have [their own flags](#pipelines):
|
||||
|
||||
@@ -36,27 +53,27 @@ pipelines have [their own flags](#pipelines):
|
||||
|
||||
### Common
|
||||
|
||||
- `-c <path>`, `--config <path>`, `--config=<path>`: the configuration file;
|
||||
unnamed, `~/.config/logwisp/logwisp.toml` if it exists, else
|
||||
`./logwisp.toml` ([Configuration](configuration.md#file-location))
|
||||
- `--check`: build every pipeline and plugin, bind nothing, report and exit;
|
||||
see [Usage Patterns](#usage-patterns)
|
||||
- `-c FILE`, `--config FILE`: the configuration file; unnamed,
|
||||
`~/.config/logwisp/logwisp.toml` if it exists, else `./logwisp.toml`
|
||||
([Configuration](configuration.md#file-location))
|
||||
- `-t`, `--check`: build every pipeline and plugin, bind nothing, report and
|
||||
exit; see [Usage Patterns](#usage-patterns)
|
||||
- `--dump`: print the effective configuration, flags, environment and presets
|
||||
resolved into it, as TOML that `-c` reads back, and exit
|
||||
- `--color [auto|always|never]`: level names in color on console sinks,
|
||||
default `auto` (a terminal, `NO_COLOR` unset, `TERM` not `dumb`); bare,
|
||||
`always`. A console sink's own `color` wins ([console sink](sinks.md#console))
|
||||
- `--quiet`: silence lw's own log and notices, default `false`; pipeline
|
||||
output still flows
|
||||
- `--status_reporter=<bool>`: periodic status logging, default `true` with a
|
||||
- `--color [WHEN]`, WHEN `auto`, `always` or `never`: level names in color on
|
||||
console sinks, default `auto` (a terminal, `NO_COLOR` unset, `TERM` not
|
||||
`dumb`); bare, `always`. A console sink's own `color` wins
|
||||
([console sink](sinks.md#console))
|
||||
- `-q`, `--quiet`: silence lw's own log and notices, default `false`;
|
||||
pipeline output still flows
|
||||
- `--status_reporter=BOOL`: periodic status logging, default `true` with a
|
||||
configuration file, `false` without
|
||||
- `--auto_reload=<bool>`: reload when the configuration file changes, default
|
||||
- `--auto_reload=BOOL`: reload when the configuration file changes, default
|
||||
`false`
|
||||
- `--version`: print the version and exit
|
||||
- `-V`, `--version`: print the version and exit
|
||||
- `-h`, `--help`, `help`: print usage and exit
|
||||
|
||||
The last file-selection flag wins. `-c=<path>` also works. A missing or empty
|
||||
path returns an error, and `--` ends option parsing.
|
||||
The last `-c` wins; a missing or empty path is an error.
|
||||
|
||||
### Logging
|
||||
|
||||
@@ -81,12 +98,11 @@ pipelines and the built-in default; every other key keeps its precedence.
|
||||
|
||||
- `--pipeline NAME` starts a pipeline; specs before the first one go to a
|
||||
pipeline named `cli`, or after the preset that starts them
|
||||
- `--preset SPEC` starts a pipeline with a [preset](#presets); later flags add
|
||||
sources, sinks and filters to it, or replace its format, rate limit and
|
||||
heartbeat
|
||||
- `-p SPEC`, `--preset SPEC` starts a pipeline with a [preset](#presets);
|
||||
later flags add sources, sinks and filters to it, or replace its format,
|
||||
rate limit and heartbeat
|
||||
- `--source SPEC`, `--sink SPEC` and `--filter SPEC` add a stage, repeatable
|
||||
- `--format SPEC`, `--rate-limit SPEC` and `--heartbeat SPEC`, once per pipeline
|
||||
- both `--flag SPEC` and `--flag=SPEC` work; `--` ends options
|
||||
- a pipeline without `--source` reads stdin (console source `stdin`), one
|
||||
without `--sink` writes stdout (console sink `stdout`)
|
||||
- stdin has one reader, so the whole configuration holds at most one console
|
||||
@@ -124,7 +140,8 @@ A misspelled plugin key fails when the plugin is built, before any listener
|
||||
opens: `failed to create sink http: ... unknown key "tls.enabeld"`.
|
||||
|
||||
```bash
|
||||
# tail a directory, serve it over SSE on every interface
|
||||
# tail a directory, serve it over SSE on every interface; a browser at
|
||||
# http://HOST:8080/ shows it
|
||||
lw --source 'file,directory=/var/log/app,pattern=*.log' \
|
||||
--sink http,host=0.0.0.0,port=8080
|
||||
|
||||
@@ -146,8 +163,10 @@ Pipelines over stdin and stdout are under [Usage Patterns](#usage-patterns).
|
||||
|
||||
`--preset NAME,key=value,...` takes the [SPEC](#pipelines) syntax. A list
|
||||
key (`hosts`, `proxy`) takes several values, repeated (`hosts=a,hosts=b`) or
|
||||
`,`-separated; any other key takes one. `lw preset NAME [-key value ...]`
|
||||
prints the pipeline a preset expands to, ready for a configuration file;
|
||||
`,`-separated; any other key takes one. `lw preset NAME [--key value ...]`
|
||||
prints the pipeline a preset expands to, ready for a configuration file; its
|
||||
flags are the keys, spelled as in the SPEC (`--password_file`), and `-u` is
|
||||
`--user`;
|
||||
`lw preset NAME -h` lists its keys and defaults, `lw preset` the presets. An
|
||||
unknown key fails and lists the valid ones. A `path` is a file, a directory
|
||||
(its files) or a glob; a preset that takes one reads stdin without it, and
|
||||
@@ -157,15 +176,17 @@ unknown key fails and lists the valid ones. A `path` is a file, a directory
|
||||
- `format` (`raw`)
|
||||
- `tail`: follow files to stdout, like `tail -F`
|
||||
- `path` (required), `from`, `format` (`raw`)
|
||||
- `serve`: a live HTTP stream (SSE), an [http sink](sinks.md#http)
|
||||
- `serve`: a live stream, an [http sink](sinks.md#http): a browser at
|
||||
`listen` gets the viewer, `/stream` is SSE
|
||||
- `path`, `from`, `format` (`json`), `listen` (`127.0.0.1:8080`)
|
||||
- `tls`: `off` (default), `self` (a self-signed certificate made at
|
||||
startup), `issuer` (one signed by `issuer_cert` and `issuer_key`), or
|
||||
`files` (`cert` and `key`); `hosts` adds names to a made certificate
|
||||
- `users`: a credentials file; readers then log in with SCRAM
|
||||
- `proxy`, `viewer=true`: the built-in login and viewer pages, which work
|
||||
only behind the TLS-terminating proxies `proxy` names (a browser cannot
|
||||
bind its login to the TLS channel); there is no standalone viewer
|
||||
- without `users` the viewer needs no login, over plain http too
|
||||
- `users`: a credentials file; readers then log in with SCRAM, and browsers
|
||||
need `proxy` and `viewer=true`: the login page and viewer behind the
|
||||
TLS-terminating proxies `proxy` names, since a browser cannot bind its
|
||||
login to the TLS channel
|
||||
- `edge`: forward to an aggregator over TLS (`tcp_chain`, or `http_chain`
|
||||
with `transport=http`)
|
||||
- `to` (required), `path`, `from`, `transport` (`tcp`), `node`
|
||||
@@ -183,6 +204,9 @@ unknown key fails and lists the valid ones. A `path` is a file, a directory
|
||||
# follow a directory, level names in color on a terminal
|
||||
lw --preset tail,path=/var/log/app
|
||||
|
||||
# serve it to a browser at http://127.0.0.1:8080/
|
||||
lw --preset serve,path=/var/log/app
|
||||
|
||||
# serve it over HTTPS, self-signed, to SCRAM users; keep the result as a file
|
||||
lw --preset serve,path=/var/log/app,tls=self,users=/etc/logwisp/users.toml --dump > serve.toml
|
||||
|
||||
@@ -281,7 +305,8 @@ service untouched; see [Configuration](configuration.md#hot-reload).
|
||||
|
||||
- `0`: the end of input, a clean shutdown (`SIGINT`, `SIGTERM`), `--version` /
|
||||
`--help`, `--dump`, or a valid `--check`
|
||||
- `1`: general error: a configuration load or validation failure (`--check`
|
||||
- `1`: general error: a command-line error (an unknown option, a missing
|
||||
`-c` path), a configuration load or validation failure (`--check`
|
||||
included), a logger init failure, a service bootstrap failure
|
||||
- `2`: an explicitly requested configuration file is not found
|
||||
- killed by `SIGPIPE` (shell status `141`): the reader of stdout went away, as
|
||||
@@ -395,13 +420,15 @@ them as a viewer. See
|
||||
[Password Authentication](security.md#password-authentication-scram).
|
||||
|
||||
```
|
||||
lw auth add-user -credentials FILE -user NAME [-password-file FILE] [-generate]
|
||||
lw auth remove-user -credentials FILE -user NAME
|
||||
lw auth token -url https://HOST:PORT[/PATH] -user NAME -password-file FILE
|
||||
[-unbound] [TLS flags]
|
||||
lw auth stream -addr HOST:PORT -user NAME -password-file FILE [TLS flags]
|
||||
lw auth add-user --credentials FILE -u NAME [--password-file FILE] [--generate]
|
||||
lw auth remove-user --credentials FILE -u NAME
|
||||
lw auth token --url https://HOST:PORT[/PATH] -u NAME --password-file FILE
|
||||
[--unbound] [TLS flags]
|
||||
lw auth stream --addr HOST:PORT -u NAME --password-file FILE [TLS flags]
|
||||
```
|
||||
|
||||
`-u` is short for `--user`.
|
||||
|
||||
- `add-user`: adds a user, or replaces its password
|
||||
- `remove-user`: removes a user; refuses the last one
|
||||
- `token`: logs in to an `http` sink or `http_chain` source and prints a
|
||||
@@ -409,18 +436,18 @@ lw auth stream -addr HOST:PORT -user NAME -password-file FILE [TLS flags]
|
||||
- `stream`: logs in to a `tcp` sink and copies its stream to stdout until
|
||||
interrupted
|
||||
|
||||
`lw auth <command> -h` lists a command's flags. The exit status is `0` on
|
||||
`lw auth COMMAND -h` lists a command's flags. The exit status is `0` on
|
||||
success, `1` on failure and `2` on a usage error.
|
||||
|
||||
**`add-user`** creates the credentials file, with a fresh `decoy_key`, when it
|
||||
does not exist or is empty (create it empty first to choose its owner). The
|
||||
password comes from `-password-file` when that file exists (at least 8 bytes;
|
||||
password comes from `--password-file` when that file exists (at least 8 bytes;
|
||||
one trailing line break is trimmed). Otherwise a random
|
||||
26-character password (130 bits) is generated and written to `-password-file`,
|
||||
26-character password (130 bits) is generated and written to `--password-file`,
|
||||
or printed once to stdout when there is none. Replacing an existing user's
|
||||
password needs an existing `-password-file` or `-generate`, so a mistyped path
|
||||
cannot replace a working password; `-generate` always generates, overwriting
|
||||
`-password-file`. New users take the file's existing Argon2 profile.
|
||||
password needs an existing `--password-file` or `--generate`, so a mistyped path
|
||||
cannot replace a working password; `--generate` always generates, overwriting
|
||||
`--password-file`. New users take the file's existing Argon2 profile.
|
||||
|
||||
Both file commands rewrite atomically (a temporary file in the same directory
|
||||
as the file, or a symlink's target, then a rename), create files `0600`, keep an
|
||||
@@ -431,46 +458,46 @@ Neither touches a running LogWisp: send `SIGHUP`, since `auto_reload` does not
|
||||
watch the credentials file.
|
||||
|
||||
**`token`** and **`stream`** build the same TLS and SCRAM client as a chain
|
||||
sink. TLS flags: `-ca-file` (default: system roots) or `-pin-sha256` (the
|
||||
`tls.pin_sha256` a self-signed listener logs), `-server-name` (default: the
|
||||
host), and `-cert-file` / `-key-file` for a listener with `tls.client_auth`.
|
||||
sink. TLS flags: `--ca-file` (default: system roots) or `--pin-sha256` (the
|
||||
`tls.pin_sha256` a self-signed listener logs), `--server-name` (default: the
|
||||
host), and `--cert-file` / `--key-file` for a listener with `tls.client_auth`.
|
||||
There is no flag to skip verification: an unverified server could relay the
|
||||
login. Redirects are not followed. `-unbound` logs in to an `http` sink behind
|
||||
login. Redirects are not followed. `--unbound` logs in to an `http` sink behind
|
||||
a TLS-terminating proxy (`auth.trusted_proxies`), still pinning the proxy's
|
||||
certificate across the two requests; only then may `-url` carry the path the
|
||||
certificate across the two requests; only then may `--url` carry the path the
|
||||
proxy mounts LogWisp at. `stream` exits `0` on `SIGINT` or `SIGTERM`, and `1`
|
||||
when the server ends the stream (a reload or shutdown).
|
||||
|
||||
Addresses, as for the plugins ([Networking](networking.md#address-family)):
|
||||
|
||||
- An IPv6 address goes in brackets: `-addr [::1]:PORT`,
|
||||
`-url https://[::1]:PORT`; in a URL a link-local zone is escaped,
|
||||
- An IPv6 address goes in brackets: `--addr [::1]:PORT`,
|
||||
`--url https://[::1]:PORT`; in a URL a link-local zone is escaped,
|
||||
`https://[fe80::1%25eth0]:PORT`.
|
||||
- The dial keeps to the address's family; a hostname resolves.
|
||||
- `-server-name` defaults to the host; an address, without brackets or zone,
|
||||
- `--server-name` defaults to the host; an address, without brackets or zone,
|
||||
must then be among the certificate's IP SANs.
|
||||
|
||||
```bash
|
||||
# listener host: a file the service user can read, users, then apply
|
||||
install -m 0640 -o root -g logwisp /dev/null /etc/logwisp/users.toml
|
||||
lw auth add-user -credentials /etc/logwisp/users.toml -user edge-01 \
|
||||
-password-file /etc/logwisp/edge-01.pass
|
||||
lw auth add-user -credentials /etc/logwisp/users.toml -user viewer \
|
||||
-password-file viewer.pass
|
||||
lw auth add-user --credentials /etc/logwisp/users.toml --user edge-01 \
|
||||
--password-file /etc/logwisp/edge-01.pass
|
||||
lw auth add-user --credentials /etc/logwisp/users.toml --user viewer \
|
||||
--password-file viewer.pass
|
||||
kill -HUP $(pidof lw)
|
||||
|
||||
# rotate: new password into the file; SIGHUP, deploy the file, SIGHUP the edge
|
||||
lw auth add-user -credentials /etc/logwisp/users.toml -user edge-01 \
|
||||
-password-file /etc/logwisp/edge-01.pass -generate
|
||||
lw auth add-user --credentials /etc/logwisp/users.toml --user edge-01 \
|
||||
--password-file /etc/logwisp/edge-01.pass --generate
|
||||
|
||||
# http sink status, with the token kept out of curl's argv
|
||||
curl --cacert ca.crt -H @<(printf 'Authorization: Bearer %s\n' \
|
||||
"$(lw auth token -url https://HOST:PORT -user viewer \
|
||||
-password-file viewer.pass -ca-file ca.crt)") https://HOST:PORT/status
|
||||
"$(lw auth token --url https://HOST:PORT --user viewer \
|
||||
--password-file viewer.pass --ca-file ca.crt)") https://HOST:PORT/status
|
||||
|
||||
# follow a tcp sink
|
||||
lw auth stream -addr HOST:PORT -user viewer -password-file viewer.pass \
|
||||
-ca-file ca.crt
|
||||
lw auth stream --addr HOST:PORT --user viewer --password-file viewer.pass \
|
||||
--ca-file ca.crt
|
||||
```
|
||||
|
||||
## `lw tls`
|
||||
@@ -479,25 +506,25 @@ Makes ECDSA P-256 certificates for `tls` blocks. It never replaces a file
|
||||
(remove one to replace it) and writes keys `0600`, certificates `0644`.
|
||||
|
||||
```
|
||||
lw tls ca -dir DIR [-name NAME] [-days 3650]
|
||||
lw tls cert -ca-dir DIR -name NAME [-host NAME,...] [-server] [-client]
|
||||
[-days 397] [-out DIR]
|
||||
lw tls ca --dir DIR [--name NAME] [--days 3650]
|
||||
lw tls cert --ca-dir DIR --name NAME [--host NAME,...] [--server] [--client]
|
||||
[--days 397] [--out DIR]
|
||||
```
|
||||
|
||||
- `ca`: `DIR/ca.crt` and `DIR/ca.key`, a CA that signs leaves only (path
|
||||
length 0). `ca.crt` is what dialers set as `tls.ca_file` and listeners as
|
||||
`tls.client_ca_file`; with `ca.key` it is a listener's
|
||||
`tls.issuer_cert_file` and `tls.issuer_key_file`.
|
||||
- `cert`: `NAME.crt` and `NAME.key`, in `-out` (default: `-ca-dir`).
|
||||
- `-server` for a listener: `-host` lists the DNS names and IP addresses it
|
||||
carries, default `NAME`; `-client` for a dialer presenting a certificate;
|
||||
- `cert`: `NAME.crt` and `NAME.key`, in `--out` (default: `--ca-dir`).
|
||||
- `--server` for a listener: `--host` lists the DNS names and IP addresses it
|
||||
carries, default `NAME`; `--client` for a dialer presenting a certificate;
|
||||
both may be given.
|
||||
- `NAME` is the subject CN, the identity `auth.type = "mtls"` matches.
|
||||
- Valid 397 days (the longest browsers accept), never past the CA. It prints
|
||||
the certificate's `pin_sha256`.
|
||||
|
||||
```bash
|
||||
lw tls ca -dir /etc/logwisp/pki
|
||||
lw tls cert -ca-dir /etc/logwisp/pki -name agg.example.org -server
|
||||
lw tls cert -ca-dir /etc/logwisp/pki -name edge-01 -client
|
||||
lw tls ca --dir /etc/logwisp/pki
|
||||
lw tls cert --ca-dir /etc/logwisp/pki --name agg.example.org --server
|
||||
lw tls cert --ca-dir /etc/logwisp/pki --name edge-01 --client
|
||||
```
|
||||
@@ -235,19 +235,22 @@ lw --quiet # bare flag means true
|
||||
```
|
||||
|
||||
A bare flag is accepted only for a boolean key: `--logging.file.directory
|
||||
--quiet` fails with `--logging.file.directory needs a value`. Unrecognized flags,
|
||||
positional words and single-dash arguments are reported on stderr before the
|
||||
logger exists and are then ignored:
|
||||
--quiet` fails with `--logging.file.directory needs a value`. Unrecognized `--`
|
||||
flags and positional words are reported on stderr before the logger exists and
|
||||
are then ignored:
|
||||
|
||||
```
|
||||
Warning: unrecognized flags ignored: [pipelines.0.name -q]
|
||||
Warning: unrecognized flags ignored: [nosuch.key]
|
||||
```
|
||||
|
||||
Single-dash options are lw's own ([CLI](cli.md#options)); an unknown one is
|
||||
an error.
|
||||
|
||||
An empty `LOGWISP_<PATH>` variable counts as unset, as the pipeline variables
|
||||
do.
|
||||
|
||||
Array-indexed paths such as `--pipelines.0.name=x` are unrecognized; whole
|
||||
pipelines have [their own flags](cli.md#pipelines).
|
||||
An array-indexed path such as `--pipelines.0.name=x` is an error (exit 1);
|
||||
whole pipelines have [their own flags](cli.md#pipelines).
|
||||
|
||||
## Validation
|
||||
|
||||
|
||||
+2
-2
@@ -147,8 +147,8 @@ already, and the `logs` jail has an address the others reach (`AGG_ADDR`).
|
||||
|
||||
4. Check: `jexec logs service logwisp status`, the files in the `logs` jail's
|
||||
`/var/log/logwisp`, and `https://AGG_ADDR:8080/status` with a token from
|
||||
`lw auth token -url https://AGG_ADDR:8080 -user ops -password-file
|
||||
~/logwisp-secrets/ops.pass -ca-file ca.crt` ([CLI](cli.md#lw-auth)).
|
||||
`lw auth token --url https://AGG_ADDR:8080 --user ops --password-file
|
||||
~/logwisp-secrets/ops.pass --ca-file ca.crt` ([CLI](cli.md#lw-auth)).
|
||||
|
||||
Variants:
|
||||
|
||||
|
||||
+3
-3
@@ -116,8 +116,8 @@ both:
|
||||
|
||||
```bash
|
||||
sudo install -m 0600 -o logwisp -g logwisp /dev/null /etc/logwisp/users.toml
|
||||
sudo lw auth add-user -credentials /etc/logwisp/users.toml -user <name> \
|
||||
-password-file <file>
|
||||
sudo lw auth add-user --credentials /etc/logwisp/users.toml --user <name> \
|
||||
--password-file <file>
|
||||
sudo systemctl reload logwisp
|
||||
```
|
||||
|
||||
@@ -230,7 +230,7 @@ docker run -d --name logwisp \
|
||||
```bash
|
||||
docker run --rm --read-only --cap-drop ALL --network none \
|
||||
-v /srv/logwisp/secrets:/work -v /srv/logwisp/passwords:/pw logwisp:<tag> \
|
||||
auth add-user -credentials /work/users.toml -user <name> -password-file /pw/<name>.pass
|
||||
auth add-user --credentials /work/users.toml --user <name> --password-file /pw/<name>.pass
|
||||
```
|
||||
|
||||
## Packaging Status
|
||||
|
||||
@@ -6,12 +6,12 @@
|
||||
.Nd LogWisp log transport
|
||||
.Sh SYNOPSIS
|
||||
.Nm
|
||||
.Op Fl \-check | Fl \-dump
|
||||
.Op Fl t | Fl \-dump
|
||||
.Op Fl q
|
||||
.Op Fl c Ar file
|
||||
.Op Fl \-quiet
|
||||
.Op Fl \-color Op Ar when
|
||||
.Op Fl \- Ns Ar key Ns = Ns Ar value ...
|
||||
.Op Fl \-preset Ar spec
|
||||
.Op Fl p Ar spec
|
||||
.Op Fl \-pipeline Ar name
|
||||
.Op Fl \-source Ar spec ...
|
||||
.Op Fl \-sink Ar spec ...
|
||||
@@ -24,7 +24,7 @@
|
||||
.Ar command
|
||||
.Op Ar flags
|
||||
.Nm
|
||||
.Fl \-version
|
||||
.Fl V | Fl \-version
|
||||
.Nm
|
||||
.Cm help | Fl h | Fl \-help
|
||||
.Sh DESCRIPTION
|
||||
@@ -107,6 +107,27 @@ service under a supervisor such as
|
||||
or
|
||||
.Xr daemon 8 .
|
||||
.Pp
|
||||
Every option has a long form, and the frequent ones a one-letter short form
|
||||
that stands for it in every command that has it.
|
||||
A value follows as the next argument or after
|
||||
.Ql = ;
|
||||
a value that starts with
|
||||
.Ql -
|
||||
follows
|
||||
.Ql = .
|
||||
Short options do not combine, and an unknown one is an error.
|
||||
The options of
|
||||
.Nm
|
||||
join words with
|
||||
.Ql - ;
|
||||
a configuration or preset key keeps its TOML spelling.
|
||||
The
|
||||
.Cm auth ,
|
||||
.Cm tls
|
||||
and
|
||||
.Cm preset
|
||||
commands also take a long option after a single dash.
|
||||
.Pp
|
||||
The options are as follows:
|
||||
.Bl -tag -width Ds
|
||||
.It Fl c Ar file , Fl \-config Ar file , Fl \-config= Ns Ar file
|
||||
@@ -136,7 +157,7 @@ program parses keeps
|
||||
.Cm auto
|
||||
or
|
||||
.Cm never .
|
||||
.It Fl \-quiet
|
||||
.It Fl q , Fl \-quiet
|
||||
Turn off the log of
|
||||
.Nm
|
||||
and the terminal reminder; console sinks still write.
|
||||
@@ -170,7 +191,7 @@ the default is
|
||||
with a configuration file and
|
||||
.Cm warn
|
||||
without.
|
||||
.It Fl \-check
|
||||
.It Fl t , Fl \-check
|
||||
Build every pipeline and plugin as a start would, loading options, TLS files
|
||||
and credentials files and printing startup warnings, without binding a port
|
||||
or opening, reading or creating a log file; print
|
||||
@@ -183,7 +204,7 @@ Print the effective configuration, with the options, environment and
|
||||
presets resolved into it, as a file
|
||||
.Fl c
|
||||
reads back, and exit.
|
||||
.It Fl \-version
|
||||
.It Fl V , Fl \-version
|
||||
Print the version, commit and build time, and exit.
|
||||
.It Fl h , Fl \-help , Cm help
|
||||
Print usage and exit.
|
||||
@@ -210,7 +231,7 @@ Specifications before the first
|
||||
belong to a pipeline named
|
||||
.Dq cli ,
|
||||
or after the preset that starts them.
|
||||
.It Fl \-preset Ar spec
|
||||
.It Fl p Ar spec , Fl \-preset Ar spec
|
||||
Start the pipeline with a preset, described in
|
||||
.Sx Presets ;
|
||||
later options add sources, sinks and filters to it, or replace its format.
|
||||
@@ -260,10 +281,14 @@ A preset builds a common pipeline from a few keys:
|
||||
.Fl \-preset Ar name Ns Op , Ns Ar key Ns = Ns Ar value ... ,
|
||||
in the syntax of the other specifications;
|
||||
.Nm
|
||||
.Cm preset Ar name Op Fl Ns Ar key Ar value ...
|
||||
.Cm preset Ar name Op Fl \- Ns Ar key Ar value ...
|
||||
prints the pipeline it expands to, and
|
||||
.Fl h
|
||||
there lists its keys and defaults.
|
||||
there lists its keys and defaults; its flags are the keys, spelled as in the
|
||||
specification, and
|
||||
.Fl u
|
||||
is
|
||||
.Fl \-user .
|
||||
A
|
||||
.Ar path
|
||||
is a file, a directory, whose files are followed, or a glob; without one, a
|
||||
@@ -292,10 +317,12 @@ or
|
||||
.Pq Cm cert No and Cm key ;
|
||||
.Cm users
|
||||
names a credentials file, so readers log in with SCRAM.
|
||||
The built-in login and viewer pages,
|
||||
.Cm viewer=true ,
|
||||
work only behind a TLS-terminating proxy named by
|
||||
A browser at the root gets a live viewer; with
|
||||
.Cm users ,
|
||||
only behind a TLS-terminating proxy named by
|
||||
.Cm proxy ,
|
||||
with
|
||||
.Cm viewer=true ,
|
||||
since a browser cannot bind its login to the TLS channel.
|
||||
.It Cm edge to Oo Cm path , transport , ca | pin , user , password_file , cert , key , node Oc
|
||||
Forward to an aggregator at
|
||||
@@ -332,19 +359,19 @@ vouches for, and write to standard output or to files in
|
||||
makes ECDSA P-256 certificates; it never replaces a file, and writes keys
|
||||
with mode 0600.
|
||||
.Bl -tag -width Ds
|
||||
.It Cm ca Fl dir Ar dir Oo Fl name Ar name Oc Op Fl days Ar n
|
||||
.It Cm ca Fl \-dir Ar dir Oo Fl \-name Ar name Oc Op Fl \-days Ar n
|
||||
Create a certificate authority,
|
||||
.Pa dir/ca.crt
|
||||
and
|
||||
.Pa dir/ca.key ,
|
||||
valid 3650 days.
|
||||
.It Cm cert Fl ca-dir Ar dir Fl name Ar name Oo Fl host Ar names Oc Oo Fl server Oc Oo Fl client Oc Oo Fl days Ar n Oc Op Fl out Ar dir
|
||||
.It Cm cert Fl \-ca\-dir Ar dir Fl \-name Ar name Oo Fl \-host Ar names Oc Oo Fl \-server Oc Oo Fl \-client Oc Oo Fl \-days Ar n Oc Op Fl \-out Ar dir
|
||||
Issue
|
||||
.Pa name.crt
|
||||
and
|
||||
.Pa name.key
|
||||
from that authority, valid 397 days and never past it.
|
||||
.Fl host
|
||||
.Fl \-host
|
||||
lists the DNS names and addresses a server certificate carries
|
||||
.Pq default: Ar name ;
|
||||
.Ar name
|
||||
@@ -378,33 +405,36 @@ restarts, so a fleet uses an issuer instead.
|
||||
.Nm
|
||||
.Cm auth
|
||||
manages SCRAM credentials files and logs in to SCRAM listeners.
|
||||
.Fl u
|
||||
is short for
|
||||
.Fl \-user .
|
||||
Changes to a credentials file apply when the server receives
|
||||
.Dv SIGHUP .
|
||||
.Bl -tag -width Ds
|
||||
.It Cm add-user Fl credentials Ar file Fl user Ar name Oo Fl password-file Ar file Oc Op Fl generate
|
||||
.It Cm add-user Fl \-credentials Ar file Fl u Ar name Oo Fl \-password\-file Ar file Oc Op Fl \-generate
|
||||
Add a user, or replace its password.
|
||||
The password is read from the
|
||||
.Fl password-file
|
||||
.Fl \-password\-file
|
||||
when it exists; otherwise one is generated and written there, or printed
|
||||
once to standard output.
|
||||
.Fl generate
|
||||
.Fl \-generate
|
||||
replaces an existing password without reading one.
|
||||
A missing or empty credentials file is created with mode 0600; an existing
|
||||
one keeps its owner and mode.
|
||||
.It Cm remove-user Fl credentials Ar file Fl user Ar name
|
||||
.It Cm remove-user Fl \-credentials Ar file Fl u Ar name
|
||||
Remove a user.
|
||||
The last user cannot be removed.
|
||||
.It Cm token Fl url Ar https://host:port Fl user Ar name Fl password-file Ar file Oo Ar tls-flags Oc Op Fl unbound
|
||||
.It Cm token Fl \-url Ar https://host:port Fl u Ar name Fl \-password\-file Ar file Oo Ar tls-flags Oc Op Fl \-unbound
|
||||
Log in to an
|
||||
.Cm http
|
||||
sink or
|
||||
.Cm http_chain
|
||||
source and print a bearer token.
|
||||
.Fl unbound
|
||||
.Fl \-unbound
|
||||
logs in without channel binding, to an
|
||||
.Cm http
|
||||
sink behind a TLS-terminating proxy; the URL may then carry the proxy's path.
|
||||
.It Cm stream Fl addr Ar host:port Fl user Ar name Fl password-file Ar file Op Ar tls-flags
|
||||
.It Cm stream Fl \-addr Ar host:port Fl u Ar name Fl \-password\-file Ar file Op Ar tls-flags
|
||||
Log in to a
|
||||
.Cm tcp
|
||||
sink and copy its stream to standard output until interrupted.
|
||||
@@ -413,15 +443,15 @@ sink and copy its stream to standard output until interrupted.
|
||||
The
|
||||
.Ar tls-flags
|
||||
are
|
||||
.Fl ca-file Ar file
|
||||
.Fl \-ca\-file Ar file
|
||||
.Pq default: the system roots
|
||||
or
|
||||
.Fl pin-sha256 Ar pin ,
|
||||
.Fl server-name Ar name ,
|
||||
.Fl \-pin\-sha256 Ar pin ,
|
||||
.Fl \-server\-name Ar name ,
|
||||
and
|
||||
.Fl cert-file Ar file
|
||||
.Fl \-cert\-file Ar file
|
||||
with
|
||||
.Fl key-file Ar file
|
||||
.Fl \-key\-file Ar file
|
||||
for a listener that requires client certificates.
|
||||
There is no flag to skip server verification.
|
||||
An IPv6
|
||||
@@ -507,8 +537,8 @@ The service's working directory on Linux and on
|
||||
.Nm
|
||||
exits 0 at the end of input, after a clean shutdown,
|
||||
.Fl \-version
|
||||
or help; 1 on a configuration, logger or startup error; and 2 when a
|
||||
configuration file named by
|
||||
or help; 1 on a command-line, configuration, logger or startup error; and 2
|
||||
when a configuration file named by
|
||||
.Fl c
|
||||
or
|
||||
.Ev LOGWISP_CONFIG_*
|
||||
@@ -591,10 +621,10 @@ the authority, its certificate and a user for the edge; copy
|
||||
.Pa ca.crt
|
||||
and the password file to the edge; then run both:
|
||||
.Bd -literal -offset indent
|
||||
# lw tls ca \-dir /etc/logwisp/pki
|
||||
# lw tls cert \-ca-dir /etc/logwisp/pki \-name agg.example.org \-server
|
||||
# lw auth add-user \-credentials /etc/logwisp/users.toml \e
|
||||
\-user edge-01 \-password-file /root/edge-01.pass
|
||||
# lw tls ca \-\-dir /etc/logwisp/pki
|
||||
# lw tls cert \-\-ca-dir /etc/logwisp/pki \-\-name agg.example.org \-\-server
|
||||
# lw auth add-user \-\-credentials /etc/logwisp/users.toml \e
|
||||
\-\-user edge-01 \-\-password-file /root/edge-01.pass
|
||||
agg# lw \-\-preset aggregator,tls=files,users=/etc/logwisp/users.toml,\e
|
||||
cert=/etc/logwisp/pki/agg.example.org.crt,\e
|
||||
key=/etc/logwisp/pki/agg.example.org.key,out=/var/log/edges
|
||||
@@ -619,8 +649,8 @@ generate the password into a file, then reload the server:
|
||||
.Bd -literal -offset indent
|
||||
# install \-m 0600 \-o logwisp \-g logwisp \e
|
||||
/dev/null /etc/logwisp/users.toml
|
||||
# lw auth add-user \-credentials /etc/logwisp/users.toml \e
|
||||
\-user alice \-password-file /root/alice.pass
|
||||
# lw auth add-user \-\-credentials /etc/logwisp/users.toml \e
|
||||
\-\-user alice \-\-password-file /root/alice.pass
|
||||
# systemctl reload logwisp
|
||||
.Ed
|
||||
.Sh SEE ALSO
|
||||
|
||||
+4
-3
@@ -25,7 +25,7 @@ Every listener and dialer keeps strictly to the family of its `host`:
|
||||
- `host` takes the bare address: `host = "::1"`. A bracketed host, one
|
||||
with a port, or an IPv4-mapped one (`::ffff:10.0.0.1`) fails at load.
|
||||
- Addresses and URLs bracket it: logs print `[::1]:8443`, and `lw auth`
|
||||
takes `-addr [::1]:8443` and `-url https://[::1]:8443`. Older `curl`
|
||||
takes `--addr [::1]:8443` and `--url https://[::1]:8443`. Older `curl`
|
||||
releases read the brackets as a glob; `-g` stops that and is harmless.
|
||||
- A link-local address carries its zone, `fe80::1%eth0`, escaped in URLs as
|
||||
`https://[fe80::1%25eth0]:8443`.
|
||||
@@ -46,7 +46,8 @@ Every listener and dialer keeps strictly to the family of its `host`:
|
||||
Listeners:
|
||||
|
||||
- `tcp` sink: raw stream; broadcasts formatted payloads to clients.
|
||||
- `http` sink: HTTP SSE; a browser-friendly live stream plus status JSON.
|
||||
- `http` sink: HTTP SSE; a browser viewer at `/`, the live stream and status
|
||||
JSON.
|
||||
- `tcp_chain` source: chain v1; ingests a persistent NDJSON stream.
|
||||
- `http_chain` source: chain v1; ingests NDJSON batches over POST.
|
||||
|
||||
@@ -249,7 +250,7 @@ headers, and entry encoding.
|
||||
between the peers terminates TLS. Pass TLS through to LogWisp; the login
|
||||
cannot work otherwise, except on an `http` sink in proxy mode
|
||||
(`auth.trusted_proxies`), where `the client bound its proof to the proxy's
|
||||
certificate` means a client that needs `lw auth token -unbound`, and
|
||||
certificate` means a client that needs `lw auth token --unbound`, and
|
||||
`the client sent an unbound proof` the reverse. On an `http_chain` sink,
|
||||
`server certificate differs from the one the SCRAM login was bound to` means
|
||||
the certificate changed after the login: a rotation (the retry binds anew),
|
||||
|
||||
+18
-16
@@ -204,16 +204,16 @@ closed.
|
||||
## CLI
|
||||
|
||||
```
|
||||
lw auth add-user -credentials F -user U [-password-file P] [-generate]
|
||||
lw auth remove-user -credentials F -user U
|
||||
lw auth token -url https://host:port[/path] -user U -password-file P [-unbound] [TLS flags]
|
||||
lw auth stream -addr host:port -user U -password-file P [TLS flags]
|
||||
lw auth add-user --credentials F --user U [--password-file P] [--generate]
|
||||
lw auth remove-user --credentials F --user U
|
||||
lw auth token --url https://host:port[/path] --user U --password-file P [--unbound] [TLS flags]
|
||||
lw auth stream --addr host:port --user U --password-file P [TLS flags]
|
||||
```
|
||||
|
||||
- `add-user` takes the password from `-password-file` when it exists (at least
|
||||
- `add-user` takes the password from `--password-file` when it exists (at least
|
||||
8 bytes); a new user without one gets a generated 130-bit password, written to
|
||||
that file (0600) or printed once. Rotating an existing user needs the file or
|
||||
`-generate`, so a mistyped path cannot replace a password. The file's KDF
|
||||
`--generate`, so a mistyped path cannot replace a password. The file's KDF
|
||||
profile and decoy key are reused, the result is validated with the daemon's
|
||||
own parser, and the rewrite (temp file beside the file or a symlink's target,
|
||||
rename) keeps the existing mode and owner, or fails.
|
||||
@@ -222,10 +222,10 @@ lw auth stream -addr host:port -user U -password-file P [TLS flags]
|
||||
credentials file.
|
||||
- `token` prints a bearer token for curl (`-H @<(...)` keeps it out of argv);
|
||||
`stream` authenticates to a `tcp` sink and copies the stream to stdout until
|
||||
interrupted. `-url` takes a path only with `-unbound`, and redirects are not
|
||||
interrupted. `--url` takes a path only with `--unbound`, and redirects are not
|
||||
followed.
|
||||
- TLS flags: `-ca-file`, `-server-name`, `-cert-file`, `-key-file`. There is no
|
||||
insecure flag.
|
||||
- TLS flags: `--ca-file` or `--pin-sha256`, `--server-name`, `--cert-file`,
|
||||
`--key-file`. There is no insecure flag.
|
||||
- Exit status: 0 success, 1 failure, 2 usage error.
|
||||
|
||||
## Rollout and Rotation
|
||||
@@ -233,7 +233,7 @@ lw auth stream -addr host:port -user U -password-file P [TLS flags]
|
||||
- Turning SCRAM on is per listener and cuts off unconfigured dialers. To migrate
|
||||
without a gap, upgrade binaries, add a second SCRAM listener, move dialers,
|
||||
then remove the old listener.
|
||||
- Password rotation: `add-user -generate`, `SIGHUP` the listener, deploy the new
|
||||
- Password rotation: `add-user --generate`, `SIGHUP` the listener, deploy the new
|
||||
password file, `SIGHUP` the dialer. The dialer retries under backoff in
|
||||
between.
|
||||
- Revocation: `remove-user`, `SIGHUP`. All tokens die with the reload.
|
||||
@@ -261,7 +261,7 @@ sink into proxy mode; it is refused on every other plugin and with `identity`.
|
||||
proxy-to-LogWisp hop could relay a login, hence the address restriction and a
|
||||
startup warning for a plaintext hop to a proxy off this host. `tls` may stay
|
||||
off. Outside proxy mode nothing changes: proofs stay bound, and a bound proof
|
||||
in proxy mode fails with a hint naming `-unbound`.
|
||||
in proxy mode fails with a hint naming `--unbound`.
|
||||
|
||||
**Session.** The proof step may ask for a cookie (`"session": "cookie"`, proxy
|
||||
mode only). The answer sets `logwisp_session` (`HttpOnly; Secure;
|
||||
@@ -289,8 +289,10 @@ token in a page variable only: it lasts no longer than the page or its lifetime.
|
||||
pages is relative, so a proxy prefix works unchanged.
|
||||
|
||||
- `POST /auth`: SCRAM hello, proof or logout; unbound in proxy mode.
|
||||
- `GET /auth/scram.js`: the client library, always served in proxy mode.
|
||||
- `GET /auth/login`, `login.js`, `style.css`: the login page (`login_page`).
|
||||
- `GET /auth/scram.js`, `style.css`: the client library and the pages' style,
|
||||
always served in proxy mode, and with the viewer.
|
||||
- `GET /`: `303` to `auth/view`, else `auth/login`, when either is served.
|
||||
- `GET /auth/login`, `login.js`: the login page (`login_page`).
|
||||
- `GET /auth/view`, `view.js`: a minimal live viewer (`viewer_page`, needs
|
||||
`login_page`).
|
||||
|
||||
@@ -316,8 +318,8 @@ refuses redirects, and reports success only after verifying the server's
|
||||
signature. A 64 MiB login takes about 2 s on a desktop. The wire protocol above
|
||||
is the contract: a site may implement its own client.
|
||||
|
||||
**CLI.** `lw auth token -unbound` logs in through the proxy, still pinning
|
||||
its certificate across the two requests; only then may `-url` carry the mount
|
||||
**CLI.** `lw auth token --unbound` logs in through the proxy, still pinning
|
||||
its certificate across the two requests; only then may `--url` carry the mount
|
||||
path.
|
||||
|
||||
## Verification
|
||||
@@ -367,7 +369,7 @@ by sign-out, and no CSP violations; a cookie the browser drops leading to the
|
||||
viewer's own form, not a loop, its token kept out of storage and the URL; a
|
||||
profile blocking every cookie signing in on the viewer, its proofs asking for
|
||||
no cookie, streaming with a bearer, sign-out revoking the token, and a reload's
|
||||
`401` asking again; `token -unbound` and curl through the proxy; direct peers
|
||||
`401` asking again; `token --unbound` and curl through the proxy; direct peers
|
||||
and plaintext-forwarded requests `403`. The existing scripts keep passing.
|
||||
|
||||
## Not Implemented
|
||||
|
||||
+15
-10
@@ -360,9 +360,9 @@ process and, at startup and every reload, a certificate for it valid 397 days
|
||||
to that certificate.
|
||||
|
||||
```bash
|
||||
lw tls ca -dir /etc/logwisp/pki # ca.crt, ca.key (0600)
|
||||
lw tls cert -ca-dir /etc/logwisp/pki -name agg.example.org -server
|
||||
lw tls cert -ca-dir /etc/logwisp/pki -name edge-01 -client
|
||||
lw tls ca --dir /etc/logwisp/pki # ca.crt, ca.key (0600)
|
||||
lw tls cert --ca-dir /etc/logwisp/pki --name agg.example.org --server
|
||||
lw tls cert --ca-dir /etc/logwisp/pki --name edge-01 --client
|
||||
```
|
||||
|
||||
## Enabling mTLS
|
||||
@@ -594,8 +594,8 @@ different users.
|
||||
- **Rollout.** Turning `scram` on cuts off every dialer of that listener that
|
||||
has no credentials. Upgrade every binary first, then add a second listener
|
||||
with `scram` on another port, move the dialers to it, and remove the old one.
|
||||
- **Password rotation.** `lw auth add-user -generate` with the user's
|
||||
`-password-file` replaces verifier and password together. `SIGHUP` the
|
||||
- **Password rotation.** `lw auth add-user --generate` with the user's
|
||||
`--password-file` replaces verifier and password together. `SIGHUP` the
|
||||
listener, deploy the password file, `SIGHUP` the dialer. Logins fail in
|
||||
between and the dialer retries under backoff, holding its current entry or
|
||||
batch while its input queue fills. To avoid the gap, add a second user, move
|
||||
@@ -664,7 +664,7 @@ location /logs/ {
|
||||
`Path`, so it scopes itself to the mount (`/logs` above). Stream and status
|
||||
accept it or a bearer token, so `new EventSource("/logs/stream")` works on any
|
||||
page of the site, beside the site's own Basic auth too, and `lw auth token
|
||||
-unbound` keeps working. `POST /auth` with `{"logout": true}` clears the
|
||||
--unbound` keeps working. `POST /auth` with `{"logout": true}` clears the
|
||||
cookie and revokes the token until it expires. `/auth` takes only
|
||||
`application/json`, which a cross-origin page cannot send without a preflight.
|
||||
- **Private windows.** Private or incognito windows keep cookies in memory,
|
||||
@@ -698,9 +698,10 @@ location /logs/ {
|
||||
- `cookiesUsable()` tells which applies: false when the session cookie would
|
||||
not stick. `loginUnavailable()`, asked first, names why neither can run
|
||||
(no secure context), or is empty.
|
||||
- **Pages.** Under `/auth/` the sink serves `scram.js` always, and with
|
||||
`login_page` / `viewer_page` the login page and a minimal live viewer with
|
||||
their script and style, under `default-src 'none'; script-src 'self';
|
||||
- **Pages.** Under `/auth/` the sink serves `scram.js` and `style.css` always,
|
||||
and with `login_page` / `viewer_page` the login page and a minimal live
|
||||
viewer with their scripts; `GET /` leads to the viewer, else the login page.
|
||||
All are served under `default-src 'none'; script-src 'self';
|
||||
connect-src 'self'; style-src 'self'; form-action 'self'; frame-ancestors
|
||||
'none'; base-uri 'none'`.
|
||||
|
||||
@@ -771,7 +772,7 @@ sets `proxy_protocol` per stream `server`, so route LogWisp SNIs without it:
|
||||
`listen`s with `proxy_protocol` and proxy_passes to LogWisp without it; that
|
||||
hop consumes the header.
|
||||
- Either way dialers must send the name: a `host` that is one, or
|
||||
`tls.server_name` (`-server-name` for `lw auth`); an IP literal sends no SNI.
|
||||
`tls.server_name` (`--server-name` for `lw auth`); an IP literal sends no SNI.
|
||||
- With HAProxy, leave `send-proxy` off the LogWisp backends.
|
||||
|
||||
```nginx
|
||||
@@ -864,6 +865,10 @@ What each exposes when `auth.type = "none"`:
|
||||
header is omitted once an auth policy is set).
|
||||
- `http` sink `status_path`: host, port, TLS flag, uptime, client counts,
|
||||
throughput counters.
|
||||
- `http` sink `GET /` (`303` to `/auth/view`) and the viewer's files: static
|
||||
pages that read the two endpoints above under the CSP and expose nothing
|
||||
beyond them. Under `mtls` they are served before the allow list, while the
|
||||
stream and status stay gated.
|
||||
- `tcp` sink: the full log stream to any client that connects.
|
||||
- `tcp_chain` and `http_chain` sources: ingest from any peer that can connect
|
||||
(with `client_auth`, any the CA vouches for), under any node label it
|
||||
|
||||
+24
-8
@@ -128,7 +128,7 @@ No options. The input queue is fixed at 1000.
|
||||
|
||||
## http
|
||||
|
||||
Server-Sent Events stream plus a JSON status endpoint.
|
||||
Server-Sent Events stream, a JSON status endpoint, and a browser viewer.
|
||||
|
||||
```toml
|
||||
[[pipelines.plugin_sinks]]
|
||||
@@ -161,17 +161,20 @@ Options, each as type and default:
|
||||
- `host` (string, `0.0.0.0`): bind address, IPv4 or IPv6 (`::`); the listener
|
||||
keeps to its family ([Networking](networking.md#address-family)).
|
||||
- `port` (int, **required**): listen port.
|
||||
- `stream_path` (string, `/stream`): SSE endpoint; must start with `/`.
|
||||
- `status_path` (string, `/status`): status endpoint; must start with `/` and
|
||||
differ from `stream_path`.
|
||||
- `stream_path` (string, `/stream`): SSE endpoint, matched exactly (`/logs/`
|
||||
does not answer `/logs/x`); it starts with `/`, is clean (no `//`, `.` or
|
||||
`..` segment) and holds none of `{`, `}`, `%`, `?`, `#`.
|
||||
- `status_path` (string, `/status`): status endpoint, under the same rules;
|
||||
differs from `stream_path`.
|
||||
- `buffer_size` (int, `1000`): sink input queue depth.
|
||||
- `client_buffer_size` (int, `256`): per-client send queue depth.
|
||||
- `write_timeout_ms` (int, `0`): per-event write deadline; `0` = none.
|
||||
- `max_connections` (int, `0`): concurrent stream cap; `0` = unlimited.
|
||||
- `login_page` (bool, `false`): serve the browser login page at `/auth/login`;
|
||||
needs `auth.trusted_proxies`.
|
||||
- `viewer_page` (bool, `false`): serve a minimal live viewer at `/auth/view`;
|
||||
needs `login_page`.
|
||||
- `login_page` (bool, `false`): `scram` in proxy mode only (an error
|
||||
elsewhere): the browser login page at `/auth/login`.
|
||||
- `viewer_page` (bool, `false`): `scram` in proxy mode only (an error
|
||||
elsewhere): the live viewer at `/auth/view`; needs `login_page`. Without
|
||||
auth, or under `mtls`, the viewer is always served.
|
||||
- `tls` (table): listener TLS; see [Security](security.md).
|
||||
- `auth` (table): client authentication (`mtls` or `scram`); see
|
||||
[Security](security.md#the-auth-block).
|
||||
@@ -194,6 +197,19 @@ Options, each as type and default:
|
||||
through `/auth/login` or the site's own copy of `/auth/scram.js`, and stream
|
||||
and status also accept the `logwisp_session` cookie. See
|
||||
[Browsers behind a TLS-terminating proxy](security.md#browsers-behind-a-tls-terminating-proxy).
|
||||
- `GET /` answers `303` to `auth/view`, a relative `Location` that a proxy
|
||||
prefix keeps, wherever a browser can read the stream:
|
||||
- without auth, over plain http too: the viewer needs no secure context, so
|
||||
`http://ADDRESS:PORT/` works;
|
||||
- under `mtls`, with the browser's client certificate (`openssl pkcs12
|
||||
-export -in NAME.crt -inkey NAME.key -out NAME.p12` imports it);
|
||||
- in proxy mode with `viewer_page`, or to `auth/login` with `login_page`
|
||||
alone;
|
||||
- never under `scram` on the sink's own TLS, which a browser cannot log in
|
||||
to: `/` is refused like any other path;
|
||||
- an endpoint at `/` keeps the root, and the viewer stays at `/auth/view`.
|
||||
The viewer shows entries from when it connects (the sink keeps no backlog)
|
||||
and holds one stream against `max_connections`.
|
||||
- Refusals are logged at WARN and counted in `auth_rejected`. The authorized
|
||||
identity is recorded in the client's session as `auth_method` /
|
||||
`auth_identity`.
|
||||
|
||||
+37
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"`,
|
||||
|
||||
@@ -116,7 +116,7 @@ func (p *Pipeline) initializeComponents() error {
|
||||
sessionProxy,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create source %s: %w", srcCfg.ID, err)
|
||||
return err // the registry names the plugin
|
||||
}
|
||||
|
||||
// Check and inject capabilities using core interfaces
|
||||
@@ -144,7 +144,7 @@ func (p *Pipeline) initializeComponents() error {
|
||||
sessionProxy,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create sink %s: %w", sinkCfg.ID, err)
|
||||
return err // the registry names the plugin
|
||||
}
|
||||
|
||||
// Check and inject capabilities using core interfaces
|
||||
|
||||
+36
-25
@@ -10,6 +10,7 @@ import (
|
||||
"maps"
|
||||
"net"
|
||||
"net/http"
|
||||
"path"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -77,7 +78,7 @@ type HTTPSink struct {
|
||||
|
||||
// Authorization
|
||||
auth *authz.Policy
|
||||
web map[string]http.Handler // proxy mode: GET /auth/... browser files
|
||||
web map[string]http.Handler // GET paths of the browser files and the root
|
||||
|
||||
// Runtime. Stop closes flush; the broker then moves what remains of its
|
||||
// input to tail and closes flushed; each stream writes its queue, tail
|
||||
@@ -128,22 +129,21 @@ func NewHTTPSinkPlugin(
|
||||
}
|
||||
if opts.StreamPath == "" {
|
||||
opts.StreamPath = DefaultHTTPStreamPath
|
||||
} else if !strings.HasPrefix(opts.StreamPath, "/") {
|
||||
return nil, fmt.Errorf("stream_path: must start with '/'")
|
||||
}
|
||||
if opts.StatusPath == "" {
|
||||
opts.StatusPath = DefaultHTTPStatusPath
|
||||
} else if !strings.HasPrefix(opts.StatusPath, "/") {
|
||||
return nil, fmt.Errorf("status_path: must start with '/'")
|
||||
}
|
||||
for _, o := range []struct{ name, p string }{{"stream_path", opts.StreamPath}, {"status_path", opts.StatusPath}} {
|
||||
if !routable(o.p) {
|
||||
return nil, fmt.Errorf("%s %q: must start with '/', hold no '//', '.' or '..' segment, and none of '{', '}', '%%', '?', '#'", o.name, o.p)
|
||||
}
|
||||
if p := o.p; p == chain.AuthPath || strings.HasPrefix(p, chain.AuthPath+"/") {
|
||||
return nil, fmt.Errorf("%s and the paths under it are reserved for authentication", chain.AuthPath)
|
||||
}
|
||||
}
|
||||
if opts.StreamPath == opts.StatusPath {
|
||||
return nil, fmt.Errorf("stream_path and status_path must differ")
|
||||
}
|
||||
for _, p := range []string{opts.StreamPath, opts.StatusPath} {
|
||||
if p == chain.AuthPath || strings.HasPrefix(p, chain.AuthPath+"/") {
|
||||
return nil, fmt.Errorf("%s and the paths under it are reserved for authentication", chain.AuthPath)
|
||||
}
|
||||
}
|
||||
if opts.BufferSize <= 0 {
|
||||
opts.BufferSize = DefaultHTTPBufferSize
|
||||
}
|
||||
@@ -160,16 +160,14 @@ func NewHTTPSinkPlugin(
|
||||
}
|
||||
switch {
|
||||
case (opts.LoginPage || opts.ViewerPage) && !authPolicy.BehindProxy():
|
||||
return nil, errors.New("login_page and viewer_page need auth.trusted_proxies: browsers log in only behind a TLS-terminating proxy")
|
||||
return nil, errors.New("login_page and viewer_page apply to scram behind auth.trusted_proxies, where browsers log in; without auth or under mtls the viewer is always served")
|
||||
case opts.ViewerPage && !opts.LoginPage:
|
||||
return nil, errors.New("viewer_page needs login_page, where it sends a signed-out viewer")
|
||||
}
|
||||
var web map[string]http.Handler
|
||||
if authPolicy.BehindProxy() {
|
||||
if web, err = webHandlers(opts); err != nil {
|
||||
web, err := webHandlers(opts, authPolicy)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
h := &HTTPSink{
|
||||
id: id,
|
||||
@@ -202,7 +200,7 @@ func NewHTTPSinkPlugin(
|
||||
"mtls", tlsCfg != nil && tlsCfg.ClientAuth == tls.RequireAndVerifyClientCert,
|
||||
"auth", authPolicy.Describe(),
|
||||
"login_page", opts.LoginPage,
|
||||
"viewer_page", opts.ViewerPage)
|
||||
"viewer_page", web[chain.AuthPath+"/view"] != nil)
|
||||
tlsx.LogWarnings(logger, "http_sink", id, opts.TLS, true)
|
||||
authPolicy.LogStartup(logger, "http_sink", id, false)
|
||||
return h, nil
|
||||
@@ -248,29 +246,27 @@ func (h *HTTPSink) serve(ctx context.Context, ln net.Listener) error {
|
||||
}
|
||||
mux := http.NewServeMux()
|
||||
// Method-scoped patterns: mux answers 405 with Allow header on non-GET
|
||||
mux.HandleFunc(http.MethodGet+" "+h.config.StreamPath, h.handleStream)
|
||||
mux.HandleFunc(http.MethodGet+" "+h.config.StatusPath, h.handleStatus)
|
||||
mux.HandleFunc(http.MethodGet+" "+exact(h.config.StreamPath), h.handleStream)
|
||||
mux.HandleFunc(http.MethodGet+" "+exact(h.config.StatusPath), h.handleStatus)
|
||||
// A GET pattern also serves HEAD, and a HEAD stream is a registered client
|
||||
// whose body writes are discarded: it never reads, so nothing but the peer
|
||||
// closing the connection ends it. The status path answers one either way.
|
||||
mux.HandleFunc(http.MethodHead+" "+h.config.StreamPath, streamHeadNotAllowed)
|
||||
mux.HandleFunc(http.MethodHead+" "+exact(h.config.StreamPath), streamHeadNotAllowed)
|
||||
|
||||
// One wrapper covers stream and status, and keeps the handlers themselves
|
||||
// unaware of authorization. Login and its browser files sit outside it;
|
||||
// in proxy mode everything sits behind the proxy gate.
|
||||
var handler http.Handler = mux
|
||||
if h.auth.Enabled() {
|
||||
// unaware of authorization; a nil policy admits every request. Login, the
|
||||
// browser files and the root sit outside it; in proxy mode everything sits
|
||||
// behind the proxy gate.
|
||||
outer := http.NewServeMux()
|
||||
outer.HandleFunc(http.MethodPost+" "+chain.AuthPath, h.handleAuth)
|
||||
for p, file := range h.web {
|
||||
outer.Handle(http.MethodGet+" "+p, file)
|
||||
}
|
||||
outer.Handle("/", h.authMiddleware(mux))
|
||||
handler = outer
|
||||
var handler http.Handler = outer
|
||||
if h.auth.BehindProxy() {
|
||||
handler = h.proxyGate(outer)
|
||||
}
|
||||
}
|
||||
|
||||
h.server = &http.Server{
|
||||
Handler: handler,
|
||||
@@ -671,6 +667,21 @@ func (h *HTTPSink) handleAuth(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
// routable reports a path ServeMux and a URL take literally: clean, a trailing
|
||||
// '/' aside, without the braces of wildcards, an escape, a query or a fragment
|
||||
func routable(p string) bool {
|
||||
c := path.Clean(p)
|
||||
return strings.HasPrefix(p, "/") && !strings.ContainsAny(p, "{}%?#") && (c == p || c != "/" && c+"/" == p)
|
||||
}
|
||||
|
||||
// exact keeps a path ending in '/' from matching every path beneath it
|
||||
func exact(p string) string {
|
||||
if strings.HasSuffix(p, "/") {
|
||||
return p + "{$}"
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// streamHeadNotAllowed refuses a body-less read of a stream that is only a body
|
||||
func streamHeadNotAllowed(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Allow", http.MethodGet)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -2,10 +2,14 @@ package http
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"maps"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -51,6 +55,27 @@ func serveTestHTTPSink(t *testing.T, h *HTTPSink) (*http.Client, string) {
|
||||
return client, "http://" + listener.Addr().String()
|
||||
}
|
||||
|
||||
// trustPKI points client at https, trusting pki's CA and, with cert,
|
||||
// presenting its client certificate
|
||||
func trustPKI(t *testing.T, client *http.Client, baseURL string, pki *testutil.PKI, cert bool) string {
|
||||
t.Helper()
|
||||
caPEM, err := os.ReadFile(pki.CA)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg := &tls.Config{RootCAs: x509.NewCertPool()}
|
||||
cfg.RootCAs.AppendCertsFromPEM(caPEM)
|
||||
if cert {
|
||||
pair, err := tls.LoadX509KeyPair(pki.ClientCert, pki.ClientKey)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg.Certificates = []tls.Certificate{pair}
|
||||
}
|
||||
client.Transport = &http.Transport{TLSClientConfig: cfg}
|
||||
return "https" + strings.TrimPrefix(baseURL, "http")
|
||||
}
|
||||
|
||||
// scramCredentials writes a credentials file holding viewer-01 under a cheap
|
||||
// Argon2 profile
|
||||
func scramCredentials(t *testing.T) string {
|
||||
|
||||
+38
-18
@@ -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()
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>LogWisp stream</title>
|
||||
<meta name="logwisp-status" content="status">
|
||||
<meta name="logwisp-login" content="scram">
|
||||
<link rel="stylesheet" href="style.css">
|
||||
<script type="module" src="view.js"></script>
|
||||
</head>
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -80,7 +80,7 @@ func TestStopWritesQueuedEventsToReadingClients(t *testing.T) {
|
||||
t.Cleanup(manager.Stop)
|
||||
created, err := NewTCPSinkPlugin("out", map[string]any{
|
||||
"host": "127.0.0.1", "port": p, "buffer_size": int64(n), "client_buffer_size": int64(64),
|
||||
"write_timeout_ms": int64(500),
|
||||
"write_timeout_ms": int64(2000),
|
||||
}, log.NewLogger(), session.NewProxy(manager, "out"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -132,8 +132,8 @@ func TestStopWritesQueuedEventsToReadingClients(t *testing.T) {
|
||||
}()
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
sink.clientsMu.Unlock()
|
||||
if d := <-stopped; d > 1500*time.Millisecond {
|
||||
t.Errorf("Stop took %v past a 500 ms flush bound", d)
|
||||
if d := <-stopped; d > 3500*time.Millisecond {
|
||||
t.Errorf("Stop took %v past a 2 s flush bound", d)
|
||||
}
|
||||
if got := <-lines; got != n {
|
||||
t.Fatalf("reading client received %d of %d lines", got, n)
|
||||
|
||||
+17
-26
@@ -1,33 +1,24 @@
|
||||
<svg viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">
|
||||
<!--
|
||||
LogWisp Logo - Vibrant Tech Stream
|
||||
- Shape: 5 swirling streams emanating from a center point.
|
||||
- Style: Wiry, energetic, and modern, suggesting data flow or a galaxy.
|
||||
- Color: Radiant gradient from a bright core to a vibrant teal.
|
||||
-->
|
||||
<!-- LogWisp: five colored wisps interlock directly at the center.
|
||||
No central core; each wing meets the others at (50,50). -->
|
||||
|
||||
<defs>
|
||||
<radialGradient id="vibrant-glow" cx="50%" cy="50%" r="50%" fx="50%" fy="50%">
|
||||
<stop offset="0%" stop-color="#E0FFFF" /> <!-- Bright, almost white cyan at the core -->
|
||||
<stop offset="100%" stop-color="#00B0B0" /> <!-- Deeper teal/cyan at the tips -->
|
||||
</radialGradient>
|
||||
<path id="wisp" d="
|
||||
M 50,50
|
||||
C 50,38.5 59.2,28.8 70.8,27.8
|
||||
C 82.9,26.7 91.2,35.4 96.7,49.3
|
||||
A 1.8,1.8 0 0 1 93.3,50.7
|
||||
C 89.1,42.8 82.0,38.2 74.1,38.4
|
||||
C 64.8,38.7 56.5,43.7 50,50
|
||||
Z
|
||||
"/>
|
||||
</defs>
|
||||
|
||||
<style>
|
||||
.wisp-path {
|
||||
fill: none;
|
||||
stroke: url(#vibrant-glow);
|
||||
stroke-width: 1.5;
|
||||
stroke-linecap: round;
|
||||
}
|
||||
</style>
|
||||
|
||||
<g id="wisps">
|
||||
<!-- A single path shape is defined, then rotated 5 times around the center (50, 50) -->
|
||||
<!-- The path is a cubic Bezier curve: M(start) C(control1, control2, end) -->
|
||||
<path class="wisp-path" d="M50,50 C58,30 85,25 95,50" transform="rotate(0, 50, 50)" />
|
||||
<path class="wisp-path" d="M50,50 C58,30 85,25 95,50" transform="rotate(72, 50, 50)" />
|
||||
<path class="wisp-path" d="M50,50 C58,30 85,25 95,50" transform="rotate(144, 50, 50)" />
|
||||
<path class="wisp-path" d="M50,50 C58,30 85,25 95,50" transform="rotate(216, 50, 50)" />
|
||||
<path class="wisp-path" d="M50,50 C58,30 85,25 95,50" transform="rotate(288, 50, 50)" />
|
||||
<use href="#wisp" fill="#E0409A"/>
|
||||
<use href="#wisp" fill="#D4640A" transform="rotate(72 50 50)"/>
|
||||
<use href="#wisp" fill="#169B58" transform="rotate(144 50 50)"/>
|
||||
<use href="#wisp" fill="#0891B2" transform="rotate(216 50 50)"/>
|
||||
<use href="#wisp" fill="#8E5CF0" transform="rotate(288 50 50)"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 797 B |
+1
-1
@@ -97,7 +97,7 @@ Append a line and watch it arrive:
|
||||
> curl -N http://127.0.0.1:$PORT_HTTP/stream
|
||||
> echo hello >>\$APP/app.log
|
||||
The secured sink (bearer keeps the token off argv):
|
||||
> token=\$(\$LW auth token -url https://127.0.0.1:$PORT_S_HTTP -user viewer-01 -password-file \$PW -ca-file \$CA)
|
||||
> token=\$(\$LW auth token --url https://127.0.0.1:$PORT_S_HTTP --user viewer-01 --password-file \$PW --ca-file \$CA)
|
||||
> curl -N --cacert \$CA -H @<(bearer) https://127.0.0.1:$PORT_S_HTTP/stream
|
||||
Logs: $(short "$LOG")/
|
||||
EOF
|
||||
|
||||
+4
-4
@@ -168,9 +168,9 @@ Ports (relay on ::1, TLS with SAN IP ::1, scram):
|
||||
Shell setup (LW, CA, PW: viewer-01's password file):
|
||||
> . $(short "$RUN")/env
|
||||
Read the tcp sink as viewer-01:
|
||||
> \$LW auth stream -addr [::1]:$PORT_TCP_SINK -user viewer-01 -password-file \$PW -ca-file \$CA
|
||||
> \$LW auth stream --addr [::1]:$PORT_TCP_SINK --user viewer-01 --password-file \$PW --ca-file \$CA
|
||||
Read the http sink (bearer keeps the token off argv; -g leaves the brackets to the URL):
|
||||
> token=\$(\$LW auth token -url https://[::1]:$PORT_HTTP_SINK -user viewer-01 -password-file \$PW -ca-file \$CA)
|
||||
> token=\$(\$LW auth token --url https://[::1]:$PORT_HTTP_SINK --user viewer-01 --password-file \$PW --ca-file \$CA)
|
||||
> curl -gN --cacert \$CA -H @<(bearer) https://[::1]:$PORT_HTTP_SINK/stream
|
||||
Which listener answers on $PORT_FAMILY:
|
||||
> curl -s http://127.0.0.1:$PORT_FAMILY/status; curl -gs http://[::1]:$PORT_FAMILY/status
|
||||
@@ -203,7 +203,7 @@ check "relay logged its listener as [::1]:$PORT_TCP_CHAIN ($n)" $((n >= 1))
|
||||
# 2. HTTP sink: lw auth token at an IPv6 URL, the token carried by curl -g
|
||||
token="$("$BIN" auth token -url "https://[::1]:$PORT_HTTP_SINK" -user viewer-01 \
|
||||
-password-file "$AUTH/viewer-01.pass" -ca-file "$PKI/ca.crt" 2>>"$LOG/auth-cli.out")"
|
||||
check "http sink: lw auth token -url https://[::1]:$PORT_HTTP_SINK issued a token" $(is_set "$token")
|
||||
check "http sink: lw auth token --url https://[::1]:$PORT_HTTP_SINK issued a token" $(is_set "$token")
|
||||
|
||||
code="$(curl "${curl_args[@]}" -o /dev/null -w '%{http_code}' --cacert "$PKI/ca.crt" \
|
||||
-H @<(printf 'Authorization: Bearer %s\n' "$token") "https://[::1]:$PORT_HTTP_SINK/status" 2>/dev/null || true)"
|
||||
@@ -220,7 +220,7 @@ timeout 4 "$BIN" auth stream -addr "[::1]:$PORT_TCP_SINK" -user viewer-01 \
|
||||
-password-file "$AUTH/viewer-01.pass" -ca-file "$PKI/ca.crt" \
|
||||
>"$RUN/stream.out" 2>"$LOG/stream.err"
|
||||
n=$(grep -c 'edge-01/' "$RUN/stream.out")
|
||||
check "tcp sink: lw auth stream -addr [::1]:$PORT_TCP_SINK streamed entries ($n lines)" $((n >= 1))
|
||||
check "tcp sink: lw auth stream --addr [::1]:$PORT_TCP_SINK streamed entries ($n lines)" $((n >= 1))
|
||||
|
||||
section "Scenario 2: each listener keeps to its address family"
|
||||
|
||||
|
||||
+12
-2
@@ -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
|
||||
|
||||
@@ -235,12 +235,12 @@ edge_unknown and edge_noauth are refused on $PORT_TCP_CHAIN. On $PORT_BOUND edge
|
||||
Shell setup (LW, CA, PW: viewer-01's password file, USERS, RELAY: its pid):
|
||||
> . $(short "$RUN")/env
|
||||
Read the tcp sink as viewer-01:
|
||||
> \$LW auth stream -addr 127.0.0.1:$PORT_TCP_SINK -user viewer-01 -password-file \$PW -ca-file \$CA
|
||||
> \$LW auth stream --addr 127.0.0.1:$PORT_TCP_SINK --user viewer-01 --password-file \$PW --ca-file \$CA
|
||||
Read the http sink (bearer keeps the token off argv):
|
||||
> token=\$(\$LW auth token -url https://127.0.0.1:$PORT_HTTP_SINK -user viewer-01 -password-file \$PW -ca-file \$CA)
|
||||
> token=\$(\$LW auth token --url https://127.0.0.1:$PORT_HTTP_SINK --user viewer-01 --password-file \$PW --ca-file \$CA)
|
||||
> curl -N --cacert \$CA -H @<(bearer) https://127.0.0.1:$PORT_HTTP_SINK/stream
|
||||
Revoke viewer-01:
|
||||
> \$LW auth remove-user -credentials \$USERS -user viewer-01 && kill -HUP \$RELAY
|
||||
> \$LW auth remove-user --credentials \$USERS --user viewer-01 && kill -HUP \$RELAY
|
||||
Ingested entries (node label forced to the username): $(short "$OUT")/
|
||||
Passwords: $(short "$AUTH")/ Logs: $(short "$LOG")/
|
||||
EOF
|
||||
|
||||
+44
-11
@@ -1,7 +1,8 @@
|
||||
#!/usr/bin/env bash
|
||||
# logwisp browser login test: an http sink in proxy mode behind a TLS-terminating
|
||||
# reverse proxy that mounts it under /logs/; headless Chromium signs in through
|
||||
# the shipped pages, with cookies and without (token mode); the CLI unbound.
|
||||
# the shipped pages, with cookies and without (token mode); the CLI unbound; an
|
||||
# open sink's viewer streams with no login on a page the browser holds insecure.
|
||||
# Requires: bash 5+, go, openssl, curl; node with playwright and both builds
|
||||
# `playwright install chromium` fetches, or the browser checks skip.
|
||||
|
||||
@@ -15,13 +16,14 @@ PROXY_SRC=$RUN/proxy
|
||||
USERS=$AUTH/users.toml
|
||||
PORT_PROXY=15831 # the site: TLS ends here, logwisp is under /logs/
|
||||
PORT_SINK=15832 # logwisp http sink, plaintext, only for 127.0.0.2
|
||||
PORT_OPEN=15833 # logwisp http sink without auth
|
||||
PROXY_ADDR=127.0.0.2
|
||||
SITE="https://127.0.0.1:$PORT_PROXY"
|
||||
e2e_init "$@"
|
||||
|
||||
section "Setup"
|
||||
need go openssl curl
|
||||
ports_free $PORT_PROXY $PORT_SINK
|
||||
ports_free $PORT_PROXY $PORT_SINK $PORT_OPEN
|
||||
# The launch each scenario makes: the module resolves without its browsers, and
|
||||
# chromium.executablePath() names full Chromium, not the default headless shell
|
||||
launches() { timeout 60 node -e 'require("playwright").chromium.launch(JSON.parse(process.argv[1])).then((b) => b.close())' "$1" >/dev/null 2>&1; }
|
||||
@@ -118,6 +120,13 @@ viewer_page = true
|
||||
type = "scram"
|
||||
credentials_file = "$USERS"
|
||||
trusted_proxies = ["$PROXY_ADDR"]
|
||||
|
||||
[[pipelines.plugin_sinks]]
|
||||
id = "open"
|
||||
type = "http"
|
||||
[pipelines.plugin_sinks.config]
|
||||
host = "127.0.0.1"
|
||||
port = $PORT_OPEN
|
||||
EOF
|
||||
|
||||
cat >"$RUN/browser.cjs" <<'EOF'
|
||||
@@ -147,7 +156,7 @@ const inlineLogin = (page) => page.waitForFunction(() =>
|
||||
|
||||
const scenarios = {
|
||||
async cookie(context, page) {
|
||||
await page.goto(base + "auth/view");
|
||||
await page.goto(base);
|
||||
await page.waitForURL(/\/auth\/login\?next=view%23signed-in$/, { timeout: 10000 });
|
||||
out.redirected_to_login = 1;
|
||||
await signIn(page, "not-the-password");
|
||||
@@ -235,6 +244,16 @@ const scenarios = {
|
||||
await inlineLogin(page);
|
||||
out.expired_asks_again = 1;
|
||||
},
|
||||
|
||||
// base names 127.0.0.1 as viewer.test, which the browser holds insecure
|
||||
async open(context, page) {
|
||||
await page.goto(base);
|
||||
await page.waitForURL(/\/auth\/view$/, { timeout: 10000 });
|
||||
await linesAtLeast(page, 3);
|
||||
out.events = await lines(page);
|
||||
out.secure = await page.evaluate(() => isSecureContext);
|
||||
out.sign_in_ui = await page.evaluate(() => !document.getElementById("logout").hidden || !document.getElementById("login").hidden);
|
||||
},
|
||||
};
|
||||
|
||||
(async () => {
|
||||
@@ -244,7 +263,8 @@ const scenarios = {
|
||||
// Full Chromium: the headless shell ignores a profile's Preferences
|
||||
context = await chromium.launchPersistentContext(profile, { ignoreHTTPSErrors: true, channel: "chromium" });
|
||||
} else {
|
||||
context = await (await chromium.launch()).newContext({ ignoreHTTPSErrors: true });
|
||||
const args = mode === "open" ? ["--host-resolver-rules=MAP viewer.test 127.0.0.1"] : [];
|
||||
context = await (await chromium.launch({ args })).newContext({ ignoreHTTPSErrors: true });
|
||||
}
|
||||
const page = await context.newPage();
|
||||
page.on("console", (m) => { if (/Content Security Policy/i.test(m.text())) out.csp_violations++; });
|
||||
@@ -266,6 +286,7 @@ spawn proxy "$PROXY_SRC/proxy" "127.0.0.1:$PORT_PROXY" "http://127.0.0.1:$PORT_S
|
||||
"$PKI/site.crt" "$PKI/site.key"
|
||||
wait_port $PORT_SINK || abort "logwisp port $PORT_SINK is not listening" logwisp
|
||||
wait_port $PORT_PROXY || abort "proxy port $PORT_PROXY is not listening" proxy
|
||||
wait_port $PORT_OPEN || abort "logwisp port $PORT_OPEN is not listening" logwisp
|
||||
daemons_up
|
||||
write_env LW="$BIN" CA="$PKI/ca.crt" PW="$AUTH/viewer-01.pass" SITE="$SITE"
|
||||
|
||||
@@ -273,13 +294,14 @@ guide "logwisp browser login test" <<EOF
|
||||
Ports:
|
||||
$PORT_PROXY the site: TLS-terminating reverse proxy, logwisp under /logs/
|
||||
$PORT_SINK logwisp http sink, plaintext, trusted_proxies = ["$PROXY_ADDR"]
|
||||
$PORT_OPEN logwisp http sink without auth: its viewer at the root
|
||||
In a browser trusting $(short "$PKI/ca.crt"), sign in as viewer-01 with the
|
||||
password in $(short "$AUTH/viewer-01.pass"):
|
||||
> $SITE/logs/auth/view
|
||||
Shell setup (LW, CA, PW: viewer-01's password file, SITE):
|
||||
> . $(short "$RUN")/env
|
||||
The CLI, unbound through the proxy (bearer keeps the token off argv):
|
||||
> token=\$(\$LW auth token -unbound -url \$SITE/logs -user viewer-01 -password-file \$PW -ca-file \$CA)
|
||||
> token=\$(\$LW auth token --unbound --url \$SITE/logs --user viewer-01 --password-file \$PW --ca-file \$CA)
|
||||
> curl -N --cacert \$CA -H @<(bearer) \$SITE/logs/stream
|
||||
Logs: $(short "$LOG")/
|
||||
EOF
|
||||
@@ -294,9 +316,9 @@ code_of() { # url [token] -> http_code; the header goes through a pipe, never ar
|
||||
fi
|
||||
}
|
||||
|
||||
browser() { # mode: runs browser.cjs, whose key=value lines val reads
|
||||
browser() { # mode [base]: runs browser.cjs, whose key=value lines val reads
|
||||
info "browser: $1 (output in $(short "$LOG")/browser-$1.out)"
|
||||
timeout 120 node "$RUN/browser.cjs" "$1" "$SITE/logs/" viewer-01 "$AUTH/viewer-01.pass" \
|
||||
timeout 120 node "$RUN/browser.cjs" "$1" "${2:-$SITE/logs/}" viewer-01 "$AUTH/viewer-01.pass" \
|
||||
"$RUN/profile" "${PIDS[0]}" >"$LOG/browser-$1.out" 2>"$LOG/browser-$1.err"
|
||||
browser_out="$(cat "$LOG/browser-$1.out")"
|
||||
[[ -z $(val error) ]] || info "${C_RED}browser error ($1): $(val error)$C_OFF"
|
||||
@@ -308,7 +330,7 @@ csp_clean() { echo $(($(is "$(val error)" "") && $(is "$(val csp_violations)" 0)
|
||||
section "Scenario 1: a person in a browser"
|
||||
if ((BROWSER)); then
|
||||
browser cookie
|
||||
check "browser: an unauthenticated viewer is sent to the login page" $(is "$(val redirected_to_login)" 1)
|
||||
check "browser: the mount sends an unauthenticated visitor to the viewer, then the login page" $(is "$(val redirected_to_login)" 1)
|
||||
check "browser: a wrong password is refused on the page" $(is "$(val wrong_password_refused)" 1)
|
||||
check "browser: login (Argon2 in the page, $(val login_ms) ms) opened the viewer" $(is_set "$(val login_ms)")
|
||||
n=$(val events)
|
||||
@@ -360,7 +382,7 @@ fi
|
||||
section "Scenario 3: the CLI through the proxy"
|
||||
token="$("$BIN" auth token -unbound -url "$SITE/logs" -user viewer-01 -password-file "$AUTH/viewer-01.pass" \
|
||||
-ca-file "$PKI/ca.crt" 2>>"$LOG/auth-cli.out")"
|
||||
check "cli: lw auth token -unbound logged in through the proxy" $(is_set "$token")
|
||||
check "cli: lw auth token --unbound logged in through the proxy" $(is_set "$token")
|
||||
code="$(code_of "$SITE/logs/status" "$token")"
|
||||
check "cli: /logs/status served with the token (HTTP $code)" $(is "$code" 200)
|
||||
sse="$(timeout 3 curl -sN --noproxy '*' --cacert "$PKI/ca.crt" \
|
||||
@@ -372,9 +394,20 @@ check "cli: /logs/status refused without a token (HTTP $code)" $(is "$code" 401)
|
||||
"$BIN" auth token -url "$SITE/logs" -user viewer-01 -password-file "$AUTH/viewer-01.pass" \
|
||||
-ca-file "$PKI/ca.crt" >/dev/null 2>>"$LOG/auth-cli.out"
|
||||
rc=$?
|
||||
check "cli: a path without -unbound is a usage error (exit $rc)" $((rc == 2))
|
||||
check "cli: a path without --unbound is a usage error (exit $rc)" $((rc == 2))
|
||||
|
||||
section "Scenario 4: around the proxy"
|
||||
section "Scenario 4: an open sink"
|
||||
if ((BROWSER)); then
|
||||
browser open "http://viewer.test:$PORT_OPEN/"
|
||||
n=$(val events)
|
||||
check "open: the root led to the viewer, which streamed with no login on an insecure page (${n:-0} lines, secure $(val secure))" \
|
||||
$((${n:-0} >= 3 && $(is "$(val secure)" false) && $(is "$(val sign_in_ui)" false)))
|
||||
check "open: no CSP violations ($(val csp_violations))" $(csp_clean)
|
||||
else
|
||||
skip "browser checks: node, playwright or its Chromium headless shell missing"
|
||||
fi
|
||||
|
||||
section "Scenario 5: around the proxy"
|
||||
code="$(code_of "http://127.0.0.1:$PORT_SINK/auth/login")"
|
||||
check "direct: the login page refuses a peer that is not the proxy (HTTP $code)" $(is "$code" 403)
|
||||
code="$(code_of "http://127.0.0.1:$PORT_SINK/status" "$token")"
|
||||
|
||||
Reference in new issue
Block a user