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
+786
-404
No files matched your search
+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 {
|
||||
|
||||
Reference in new issue
Block a user