v0.23.0 color, presets, certificates made at startup, cli.go, sink flush
This commit is contained in:
1 parent
15745682bc
commit
bf410e1225
50 files changed
+2649
-426
No files matched your search
@@ -76,7 +76,12 @@ change touches.
|
|||||||
- Plugin options decode through `config.Scan`, never `lconfig.ScanMap`: unknown
|
- Plugin options decode through `config.Scan`, never `lconfig.ScanMap`: unknown
|
||||||
keys must fail at any depth. Set defaults on the options struct before the
|
keys must fail at any depth. Set defaults on the options struct before the
|
||||||
call; `TestEveryPluginRejectsUnknownKeys` covers every registered plugin.
|
call; `TestEveryPluginRejectsUnknownKeys` covers every registered plugin.
|
||||||
- Network security has two seams: `tlsx` (TLS configs, startup warnings) and
|
- `cmd/lw/cli.go` owns the command line: lw's own flags, and the commands
|
||||||
|
table whose groups (`auth`, `tls`, `preset`) share one flag mechanism;
|
||||||
|
`config.Load` takes the parsed `config.Args`. Presets are rows of one table
|
||||||
|
in `internal/config/preset.go`.
|
||||||
|
- Network security has two seams: `tlsx` (TLS configs, certificates made at
|
||||||
|
startup or by `lw tls`, pins, startup warnings) and
|
||||||
`authz`. Listeners admit only through `Admit`/`AuthorizeRequest`, dialers
|
`authz`. Listeners admit only through `Admit`/`AuthorizeRequest`, dialers
|
||||||
through `Greet`/`Prepare`; `Authorize` refuses under scram, so a plugin gated
|
through `Greet`/`Prepare`; `Authorize` refuses under scram, so a plugin gated
|
||||||
on it alone fails closed. Nil policies are valid: keep call sites branch-free.
|
on it alone fails closed. Nil policies are valid: keep call sites branch-free.
|
||||||
|
|||||||
@@ -22,6 +22,9 @@ streams, or downstream LogWisp nodes.
|
|||||||
- **A Unix filter out of the box**: with no configuration, `lw` copies stdin
|
- **A Unix filter out of the box**: with no configuration, `lw` copies stdin
|
||||||
to stdout line for line and exits at the end of input; flags add filters,
|
to stdout line for line and exits at the end of input; flags add filters,
|
||||||
formats, sources and sinks
|
formats, sources and sinks
|
||||||
|
- **Presets**: `tail`, `serve`, `edge` and `aggregator` build the common
|
||||||
|
pipelines from a few keys (`lw --preset tail,path=/var/log/app`);
|
||||||
|
`lw --dump` turns any command line into a configuration file
|
||||||
- **Independent pipelines**, each `sources → flow → sinks`, running concurrently
|
- **Independent pipelines**, each `sources → flow → sinks`, running concurrently
|
||||||
in one process
|
in one process
|
||||||
- **Fan-in and fan-out**: many sources and many sinks per pipeline
|
- **Fan-in and fan-out**: many sources and many sinks per pipeline
|
||||||
@@ -39,7 +42,8 @@ streams, or downstream LogWisp nodes.
|
|||||||
|
|
||||||
### Outputs
|
### Outputs
|
||||||
|
|
||||||
`console` (control characters escaped on a terminal), `file` (rotating with
|
`console` (level names in color and control characters escaped on a
|
||||||
|
terminal), `file` (rotating with
|
||||||
retention), `http` (Server-Sent Events plus a
|
retention), `http` (Server-Sent Events plus a
|
||||||
JSON status endpoint), `tcp` (broadcast server), `null`, and the chain
|
JSON status endpoint), `tcp` (broadcast server), `null`, and the chain
|
||||||
forwarders `tcp_chain` and `http_chain`.
|
forwarders `tcp_chain` and `http_chain`.
|
||||||
@@ -64,6 +68,9 @@ exponential backoff and jitter.
|
|||||||
- TLS 1.2/1.3 on every network source and sink, listener and dialer alike
|
- TLS 1.2/1.3 on every network source and sink, listener and dialer alike
|
||||||
- Mutual TLS: listeners can require and verify client certificates; dialers can
|
- Mutual TLS: listeners can require and verify client certificates; dialers can
|
||||||
present a client identity
|
present a client identity
|
||||||
|
- Certificates without a PKI: `lw tls` makes a CA and certificates, and a
|
||||||
|
listener can make its own at startup, self-signed (dialers pin its key with
|
||||||
|
`pin_sha256`) or signed by a CA
|
||||||
- Authorization by certificate identity: an `auth` block admits named peers
|
- Authorization by certificate identity: an `auth` block admits named peers
|
||||||
(exact or RE2) rather than everything the CA issued, gates the `http` sink's
|
(exact or RE2) rather than everything the CA issued, gates the `http` sink's
|
||||||
stream and status endpoints, and lets a dialer pin the server it talks to
|
stream and status endpoints, and lets a dialer pin the server it talks to
|
||||||
@@ -109,8 +116,7 @@ authentication designs for the rationale and what is deliberately left out.
|
|||||||
- [Operations](doc/operations.md): running, monitoring, tuning,
|
- [Operations](doc/operations.md): running, monitoring, tuning,
|
||||||
troubleshooting
|
troubleshooting
|
||||||
- [To Do](doc/todo.md): planned work in priority order: network access
|
- [To Do](doc/todo.md): planned work in priority order: network access
|
||||||
control and the PROXY protocol, a hardening review of the config libraries,
|
control and the PROXY protocol, packaging
|
||||||
packaging
|
|
||||||
|
|
||||||
A fully annotated configuration covering every option ships as
|
A fully annotated configuration covering every option ships as
|
||||||
[`config/logwisp.toml`](config/logwisp.toml).
|
[`config/logwisp.toml`](config/logwisp.toml).
|
||||||
@@ -135,6 +141,9 @@ journalctl -f | ./bin/lw --sink http,host=127.0.0.1,port=8080
|
|||||||
# tail a directory instead of stdin
|
# tail a directory instead of stdin
|
||||||
./bin/lw --source 'file,directory=/var/log/myapp,pattern=*.log' \
|
./bin/lw --source 'file,directory=/var/log/myapp,pattern=*.log' \
|
||||||
--format json,sanitizer_policy=json > all.json
|
--format json,sanitizer_policy=json > all.json
|
||||||
|
|
||||||
|
# the same with a preset; on a terminal the level names are in color
|
||||||
|
./bin/lw --preset tail,path=/var/log/myapp
|
||||||
```
|
```
|
||||||
|
|
||||||
As a service, a configuration file holds the pipelines:
|
As a service, a configuration file holds the pipelines:
|
||||||
|
|||||||
+2
-80
@@ -28,15 +28,7 @@ import (
|
|||||||
|
|
||||||
const reloadHint = "send SIGHUP to logwisp to apply (auto_reload does not watch the credentials file)"
|
const reloadHint = "send SIGHUP to logwisp to apply (auto_reload does not watch the credentials file)"
|
||||||
|
|
||||||
// authCommand is one `lw auth` subcommand: define registers its flags
|
var authCommands = []subcommand{
|
||||||
// and returns the command, which runs once they are parsed and checked.
|
|
||||||
type authCommand struct {
|
|
||||||
name, synopsis, summary string
|
|
||||||
required []string
|
|
||||||
define func(fs *flag.FlagSet) func(stdout, stderr io.Writer) error
|
|
||||||
}
|
|
||||||
|
|
||||||
var authCommands = []authCommand{
|
|
||||||
{"add-user", "-credentials FILE -user NAME [-password-file FILE] [-generate]",
|
{"add-user", "-credentials FILE -user NAME [-password-file FILE] [-generate]",
|
||||||
"Add a user to a credentials file, or replace its password",
|
"Add a user to a credentials file, or replace its password",
|
||||||
[]string{"credentials", "user"}, defineAddUser},
|
[]string{"credentials", "user"}, defineAddUser},
|
||||||
@@ -51,77 +43,6 @@ var authCommands = []authCommand{
|
|||||||
[]string{"addr", "user", "password-file"}, defineStream},
|
[]string{"addr", "user", "password-file"}, defineStream},
|
||||||
}
|
}
|
||||||
|
|
||||||
// usageError is a command-line mistake: exit status 2 rather than 1
|
|
||||||
type usageError string
|
|
||||||
|
|
||||||
func (e usageError) Error() string { return string(e) }
|
|
||||||
|
|
||||||
// runAuth runs `lw auth` and returns the exit status: 0 success or
|
|
||||||
// help, 1 failure, 2 usage error.
|
|
||||||
func runAuth(args []string, stdout, stderr io.Writer) int {
|
|
||||||
if len(args) == 0 || slices.Contains([]string{"-h", "-help", "--help", "help"}, args[0]) {
|
|
||||||
printAuthUsage(stderr)
|
|
||||||
if len(args) == 0 {
|
|
||||||
return 2
|
|
||||||
}
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
i := slices.IndexFunc(authCommands, func(c authCommand) bool { return c.name == args[0] })
|
|
||||||
if i < 0 {
|
|
||||||
fmt.Fprintf(stderr, "lw auth: unknown command %q\n\n", args[0])
|
|
||||||
printAuthUsage(stderr)
|
|
||||||
return 2
|
|
||||||
}
|
|
||||||
c := authCommands[i]
|
|
||||||
fs := flag.NewFlagSet("lw auth "+c.name, flag.ContinueOnError)
|
|
||||||
fs.SetOutput(stderr)
|
|
||||||
fs.Usage = func() {
|
|
||||||
fmt.Fprintf(stderr, "Usage: %s %s\n\n%s.\n\n", fs.Name(), c.synopsis, c.summary)
|
|
||||||
fs.PrintDefaults()
|
|
||||||
}
|
|
||||||
run := c.define(fs)
|
|
||||||
if err := fs.Parse(args[1:]); err != nil {
|
|
||||||
if errors.Is(err, flag.ErrHelp) {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
return 2 // already reported by flag, with the usage
|
|
||||||
}
|
|
||||||
err := checkArgs(fs, c.required)
|
|
||||||
if err == nil {
|
|
||||||
err = run(stdout, stderr)
|
|
||||||
}
|
|
||||||
if err == nil {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
fmt.Fprintf(stderr, "%s: %v\n", fs.Name(), err)
|
|
||||||
if _, ok := errors.AsType[usageError](err); ok {
|
|
||||||
fs.Usage()
|
|
||||||
return 2
|
|
||||||
}
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
|
|
||||||
func printAuthUsage(w io.Writer) {
|
|
||||||
fmt.Fprint(w, "Usage: lw auth <command> [flags]\n\n")
|
|
||||||
for _, c := range authCommands {
|
|
||||||
fmt.Fprintf(w, " %-12s %s\n", c.name, c.summary)
|
|
||||||
}
|
|
||||||
fmt.Fprint(w, "\nRun lw auth <command> -h for its flags. Credential changes apply on SIGHUP.\n"+
|
|
||||||
"Exit status: 0 success, 1 failure, 2 usage error.\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
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 nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func defineAddUser(fs *flag.FlagSet) func(stdout, stderr io.Writer) error {
|
func defineAddUser(fs *flag.FlagSet) func(stdout, stderr io.Writer) error {
|
||||||
path := fs.String("credentials", "", "credentials `file`, created when missing")
|
path := fs.String("credentials", "", "credentials `file`, created when missing")
|
||||||
user := fs.String("user", "", "user `name`")
|
user := fs.String("user", "", "user `name`")
|
||||||
@@ -323,6 +244,7 @@ func defineStream(fs *flag.FlagSet) func(stdout, stderr io.Writer) error {
|
|||||||
func tlsFlags(fs *flag.FlagSet) *config.TLSOptions {
|
func tlsFlags(fs *flag.FlagSet) *config.TLSOptions {
|
||||||
o := &config.TLSOptions{Enabled: true}
|
o := &config.TLSOptions{Enabled: true}
|
||||||
fs.StringVar(&o.CAFile, "ca-file", "", "CA `file` that verifies the server (default: system roots)")
|
fs.StringVar(&o.CAFile, "ca-file", "", "CA `file` that verifies the server (default: system roots)")
|
||||||
|
fs.StringVar(&o.PinSHA256, "pin-sha256", "", "sha256//BASE64 `pin` of the server key, in place of -ca-file (a self_signed listener logs it)")
|
||||||
fs.StringVar(&o.ServerName, "server-name", "", "`name` the server certificate must carry (default: the host)")
|
fs.StringVar(&o.ServerName, "server-name", "", "`name` the server certificate must carry (default: the host)")
|
||||||
fs.StringVar(&o.CertFile, "cert-file", "", "client certificate `file`, for listeners with tls.client_auth")
|
fs.StringVar(&o.CertFile, "cert-file", "", "client certificate `file`, for listeners with tls.client_auth")
|
||||||
fs.StringVar(&o.KeyFile, "key-file", "", "client key `file`")
|
fs.StringVar(&o.KeyFile, "key-file", "", "client key `file`")
|
||||||
|
|||||||
+1
-4
@@ -14,10 +14,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func runAuthTest(t *testing.T, args ...string) (code int, stdout, stderr string) {
|
func runAuthTest(t *testing.T, args ...string) (code int, stdout, stderr string) {
|
||||||
t.Helper()
|
return runCommand(t, "auth", args...)
|
||||||
var out, errOut bytes.Buffer
|
|
||||||
code = runAuth(args, &out, &errOut)
|
|
||||||
return code, out.String(), errOut.String()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// writeCredentials writes users sharing a cheap Argon2 profile. add-user
|
// writeCredentials writes users sharing a cheap Argon2 profile. add-user
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"os"
|
"os"
|
||||||
|
|
||||||
_ "github.com/lixenwraith/logwisp/internal/source/console"
|
_ "github.com/lixenwraith/logwisp/internal/source/console"
|
||||||
@@ -27,6 +28,7 @@ import (
|
|||||||
|
|
||||||
"github.com/lixenwraith/log"
|
"github.com/lixenwraith/log"
|
||||||
"github.com/lixenwraith/log/sanitizer"
|
"github.com/lixenwraith/log/sanitizer"
|
||||||
|
"github.com/lixenwraith/toml"
|
||||||
)
|
)
|
||||||
|
|
||||||
// bootstrapInitial handles initial service startup with status reporter
|
// bootstrapInitial handles initial service startup with status reporter
|
||||||
@@ -130,6 +132,29 @@ func checkConfig(cfg *config.Config) int {
|
|||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// dumpConfig prints the effective configuration, flags and environment
|
||||||
|
// resolved into it, as a file lw -c reads back.
|
||||||
|
func dumpConfig(cfg *config.Config, w io.Writer) int {
|
||||||
|
cfg.Dump = false
|
||||||
|
// Snapshot gave each console sink the top-level color: keep it inherited
|
||||||
|
for _, p := range cfg.Pipelines {
|
||||||
|
for _, s := range p.PluginSinks {
|
||||||
|
if s.Type == "console" && s.Config["color"] == cfg.Color {
|
||||||
|
delete(s.Config, "color")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
data, err := toml.Marshal(cfg)
|
||||||
|
if err == nil {
|
||||||
|
_, err = w.Write(data)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
// initializeLogger sets up the global logger based on the application's configuration
|
// initializeLogger sets up the global logger based on the application's configuration
|
||||||
func initializeLogger(cfg *config.Config) error {
|
func initializeLogger(cfg *config.Config) error {
|
||||||
logger = log.NewLogger()
|
logger = log.NewLogger()
|
||||||
|
|||||||
+213
@@ -0,0 +1,213 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"cmp"
|
||||||
|
"errors"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/lixenwraith/logwisp/internal/config"
|
||||||
|
|
||||||
|
"github.com/lixenwraith/toml"
|
||||||
|
)
|
||||||
|
|
||||||
|
// command is a group of subcommands, recognized only as lw's first argument
|
||||||
|
type command struct {
|
||||||
|
name, summary, footer string
|
||||||
|
subcommands []subcommand
|
||||||
|
}
|
||||||
|
|
||||||
|
// subcommand is one `lw COMMAND NAME`: define registers its flags and
|
||||||
|
// returns the work, which runs once they are parsed and checked.
|
||||||
|
type subcommand struct {
|
||||||
|
name, synopsis, summary string
|
||||||
|
required []string
|
||||||
|
define func(fs *flag.FlagSet) func(stdout, stderr io.Writer) error
|
||||||
|
}
|
||||||
|
|
||||||
|
var commands = []command{
|
||||||
|
{"auth", "SCRAM users, bearer tokens, tcp sink viewing",
|
||||||
|
"Credential changes apply on SIGHUP.", authCommands},
|
||||||
|
{"tls", "A CA and certificates for TLS and mTLS",
|
||||||
|
"Files are never overwritten; keys are written mode 0600.", tlsCommands},
|
||||||
|
{"preset", "Show the pipeline a preset makes, as TOML",
|
||||||
|
"lw --preset NAME,key=value runs one; flags here are its keys.", presetCommands()},
|
||||||
|
}
|
||||||
|
|
||||||
|
// usageError is a command-line mistake: exit status 2 rather than 1
|
||||||
|
type usageError string
|
||||||
|
|
||||||
|
func (e usageError) Error() string { return string(e) }
|
||||||
|
|
||||||
|
// run runs a subcommand and returns the exit status: 0 success or help,
|
||||||
|
// 1 failure, 2 usage error.
|
||||||
|
func (c *command) run(args []string, stdout, stderr io.Writer) int {
|
||||||
|
if len(args) == 0 || slices.Contains([]string{"-h", "-help", "--help", "help"}, args[0]) {
|
||||||
|
c.usage(stderr)
|
||||||
|
if len(args) == 0 {
|
||||||
|
return 2
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
i := slices.IndexFunc(c.subcommands, func(s subcommand) bool { return s.name == args[0] })
|
||||||
|
if i < 0 {
|
||||||
|
fmt.Fprintf(stderr, "lw %s: unknown command %q\n\n", c.name, args[0])
|
||||||
|
c.usage(stderr)
|
||||||
|
return 2
|
||||||
|
}
|
||||||
|
s := c.subcommands[i]
|
||||||
|
fs := flag.NewFlagSet("lw "+c.name+" "+s.name, flag.ContinueOnError)
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
work := s.define(fs)
|
||||||
|
if err := fs.Parse(args[1:]); err != nil {
|
||||||
|
if errors.Is(err, flag.ErrHelp) {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return 2 // already reported by flag, with the usage
|
||||||
|
}
|
||||||
|
err := checkArgs(fs, s.required)
|
||||||
|
if err == nil {
|
||||||
|
err = work(stdout, stderr)
|
||||||
|
}
|
||||||
|
if err == nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
fmt.Fprintf(stderr, "%s: %v\n", fs.Name(), err)
|
||||||
|
if _, ok := errors.AsType[usageError](err); ok {
|
||||||
|
fs.Usage()
|
||||||
|
return 2
|
||||||
|
}
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *command) usage(w io.Writer) {
|
||||||
|
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"+
|
||||||
|
"Exit status: 0 success, 1 failure, 2 usage error.\n", c.name, c.footer)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// invocation is a parsed command line: a command with its arguments, a help
|
||||||
|
// request, or what config.Load reads.
|
||||||
|
type invocation struct {
|
||||||
|
command *command
|
||||||
|
args []string
|
||||||
|
help bool
|
||||||
|
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.
|
||||||
|
func parseCommandLine(argv []string) (inv invocation, err error) {
|
||||||
|
defer func() {
|
||||||
|
if inv.help {
|
||||||
|
err = nil
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
if len(argv) > 0 {
|
||||||
|
if i := slices.IndexFunc(commands, func(c command) bool { return c.name == argv[0] }); i >= 0 {
|
||||||
|
return invocation{command: &commands[i], args: argv[1:]}, nil
|
||||||
|
}
|
||||||
|
inv.help = argv[0] == "help"
|
||||||
|
}
|
||||||
|
specFlags := config.SpecFlags()
|
||||||
|
next := func(i *int) string {
|
||||||
|
if *i+1 < len(argv) && !strings.HasPrefix(argv[*i+1], "-") {
|
||||||
|
*i++
|
||||||
|
return argv[*i]
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
for i := 0; i < len(argv); i++ {
|
||||||
|
arg := argv[i]
|
||||||
|
name, value, inline := strings.Cut(arg, "=")
|
||||||
|
switch {
|
||||||
|
case arg == "--":
|
||||||
|
inv.load.Overrides = append(inv.load.Overrides, argv[i:]...)
|
||||||
|
return inv, err
|
||||||
|
case arg == "-h" || arg == "--help":
|
||||||
|
inv.help = true
|
||||||
|
case name == "-c" || name == "--config":
|
||||||
|
if !inline {
|
||||||
|
value = next(&i)
|
||||||
|
}
|
||||||
|
if value == "" {
|
||||||
|
err = cmp.Or(err, fmt.Errorf("%s requires a configuration file path", name))
|
||||||
|
}
|
||||||
|
inv.load.File = value
|
||||||
|
case arg == "--color":
|
||||||
|
// Bare, it means always: config takes no bare flag for a string key
|
||||||
|
inv.load.Overrides = append(inv.load.Overrides, "--color="+cmp.Or(next(&i), "always"))
|
||||||
|
case strings.HasPrefix(name, "--") && slices.Contains(specFlags, name[2:]):
|
||||||
|
if !inline {
|
||||||
|
value = next(&i)
|
||||||
|
}
|
||||||
|
inv.load.Specs = append(inv.load.Specs, config.Spec{Flag: name[2:], Value: value})
|
||||||
|
default:
|
||||||
|
inv.load.Overrides = append(inv.load.Overrides, arg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return inv, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// presetCommands makes each preset a subcommand whose flags are its keys
|
||||||
|
func presetCommands() []subcommand {
|
||||||
|
var subs []subcommand
|
||||||
|
for _, p := range config.Presets() {
|
||||||
|
s := subcommand{name: p.Name, summary: p.Summary}
|
||||||
|
var synopsis []string
|
||||||
|
for _, k := range p.Params {
|
||||||
|
if k.Required {
|
||||||
|
s.required = append(s.required, k.Name)
|
||||||
|
synopsis = append(synopsis, "-"+k.Name+" 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 {
|
||||||
|
values[k.Name] = fs.String(k.Name, k.Default, k.Help)
|
||||||
|
}
|
||||||
|
return func(stdout, _ io.Writer) error {
|
||||||
|
set := map[string]string{}
|
||||||
|
for name, v := range values {
|
||||||
|
set[name] = *v
|
||||||
|
}
|
||||||
|
pipeline, err := config.ExpandPreset(p.Name, set)
|
||||||
|
if err != nil {
|
||||||
|
return usageError(err.Error())
|
||||||
|
}
|
||||||
|
data, err := toml.Marshal(map[string]any{"pipelines": []config.PipelineConfig{pipeline}})
|
||||||
|
if err == nil {
|
||||||
|
_, err = stdout.Write(data)
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
subs = append(subs, s)
|
||||||
|
}
|
||||||
|
return subs
|
||||||
|
}
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"reflect"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/lixenwraith/logwisp/internal/config"
|
||||||
|
"github.com/lixenwraith/logwisp/internal/testutil"
|
||||||
|
"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.
|
||||||
|
func TestCommandLineGrammar(t *testing.T) {
|
||||||
|
for _, c := range []struct {
|
||||||
|
argv []string
|
||||||
|
want invocation
|
||||||
|
}{
|
||||||
|
{[]string{"-c", "a.toml", "--config=b.toml", "--quiet"}, invocation{load: config.Args{File: "b.toml", Overrides: []string{"--quiet"}}}},
|
||||||
|
{[]string{"-c=a.toml", "--config", "b.toml"}, invocation{load: config.Args{File: "b.toml"}}},
|
||||||
|
{[]string{"--source=null", "--sink", "http,port=1", "--filter", "--sink"},
|
||||||
|
invocation{load: config.Args{Specs: []config.Spec{{Flag: "source", Value: "null"}, {Flag: "sink", Value: "http,port=1"}, {Flag: "filter"}, {Flag: "sink"}}}}},
|
||||||
|
{[]string{"--preset", "tail,path=x", "--pipeline", "b"}, invocation{load: config.Args{Specs: []config.Spec{{Flag: "preset", Value: "tail,path=x"}, {Flag: "pipeline", Value: "b"}}}}},
|
||||||
|
{[]string{"--color", "--color", "never", "--color=auto"}, invocation{load: config.Args{Overrides: []string{"--color=always", "--color=never", "--color=auto"}}}},
|
||||||
|
{[]string{"--quiet", "--", "-c", "x", "--source", "null"}, invocation{load: config.Args{Overrides: []string{"--quiet", "--", "-c", "x", "--source", "null"}}}},
|
||||||
|
{[]string{"--quiet", "-h"}, invocation{help: true, load: config.Args{Overrides: []string{"--quiet"}}}},
|
||||||
|
{[]string{"-c", "-h"}, invocation{help: true}},
|
||||||
|
{[]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"}}}},
|
||||||
|
} {
|
||||||
|
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"}} {
|
||||||
|
if _, err := parseCommandLine(argv); err == nil {
|
||||||
|
t.Errorf("%q: missing path accepted", argv)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --dump prints a file that lw -c reads back to the same configuration, its
|
||||||
|
// console sinks still taking the top-level color
|
||||||
|
func TestDumpReadsBack(t *testing.T) {
|
||||||
|
testLogger(t)
|
||||||
|
dir := t.TempDir()
|
||||||
|
empty := filepath.Join(dir, "empty.toml")
|
||||||
|
if err := os.WriteFile(empty, nil, 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
args := []string{"--color", "--preset", "serve,listen=127.0.0.1:15841,tls=self,hosts=a.example,hosts=b.example",
|
||||||
|
"--filter", `exclude,patterns=password=\S{8\,64}`, "--sink", "console", "--logging.level=debug"}
|
||||||
|
cfg := loadTestConfig(t, empty, args...)
|
||||||
|
var dump bytes.Buffer
|
||||||
|
if code := dumpConfig(loadTestConfig(t, empty, args...), &dump); code != 0 {
|
||||||
|
t.Fatalf("dump exit %d", code)
|
||||||
|
}
|
||||||
|
path := filepath.Join(dir, "dump.toml")
|
||||||
|
if err := os.WriteFile(path, dump.Bytes(), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
again := loadTestConfig(t, path)
|
||||||
|
if !reflect.DeepEqual(again.Pipelines, cfg.Pipelines) || again.Color != "always" || again.Logging.Level != "debug" ||
|
||||||
|
strings.Count(dump.String(), "color = ") != 1 {
|
||||||
|
t.Fatalf("dump does not read back:\n%s\n got %+v\nwant %+v", dump.String(), again.Pipelines, cfg.Pipelines)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every preset expands to plugins that build, as lw --check builds them
|
||||||
|
func TestEveryPresetBuilds(t *testing.T) {
|
||||||
|
testLogger(t)
|
||||||
|
dir := t.TempDir()
|
||||||
|
users, pass, empty := filepath.Join(dir, "users.toml"), filepath.Join(dir, "edge.pass"), filepath.Join(dir, "empty.toml")
|
||||||
|
writeCredentials(t, users, "edge-01")
|
||||||
|
for path, data := range map[string]string{pass: "password-edge-01\n", empty: ""} {
|
||||||
|
if err := os.WriteFile(path, []byte(data), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pin := "sha256//" + strings.Repeat("A", 43) + "="
|
||||||
|
samples := map[string]string{
|
||||||
|
"pipe": "pipe,format=txt",
|
||||||
|
"tail": "tail,path=" + dir,
|
||||||
|
"serve": "serve,path=" + filepath.Join(dir, "*.log") + ",listen=127.0.0.1:15841,tls=self,users=" + users,
|
||||||
|
"edge": "edge,to=127.0.0.1:15842,transport=http,pin=" + pin + ",user=edge-01,password_file=" + pass,
|
||||||
|
"aggregator": "aggregator,listen=127.0.0.1:15843,users=" + users + ",out=" + filepath.Join(dir, "out"),
|
||||||
|
}
|
||||||
|
for _, p := range config.Presets() {
|
||||||
|
sample, ok := samples[p.Name]
|
||||||
|
if !ok {
|
||||||
|
t.Errorf("preset %s has no sample here", p.Name)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if code := checkConfig(loadTestConfig(t, empty, "--preset", sample)); code != 0 {
|
||||||
|
t.Errorf("%s: lw --check exit %d", sample, code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// lw tls ca and lw tls cert write a chain a dialer verifies by the CA file,
|
||||||
|
// keys private, and never replace a file
|
||||||
|
func TestTLSCommandsIssueAVerifiableChain(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
steps := [][]string{
|
||||||
|
{"ca", "-dir", dir, "-name", "test CA"},
|
||||||
|
{"cert", "-ca-dir", dir, "-name", "agg", "-server", "-host", "127.0.0.1"},
|
||||||
|
}
|
||||||
|
for _, args := range steps {
|
||||||
|
if code, _, stderr := runCommand(t, "tls", args...); code != 0 {
|
||||||
|
t.Fatalf("%v: exit %d: %s", args, code, stderr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, key := range []string{"ca.key", "agg.key"} {
|
||||||
|
if fi, err := os.Stat(filepath.Join(dir, key)); err != nil || fi.Mode().Perm() != 0o600 {
|
||||||
|
t.Fatalf("%s: %v %v", key, fi.Mode(), err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
server, err := tlsx.Server(&config.TLSOptions{Enabled: true, CertFile: filepath.Join(dir, "agg.crt"), KeyFile: filepath.Join(dir, "agg.key")}, "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
client, err := tlsx.Client(&config.TLSOptions{Enabled: true, CAFile: filepath.Join(dir, "ca.crt")}, "127.0.0.1")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := testutil.Handshake(t, server, client); err != nil {
|
||||||
|
t.Fatalf("issued chain: %v", err)
|
||||||
|
}
|
||||||
|
if code, _, stderr := runCommand(t, "tls", steps[0]...); code != 1 || !strings.Contains(stderr, "exists") {
|
||||||
|
t.Fatalf("second ca: exit %d: %s", code, stderr)
|
||||||
|
}
|
||||||
|
}
|
||||||
+22
-24
@@ -2,7 +2,7 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"strings"
|
||||||
|
|
||||||
"github.com/lixenwraith/logwisp/internal/version"
|
"github.com/lixenwraith/logwisp/internal/version"
|
||||||
)
|
)
|
||||||
@@ -15,19 +15,21 @@ Usage:
|
|||||||
line for line, like cat; exits at end of input
|
line for line, like cat; exits at end of input
|
||||||
lw -c <file> [options] Run the file's pipelines
|
lw -c <file> [options] Run the file's pipelines
|
||||||
lw --check [options] Build every pipeline and plugin, report, exit
|
lw --check [options] Build every pipeline and plugin, report, exit
|
||||||
|
lw --dump [options] Print the effective configuration as TOML, exit
|
||||||
lw help | -h | --help
|
lw help | -h | --help
|
||||||
lw --version
|
lw --version
|
||||||
|
|
||||||
Subcommands:
|
Commands (lw <command> -h lists its subcommands):
|
||||||
lw auth <command> SCRAM credentials files, bearer tokens and tcp
|
%s
|
||||||
sink viewing; lw auth -h lists commands
|
|
||||||
|
|
||||||
Scalar configuration keys are settable as flags using their TOML paths:
|
Scalar configuration keys are settable as flags using their TOML paths:
|
||||||
--<path>=<value> e.g. --logging.level=debug
|
--<path>=<value> e.g. --logging.level=debug
|
||||||
|
|
||||||
Common options:
|
Common options:
|
||||||
-c, --config <path> Configuration file (default: ./logwisp.toml)
|
-c, --config <path> Configuration file (default: ./logwisp.toml)
|
||||||
--quiet Suppress console output
|
--quiet Suppress console output
|
||||||
|
--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
|
--status_reporter=<bool> Periodic status logging (default: true with
|
||||||
a configuration file)
|
a configuration file)
|
||||||
--auto_reload=<bool> Config hot reload on file change (default: false)
|
--auto_reload=<bool> Config hot reload on file change (default: false)
|
||||||
@@ -39,6 +41,9 @@ at warn without one):
|
|||||||
--logging.file.directory=<path>
|
--logging.file.directory=<path>
|
||||||
|
|
||||||
Pipelines (replace the file's pipelines; see doc/cli.md):
|
Pipelines (replace the file's pipelines; see doc/cli.md):
|
||||||
|
--preset <name>[,k=v...] Start a pipeline with a preset: pipe, tail,
|
||||||
|
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"
|
--pipeline <name> Start a pipeline; earlier specs go to "cli"
|
||||||
--source <spec> TYPE[,key=value...], repeatable
|
--source <spec> TYPE[,key=value...], repeatable
|
||||||
--sink <spec> e.g. http,host=0.0.0.0,port=8080, repeatable
|
--sink <spec> e.g. http,host=0.0.0.0,port=8080, repeatable
|
||||||
@@ -56,14 +61,19 @@ Examples:
|
|||||||
journalctl -f | lw --sink http,host=127.0.0.1,port=8080
|
journalctl -f | lw --sink http,host=127.0.0.1,port=8080
|
||||||
Serve stdin as a live stream
|
Serve stdin as a live stream
|
||||||
lw --source file,directory=/var/log/app,pattern='*.log' --format txt
|
lw --source file,directory=/var/log/app,pattern='*.log' --format txt
|
||||||
|
lw --preset tail,path=/var/log/app Follow a directory's files
|
||||||
|
lw --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,\
|
||||||
|
password_file=edge-01.pass Forward to an aggregator
|
||||||
|
|
||||||
Environment:
|
Environment:
|
||||||
LOGWISP_<PATH> Config path, '.' -> '_', uppercase
|
LOGWISP_<PATH> Config path, '.' -> '_', uppercase
|
||||||
e.g. LOGWISP_LOGGING_LEVEL=debug
|
e.g. LOGWISP_LOGGING_LEVEL=debug
|
||||||
LOGWISP_CONFIG_FILE Configuration file path
|
LOGWISP_CONFIG_FILE Configuration file path
|
||||||
LOGWISP_CONFIG_DIR Configuration directory
|
LOGWISP_CONFIG_DIR Configuration directory
|
||||||
LOGWISP_PIPELINE, LOGWISP_SOURCE[_N], LOGWISP_SINK[_N], LOGWISP_FILTER[_N],
|
LOGWISP_PIPELINE, LOGWISP_PRESET, LOGWISP_SOURCE[_N], LOGWISP_SINK[_N],
|
||||||
LOGWISP_FORMAT, LOGWISP_RATE_LIMIT, LOGWISP_HEARTBEAT
|
LOGWISP_FILTER[_N], LOGWISP_FORMAT, LOGWISP_RATE_LIMIT, LOGWISP_HEARTBEAT
|
||||||
One pipeline, specs as the flags (_N adds
|
One pipeline, specs as the flags (_N adds
|
||||||
more); ignored when a pipeline flag is given
|
more); ignored when a pipeline flag is given
|
||||||
|
|
||||||
@@ -77,22 +87,10 @@ Exit codes:
|
|||||||
2 configuration file not found
|
2 configuration file not found
|
||||||
`
|
`
|
||||||
|
|
||||||
// handleHelp prints usage and exits if a help request is present in args
|
|
||||||
func handleHelp(args []string) {
|
|
||||||
if len(args) > 0 && args[0] == "help" {
|
|
||||||
printHelp()
|
|
||||||
}
|
|
||||||
for _, arg := range args {
|
|
||||||
if arg == "--" {
|
|
||||||
break // end of flags
|
|
||||||
}
|
|
||||||
if arg == "-h" || arg == "--help" {
|
|
||||||
printHelp()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func printHelp() {
|
func printHelp() {
|
||||||
fmt.Printf(helpText, version.Short())
|
var list strings.Builder
|
||||||
os.Exit(0)
|
for _, c := range commands {
|
||||||
|
fmt.Fprintf(&list, " lw %-26s %s\n", c.name+" <command>", c.summary)
|
||||||
|
}
|
||||||
|
fmt.Printf(helpText, version.Short(), list.String())
|
||||||
}
|
}
|
||||||
+14
-7
@@ -20,19 +20,23 @@ import (
|
|||||||
var logger *log.Logger
|
var logger *log.Logger
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
// Before handleHelp, so `lw auth <command> -h` prints the auth usage
|
inv, err := parseCommandLine(os.Args[1:])
|
||||||
if len(os.Args) > 1 && os.Args[1] == "auth" {
|
switch {
|
||||||
os.Exit(runAuth(os.Args[2:], os.Stdout, os.Stderr))
|
case err != nil:
|
||||||
|
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
case inv.command != nil:
|
||||||
|
os.Exit(inv.command.run(inv.args, os.Stdout, os.Stderr))
|
||||||
|
case inv.help:
|
||||||
|
printHelp()
|
||||||
|
os.Exit(0)
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- 1. Initial setup ---
|
// --- 1. Initial setup ---
|
||||||
// Emulates nohup
|
// Emulates nohup
|
||||||
signal.Ignore(syscall.SIGHUP)
|
signal.Ignore(syscall.SIGHUP)
|
||||||
|
|
||||||
// Before config parsing: the loader has no help flag
|
manager, err := config.Load(inv.load)
|
||||||
handleHelp(os.Args[1:])
|
|
||||||
|
|
||||||
manager, err := config.Load(os.Args[1:])
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, config.ErrConfigNotFound) {
|
if errors.Is(err, config.ErrConfigNotFound) {
|
||||||
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||||
@@ -57,6 +61,9 @@ func main() {
|
|||||||
if cfg.Check {
|
if cfg.Check {
|
||||||
os.Exit(checkConfig(cfg))
|
os.Exit(checkConfig(cfg))
|
||||||
}
|
}
|
||||||
|
if cfg.Dump {
|
||||||
|
os.Exit(dumpConfig(cfg, os.Stdout))
|
||||||
|
}
|
||||||
|
|
||||||
if err := initializeLogger(cfg); err != nil {
|
if err := initializeLogger(cfg); err != nil {
|
||||||
FatalError(1, "Failed to initialize logger: %v\n", err)
|
FatalError(1, "Failed to initialize logger: %v\n", err)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/lixenwraith/logwisp/internal/config"
|
"github.com/lixenwraith/logwisp/internal/config"
|
||||||
@@ -19,7 +20,11 @@ func testLogger(t *testing.T) {
|
|||||||
func loadTestConfig(t *testing.T, path string, args ...string) *config.Config {
|
func loadTestConfig(t *testing.T, path string, args ...string) *config.Config {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
testutil.ClearEnvPrefix(t, "LOGWISP_")
|
testutil.ClearEnvPrefix(t, "LOGWISP_")
|
||||||
m, err := config.Load(append([]string{"-c", path, "--quiet", "--status_reporter=false"}, args...))
|
inv, err := parseCommandLine(append([]string{"-c", path, "--quiet", "--status_reporter=false"}, args...))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
m, err := config.Load(inv.load)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -30,3 +35,15 @@ func loadTestConfig(t *testing.T, path string, args ...string) *config.Config {
|
|||||||
}
|
}
|
||||||
return cfg
|
return cfg
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// runCommand runs `lw NAME ARGS...` and returns its exit status and output
|
||||||
|
func runCommand(t *testing.T, name string, args ...string) (code int, stdout, stderr string) {
|
||||||
|
t.Helper()
|
||||||
|
inv, err := parseCommandLine(append([]string{name}, args...))
|
||||||
|
if err != nil || inv.command == nil {
|
||||||
|
t.Fatalf("lw %s: not a command: %v", name, err)
|
||||||
|
}
|
||||||
|
var out, errOut bytes.Buffer
|
||||||
|
code = inv.command.run(inv.args, &out, &errOut)
|
||||||
|
return code, out.String(), errOut.String()
|
||||||
|
}
|
||||||
+147
@@ -0,0 +1,147 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto"
|
||||||
|
"crypto/x509"
|
||||||
|
"errors"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/lixenwraith/logwisp/internal/tlsx"
|
||||||
|
)
|
||||||
|
|
||||||
|
// maxDays keeps a validity well inside time.Duration, whose overflow would
|
||||||
|
// date a certificate in the past
|
||||||
|
const maxDays = 36500
|
||||||
|
|
||||||
|
var tlsCommands = []subcommand{
|
||||||
|
{"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]",
|
||||||
|
"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")
|
||||||
|
name := fs.String("name", "logwisp CA", "subject common `name`")
|
||||||
|
days := fs.Int("days", int(tlsx.CAValidity/(24*time.Hour)), "validity in `days`")
|
||||||
|
return func(_, stderr io.Writer) error {
|
||||||
|
if *days < 1 || *days > 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 {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := writePair(*dir, "ca", cert, key); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
fmt.Fprintf(stderr, "wrote %s and %s, valid until %s\n"+
|
||||||
|
"ca.crt verifies: dialers' tls.ca_file, listeners' tls.client_ca_file; with ca.key, it signs: lw tls cert, tls.issuer_cert_file\n",
|
||||||
|
filepath.Join(*dir, "ca.crt"), filepath.Join(*dir, "ca.key"), cert.NotAfter.UTC().Format(time.DateOnly))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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)")
|
||||||
|
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)")
|
||||||
|
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)")
|
||||||
|
return func(_, stderr io.Writer) error {
|
||||||
|
switch {
|
||||||
|
case !*server && !*client:
|
||||||
|
return usageError("-server, -client or both is required")
|
||||||
|
case *days < 1 || *days > 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))
|
||||||
|
}
|
||||||
|
issuer, signer, err := tlsx.LoadIssuer(filepath.Join(*caDir, "ca.crt"), filepath.Join(*caDir, "ca.key"))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
leaf := tlsx.Leaf{Name: *name, Server: *server, Client: *client, Validity: time.Duration(*days) * 24 * time.Hour}
|
||||||
|
for h := range strings.SplitSeq(*hosts, ",") {
|
||||||
|
if h = strings.TrimSpace(h); h != "" {
|
||||||
|
leaf.Hosts = append(leaf.Hosts, h)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(leaf.Hosts) == 0 && *server {
|
||||||
|
leaf.Hosts = []string{*name}
|
||||||
|
}
|
||||||
|
key, err := tlsx.NewKey()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
cert, err := leaf.Issue(&key.PublicKey, issuer, signer)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
dir := *out
|
||||||
|
if dir == "" {
|
||||||
|
dir = *caDir
|
||||||
|
}
|
||||||
|
if err := writePair(dir, *name, cert, key); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
fmt.Fprintf(stderr, "wrote %s and %s for %v, valid until %s, pin_sha256 %s\n",
|
||||||
|
filepath.Join(dir, *name+".crt"), filepath.Join(dir, *name+".key"),
|
||||||
|
append(leaf.Hosts, "CN="+*name), cert.NotAfter.UTC().Format(time.DateOnly), tlsx.PinSHA256(cert))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// writePair writes NAME.crt (0644) and NAME.key (0600) in dir, refusing to
|
||||||
|
// replace either: a reissued key would silently break every pin and peer.
|
||||||
|
func writePair(dir, name string, cert *x509.Certificate, key crypto.PrivateKey) error {
|
||||||
|
keyPEM, err := tlsx.EncodeKey(key)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
certPath, keyPath := filepath.Join(dir, name+".crt"), filepath.Join(dir, name+".key")
|
||||||
|
for _, p := range []string{certPath, keyPath} {
|
||||||
|
if _, err := os.Lstat(p); !errors.Is(err, os.ErrNotExist) {
|
||||||
|
return fmt.Errorf("%s exists; remove it to replace it", p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := writeNew(keyPath, keyPEM, 0o600); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := writeNew(certPath, tlsx.EncodeCert(cert), 0o644); err != nil {
|
||||||
|
os.Remove(keyPath)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeNew(path string, data []byte, mode os.FileMode) error {
|
||||||
|
f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, mode)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err = f.Write(data)
|
||||||
|
if err == nil {
|
||||||
|
err = f.Sync()
|
||||||
|
}
|
||||||
|
if cerr := f.Close(); err == nil {
|
||||||
|
err = cerr
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
os.Remove(path)
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
+15
-3
@@ -26,6 +26,7 @@
|
|||||||
quiet = false # No log of lw's own; pipeline output still flows
|
quiet = false # No log of lw's own; pipeline output still flows
|
||||||
status_reporter = true # Periodic status logging (30s, DEBUG level); false without a file
|
status_reporter = true # Periodic status logging (30s, DEBUG level); false without a file
|
||||||
auto_reload = false # Watch file; read at startup (signals always reread it)
|
auto_reload = false # Watch file; read at startup (signals always reread it)
|
||||||
|
color = "auto" # auto|always|never: level names on console sinks; auto on a terminal, NO_COLOR unset
|
||||||
|
|
||||||
###############################################################################
|
###############################################################################
|
||||||
### Logging (LogWisp's internal operational logging)
|
### Logging (LogWisp's internal operational logging)
|
||||||
@@ -93,10 +94,12 @@ name = "default"
|
|||||||
### TLS (shared shape; applies to every network source and sink)
|
### TLS (shared shape; applies to every network source and sink)
|
||||||
###
|
###
|
||||||
### Listeners (tcp/http sinks, tcp_chain/http_chain sources):
|
### Listeners (tcp/http sinks, tcp_chain/http_chain sources):
|
||||||
### cert_file + key_file are REQUIRED; client_auth + client_ca_file enable mTLS.
|
### cert_file + key_file, self_signed, or issuer_cert_file + issuer_key_file
|
||||||
|
### (exactly one); client_auth + client_ca_file enable mTLS.
|
||||||
### Dialers (tcp_chain/http_chain sinks):
|
### Dialers (tcp_chain/http_chain sinks):
|
||||||
### ca_file + server_name verify the server; cert_file + key_file present a
|
### ca_file + server_name, or pin_sha256, verify the server; cert_file +
|
||||||
### client identity for mTLS.
|
### key_file present a client identity for mTLS.
|
||||||
|
### `lw tls ca` / `lw tls cert` make a CA and certificates.
|
||||||
###
|
###
|
||||||
### enabled = false Master switch
|
### enabled = false Master switch
|
||||||
### cert_file = "" Local certificate
|
### cert_file = "" Local certificate
|
||||||
@@ -105,7 +108,15 @@ name = "default"
|
|||||||
### client_ca_file = "" Listener: CA bundle verifying client certs
|
### client_ca_file = "" Listener: CA bundle verifying client certs
|
||||||
### ca_file = "" Dialer: CA bundle verifying the server (empty = system)
|
### ca_file = "" Dialer: CA bundle verifying the server (empty = system)
|
||||||
### server_name = "" Dialer: SNI / name to verify (empty = configured host)
|
### server_name = "" Dialer: SNI / name to verify (empty = configured host)
|
||||||
|
### self_signed = false Listener: make a self-signed certificate at startup;
|
||||||
|
### it logs the pin_sha256 dialers set (changes on restart)
|
||||||
|
### issuer_cert_file = "" Listener: make one at startup signed by this CA ...
|
||||||
|
### issuer_key_file = "" ... and its key (lw tls ca writes both)
|
||||||
|
### hosts = [] Listener: names/addresses of a made certificate, beyond
|
||||||
|
### host, os.Hostname(), localhost, 127.0.0.1, ::1
|
||||||
### insecure_skip_verify = false Dialer: disable verification (never in prod)
|
### insecure_skip_verify = false Dialer: disable verification (never in prod)
|
||||||
|
### pin_sha256 = "" Dialer: sha256//BASE64 of the server key, in place of
|
||||||
|
### ca_file; ';' between several
|
||||||
### min_version = "1.3" "1.2" or "1.3". No max_version, no cipher_suites.
|
### min_version = "1.3" "1.2" or "1.3". No max_version, no cipher_suites.
|
||||||
###
|
###
|
||||||
### TLS alone is a CA membership check: ANY certificate the CA signed is
|
### TLS alone is a CA membership check: ANY certificate the CA signed is
|
||||||
@@ -257,6 +268,7 @@ type = "console"
|
|||||||
target = "stdout" # stdout|stderr ("split" NOT supported)
|
target = "stdout" # stdout|stderr ("split" NOT supported)
|
||||||
# buffer_size = 1000
|
# buffer_size = 1000
|
||||||
# escape = "auto" # auto|always|never: control characters as <hex>, auto on a terminal
|
# escape = "auto" # auto|always|never: control characters as <hex>, auto on a terminal
|
||||||
|
# color = "auto" # auto|always|never: level names in color (default: top-level color)
|
||||||
|
|
||||||
## File sink (rotating)
|
## File sink (rotating)
|
||||||
# [[pipelines.plugin_sinks]]
|
# [[pipelines.plugin_sinks]]
|
||||||
|
|||||||
+1
-2
@@ -31,8 +31,7 @@ streams, or downstream LogWisp nodes.
|
|||||||
manual, [`lw.1`](lw.1)
|
manual, [`lw.1`](lw.1)
|
||||||
- [Operations](operations.md): running, monitoring, tuning, troubleshooting
|
- [Operations](operations.md): running, monitoring, tuning, troubleshooting
|
||||||
- [To Do](todo.md): planned work in priority order: network access control
|
- [To Do](todo.md): planned work in priority order: network access control
|
||||||
and the PROXY protocol, a hardening review of the config libraries,
|
and the PROXY protocol, packaging
|
||||||
packaging
|
|
||||||
|
|
||||||
A fully annotated configuration covering every option lives at
|
A fully annotated configuration covering every option lives at
|
||||||
[`config/logwisp.toml`](../config/logwisp.toml).
|
[`config/logwisp.toml`](../config/logwisp.toml).
|
||||||
|
|||||||
+3
-1
@@ -26,7 +26,9 @@ nodes can filter and reformat as if the entries were local.
|
|||||||
|
|
||||||
Both chain sources can feed a single pipeline (fan-in) whose sinks then fan the
|
Both chain sources can feed a single pipeline (fan-in) whose sinks then fan the
|
||||||
merged stream out. `test/chain-test.sh` builds the two-independent-pipelines
|
merged stream out. `test/chain-test.sh` builds the two-independent-pipelines
|
||||||
variant; `test/chain-aggregate-test.sh` builds the fan-in variant.
|
variant; `test/chain-aggregate-test.sh` builds the fan-in variant. The `edge`
|
||||||
|
and `aggregator` [presets](cli.md#presets) build an authenticated TLS link of
|
||||||
|
either transport from a few keys.
|
||||||
|
|
||||||
## Node Identity
|
## Node Identity
|
||||||
|
|
||||||
|
|||||||
+111
-13
@@ -4,7 +4,11 @@
|
|||||||
lw [options] [< input]
|
lw [options] [< input]
|
||||||
lw -c <file> [options]
|
lw -c <file> [options]
|
||||||
lw --check [options]
|
lw --check [options]
|
||||||
|
lw --dump [options]
|
||||||
|
lw --preset NAME[,key=value...] [options]
|
||||||
lw auth <command> [flags]
|
lw auth <command> [flags]
|
||||||
|
lw tls <command> [flags]
|
||||||
|
lw preset <name> [flags]
|
||||||
lw help | -h | --help
|
lw help | -h | --help
|
||||||
lw --version
|
lw --version
|
||||||
```
|
```
|
||||||
@@ -14,9 +18,10 @@ stdout, line for line, until the end of input ([Built-in
|
|||||||
Defaults](#built-in-defaults)). With a file it runs the file's pipelines as a
|
Defaults](#built-in-defaults)). With a file it runs the file's pipelines as a
|
||||||
service.
|
service.
|
||||||
|
|
||||||
`lw auth` manages SCRAM credentials and logs in to `scram` listeners; see
|
A [preset](#presets) builds a common pipeline from a few keys. `lw auth`
|
||||||
[below](#lw-auth). There is no certificate-generation command: use
|
manages SCRAM credentials and logs in to `scram` listeners
|
||||||
`openssl` or your PKI tooling — see [Security](security.md#enabling-mtls).
|
([below](#lw-auth)); `lw tls` makes a certificate authority and certificates
|
||||||
|
([below](#lw-tls)). A command is recognized only as the first argument.
|
||||||
|
|
||||||
## Options
|
## Options
|
||||||
|
|
||||||
@@ -36,6 +41,11 @@ pipelines have [their own flags](#pipelines):
|
|||||||
`./logwisp.toml` ([Configuration](configuration.md#file-location))
|
`./logwisp.toml` ([Configuration](configuration.md#file-location))
|
||||||
- `--check`: build every pipeline and plugin, bind nothing, report and exit;
|
- `--check`: build every pipeline and plugin, bind nothing, report and exit;
|
||||||
see [Usage Patterns](#usage-patterns)
|
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
|
- `--quiet`: silence lw's own log and notices, default `false`; pipeline
|
||||||
output still flows
|
output still flows
|
||||||
- `--status_reporter=<bool>`: periodic status logging, default `true` with a
|
- `--status_reporter=<bool>`: periodic status logging, default `true` with a
|
||||||
@@ -70,7 +80,10 @@ Pipeline flags define whole pipelines. They replace the configuration file's
|
|||||||
pipelines and the built-in default; every other key keeps its precedence.
|
pipelines and the built-in default; every other key keeps its precedence.
|
||||||
|
|
||||||
- `--pipeline NAME` starts a pipeline; specs before the first one go to a
|
- `--pipeline NAME` starts a pipeline; specs before the first one go to a
|
||||||
pipeline named `cli`
|
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
|
||||||
- `--source SPEC`, `--sink SPEC` and `--filter SPEC` add a stage, repeatable
|
- `--source SPEC`, `--sink SPEC` and `--filter SPEC` add a stage, repeatable
|
||||||
- `--format SPEC`, `--rate-limit SPEC` and `--heartbeat SPEC`, once per pipeline
|
- `--format SPEC`, `--rate-limit SPEC` and `--heartbeat SPEC`, once per pipeline
|
||||||
- both `--flag SPEC` and `--flag=SPEC` work; `--` ends options
|
- both `--flag SPEC` and `--flag=SPEC` work; `--` ends options
|
||||||
@@ -129,6 +142,61 @@ lw --pipeline app --source file,directory=/var/log/app \
|
|||||||
|
|
||||||
Pipelines over stdin and stdout are under [Usage Patterns](#usage-patterns).
|
Pipelines over stdin and stdout are under [Usage Patterns](#usage-patterns).
|
||||||
|
|
||||||
|
## Presets
|
||||||
|
|
||||||
|
`--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;
|
||||||
|
`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
|
||||||
|
`from` (`end`, or `start`) is where reading a file starts.
|
||||||
|
|
||||||
|
- `pipe`: stdin to stdout, the built-in default
|
||||||
|
- `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)
|
||||||
|
- `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
|
||||||
|
- `edge`: forward to an aggregator over TLS (`tcp_chain`, or `http_chain`
|
||||||
|
with `transport=http`)
|
||||||
|
- `to` (required), `path`, `from`, `transport` (`tcp`), `node`
|
||||||
|
- verify the aggregator by `ca` (a CA file; default: system roots) or `pin`
|
||||||
|
(its `tls.pin_sha256`), and `server_name`
|
||||||
|
- authenticate by `user` and `password_file` (SCRAM), or a client `cert` and
|
||||||
|
`key` (mTLS): without either the preset refuses to run
|
||||||
|
- `aggregator`: receive from edges over TLS, to stdout or files
|
||||||
|
- `listen` (`0.0.0.0:9000`), `transport` (`tcp`), `format` (`json`)
|
||||||
|
- `tls`: `self` (default), `issuer` or `files`, as for `serve`; never `off`
|
||||||
|
- authenticate by `users` (SCRAM) or `client_ca` (mTLS): one is required
|
||||||
|
- `out`: a directory for `aggregate*.log` files; default stdout
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# follow a directory, level names in color on a terminal
|
||||||
|
lw --preset tail,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
|
||||||
|
|
||||||
|
# an aggregator with a certificate made at startup, and an edge pinning it
|
||||||
|
lw --preset aggregator,users=/etc/logwisp/users.toml,out=/var/log/edges
|
||||||
|
lw --preset edge,path=/var/log/app,to=agg.example.org:9000,pin=sha256//BASE64,\
|
||||||
|
user=edge-01,password_file=/etc/logwisp/edge-01.pass
|
||||||
|
```
|
||||||
|
|
||||||
|
A self-signed aggregator logs its pin at startup (`pin_sha256`, a warning, so
|
||||||
|
it shows without a configuration file too). The pin holds across reloads and
|
||||||
|
changes when lw restarts; a fleet signs with an issuer instead, and edges set
|
||||||
|
`ca`. See [Security](security.md#certificates-made-at-startup).
|
||||||
|
|
||||||
## Environment Variables
|
## Environment Variables
|
||||||
|
|
||||||
Configuration paths map to environment variables by replacing `.` with `_`,
|
Configuration paths map to environment variables by replacing `.` with `_`,
|
||||||
@@ -156,6 +224,7 @@ One pipeline can come from the environment, with the [SPEC](#pipelines) syntax.
|
|||||||
Any pipeline flag on the command line makes LogWisp ignore all of them.
|
Any pipeline flag on the command line makes LogWisp ignore all of them.
|
||||||
|
|
||||||
- `LOGWISP_PIPELINE`: the pipeline's name, default `cli`
|
- `LOGWISP_PIPELINE`: the pipeline's name, default `cli`
|
||||||
|
- `LOGWISP_PRESET`: the [preset](#presets) that starts it
|
||||||
- `LOGWISP_SOURCE`, `LOGWISP_SINK`, `LOGWISP_FILTER`: one stage each
|
- `LOGWISP_SOURCE`, `LOGWISP_SINK`, `LOGWISP_FILTER`: one stage each
|
||||||
- `LOGWISP_SOURCE_1` .. `LOGWISP_SOURCE_N` add more, after the unnumbered
|
- `LOGWISP_SOURCE_1` .. `LOGWISP_SOURCE_N` add more, after the unnumbered
|
||||||
one in numeric order; likewise `LOGWISP_SINK_N` and `LOGWISP_FILTER_N`
|
one in numeric order; likewise `LOGWISP_SINK_N` and `LOGWISP_FILTER_N`
|
||||||
@@ -211,7 +280,7 @@ service untouched; see [Configuration](configuration.md#hot-reload).
|
|||||||
## Exit Codes
|
## Exit Codes
|
||||||
|
|
||||||
- `0`: the end of input, a clean shutdown (`SIGINT`, `SIGTERM`), `--version` /
|
- `0`: the end of input, a clean shutdown (`SIGINT`, `SIGTERM`), `--version` /
|
||||||
`--help`, or a valid `--check`
|
`--help`, `--dump`, or a valid `--check`
|
||||||
- `1`: general error: a configuration load or validation failure (`--check`
|
- `1`: general error: a configuration load or validation failure (`--check`
|
||||||
included), a logger init failure, a service bootstrap failure
|
included), a logger init failure, a service bootstrap failure
|
||||||
- `2`: an explicitly requested configuration file is not found
|
- `2`: an explicitly requested configuration file is not found
|
||||||
@@ -239,8 +308,8 @@ lw < app.log > copy.log
|
|||||||
- A line over 1 MiB continues in the next entry; no byte is lost.
|
- A line over 1 MiB continues in the next entry; no byte is lost.
|
||||||
- Nothing is dropped: a slow reader slows the reading of stdin.
|
- Nothing is dropped: a slow reader slows the reading of stdin.
|
||||||
- On a terminal, control characters are written as `<hex>` (`ESC` becomes
|
- On a terminal, control characters are written as `<hex>` (`ESC` becomes
|
||||||
`<1b>`); pipes and files get the bytes unchanged
|
`<1b>`) and level names are in color; pipes and files get the bytes
|
||||||
([console sink](sinks.md#console)).
|
unchanged ([console sink](sinks.md#console)).
|
||||||
- When a console source reads a terminal, lw says so once on stderr, unless
|
- When a console source reads a terminal, lw says so once on stderr, unless
|
||||||
`--quiet`: `lw: reading standard input; Ctrl-D ends it (lw --help for usage)`.
|
`--quiet`: `lw: reading standard input; Ctrl-D ends it (lw --help for usage)`.
|
||||||
|
|
||||||
@@ -255,10 +324,9 @@ values from the file, the environment or flags win.
|
|||||||
finishes when all its sources have ended, and once every pipeline has
|
finishes when all its sources have ended, and once every pipeline has
|
||||||
finished lw shuts down gracefully and exits 0. Only a console source ends on
|
finished lw shuts down gracefully and exits 0. Only a console source ends on
|
||||||
its own: file and network sources run until a signal, and so does a pipeline
|
its own: file and network sources run until a signal, and so does a pipeline
|
||||||
holding one. Console and file sinks write what is queued before the
|
holding one. Every sink writes what is queued before the exit; the `http`
|
||||||
exit. The `http` and `tcp` sinks do not yet flush their clients' queues, so a
|
and `tcp` sinks give each connected client `write_timeout_ms`, at most 2 s,
|
||||||
connected client can miss the last entries of a finite input
|
to take it.
|
||||||
([To Do](todo.md)).
|
|
||||||
|
|
||||||
## Usage Patterns
|
## Usage Patterns
|
||||||
|
|
||||||
@@ -363,8 +431,9 @@ Neither touches a running LogWisp: send `SIGHUP`, since `auto_reload` does not
|
|||||||
watch the credentials file.
|
watch the credentials file.
|
||||||
|
|
||||||
**`token`** and **`stream`** build the same TLS and SCRAM client as a chain
|
**`token`** and **`stream`** build the same TLS and SCRAM client as a chain
|
||||||
sink. TLS flags: `-ca-file` (default: system roots), `-server-name` (default:
|
sink. TLS flags: `-ca-file` (default: system roots) or `-pin-sha256` (the
|
||||||
the host), and `-cert-file` / `-key-file` for a listener with `tls.client_auth`.
|
`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
|
There is no flag to skip verification: an unverified server could relay the
|
||||||
login. Redirects are not followed. `-unbound` logs in to an `http` sink behind
|
login. Redirects are not followed. `-unbound` logs in to an `http` sink behind
|
||||||
a TLS-terminating proxy (`auth.trusted_proxies`), still pinning the proxy's
|
a TLS-terminating proxy (`auth.trusted_proxies`), still pinning the proxy's
|
||||||
@@ -403,3 +472,32 @@ curl --cacert ca.crt -H @<(printf 'Authorization: Bearer %s\n' \
|
|||||||
lw auth stream -addr HOST:PORT -user viewer -password-file viewer.pass \
|
lw auth stream -addr HOST:PORT -user viewer -password-file viewer.pass \
|
||||||
-ca-file ca.crt
|
-ca-file ca.crt
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## `lw tls`
|
||||||
|
|
||||||
|
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]
|
||||||
|
```
|
||||||
|
|
||||||
|
- `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;
|
||||||
|
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
|
||||||
|
```
|
||||||
@@ -46,9 +46,12 @@ Top-level keys, each as type and default:
|
|||||||
emit a status report every 30 s at DEBUG level.
|
emit a status report every 30 s at DEBUG level.
|
||||||
- `auto_reload` (bool, `false`): watch the config file and reload pipelines on
|
- `auto_reload` (bool, `false`): watch the config file and reload pipelines on
|
||||||
change.
|
change.
|
||||||
|
- `color` (string, `"auto"`): level names in color on console sinks, which may
|
||||||
|
set their own: `"auto"` (a terminal, `NO_COLOR` unset, `TERM` not `dumb`),
|
||||||
|
`"always"` or `"never"`; see the [console sink](sinks.md#console).
|
||||||
|
|
||||||
`--version` prints version information and exits; it is not a persistent
|
`--version`, `--check` and `--dump` print and exit; they are not persistent
|
||||||
setting.
|
settings.
|
||||||
|
|
||||||
Note that `status_reporter` writes at DEBUG level, so it produces nothing unless
|
Note that `status_reporter` writes at DEBUG level, so it produces nothing unless
|
||||||
`logging.level = "debug"`.
|
`logging.level = "debug"`.
|
||||||
@@ -311,6 +314,7 @@ What a reload applies:
|
|||||||
- pipelines, sources, sinks
|
- pipelines, sources, sinks
|
||||||
- filters, formatters, rate limits, heartbeats
|
- filters, formatters, rate limits, heartbeats
|
||||||
- `status_reporter`
|
- `status_reporter`
|
||||||
|
- `color`
|
||||||
- Not reloaded:
|
- Not reloaded:
|
||||||
- `logging.*`: applied once at startup
|
- `logging.*`: applied once at startup
|
||||||
- `quiet`
|
- `quiet`
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
.Dd October 3, 2026
|
.Dd October 4, 2026
|
||||||
.Dt LW 1
|
.Dt LW 1
|
||||||
.Os
|
.Os
|
||||||
.Sh NAME
|
.Sh NAME
|
||||||
@@ -6,10 +6,12 @@
|
|||||||
.Nd LogWisp log transport
|
.Nd LogWisp log transport
|
||||||
.Sh SYNOPSIS
|
.Sh SYNOPSIS
|
||||||
.Nm
|
.Nm
|
||||||
.Op Fl \-check
|
.Op Fl \-check | Fl \-dump
|
||||||
.Op Fl c Ar file
|
.Op Fl c Ar file
|
||||||
.Op Fl \-quiet
|
.Op Fl \-quiet
|
||||||
|
.Op Fl \-color Op Ar when
|
||||||
.Op Fl \- Ns Ar key Ns = Ns Ar value ...
|
.Op Fl \- Ns Ar key Ns = Ns Ar value ...
|
||||||
|
.Op Fl \-preset Ar spec
|
||||||
.Op Fl \-pipeline Ar name
|
.Op Fl \-pipeline Ar name
|
||||||
.Op Fl \-source Ar spec ...
|
.Op Fl \-source Ar spec ...
|
||||||
.Op Fl \-sink Ar spec ...
|
.Op Fl \-sink Ar spec ...
|
||||||
@@ -18,7 +20,7 @@
|
|||||||
.Op Fl \-rate\-limit Ar spec
|
.Op Fl \-rate\-limit Ar spec
|
||||||
.Op Fl \-heartbeat Ar spec
|
.Op Fl \-heartbeat Ar spec
|
||||||
.Nm
|
.Nm
|
||||||
.Cm auth
|
.Cm auth | tls | preset
|
||||||
.Ar command
|
.Ar command
|
||||||
.Op Ar flags
|
.Op Ar flags
|
||||||
.Nm
|
.Nm
|
||||||
@@ -63,11 +65,13 @@ source runs until shutdown.
|
|||||||
When every pipeline has finished,
|
When every pipeline has finished,
|
||||||
.Nm
|
.Nm
|
||||||
shuts down gracefully and exits 0.
|
shuts down gracefully and exits 0.
|
||||||
Console and file sinks first write what they hold; the
|
Every sink first writes what it holds; the
|
||||||
.Cm http
|
.Cm http
|
||||||
and
|
and
|
||||||
.Cm tcp
|
.Cm tcp
|
||||||
sinks do not yet, so a client can miss the last lines of a finite input.
|
sinks give each client
|
||||||
|
.Cm write_timeout_ms ,
|
||||||
|
at most 2 seconds, to take it.
|
||||||
Under a supervisor, standard input is usually
|
Under a supervisor, standard input is usually
|
||||||
.Pa /dev/null :
|
.Pa /dev/null :
|
||||||
a console source there ends at once.
|
a console source there ends at once.
|
||||||
@@ -88,6 +92,13 @@ option sets this:
|
|||||||
.Cm always
|
.Cm always
|
||||||
or
|
or
|
||||||
.Cm never .
|
.Cm never .
|
||||||
|
Its
|
||||||
|
.Cm color
|
||||||
|
option, by default the top-level
|
||||||
|
.Cm color ,
|
||||||
|
paints the level name in each line: debug cyan, info green, warn yellow,
|
||||||
|
error bold red, trace grey, in the 16 colors every terminal and text console
|
||||||
|
renders, with the terminal theme choosing the shades.
|
||||||
.Pp
|
.Pp
|
||||||
.Nm
|
.Nm
|
||||||
stays in the foreground and logs to standard error; run a configuration as a
|
stays in the foreground and logs to standard error; run a configuration as a
|
||||||
@@ -109,6 +120,22 @@ Set any scalar configuration key by its TOML path, such as
|
|||||||
A bare
|
A bare
|
||||||
.Fl \- Ns Ar key
|
.Fl \- Ns Ar key
|
||||||
sets a boolean to true.
|
sets a boolean to true.
|
||||||
|
.It Fl \-color Op Ar when
|
||||||
|
Color the level names console sinks write:
|
||||||
|
.Cm auto
|
||||||
|
.Pq the default: on a terminal, with Ev NO_COLOR unset and Ev TERM not Cm dumb ,
|
||||||
|
.Cm always
|
||||||
|
.Pq what a bare Fl \-color No means
|
||||||
|
or
|
||||||
|
.Cm never .
|
||||||
|
A console sink's own
|
||||||
|
.Cm color
|
||||||
|
option takes precedence.
|
||||||
|
The codes go into whatever the format makes, JSON included, so output another
|
||||||
|
program parses keeps
|
||||||
|
.Cm auto
|
||||||
|
or
|
||||||
|
.Cm never .
|
||||||
.It Fl \-quiet
|
.It Fl \-quiet
|
||||||
Turn off the log of
|
Turn off the log of
|
||||||
.Nm
|
.Nm
|
||||||
@@ -151,6 +178,11 @@ or opening, reading or creating a log file; print
|
|||||||
and exit 0, or the error and exit 1, or 2 when a named configuration file is
|
and exit 0, or the error and exit 1, or 2 when a named configuration file is
|
||||||
missing
|
missing
|
||||||
.Pq see Sx EXIT STATUS .
|
.Pq see Sx EXIT STATUS .
|
||||||
|
.It Fl \-dump
|
||||||
|
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 \-version
|
||||||
Print the version, commit and build time, and exit.
|
Print the version, commit and build time, and exit.
|
||||||
.It Fl h , Fl \-help , Cm help
|
.It Fl h , Fl \-help , Cm help
|
||||||
@@ -176,7 +208,12 @@ Start a new pipeline.
|
|||||||
Specifications before the first
|
Specifications before the first
|
||||||
.Fl \-pipeline
|
.Fl \-pipeline
|
||||||
belong to a pipeline named
|
belong to a pipeline named
|
||||||
.Dq cli .
|
.Dq cli ,
|
||||||
|
or after the preset that starts them.
|
||||||
|
.It 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.
|
||||||
.It Fl \-source Ar spec , Fl \-sink Ar spec , Fl \-filter Ar spec
|
.It Fl \-source Ar spec , Fl \-sink Ar spec , Fl \-filter Ar spec
|
||||||
Add a source, sink or filter; each is repeatable.
|
Add a source, sink or filter; each is repeatable.
|
||||||
.It Fl \-format Ar spec , Fl \-rate\-limit Ar spec , Fl \-heartbeat Ar spec
|
.It Fl \-format Ar spec , Fl \-rate\-limit Ar spec , Fl \-heartbeat Ar spec
|
||||||
@@ -218,6 +255,125 @@ Any pipeline flag on the command line makes
|
|||||||
.Nm
|
.Nm
|
||||||
ignore the pipeline variables in
|
ignore the pipeline variables in
|
||||||
.Sx ENVIRONMENT .
|
.Sx ENVIRONMENT .
|
||||||
|
.Ss Presets
|
||||||
|
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 ...
|
||||||
|
prints the pipeline it expands to, and
|
||||||
|
.Fl h
|
||||||
|
there lists its keys and defaults.
|
||||||
|
A
|
||||||
|
.Ar path
|
||||||
|
is a file, a directory, whose files are followed, or a glob; without one, a
|
||||||
|
preset that takes it reads standard input.
|
||||||
|
.Bl -tag -width Ds
|
||||||
|
.It Cm pipe Op Cm format
|
||||||
|
Standard input to standard output, the pipeline
|
||||||
|
.Nm
|
||||||
|
runs without a configuration.
|
||||||
|
.It Cm tail path Op Cm from , format
|
||||||
|
Follow files to standard output, like
|
||||||
|
.Ql tail \-F .
|
||||||
|
.It Cm serve Oo Cm path , listen , format , tls , users , proxy , viewer Oc
|
||||||
|
Serve a live HTTP stream on
|
||||||
|
.Cm listen
|
||||||
|
.Pq default Li 127.0.0.1:8080 .
|
||||||
|
.Cm tls
|
||||||
|
is
|
||||||
|
.Cm off ,
|
||||||
|
.Cm self
|
||||||
|
.Pq a self-signed certificate made at startup ,
|
||||||
|
.Cm issuer
|
||||||
|
.Pq one signed by Cm issuer_cert No and Cm issuer_key
|
||||||
|
or
|
||||||
|
.Cm files
|
||||||
|
.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
|
||||||
|
.Cm proxy ,
|
||||||
|
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
|
||||||
|
.Cm to
|
||||||
|
over TLS, by
|
||||||
|
.Cm tcp_chain
|
||||||
|
or, with
|
||||||
|
.Cm transport=http ,
|
||||||
|
.Cm http_chain .
|
||||||
|
It refuses to run without credentials:
|
||||||
|
.Cm user
|
||||||
|
and
|
||||||
|
.Cm password_file ,
|
||||||
|
or a client
|
||||||
|
.Cm cert
|
||||||
|
and
|
||||||
|
.Cm key .
|
||||||
|
.It Cm aggregator Oo Cm listen , transport , tls , users , client_ca , out , format Oc
|
||||||
|
Receive from edges on
|
||||||
|
.Cm listen
|
||||||
|
.Pq default Li 0.0.0.0:9000
|
||||||
|
over TLS, never off
|
||||||
|
.Pq default Cm tls=self ,
|
||||||
|
authenticated by the
|
||||||
|
.Cm users
|
||||||
|
credentials file or the client certificates
|
||||||
|
.Cm client_ca
|
||||||
|
vouches for, and write to standard output or to files in
|
||||||
|
.Cm out .
|
||||||
|
.El
|
||||||
|
.Ss Certificate commands
|
||||||
|
.Nm
|
||||||
|
.Cm tls
|
||||||
|
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
|
||||||
|
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
|
||||||
|
Issue
|
||||||
|
.Pa name.crt
|
||||||
|
and
|
||||||
|
.Pa name.key
|
||||||
|
from that authority, valid 397 days and never past it.
|
||||||
|
.Fl host
|
||||||
|
lists the DNS names and addresses a server certificate carries
|
||||||
|
.Pq default: Ar name ;
|
||||||
|
.Ar name
|
||||||
|
is also the subject, the identity
|
||||||
|
.Cm auth.type=mtls
|
||||||
|
checks.
|
||||||
|
.El
|
||||||
|
.Pp
|
||||||
|
A listener needs no files:
|
||||||
|
.Cm tls.self_signed=true ,
|
||||||
|
or
|
||||||
|
.Cm tls.issuer_cert_file
|
||||||
|
with
|
||||||
|
.Cm tls.issuer_key_file ,
|
||||||
|
make its certificate at startup, for
|
||||||
|
.Cm tls.hosts ,
|
||||||
|
its
|
||||||
|
.Cm host ,
|
||||||
|
this machine's name, localhost and the loopback addresses.
|
||||||
|
Its key lasts until
|
||||||
|
.Nm
|
||||||
|
exits, across reloads.
|
||||||
|
A self-signed listener logs the
|
||||||
|
.Cm tls.pin_sha256
|
||||||
|
that dialers set in place of
|
||||||
|
.Cm tls.ca_file ;
|
||||||
|
it changes when
|
||||||
|
.Nm
|
||||||
|
restarts, so a fleet uses an issuer instead.
|
||||||
.Ss Authentication commands
|
.Ss Authentication commands
|
||||||
.Nm
|
.Nm
|
||||||
.Cm auth
|
.Cm auth
|
||||||
@@ -258,7 +414,9 @@ The
|
|||||||
.Ar tls-flags
|
.Ar tls-flags
|
||||||
are
|
are
|
||||||
.Fl ca-file Ar file
|
.Fl ca-file Ar file
|
||||||
.Pq default: the system roots ,
|
.Pq default: the system roots
|
||||||
|
or
|
||||||
|
.Fl pin-sha256 Ar pin ,
|
||||||
.Fl server-name Ar name ,
|
.Fl server-name Ar name ,
|
||||||
and
|
and
|
||||||
.Fl cert-file Ar file
|
.Fl cert-file Ar file
|
||||||
@@ -290,6 +448,8 @@ The directory holding
|
|||||||
.Pa logwisp.toml .
|
.Pa logwisp.toml .
|
||||||
.It Ev LOGWISP_PIPELINE
|
.It Ev LOGWISP_PIPELINE
|
||||||
The name of the one pipeline the variables below define.
|
The name of the one pipeline the variables below define.
|
||||||
|
.It Ev LOGWISP_PRESET
|
||||||
|
The preset that starts it.
|
||||||
All of these are ignored when a pipeline option is on the command line.
|
All of these are ignored when a pipeline option is on the command line.
|
||||||
.It Ev LOGWISP_SOURCE , LOGWISP_SINK , LOGWISP_FILTER
|
.It Ev LOGWISP_SOURCE , LOGWISP_SINK , LOGWISP_FILTER
|
||||||
Source, sink and filter specifications, in the syntax of the matching
|
Source, sink and filter specifications, in the syntax of the matching
|
||||||
@@ -299,6 +459,13 @@ counting from 1, adds more, as in
|
|||||||
.Ev LOGWISP_SINK_1 .
|
.Ev LOGWISP_SINK_1 .
|
||||||
.It Ev LOGWISP_FORMAT , LOGWISP_RATE_LIMIT , LOGWISP_HEARTBEAT
|
.It Ev LOGWISP_FORMAT , LOGWISP_RATE_LIMIT , LOGWISP_HEARTBEAT
|
||||||
The pipeline's format, rate limit and heartbeat specifications.
|
The pipeline's format, rate limit and heartbeat specifications.
|
||||||
|
.It Ev NO_COLOR , TERM
|
||||||
|
Set to anything,
|
||||||
|
.Ev NO_COLOR
|
||||||
|
turns
|
||||||
|
.Cm auto
|
||||||
|
color off, as does
|
||||||
|
.Ev TERM=dumb .
|
||||||
.El
|
.El
|
||||||
.Pp
|
.Pp
|
||||||
Secrets never go in the environment: TLS keys, credentials files and
|
Secrets never go in the environment: TLS keys, credentials files and
|
||||||
@@ -357,8 +524,11 @@ as it ends
|
|||||||
the shell reports status 141.
|
the shell reports status 141.
|
||||||
.Pp
|
.Pp
|
||||||
.Nm
|
.Nm
|
||||||
.Cm auth
|
.Cm auth ,
|
||||||
exits 0 on success or help, 1 on failure, and 2 on a usage error.
|
.Cm tls
|
||||||
|
and
|
||||||
|
.Cm preset
|
||||||
|
exit 0 on success or help, 1 on failure, and 2 on a usage error.
|
||||||
.Sh SIGNALS
|
.Sh SIGNALS
|
||||||
.Bl -tag -width "SIGINT, SIGTERM"
|
.Bl -tag -width "SIGINT, SIGTERM"
|
||||||
.It Dv SIGINT , SIGTERM
|
.It Dv SIGINT , SIGTERM
|
||||||
@@ -405,6 +575,33 @@ $ lw \-\-source 'file,directory=/var/log/app,pattern=*.log,raw=true' \e
|
|||||||
> all.log
|
> all.log
|
||||||
.Ed
|
.Ed
|
||||||
.Pp
|
.Pp
|
||||||
|
Follow every file in a directory, with the level names in color on a
|
||||||
|
terminal:
|
||||||
|
.Bd -literal -offset indent
|
||||||
|
$ lw \-\-preset tail,path=/var/log/app
|
||||||
|
.Ed
|
||||||
|
.Pp
|
||||||
|
Print the configuration a set of options makes, to keep as a file:
|
||||||
|
.Bd -literal -offset indent
|
||||||
|
$ lw \-\-preset serve,path=/var/log/app,tls=self \-\-dump > serve.toml
|
||||||
|
.Ed
|
||||||
|
.Pp
|
||||||
|
Ship a directory from an edge to an aggregator: on the aggregator, create
|
||||||
|
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
|
||||||
|
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
|
||||||
|
edge# lw \-\-preset edge,path=/var/log/app,to=agg.example.org:9000,\e
|
||||||
|
ca=/etc/logwisp/pki/ca.crt,user=edge-01,password_file=/root/edge-01.pass
|
||||||
|
.Ed
|
||||||
|
.Pp
|
||||||
Check a configuration without binding a port or touching a log file:
|
Check a configuration without binding a port or touching a log file:
|
||||||
.Bd -literal -offset indent
|
.Bd -literal -offset indent
|
||||||
$ lw \-\-check \-c /etc/logwisp/logwisp.toml
|
$ lw \-\-check \-c /etc/logwisp/logwisp.toml
|
||||||
|
|||||||
@@ -213,6 +213,9 @@ headers, and entry encoding.
|
|||||||
`client_ca_file` does not contain the issuer of the client certificate.
|
`client_ca_file` does not contain the issuer of the client certificate.
|
||||||
- `certificate is not valid for any names` / SAN mismatch — the dialed `host`
|
- `certificate is not valid for any names` / SAN mismatch — the dialed `host`
|
||||||
is not covered by the server certificate's SANs; set `server_name`.
|
is not covered by the server certificate's SANs; set `server_name`.
|
||||||
|
- `server key sha256//... matches no tls.pin_sha256` — the listener is not the
|
||||||
|
one pinned, or a self-signed listener restarted and made a new key: take the
|
||||||
|
pin from its startup warning, or move it to an issuer and `ca_file`.
|
||||||
- `protocol version not supported` — one side is pinned to `min_version = "1.3"`
|
- `protocol version not supported` — one side is pinned to `min_version = "1.3"`
|
||||||
and the other cannot negotiate it.
|
and the other cannot negotiate it.
|
||||||
- Listeners log handshake failures at WARN with the remote address (the `tcp`
|
- Listeners log handshake failures at WARN with the remote address (the `tcp`
|
||||||
|
|||||||
+4
-2
@@ -17,8 +17,10 @@ lw exits on its own only at the end of input: once every pipeline has
|
|||||||
finished, a pipeline finishing when all its sources have ended. Only a console
|
finished, a pipeline finishing when all its sources have ended. Only a console
|
||||||
source ends, when stdin does; file and network sources run until a signal, and
|
source ends, when stdin does; file and network sources run until a signal, and
|
||||||
lw shuts down gracefully, exit 0, either way.
|
lw shuts down gracefully, exit 0, either way.
|
||||||
Console and file sinks write what is queued before the exit; the `http` and
|
Every sink writes what is queued before the exit; the `http` and `tcp` sinks
|
||||||
`tcp` sinks do not yet flush their clients' queues ([To Do](todo.md)). See
|
give each client `write_timeout_ms`, at most 2 s, to take it. A reload stops
|
||||||
|
the old sinks the same way, so a client that stopped reading delays it by
|
||||||
|
that much. See
|
||||||
[CLI](cli.md#built-in-defaults).
|
[CLI](cli.md#built-in-defaults).
|
||||||
|
|
||||||
There is no built-in daemon mode. Run LogWisp in the foreground under a
|
There is no built-in daemon mode. Run LogWisp in the foreground under a
|
||||||
|
|||||||
+72
-13
@@ -8,7 +8,11 @@ configure it, and — equally important — what it does not yet do.
|
|||||||
**Implemented:**
|
**Implemented:**
|
||||||
|
|
||||||
- TLS 1.2 / 1.3 on all network sources and sinks.
|
- TLS 1.2 / 1.3 on all network sources and sinks.
|
||||||
- Server certificate verification by dialers.
|
- Server certificate verification by dialers, by a CA or by the pin of the
|
||||||
|
server's key (`pin_sha256`).
|
||||||
|
- Certificates without files: listeners make theirs at startup, self-signed
|
||||||
|
or from an issuer CA, and `lw tls` makes a CA and certificates: see
|
||||||
|
[Certificates made at startup](#certificates-made-at-startup).
|
||||||
- Mutual TLS (client certificate required and verified), at the transport
|
- Mutual TLS (client certificate required and verified), at the transport
|
||||||
layer.
|
layer.
|
||||||
- Peer identity recorded per session.
|
- Peer identity recorded per session.
|
||||||
@@ -26,16 +30,20 @@ configure it, and — equally important — what it does not yet do.
|
|||||||
[Startup Warnings](#startup-warnings).
|
[Startup Warnings](#startup-warnings).
|
||||||
- Unknown configuration keys rejected: a typo in `tls`, `auth` or a table path
|
- Unknown configuration keys rejected: a typo in `tls`, `auth` or a table path
|
||||||
fails startup.
|
fails startup.
|
||||||
- Configuration input hardened (lixenwraith/config v0.2.2, reviewed with
|
- Configuration input hardened (lixenwraith/config v0.2.2 and
|
||||||
adversarial tests and fuzzing like `auth`):
|
lixenwraith/toml v0.1.3, reviewed with adversarial tests and fuzzing like
|
||||||
|
`auth`):
|
||||||
|
- a document creates at most 65536 tables, so a small file cannot exhaust
|
||||||
|
memory, and unescaped control characters are refused in strings and
|
||||||
|
comments;
|
||||||
- a string in the file is one list entry, so a filter pattern holding a
|
- a string in the file is one list entry, so a filter pattern holding a
|
||||||
comma is not split into patterns that match nothing;
|
comma is not split into patterns that match nothing;
|
||||||
- the file is opened without blocking, so a FIFO swapped in for it cannot
|
- the file is opened without blocking, so a FIFO swapped in for it cannot
|
||||||
hang the watcher or a `SIGHUP` reload;
|
hang the watcher or a `SIGHUP` reload;
|
||||||
- a permission change on the file is reported once and does not stop
|
- a permission change on the file is reported once and does not stop
|
||||||
auto-reload;
|
auto-reload;
|
||||||
- conversion errors name the key, never the value, so a secret placed in
|
- parse and conversion errors name the key, never the value, so a secret
|
||||||
the wrong key does not reach the log.
|
placed in the wrong key does not reach the log.
|
||||||
|
|
||||||
**Not implemented:**
|
**Not implemented:**
|
||||||
|
|
||||||
@@ -69,6 +77,11 @@ client_ca_file = ""
|
|||||||
ca_file = ""
|
ca_file = ""
|
||||||
server_name = ""
|
server_name = ""
|
||||||
insecure_skip_verify = false
|
insecure_skip_verify = false
|
||||||
|
pin_sha256 = ""
|
||||||
|
self_signed = false
|
||||||
|
issuer_cert_file = ""
|
||||||
|
issuer_key_file = ""
|
||||||
|
hosts = []
|
||||||
min_version = "1.3"
|
min_version = "1.3"
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -76,11 +89,16 @@ Options by role, each with its default:
|
|||||||
|
|
||||||
- Both roles
|
- Both roles
|
||||||
- `enabled` (`false`): master switch; when false the whole block is ignored.
|
- `enabled` (`false`): master switch; when false the whole block is ignored.
|
||||||
- `cert_file`: local certificate; **required** for listeners, an optional
|
- `cert_file`: local certificate; a listener needs it, `self_signed` or the
|
||||||
client identity for dialers.
|
issuer files, a dialer may present it as its client identity.
|
||||||
- `key_file`: private key for `cert_file`; set the two together.
|
- `key_file`: private key for `cert_file`; set the two together.
|
||||||
- `min_version` (`"1.3"`): `"1.2"` or `"1.3"`.
|
- `min_version` (`"1.3"`): `"1.2"` or `"1.3"`.
|
||||||
- Listeners
|
- Listeners
|
||||||
|
- `self_signed` (`false`): make a self-signed certificate at startup.
|
||||||
|
- `issuer_cert_file`, `issuer_key_file`: make one at startup, signed by this
|
||||||
|
CA (`lw tls ca`).
|
||||||
|
- `hosts` (`[]`): names and addresses a made certificate carries, beyond the
|
||||||
|
listener's `host`, `os.Hostname()`, `localhost`, `127.0.0.1` and `::1`.
|
||||||
- `client_auth` (`false`): require and verify a client certificate (mTLS).
|
- `client_auth` (`false`): require and verify a client certificate (mTLS).
|
||||||
- `client_ca_file`: CA bundle that verifies client certificates; **required**
|
- `client_ca_file`: CA bundle that verifies client certificates; **required**
|
||||||
when `client_auth` is true.
|
when `client_auth` is true.
|
||||||
@@ -89,6 +107,9 @@ Options by role, each with its default:
|
|||||||
- `server_name` (the configured `host`): SNI and certificate name to verify
|
- `server_name` (the configured `host`): SNI and certificate name to verify
|
||||||
against.
|
against.
|
||||||
- `insecure_skip_verify` (`false`): disable server verification.
|
- `insecure_skip_verify` (`false`): disable server verification.
|
||||||
|
- `pin_sha256`: `sha256//BASE64` of the server's public key, curl's
|
||||||
|
`--pinnedpubkey` form, `;` between several; it replaces the CA and name
|
||||||
|
checks.
|
||||||
|
|
||||||
Listeners are the `tcp` and `http` sinks and the `tcp_chain` and `http_chain`
|
Listeners are the `tcp` and `http` sinks and the `tcp_chain` and `http_chain`
|
||||||
sources; dialers are the `tcp_chain` and `http_chain` sinks.
|
sources; dialers are the `tcp_chain` and `http_chain` sinks.
|
||||||
@@ -102,7 +123,12 @@ sources; dialers are the `tcp_chain` and `http_chain` sinks.
|
|||||||
|
|
||||||
Misconfiguration fails at plugin construction, before the pipeline starts:
|
Misconfiguration fails at plugin construction, before the pipeline starts:
|
||||||
|
|
||||||
- a listener with `enabled = true` and no `cert_file`/`key_file`
|
- a listener with `enabled = true` and none of `cert_file`/`key_file`,
|
||||||
|
`self_signed`, `issuer_cert_file`/`issuer_key_file`, or more than one; an
|
||||||
|
issuer that is no CA (`lw tls ca` makes one)
|
||||||
|
- `pin_sha256` with `ca_file` or `insecure_skip_verify`, or not of the
|
||||||
|
`sha256//BASE64` form; a listener key on a dialer, or `pin_sha256` on a
|
||||||
|
listener
|
||||||
- `client_auth = true` with no `client_ca_file`
|
- `client_auth = true` with no `client_ca_file`
|
||||||
- a dialer with only one of `cert_file` / `key_file`
|
- a dialer with only one of `cert_file` / `key_file`
|
||||||
- a certificate or key that will not load, or a CA file containing no
|
- a certificate or key that will not load, or a CA file containing no
|
||||||
@@ -298,20 +324,53 @@ Errors read like `auth: type "mtls" requires tls.client_auth`.
|
|||||||
Some settings work but are usually mistakes. Each plugin reports them at WARN
|
Some settings work but are usually mistakes. Each plugin reports them at WARN
|
||||||
when it is constructed, so startup and every reload repeat them:
|
when it is constructed, so startup and every reload repeat them:
|
||||||
|
|
||||||
- a certificate in `cert_file`, `ca_file` or `client_ca_file` that has expired,
|
- a certificate in `cert_file`, `ca_file`, `client_ca_file` or
|
||||||
is not yet valid, or expires within 30 days
|
`issuer_cert_file` that has expired, is not yet valid, or expires within 30
|
||||||
|
days
|
||||||
|
- a self-signed listener, with the `pin_sha256` its dialers need
|
||||||
- `insecure_skip_verify` on a dialer
|
- `insecure_skip_verify` on a dialer
|
||||||
- an `allow_patterns` entry not anchored at both ends of every alternative
|
- an `allow_patterns` entry not anchored at both ends of every alternative
|
||||||
(`^a|b$` admits `a…` and `…b`)
|
(`^a|b$` admits `a…` and `…b`)
|
||||||
- a `key_file`, `credentials_file` or `password_file` every local user can read
|
- a `key_file`, `issuer_key_file`, `credentials_file` or `password_file` every
|
||||||
|
local user can read
|
||||||
(once per path per process)
|
(once per path per process)
|
||||||
|
|
||||||
|
## Certificates made at startup
|
||||||
|
|
||||||
|
A listener needs no certificate files. With `self_signed = true`, or with
|
||||||
|
`issuer_cert_file` and `issuer_key_file`, it makes an ECDSA P-256 key once per
|
||||||
|
process and, at startup and every reload, a certificate for it valid 397 days
|
||||||
|
(never past the issuer), for `hosts`, its `host` (unless a wildcard),
|
||||||
|
`os.Hostname()`, `localhost`, `127.0.0.1` and `::1`.
|
||||||
|
|
||||||
|
- **Self-signed.** No CA can vouch for it, so dialers pin its key:
|
||||||
|
`pin_sha256`, which the listener logs at WARN on every start. A reload
|
||||||
|
keeps the key and so the pin; a restart changes both, and every dialer must
|
||||||
|
be given the new pin. Suits one aggregator and a few edges, a test, or a
|
||||||
|
stream read by `curl --pinnedpubkey`.
|
||||||
|
- **Issuer.** `lw tls ca` makes a CA once; the listener signs its own
|
||||||
|
certificate with it, and dialers verify it with `ca_file = ca.crt`, by name,
|
||||||
|
across restarts. The CA key then lives on the listener's host: give each
|
||||||
|
listener group its own CA, or issue certificates elsewhere with
|
||||||
|
`lw tls cert` and use `cert_file`.
|
||||||
|
- **Pins.** A pin is the SHA-256 of the server's public key, not of its
|
||||||
|
certificate, so it survives reissue. `pin_sha256` accepts several, `;`
|
||||||
|
between them, for a rotation. It replaces chain, name and validity checks:
|
||||||
|
whoever holds the key is the server. `scram` dialers still bind every login
|
||||||
|
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
|
||||||
|
```
|
||||||
|
|
||||||
## Enabling mTLS
|
## Enabling mTLS
|
||||||
|
|
||||||
### 1. Generate a CA and certificates
|
### 1. Generate a CA and certificates
|
||||||
|
|
||||||
LogWisp does not generate certificates; use `openssl`, `cfssl`, `step-cli`, or
|
`lw tls` makes them ([above](#certificates-made-at-startup)); with `openssl`,
|
||||||
your existing PKI.
|
`cfssl`, `step-cli` or an existing PKI:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# CA
|
# CA
|
||||||
|
|||||||
+19
-6
@@ -27,6 +27,7 @@ type = "console"
|
|||||||
target = "stdout"
|
target = "stdout"
|
||||||
buffer_size = 1000
|
buffer_size = 1000
|
||||||
escape = "auto"
|
escape = "auto"
|
||||||
|
color = "auto"
|
||||||
```
|
```
|
||||||
|
|
||||||
Options, each as type and default:
|
Options, each as type and default:
|
||||||
@@ -36,6 +37,9 @@ Options, each as type and default:
|
|||||||
- `escape` (string, `auto`): write control characters as `<hex>` of their
|
- `escape` (string, `auto`): write control characters as `<hex>` of their
|
||||||
bytes, `ESC` as `<1b>`: `auto` when the output is a terminal, `always`, or
|
bytes, `ESC` as `<1b>`: `auto` when the output is a terminal, `always`, or
|
||||||
`never`.
|
`never`.
|
||||||
|
- `color` (string, the top-level `color`, itself `auto`): paint level names:
|
||||||
|
`auto` on a terminal with `NO_COLOR` unset and `TERM` not `dumb`, `always`,
|
||||||
|
or `never`.
|
||||||
|
|
||||||
> `split` is **not** a valid target for this sink and is rejected at startup.
|
> `split` is **not** a valid target for this sink and is rejected at startup.
|
||||||
> Level-based splitting exists only for LogWisp's own application log
|
> Level-based splitting exists only for LogWisp's own application log
|
||||||
@@ -50,6 +54,14 @@ Payloads are written as the formatter made them, one record per line.
|
|||||||
characters, such as the joiners emoji and Indic scripts need, are kept.
|
characters, such as the joiners emoji and Indic scripts need, are kept.
|
||||||
`never` keeps an application's own ANSI colours; under `auto`, pipes and
|
`never` keeps an application's own ANSI colours; under `auto`, pipes and
|
||||||
files get the bytes unchanged.
|
files get the bytes unchanged.
|
||||||
|
- Color: the first whole word in a line, in any case, that names the entry's
|
||||||
|
level is painted: `DEBUG`/`DBG` cyan (blue is hard to read on black),
|
||||||
|
`INFO`/`INF` green, `WARNING`/`WARN` yellow, `ERROR`/`ERR`/`FATAL` bold red,
|
||||||
|
`TRACE` grey. The colors are ANSI 16, so the terminal's theme picks the
|
||||||
|
shades and a Linux or BSD text console renders them; the codes are the
|
||||||
|
sink's own, written after escaping. A line whose source found no level
|
||||||
|
stays plain. The codes go into whatever the format makes, JSON included:
|
||||||
|
for output another program parses keep `auto` or `never`, not `always`.
|
||||||
- Backpressure: the sink never drops. When its output is slow (a slow reader,
|
- Backpressure: the sink never drops. When its output is slow (a slow reader,
|
||||||
a paused terminal, a stalled log collector) the pipeline waits for it, with
|
a paused terminal, a stalled log collector) the pipeline waits for it, with
|
||||||
its other sinks. A service whose stdout may stall writes to a `file` sink
|
its other sinks. A service whose stdout may stall writes to a `file` sink
|
||||||
@@ -205,10 +217,11 @@ Options, each as type and default:
|
|||||||
- Clients whose session has been idle-expired by the session manager are
|
- Clients whose session has been idle-expired by the session manager are
|
||||||
evicted by the broker. With the idle comment above, that reaches only a peer
|
evicted by the broker. With the idle comment above, that reaches only a peer
|
||||||
that has stopped accepting bytes on a sink configured `write_timeout_ms = 0`.
|
that has stopped accepting bytes on a sink configured `write_timeout_ms = 0`.
|
||||||
- On shutdown, connected clients receive
|
- On shutdown (a reload too), each stream writes its queue and what remains
|
||||||
`event: disconnect / data: {"reason":"server_shutdown"}`. Entries still
|
of the sink's input, then
|
||||||
queued for them are not flushed first, so a client can miss the last ones
|
`event: disconnect / data: {"reason":"server_shutdown"}`, on its own and
|
||||||
of a finite input ([To Do](todo.md)).
|
within `write_timeout_ms`, at most 2 s; a client still behind is cut then,
|
||||||
|
and one that stopped reading costs the others nothing.
|
||||||
- HTTP/2 is negotiated via ALPN when TLS is enabled; plaintext is HTTP/1.1.
|
- HTTP/2 is negotiated via ALPN when TLS is enabled; plaintext is HTTP/1.1.
|
||||||
|
|
||||||
**Status endpoint** returns service and version identity, host, port, TLS flag,
|
**Status endpoint** returns service and version identity, host, port, TLS flag,
|
||||||
@@ -287,8 +300,8 @@ Options, each as type and default:
|
|||||||
whole timeout, so the client is disconnected immediately rather than retried.
|
whole timeout, so the client is disconnected immediately rather than retried.
|
||||||
- A client whose send queue is full has that event dropped (`dropped_writes`)
|
- A client whose send queue is full has that event dropped (`dropped_writes`)
|
||||||
and stays connected.
|
and stays connected.
|
||||||
- On shutdown, entries still queued for clients are not flushed first, as
|
- On shutdown, as with the `http` sink, each client receives what is queued,
|
||||||
with the `http` sink.
|
within `write_timeout_ms`, at most 2 s, before it is disconnected.
|
||||||
- With TLS enabled the handshake runs under a 10 s bound *after* the
|
- With TLS enabled the handshake runs under a 10 s bound *after* the
|
||||||
`max_connections` check, so concurrent handshakes are bounded too.
|
`max_connections` check, so concurrent handshakes are bounded too.
|
||||||
- With an `auth` block, authorization runs after that handshake and *before*
|
- With an `auth` block, authorization runs after that handshake and *before*
|
||||||
|
|||||||
+1
-17
@@ -122,23 +122,7 @@ connection level, below TLS, so a refused peer costs no handshake.
|
|||||||
- Denied and allowed clients; per-client SCRAM budgets behind one proxy
|
- Denied and allowed clients; per-client SCRAM budgets behind one proxy
|
||||||
address.
|
address.
|
||||||
|
|
||||||
## 2. Flush network sinks at shutdown
|
## 2. Packaging: AUR, FreeBSD ports, Debian
|
||||||
|
|
||||||
At the end of a finite input lw shuts down, and the `http` and `tcp` sinks
|
|
||||||
disconnect their clients without writing the entries still queued for them, so
|
|
||||||
a client can miss the last ones. On `Stop`, each sink's broker should first move
|
|
||||||
its input queue into the client queues, then let every client writer drain its
|
|
||||||
queue within a bound (`write_timeout_ms`) before the disconnect frame. Verify
|
|
||||||
with a finite stdin into each sink and a connected client counting lines.
|
|
||||||
|
|
||||||
## 3. Bump `lixenwraith/toml`
|
|
||||||
|
|
||||||
Its hardening commits (a table budget, TOML 1.1 escapes, control characters
|
|
||||||
refused outside strings, bounded errors that never print a value) wait to merge
|
|
||||||
in that repository. Then bump it here and drop this item; security.md's library
|
|
||||||
note already covers it.
|
|
||||||
|
|
||||||
## 4. Packaging: AUR, FreeBSD ports, Debian
|
|
||||||
|
|
||||||
The foundation exists:
|
The foundation exists:
|
||||||
- The `lw` binary name, free in Arch (official repositories and AUR), Ubuntu
|
- The `lw` binary name, free in Arch (official repositories and AUR), Ubuntu
|
||||||
|
|||||||
@@ -4,8 +4,10 @@ go 1.27.1
|
|||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/lixenwraith/auth v0.0.0-20261002191639-3ed0e995807c
|
github.com/lixenwraith/auth v0.0.0-20261002191639-3ed0e995807c
|
||||||
|
github.com/lixenwraith/color v0.1.3
|
||||||
github.com/lixenwraith/config v0.2.2
|
github.com/lixenwraith/config v0.2.2
|
||||||
github.com/lixenwraith/log v0.1.11
|
github.com/lixenwraith/log v0.1.11
|
||||||
|
github.com/lixenwraith/terminal v0.1.9
|
||||||
github.com/lixenwraith/toml v0.1.3
|
github.com/lixenwraith/toml v0.1.3
|
||||||
golang.org/x/term v0.46.0
|
golang.org/x/term v0.46.0
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -2,10 +2,14 @@ github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63Y
|
|||||||
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||||
github.com/lixenwraith/auth v0.0.0-20261002191639-3ed0e995807c h1:zwpVa85g267vo13UCzCao4lfVgUPaU79CmxPW9OdF8w=
|
github.com/lixenwraith/auth v0.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.0.0-20261002191639-3ed0e995807c/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=
|
github.com/lixenwraith/config v0.2.2 h1:X+l3Tf8Rbk49cipYBJU7/pfdw5wDwD4hRxtFH7rZgOc=
|
||||||
github.com/lixenwraith/config v0.2.2/go.mod h1:yfJQwKS+/6Dg70GIoHeYc0eYztOPETpqe6nUIbakcyg=
|
github.com/lixenwraith/config v0.2.2/go.mod h1:yfJQwKS+/6Dg70GIoHeYc0eYztOPETpqe6nUIbakcyg=
|
||||||
github.com/lixenwraith/log v0.1.11 h1:dKWLnaR8dhXxj4l/aU5XZiFIsr8geaJQbOf8m7SiKhU=
|
github.com/lixenwraith/log v0.1.11 h1:dKWLnaR8dhXxj4l/aU5XZiFIsr8geaJQbOf8m7SiKhU=
|
||||||
github.com/lixenwraith/log v0.1.11/go.mod h1:EE8i5nSAW2yZBTytKuhdgehmgzrby6kW1k5DyH1SvBg=
|
github.com/lixenwraith/log v0.1.11/go.mod h1:EE8i5nSAW2yZBTytKuhdgehmgzrby6kW1k5DyH1SvBg=
|
||||||
|
github.com/lixenwraith/terminal v0.1.9 h1:WPxLdqdzZoqtPgTb86oEwE66lF1+iPsJ/gpfo6h/rik=
|
||||||
|
github.com/lixenwraith/terminal v0.1.9/go.mod h1:f9Ntk+xKBS2LWQEe6gk/1TOpG22HH0scTe4wlPhEy44=
|
||||||
github.com/lixenwraith/toml v0.1.3 h1:mhnNVLNogdaqjGd3eC4rV9ZqY3j1Sv+DK1erZ+SPFeI=
|
github.com/lixenwraith/toml v0.1.3 h1:mhnNVLNogdaqjGd3eC4rV9ZqY3j1Sv+DK1erZ+SPFeI=
|
||||||
github.com/lixenwraith/toml v0.1.3/go.mod h1:itcT/Vt5mwmX9KVGCMsWZr6vbU/JgZ24wV/I0J5cMt0=
|
github.com/lixenwraith/toml v0.1.3/go.mod h1:itcT/Vt5mwmX9KVGCMsWZr6vbU/JgZ24wV/I0J5cMt0=
|
||||||
golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M=
|
golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M=
|
||||||
|
|||||||
@@ -129,8 +129,9 @@ func New(o *config.AuthOptions, tlsCfg *tls.Config, role Role, transport Transpo
|
|||||||
if tlsCfg == nil && (o.Type != MethodSCRAM || len(o.TrustedProxies) == 0) {
|
if tlsCfg == nil && (o.Type != MethodSCRAM || len(o.TrustedProxies) == 0) {
|
||||||
return nil, fmt.Errorf("auth: type %q requires tls.enabled", o.Type)
|
return nil, fmt.Errorf("auth: type %q requires tls.enabled", o.Type)
|
||||||
}
|
}
|
||||||
if role == RoleDialer && tlsCfg != nil && tlsCfg.InsecureSkipVerify {
|
if role == RoleDialer && tlsCfg != nil && tlsCfg.InsecureSkipVerify && tlsCfg.VerifyPeerCertificate == nil {
|
||||||
// An unverified server makes identities claims and exposes credentials
|
// An unverified server makes identities claims and exposes credentials;
|
||||||
|
// tls.pin_sha256 verifies it in VerifyPeerCertificate instead
|
||||||
return nil, fmt.Errorf("auth: type %q cannot be used with tls.insecure_skip_verify", o.Type)
|
return nil, fmt.Errorf("auth: type %q cannot be used with tls.insecure_skip_verify", o.Type)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -167,10 +167,13 @@ func TestNewValidation(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// The rows' bases are valid: a dialer needs TLS but not client_auth, as it
|
// The rows' bases are valid: a dialer needs TLS but not client_auth, as it
|
||||||
// pins the server's identity
|
// pins the server's identity; tls.pin_sha256 verifies in place of the chain
|
||||||
|
pinned := &tls.Config{InsecureSkipVerify: true, VerifyPeerCertificate: func([][]byte, [][]*x509.Certificate) error { return nil }}
|
||||||
for _, o := range []*config.AuthOptions{{Type: MethodMTLS}, dialer(config.AuthOptions{})} {
|
for _, o := range []*config.AuthOptions{{Type: MethodMTLS}, dialer(config.AuthOptions{})} {
|
||||||
if _, err := New(o, &tls.Config{}, RoleDialer, TCP); err != nil {
|
for _, c := range []*tls.Config{{}, pinned} {
|
||||||
t.Fatalf("dialer policy %s rejected: %v", o.Type, err)
|
if _, err := New(o, c, RoleDialer, TCP); err != nil {
|
||||||
|
t.Fatalf("dialer policy %s rejected: %v", o.Type, err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if _, err := New(listener(config.AuthOptions{TokenLifetimeMS: 60000}), f.serverTLS, RoleListener, HTTP); err != nil {
|
if _, err := New(listener(config.AuthOptions{TokenLifetimeMS: 60000}), f.serverTLS, RoleListener, HTTP); err != nil {
|
||||||
|
|||||||
@@ -7,8 +7,13 @@ type Config struct {
|
|||||||
// Top-level flags for application control
|
// Top-level flags for application control
|
||||||
ShowVersion bool `toml:"version"`
|
ShowVersion bool `toml:"version"`
|
||||||
Check bool `toml:"check"` // build every plugin, report, exit: lw --check
|
Check bool `toml:"check"` // build every plugin, report, exit: lw --check
|
||||||
|
Dump bool `toml:"dump"` // print the effective configuration, exit: lw --dump
|
||||||
Quiet bool `toml:"quiet"`
|
Quiet bool `toml:"quiet"`
|
||||||
|
|
||||||
|
// Console sinks without their own color: "auto" (a terminal, NO_COLOR
|
||||||
|
// unset, TERM not dumb), "always", "never"
|
||||||
|
Color string `toml:"color"`
|
||||||
|
|
||||||
// Runtime behavior flags
|
// Runtime behavior flags
|
||||||
StatusReporter bool `toml:"status_reporter"`
|
StatusReporter bool `toml:"status_reporter"`
|
||||||
ConfigAutoReload bool `toml:"auto_reload"`
|
ConfigAutoReload bool `toml:"auto_reload"`
|
||||||
@@ -85,7 +90,7 @@ type PipelineConfig struct {
|
|||||||
type FlowConfig struct {
|
type FlowConfig struct {
|
||||||
Heartbeat *HeartbeatConfig `toml:"heartbeat"`
|
Heartbeat *HeartbeatConfig `toml:"heartbeat"`
|
||||||
RateLimit *RateLimitConfig `toml:"rate_limit"`
|
RateLimit *RateLimitConfig `toml:"rate_limit"`
|
||||||
Filters []FilterConfig `toml:"filters"`
|
Filters []FilterConfig `toml:"filters,omitempty"`
|
||||||
Format *FormatConfig `toml:"format"`
|
Format *FormatConfig `toml:"format"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -105,9 +110,9 @@ type HeartbeatConfig struct {
|
|||||||
// FormatConfig is a polymorphic struct representing log entry formatting options
|
// FormatConfig is a polymorphic struct representing log entry formatting options
|
||||||
type FormatConfig struct {
|
type FormatConfig struct {
|
||||||
Type string `toml:"type"` // "json", "txt", "raw"
|
Type string `toml:"type"` // "json", "txt", "raw"
|
||||||
Flags int64 `toml:"flags"`
|
Flags int64 `toml:"flags,omitempty"`
|
||||||
TimestampFormat string `toml:"timestamp_format"`
|
TimestampFormat string `toml:"timestamp_format,omitempty"`
|
||||||
SanitizerPolicy string `toml:"sanitizer_policy"` // "raw", "json", "txt", "shell"
|
SanitizerPolicy string `toml:"sanitizer_policy,omitempty"` // "raw", "json", "txt", "shell"
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Rate Limit Options ---
|
// --- Rate Limit Options ---
|
||||||
@@ -245,6 +250,7 @@ type ConsoleSinkOptions struct {
|
|||||||
Target string `toml:"target"` // "stdout", "stderr"
|
Target string `toml:"target"` // "stdout", "stderr"
|
||||||
BufferSize int64 `toml:"buffer_size"`
|
BufferSize int64 `toml:"buffer_size"`
|
||||||
Escape string `toml:"escape"` // control characters as <hex>: "auto" (on a terminal), "always", "never"
|
Escape string `toml:"escape"` // control characters as <hex>: "auto" (on a terminal), "always", "never"
|
||||||
|
Color string `toml:"color"` // level names in color: "auto", "always", "never"; default: the top-level color
|
||||||
}
|
}
|
||||||
|
|
||||||
// FileSinkOptions defines settings for a file-based sink
|
// FileSinkOptions defines settings for a file-based sink
|
||||||
@@ -366,9 +372,10 @@ type AuthOptions struct {
|
|||||||
// --- TLS Options ---
|
// --- TLS Options ---
|
||||||
|
|
||||||
// TLSOptions is one shape for both roles. Listeners present cert_file and
|
// TLSOptions is one shape for both roles. Listeners present cert_file and
|
||||||
// key_file and verify clients with client_auth and client_ca_file; dialers
|
// key_file, or a certificate issued at startup (self_signed, or from the
|
||||||
// verify the server with ca_file and server_name and may present
|
// issuer files), and verify clients with client_auth and client_ca_file;
|
||||||
// cert_file and key_file.
|
// dialers verify the server with ca_file and server_name, or pin_sha256, and
|
||||||
|
// may present cert_file and key_file.
|
||||||
type TLSOptions struct {
|
type TLSOptions struct {
|
||||||
Enabled bool `toml:"enabled"`
|
Enabled bool `toml:"enabled"`
|
||||||
|
|
||||||
@@ -376,6 +383,14 @@ type TLSOptions struct {
|
|||||||
CertFile string `toml:"cert_file"`
|
CertFile string `toml:"cert_file"`
|
||||||
KeyFile string `toml:"key_file"`
|
KeyFile string `toml:"key_file"`
|
||||||
|
|
||||||
|
// Listeners without cert_file: a certificate on a key made at startup,
|
||||||
|
// self-signed or signed by the issuer CA, for hosts plus the host option,
|
||||||
|
// os.Hostname(), localhost and the loopback addresses
|
||||||
|
SelfSigned bool `toml:"self_signed"`
|
||||||
|
IssuerCertFile string `toml:"issuer_cert_file"`
|
||||||
|
IssuerKeyFile string `toml:"issuer_key_file"`
|
||||||
|
Hosts []string `toml:"hosts"`
|
||||||
|
|
||||||
// Listener-side peer verification (mTLS)
|
// Listener-side peer verification (mTLS)
|
||||||
ClientAuth bool `toml:"client_auth"`
|
ClientAuth bool `toml:"client_auth"`
|
||||||
ClientCAFile string `toml:"client_ca_file"`
|
ClientCAFile string `toml:"client_ca_file"`
|
||||||
@@ -384,6 +399,7 @@ type TLSOptions struct {
|
|||||||
CAFile string `toml:"ca_file"` // empty = system trust store
|
CAFile string `toml:"ca_file"` // empty = system trust store
|
||||||
ServerName string `toml:"server_name"` // default: config host
|
ServerName string `toml:"server_name"` // default: config host
|
||||||
InsecureSkipVerify bool `toml:"insecure_skip_verify"`
|
InsecureSkipVerify bool `toml:"insecure_skip_verify"`
|
||||||
|
PinSHA256 string `toml:"pin_sha256"` // sha256//BASE64 of the server key, ';' between several
|
||||||
|
|
||||||
// Minimum protocol version: "1.2" | "1.3" (default "1.3")
|
// Minimum protocol version: "1.2" | "1.3" (default "1.3")
|
||||||
MinVersion string `toml:"min_version"`
|
MinVersion string `toml:"min_version"`
|
||||||
|
|||||||
+33
-41
@@ -6,7 +6,6 @@ import (
|
|||||||
"io/fs"
|
"io/fs"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/lixenwraith/logwisp/internal/core"
|
"github.com/lixenwraith/logwisp/internal/core"
|
||||||
|
|
||||||
@@ -26,14 +25,18 @@ type Manager struct {
|
|||||||
specs []pipelineSpec // command-line or environment pipelines, kept across reloads
|
specs []pipelineSpec // command-line or environment pipelines, kept across reloads
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Args is a parsed command line; cmd/lw owns its grammar.
|
||||||
|
type Args struct {
|
||||||
|
File string // -c: the configuration file, "" for the environment or discovery
|
||||||
|
Specs []Spec // pipeline flags, in order
|
||||||
|
Overrides []string // --path=value settings, for lixenwraith/config
|
||||||
|
}
|
||||||
|
|
||||||
// Load reads the startup sources and validates the initial configuration.
|
// Load reads the startup sources and validates the initial configuration.
|
||||||
// Watching starts only when Watch is called after successful service startup.
|
// Watching starts only when Watch is called after successful service startup.
|
||||||
func Load(args []string) (*Manager, error) {
|
func Load(args Args) (*Manager, error) {
|
||||||
configPath, isExplicit, configArgs, err := resolveConfigPath(args)
|
configPath, isExplicit := resolveConfigPath(args.File)
|
||||||
if err != nil {
|
specs, err := cliSpecs(args.Specs)
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
specs, configArgs, err := takePipelineSpecs(configArgs)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -53,7 +56,7 @@ func Load(args []string) (*Manager, error) {
|
|||||||
cfg, err := lconfig.NewBuilder().
|
cfg, err := lconfig.NewBuilder().
|
||||||
WithTarget(initial).
|
WithTarget(initial).
|
||||||
WithEnvPrefix("LOGWISP_").
|
WithEnvPrefix("LOGWISP_").
|
||||||
WithArgs(configArgs).
|
WithArgs(args.Overrides).
|
||||||
WithFile(configPath).
|
WithFile(configPath).
|
||||||
WithTypedValidator(func(cfg *Config) error {
|
WithTypedValidator(func(cfg *Config) error {
|
||||||
effective := *cfg // the builder keeps cfg; only the copy is replaced
|
effective := *cfg // the builder keeps cfg; only the copy is replaced
|
||||||
@@ -96,6 +99,17 @@ func (m *Manager) Snapshot() (*Config, error) {
|
|||||||
if err := m.usePipelines(cfg); err != nil {
|
if err := m.usePipelines(cfg); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
// The top-level color is each console sink's default; the maps are fresh
|
||||||
|
for _, p := range cfg.Pipelines {
|
||||||
|
for i, s := range p.PluginSinks {
|
||||||
|
if _, set := s.Config["color"]; s.Type == "console" && !set {
|
||||||
|
if s.Config == nil {
|
||||||
|
p.PluginSinks[i].Config = map[string]any{}
|
||||||
|
}
|
||||||
|
p.PluginSinks[i].Config["color"] = cfg.Color
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
if err := checkFileKeys(m.path); err != nil {
|
if err := checkFileKeys(m.path); err != nil {
|
||||||
return nil, fmt.Errorf("config file %q: %w", m.path, err)
|
return nil, fmt.Errorf("config file %q: %w", m.path, err)
|
||||||
}
|
}
|
||||||
@@ -145,6 +159,7 @@ func (m *Manager) Close() { m.config.StopAutoUpdate() }
|
|||||||
func defaults() *Config {
|
func defaults() *Config {
|
||||||
return &Config{
|
return &Config{
|
||||||
StatusReporter: true,
|
StatusReporter: true,
|
||||||
|
Color: "auto",
|
||||||
Logging: &LogConfig{
|
Logging: &LogConfig{
|
||||||
Output: "stderr",
|
Output: "stderr",
|
||||||
Level: "info",
|
Level: "info",
|
||||||
@@ -174,51 +189,28 @@ func pipeDefault(name string) PipelineConfig {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// resolveConfigPath consumes file-selection flags before schema CLI parsing.
|
// resolveConfigPath picks the file: -c, then LOGWISP_CONFIG_FILE and
|
||||||
// The last selection wins, and -- terminates option handling.
|
// LOGWISP_CONFIG_DIR, then the first of the user's and the working directory's
|
||||||
func resolveConfigPath(args []string) (path string, isExplicit bool, remaining []string, err error) {
|
// logwisp.toml. Only the discovered default may be missing.
|
||||||
remaining = make([]string, 0, len(args))
|
func resolveConfigPath(file string) (path string, isExplicit bool) {
|
||||||
for i := 0; i < len(args); i++ {
|
if file != "" {
|
||||||
arg := args[i]
|
return file, true
|
||||||
if arg == "--" {
|
|
||||||
remaining = append(remaining, args[i:]...)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
switch {
|
|
||||||
case arg == "-c" || arg == "--config":
|
|
||||||
if i+1 == len(args) || args[i+1] == "" || strings.HasPrefix(args[i+1], "-") {
|
|
||||||
return "", false, nil, fmt.Errorf("%s requires a configuration file path", arg)
|
|
||||||
}
|
|
||||||
i++
|
|
||||||
path, isExplicit = args[i], true
|
|
||||||
case strings.HasPrefix(arg, "--config=") || strings.HasPrefix(arg, "-c="):
|
|
||||||
_, path, _ = strings.Cut(arg, "=")
|
|
||||||
if path == "" {
|
|
||||||
return "", false, nil, fmt.Errorf("%s requires a configuration file path", arg)
|
|
||||||
}
|
|
||||||
isExplicit = true
|
|
||||||
default:
|
|
||||||
remaining = append(remaining, arg)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if isExplicit {
|
|
||||||
return path, true, remaining, nil
|
|
||||||
}
|
}
|
||||||
if configFile := os.Getenv("LOGWISP_CONFIG_FILE"); configFile != "" {
|
if configFile := os.Getenv("LOGWISP_CONFIG_FILE"); configFile != "" {
|
||||||
path = configFile
|
path = configFile
|
||||||
if configDir := os.Getenv("LOGWISP_CONFIG_DIR"); configDir != "" {
|
if configDir := os.Getenv("LOGWISP_CONFIG_DIR"); configDir != "" {
|
||||||
path = filepath.Join(configDir, configFile)
|
path = filepath.Join(configDir, configFile)
|
||||||
}
|
}
|
||||||
return path, true, remaining, nil
|
return path, true
|
||||||
}
|
}
|
||||||
if configDir := os.Getenv("LOGWISP_CONFIG_DIR"); configDir != "" {
|
if configDir := os.Getenv("LOGWISP_CONFIG_DIR"); configDir != "" {
|
||||||
return filepath.Join(configDir, "logwisp.toml"), true, remaining, nil
|
return filepath.Join(configDir, "logwisp.toml"), true
|
||||||
}
|
}
|
||||||
if homeDir, err := os.UserHomeDir(); err == nil {
|
if homeDir, err := os.UserHomeDir(); err == nil {
|
||||||
path = filepath.Join(homeDir, ".config", "logwisp", "logwisp.toml")
|
path = filepath.Join(homeDir, ".config", "logwisp", "logwisp.toml")
|
||||||
if _, err := os.Stat(path); err == nil {
|
if _, err := os.Stat(path); err == nil {
|
||||||
return path, false, remaining, nil
|
return path, false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return "logwisp.toml", false, remaining, nil
|
return "logwisp.toml", false
|
||||||
}
|
}
|
||||||
@@ -12,29 +12,20 @@ import (
|
|||||||
"github.com/lixenwraith/logwisp/internal/testutil"
|
"github.com/lixenwraith/logwisp/internal/testutil"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestResolveConfigArguments(t *testing.T) {
|
// -c wins over the environment, which wins over discovery; only the
|
||||||
|
// discovered default is not explicit, so only it may be missing.
|
||||||
|
func TestResolveConfigPath(t *testing.T) {
|
||||||
isolateConfig(t)
|
isolateConfig(t)
|
||||||
for _, args := range [][]string{{"-c", "chosen.toml"}, {"--config", "chosen.toml"}, {"--config=chosen.toml"}, {"-c=chosen.toml"}, {"-c", "old.toml", "--config=chosen.toml"}} {
|
if path, explicit := resolveConfigPath(""); path != "logwisp.toml" || explicit {
|
||||||
path, explicit, remaining, err := resolveConfigPath(append(args, "--quiet"))
|
t.Fatalf("discovered default: %q %v", path, explicit)
|
||||||
if err != nil || path != "chosen.toml" || !explicit || !reflect.DeepEqual(remaining, []string{"--quiet"}) {
|
|
||||||
t.Fatalf("%v: %q %v %v %v", args, path, explicit, remaining, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for _, args := range [][]string{{"-c"}, {"--config"}, {"--config="}, {"-c="}, {"-c", "--quiet"}} {
|
|
||||||
if _, _, _, err := resolveConfigPath(args); err == nil {
|
|
||||||
t.Fatalf("missing path accepted: %v", args)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
args := []string{"--", "-c", "ignored.toml"}
|
|
||||||
path, explicit, remaining, err := resolveConfigPath(args)
|
|
||||||
if err != nil || explicit || path != "logwisp.toml" || !reflect.DeepEqual(remaining, args) {
|
|
||||||
t.Fatalf("terminator ignored: %q %v %v %v", path, explicit, remaining, err)
|
|
||||||
}
|
}
|
||||||
t.Setenv("LOGWISP_CONFIG_DIR", "configs")
|
t.Setenv("LOGWISP_CONFIG_DIR", "configs")
|
||||||
t.Setenv("LOGWISP_CONFIG_FILE", "env.toml")
|
t.Setenv("LOGWISP_CONFIG_FILE", "env.toml")
|
||||||
path, explicit, _, err = resolveConfigPath(nil)
|
if path, explicit := resolveConfigPath(""); path != filepath.Join("configs", "env.toml") || !explicit {
|
||||||
if err != nil || !explicit || path != filepath.Join("configs", "env.toml") {
|
t.Fatalf("environment path: %q %v", path, explicit)
|
||||||
t.Fatalf("environment path: %q %v %v", path, explicit, err)
|
}
|
||||||
|
if path, explicit := resolveConfigPath("chosen.toml"); path != "chosen.toml" || !explicit {
|
||||||
|
t.Fatalf("-c path: %q %v", path, explicit)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -43,7 +34,7 @@ func TestLoadPrecedenceAndDetachedSnapshots(t *testing.T) {
|
|||||||
testutil.WriteFile(t, "selected.toml", "config_file = \"ignored.toml\"\n[logging]\nlevel = \"error\"\n")
|
testutil.WriteFile(t, "selected.toml", "config_file = \"ignored.toml\"\n[logging]\nlevel = \"error\"\n")
|
||||||
t.Setenv("LOGGING_LEVEL", "invalid-bare-variable")
|
t.Setenv("LOGGING_LEVEL", "invalid-bare-variable")
|
||||||
t.Setenv("LOGWISP_LOGGING_LEVEL", "debug")
|
t.Setenv("LOGWISP_LOGGING_LEVEL", "debug")
|
||||||
m, err := Load([]string{"--config", "selected.toml", "--logging.level=warn", "--quiet"})
|
m, err := Load(Args{File: "selected.toml", Overrides: []string{"--logging.level=warn", "--quiet"}})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -61,7 +52,7 @@ func TestLoadPrecedenceAndDetachedSnapshots(t *testing.T) {
|
|||||||
if _, aliased := second.Pipelines[0].PluginSources[0].Config["buffer_size"]; err != nil || second.Logging.Level != "warn" || aliased {
|
if _, aliased := second.Pipelines[0].PluginSources[0].Config["buffer_size"]; err != nil || second.Logging.Level != "warn" || aliased {
|
||||||
t.Fatalf("snapshot aliases another result: %+v %v", second, err)
|
t.Fatalf("snapshot aliases another result: %+v %v", second, err)
|
||||||
}
|
}
|
||||||
env, err := Load([]string{"-c=selected.toml"})
|
env, err := Load(Args{File: "selected.toml"})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -74,19 +65,19 @@ func TestLoadPrecedenceAndDetachedSnapshots(t *testing.T) {
|
|||||||
|
|
||||||
func TestMissingFilesDoNotHideInvalidOverrides(t *testing.T) {
|
func TestMissingFilesDoNotHideInvalidOverrides(t *testing.T) {
|
||||||
isolateConfig(t)
|
isolateConfig(t)
|
||||||
m, err := Load(nil)
|
m, err := Load(Args{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
m.Close()
|
m.Close()
|
||||||
if _, err := Load([]string{"-c", "missing.toml"}); !errors.Is(err, ErrConfigNotFound) {
|
if _, err := Load(Args{File: "missing.toml"}); !errors.Is(err, ErrConfigNotFound) {
|
||||||
t.Fatalf("missing explicit path: %v", err)
|
t.Fatalf("missing explicit path: %v", err)
|
||||||
}
|
}
|
||||||
if _, err := Load([]string{"--logging.file.max_size_mb=1.5"}); err == nil || errors.Is(err, ErrConfigNotFound) {
|
if _, err := Load(Args{Overrides: []string{"--logging.file.max_size_mb=1.5"}}); err == nil || errors.Is(err, ErrConfigNotFound) {
|
||||||
t.Fatalf("invalid CLI hidden by missing default: %v", err)
|
t.Fatalf("invalid CLI hidden by missing default: %v", err)
|
||||||
}
|
}
|
||||||
t.Setenv("LOGWISP_STATUS_REPORTER", "invalid")
|
t.Setenv("LOGWISP_STATUS_REPORTER", "invalid")
|
||||||
if _, err := Load(nil); err == nil || errors.Is(err, ErrConfigNotFound) {
|
if _, err := Load(Args{}); err == nil || errors.Is(err, ErrConfigNotFound) {
|
||||||
t.Fatalf("invalid environment hidden by missing default: %v", err)
|
t.Fatalf("invalid environment hidden by missing default: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -94,7 +85,7 @@ func TestMissingFilesDoNotHideInvalidOverrides(t *testing.T) {
|
|||||||
func TestReloadReadsDiskValidatesAndPreservesOldSnapshots(t *testing.T) {
|
func TestReloadReadsDiskValidatesAndPreservesOldSnapshots(t *testing.T) {
|
||||||
isolateConfig(t)
|
isolateConfig(t)
|
||||||
testutil.WriteFile(t, "reload.toml", "status_reporter = true\n")
|
testutil.WriteFile(t, "reload.toml", "status_reporter = true\n")
|
||||||
m, err := Load([]string{"-c", "reload.toml"})
|
m, err := Load(Args{File: "reload.toml"})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -137,7 +128,7 @@ func TestReloadReadsDiskValidatesAndPreservesOldSnapshots(t *testing.T) {
|
|||||||
func TestWatchStartsOnSubscriptionAndCloses(t *testing.T) {
|
func TestWatchStartsOnSubscriptionAndCloses(t *testing.T) {
|
||||||
isolateConfig(t)
|
isolateConfig(t)
|
||||||
testutil.WriteFile(t, "watched.toml", "auto_reload = true\n")
|
testutil.WriteFile(t, "watched.toml", "auto_reload = true\n")
|
||||||
m, err := Load([]string{"-c", "watched.toml"})
|
m, err := Load(Args{File: "watched.toml"})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -181,7 +172,7 @@ server_name = "relay.example"
|
|||||||
type = "none"
|
type = "none"
|
||||||
allow = ["one", "two"]
|
allow = ["one", "two"]
|
||||||
`)
|
`)
|
||||||
m, err := Load([]string{"-c", "plugins.toml"})
|
m, err := Load(Args{File: "plugins.toml"})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -221,7 +212,7 @@ type = "tcp"
|
|||||||
[pipelines.plugin_sinks.config]
|
[pipelines.plugin_sinks.config]
|
||||||
port = 9000
|
port = 9000
|
||||||
`+table+"\n")
|
`+table+"\n")
|
||||||
m, err := Load([]string{"-c", "plugins.toml"})
|
m, err := Load(Args{File: "plugins.toml"})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -257,7 +248,7 @@ type = "tcp"
|
|||||||
[pipelines.plugin_sinks.config]
|
[pipelines.plugin_sinks.config]
|
||||||
port = 9000
|
port = 9000
|
||||||
`+body+"\n")
|
`+body+"\n")
|
||||||
m, err := Load([]string{"-c", "typo.toml"})
|
m, err := Load(Args{File: "typo.toml"})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -280,7 +271,7 @@ func TestNoConfigFileMeansQuietLogging(t *testing.T) {
|
|||||||
if c.file != "" {
|
if c.file != "" {
|
||||||
testutil.WriteFile(t, "logwisp.toml", c.file)
|
testutil.WriteFile(t, "logwisp.toml", c.file)
|
||||||
}
|
}
|
||||||
m, err := Load(nil)
|
m, err := Load(Args{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -292,7 +283,7 @@ func TestNoConfigFileMeansQuietLogging(t *testing.T) {
|
|||||||
}
|
}
|
||||||
t.Setenv("LOGWISP_LOGGING_LEVEL", "debug")
|
t.Setenv("LOGWISP_LOGGING_LEVEL", "debug")
|
||||||
os.Remove("logwisp.toml")
|
os.Remove("logwisp.toml")
|
||||||
m, err := Load(nil)
|
m, err := Load(Args{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -309,7 +300,7 @@ func TestFileStringIsOneListEntry(t *testing.T) {
|
|||||||
testutil.WriteFile(t, "logwisp.toml", "[[pipelines]]\nname = \"p\"\n"+
|
testutil.WriteFile(t, "logwisp.toml", "[[pipelines]]\nname = \"p\"\n"+
|
||||||
"[[pipelines.plugin_sources]]\nid = \"in\"\ntype = \"null\"\n[[pipelines.plugin_sinks]]\nid = \"out\"\ntype = \"null\"\n"+
|
"[[pipelines.plugin_sources]]\nid = \"in\"\ntype = \"null\"\n[[pipelines.plugin_sinks]]\nid = \"out\"\ntype = \"null\"\n"+
|
||||||
"[[pipelines.flow.filters]]\ntype = \"exclude\"\npatterns = \"password=\\\\S{8,64}\"\n")
|
"[[pipelines.flow.filters]]\ntype = \"exclude\"\npatterns = \"password=\\\\S{8,64}\"\n")
|
||||||
m, err := Load(nil)
|
m, err := Load(Args{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -319,3 +310,25 @@ func TestFileStringIsOneListEntry(t *testing.T) {
|
|||||||
t.Fatalf("patterns: %v %v", cfg.Pipelines[0].Flow.Filters, err)
|
t.Fatalf("patterns: %v %v", cfg.Pipelines[0].Flow.Filters, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The top-level color is the default of every console sink, which may set
|
||||||
|
// its own; other sinks do not take the key.
|
||||||
|
func TestTopLevelColorIsEachConsoleSinksDefault(t *testing.T) {
|
||||||
|
isolateConfig(t)
|
||||||
|
m, err := Load(Args{Specs: specs("sink", "console", "sink", "console,color=never", "sink", "null"), Overrides: []string{"--color=always"}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer m.Close()
|
||||||
|
cfg, err := m.Snapshot()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var colors []any
|
||||||
|
for _, s := range cfg.Pipelines[0].PluginSinks {
|
||||||
|
colors = append(colors, s.Config["color"])
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(colors, []any{"always", "never", nil}) {
|
||||||
|
t.Fatalf("console sink colors: %v", colors)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,332 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"cmp"
|
||||||
|
"fmt"
|
||||||
|
"maps"
|
||||||
|
"net"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"slices"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Preset is a pipeline built from a few keys: --preset NAME,key=value starts
|
||||||
|
// a pipeline with it. Later flags add sources, sinks and filters, or replace
|
||||||
|
// its format, rate limit and heartbeat.
|
||||||
|
type Preset struct {
|
||||||
|
Name, Summary string
|
||||||
|
Params []PresetParam
|
||||||
|
build func(p *PipelineConfig, v map[string]string) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// PresetParam is one key. A list takes several values, ',' between them or
|
||||||
|
// the key repeated; any other key takes one.
|
||||||
|
type PresetParam struct {
|
||||||
|
Name, Default, Help string
|
||||||
|
Required, List bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keys several presets share
|
||||||
|
var (
|
||||||
|
pathParam = PresetParam{Name: "path", Help: "file, directory (its files) or glob to follow; default: standard input"}
|
||||||
|
fromParam = PresetParam{Name: "from", Default: "end", Help: "start|end: where reading a file starts"}
|
||||||
|
formatParam = func(def string) PresetParam { return PresetParam{Name: "format", Default: def, Help: "raw|txt|json"} }
|
||||||
|
tlsParams = []PresetParam{
|
||||||
|
{Name: "cert", Help: "certificate file, for tls=files"},
|
||||||
|
{Name: "key", Help: "key file, for tls=files"},
|
||||||
|
{Name: "issuer_cert", Help: "CA certificate file (lw tls ca), for tls=issuer"},
|
||||||
|
{Name: "issuer_key", Help: "CA key file, for tls=issuer"},
|
||||||
|
{Name: "hosts", List: true, Help: "names and addresses, beyond this host's, of a self or issuer certificate; ',' between them"},
|
||||||
|
}
|
||||||
|
transportParam = PresetParam{Name: "transport", Default: "tcp", Help: "tcp|http: tcp_chain or http_chain"}
|
||||||
|
)
|
||||||
|
|
||||||
|
var presets = []Preset{
|
||||||
|
{"pipe", "Standard input to standard output, line for line: lw without a file",
|
||||||
|
[]PresetParam{formatParam("raw")},
|
||||||
|
func(p *PipelineConfig, v map[string]string) error {
|
||||||
|
stdio := pipeDefault("")
|
||||||
|
p.PluginSources, p.PluginSinks = stdio.PluginSources, stdio.PluginSinks
|
||||||
|
p.Flow.Format = &FormatConfig{Type: v["format"]}
|
||||||
|
return nil
|
||||||
|
}},
|
||||||
|
{"tail", "Follow files to standard output, like tail -F",
|
||||||
|
[]PresetParam{{Name: "path", Required: true, Help: "file, directory (its files) or glob to follow"}, fromParam, formatParam("raw")},
|
||||||
|
func(p *PipelineConfig, v map[string]string) error {
|
||||||
|
p.PluginSources, p.PluginSinks = pathOrStdin(v), pipeDefault("").PluginSinks
|
||||||
|
p.Flow.Format = &FormatConfig{Type: v["format"]}
|
||||||
|
return nil
|
||||||
|
}},
|
||||||
|
{"serve", "Serve files or standard input as a live HTTP stream (SSE)",
|
||||||
|
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"},
|
||||||
|
}, tlsParams...),
|
||||||
|
func(p *PipelineConfig, v map[string]string) error {
|
||||||
|
p.PluginSources = pathOrStdin(v)
|
||||||
|
sink, err := listener(v, "off")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if v["users"] != "" {
|
||||||
|
sink["auth"] = map[string]any{"type": "scram", "credentials_file": v["users"]}
|
||||||
|
}
|
||||||
|
if v["proxy"] != "" {
|
||||||
|
auth, ok := sink["auth"].(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("proxy needs users: browsers log in with SCRAM")
|
||||||
|
}
|
||||||
|
auth["trusted_proxies"] = list(v["proxy"])
|
||||||
|
}
|
||||||
|
switch v["viewer"] {
|
||||||
|
case "true":
|
||||||
|
sink["login_page"], sink["viewer_page"] = true, true
|
||||||
|
case "false":
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("viewer %q (valid: true, false)", v["viewer"])
|
||||||
|
}
|
||||||
|
p.Flow.Format = &FormatConfig{Type: v["format"]}
|
||||||
|
p.PluginSinks = append(p.PluginSinks, PluginSinkConfig{ID: "http", Type: "http", Config: sink})
|
||||||
|
return nil
|
||||||
|
}},
|
||||||
|
{"edge", "Forward files or standard input to an aggregator over TLS, authenticated",
|
||||||
|
[]PresetParam{pathParam, fromParam,
|
||||||
|
{Name: "to", Required: true, Help: "the aggregator's HOST:PORT; [IPV6]:PORT"},
|
||||||
|
transportParam,
|
||||||
|
{Name: "ca", Help: "CA file that verifies the aggregator; default: system roots"},
|
||||||
|
{Name: "pin", Help: "sha256//BASE64 of the aggregator's key, which it logs with tls=self"},
|
||||||
|
{Name: "server_name", Help: "name the aggregator's certificate carries; default: to's host"},
|
||||||
|
{Name: "user", Help: "SCRAM user (lw auth add-user on the aggregator)"},
|
||||||
|
{Name: "password_file", Help: "file holding the SCRAM password"},
|
||||||
|
{Name: "cert", Help: "client certificate file, for an aggregator with client_ca"},
|
||||||
|
{Name: "key", Help: "client key file"},
|
||||||
|
{Name: "node", Help: "origin label; default: this host's name"},
|
||||||
|
},
|
||||||
|
func(p *PipelineConfig, v map[string]string) error {
|
||||||
|
p.PluginSources = pathOrStdin(v)
|
||||||
|
typ, err := chainType(v["transport"])
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
host, port, err := splitAddr("to", v["to"])
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
tls := map[string]any{"enabled": true}
|
||||||
|
for key, option := range map[string]string{"ca": "ca_file", "pin": "pin_sha256", "server_name": "server_name", "cert": "cert_file", "key": "key_file"} {
|
||||||
|
if v[key] != "" {
|
||||||
|
tls[option] = v[key]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var auth map[string]any
|
||||||
|
switch {
|
||||||
|
case v["user"] != "" && v["password_file"] != "":
|
||||||
|
auth = map[string]any{"type": "scram", "username": v["user"], "password_file": v["password_file"]}
|
||||||
|
case v["user"] != "" || v["password_file"] != "":
|
||||||
|
return fmt.Errorf("user and password_file go together")
|
||||||
|
case v["cert"] != "" && v["key"] != "":
|
||||||
|
auth = map[string]any{"type": "mtls"}
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("edge needs user and password_file, or cert and key: it never sends unauthenticated")
|
||||||
|
}
|
||||||
|
sink := map[string]any{"host": host, "port": port, "tls": tls, "auth": auth}
|
||||||
|
if v["node"] != "" {
|
||||||
|
sink["node"] = v["node"]
|
||||||
|
}
|
||||||
|
p.PluginSinks = append(p.PluginSinks, PluginSinkConfig{ID: "edge", Type: typ, Config: sink})
|
||||||
|
return nil
|
||||||
|
}},
|
||||||
|
{"aggregator", "Receive from edges over TLS, authenticated, to files or standard output",
|
||||||
|
append([]PresetParam{
|
||||||
|
{Name: "listen", Default: "0.0.0.0:9000", Help: "HOST:PORT; [IPV6]:PORT; [::] for IPv6 only"},
|
||||||
|
transportParam,
|
||||||
|
{Name: "tls", Default: "self", Help: "self|issuer|files: a certificate made at startup (self-signed or from issuer_*), or files"},
|
||||||
|
{Name: "users", Help: "credentials file (lw auth add-user): edges log in with SCRAM"},
|
||||||
|
{Name: "client_ca", Help: "CA file that verifies edge certificates (mTLS)"},
|
||||||
|
{Name: "out", Help: "directory for the received entries; default: standard output"},
|
||||||
|
formatParam("json"),
|
||||||
|
}, tlsParams...),
|
||||||
|
func(p *PipelineConfig, v map[string]string) error {
|
||||||
|
typ, err := chainType(v["transport"])
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if v["tls"] == "off" {
|
||||||
|
return fmt.Errorf("tls=off: edges send credentials and logs, the aggregator always serves TLS")
|
||||||
|
}
|
||||||
|
src, err := listener(v, "self")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if v["client_ca"] != "" {
|
||||||
|
tls := src["tls"].(map[string]any)
|
||||||
|
tls["client_auth"], tls["client_ca_file"] = true, v["client_ca"]
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case v["users"] != "":
|
||||||
|
src["auth"] = map[string]any{"type": "scram", "credentials_file": v["users"]}
|
||||||
|
case v["client_ca"] != "":
|
||||||
|
src["auth"] = map[string]any{"type": "mtls"}
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("aggregator needs users or client_ca: it never receives unauthenticated")
|
||||||
|
}
|
||||||
|
p.PluginSources = append(p.PluginSources, PluginSourceConfig{ID: "edges", Type: typ, Config: src})
|
||||||
|
p.PluginSinks = pipeDefault("").PluginSinks
|
||||||
|
if v["out"] != "" {
|
||||||
|
p.PluginSinks = []PluginSinkConfig{{ID: "file", Type: "file", Config: map[string]any{"directory": v["out"], "name": "aggregate"}}}
|
||||||
|
}
|
||||||
|
p.Flow.Format = &FormatConfig{Type: v["format"]}
|
||||||
|
return nil
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Presets lists the presets, for lw preset
|
||||||
|
func Presets() []Preset { return slices.Clone(presets) }
|
||||||
|
|
||||||
|
// ExpandPreset builds the pipeline a preset makes of values, as --preset does
|
||||||
|
func ExpandPreset(name string, values map[string]string) (PipelineConfig, error) {
|
||||||
|
p := PipelineConfig{Flow: &FlowConfig{}}
|
||||||
|
each := map[string][]string{}
|
||||||
|
for k, v := range values {
|
||||||
|
each[k] = []string{v}
|
||||||
|
}
|
||||||
|
err := applyPreset(&p, name, each)
|
||||||
|
return p, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// applyPreset fills defaults and checks keys before the preset's build
|
||||||
|
func applyPreset(p *PipelineConfig, name string, values map[string][]string) error {
|
||||||
|
i := slices.IndexFunc(presets, func(r Preset) bool { return r.Name == name })
|
||||||
|
if i < 0 {
|
||||||
|
var names []string
|
||||||
|
for _, r := range presets {
|
||||||
|
names = append(names, r.Name)
|
||||||
|
}
|
||||||
|
return fmt.Errorf("no preset %q (valid: %s)", name, strings.Join(names, ", "))
|
||||||
|
}
|
||||||
|
r := presets[i]
|
||||||
|
v, keys := map[string]string{}, []string{}
|
||||||
|
for _, k := range r.Params {
|
||||||
|
v[k.Name] = k.Default
|
||||||
|
keys = append(keys, k.Name)
|
||||||
|
}
|
||||||
|
for k, each := range values {
|
||||||
|
j := slices.IndexFunc(r.Params, func(p PresetParam) bool { return p.Name == k })
|
||||||
|
switch {
|
||||||
|
case j < 0:
|
||||||
|
return fmt.Errorf("preset %s has no key %q (valid: %s)", name, k, strings.Join(keys, ", "))
|
||||||
|
case len(each) > 1 && !r.Params[j].List:
|
||||||
|
return fmt.Errorf("preset %s key %q takes one value", name, k)
|
||||||
|
}
|
||||||
|
v[k] = cmp.Or(strings.Join(each, ","), v[k])
|
||||||
|
}
|
||||||
|
for _, k := range r.Params {
|
||||||
|
if k.Required && v[k.Name] == "" {
|
||||||
|
return fmt.Errorf("preset %s needs %s", name, k.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
p.Name = cmp.Or(p.Name, name)
|
||||||
|
return r.build(p, v)
|
||||||
|
}
|
||||||
|
|
||||||
|
func pathOrStdin(v map[string]string) []PluginSourceConfig {
|
||||||
|
if v["path"] == "" {
|
||||||
|
return pipeDefault("").PluginSources
|
||||||
|
}
|
||||||
|
return []PluginSourceConfig{fileSource(v["path"], v["from"])}
|
||||||
|
}
|
||||||
|
|
||||||
|
// fileSource follows path: a directory's files, or the files a glob or a
|
||||||
|
// file name matches in its directory.
|
||||||
|
func fileSource(path, from string) PluginSourceConfig {
|
||||||
|
dir, pattern := path, "*"
|
||||||
|
if fi, err := os.Stat(path); err != nil || !fi.IsDir() {
|
||||||
|
dir, pattern = filepath.Dir(path), filepath.Base(path)
|
||||||
|
}
|
||||||
|
return PluginSourceConfig{ID: "file", Type: "file", Config: map[string]any{"directory": dir, "pattern": pattern, "from": from}}
|
||||||
|
}
|
||||||
|
|
||||||
|
// tlsModes is what each preset tls mode sets, and the preset keys it needs
|
||||||
|
// by the tls option each fills; made marks a certificate made at startup,
|
||||||
|
// which takes hosts.
|
||||||
|
var tlsModes = map[string]struct {
|
||||||
|
set map[string]any
|
||||||
|
needs map[string]string
|
||||||
|
made bool
|
||||||
|
}{
|
||||||
|
"off": {},
|
||||||
|
"self": {set: map[string]any{"self_signed": true}, made: true},
|
||||||
|
"issuer": {needs: map[string]string{"issuer_cert": "issuer_cert_file", "issuer_key": "issuer_key_file"}, made: true},
|
||||||
|
"files": {needs: map[string]string{"cert": "cert_file", "key": "key_file"}},
|
||||||
|
}
|
||||||
|
|
||||||
|
// listener starts a listening plugin's options: host, port and tls, whose
|
||||||
|
// mode decides which certificate keys apply.
|
||||||
|
func listener(v map[string]string, def string) (map[string]any, error) {
|
||||||
|
host, port, err := splitAddr("listen", v["listen"])
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
opts := map[string]any{"host": host, "port": port}
|
||||||
|
mode := cmp.Or(v["tls"], def)
|
||||||
|
m, ok := tlsModes[mode]
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("tls %q (valid: %s)", mode, strings.Join(slices.Sorted(maps.Keys(tlsModes)), ", "))
|
||||||
|
}
|
||||||
|
for _, other := range tlsModes {
|
||||||
|
for k := range other.needs {
|
||||||
|
if _, needed := m.needs[k]; needed && v[k] == "" {
|
||||||
|
return nil, fmt.Errorf("tls=%s needs %s", mode, k)
|
||||||
|
} else if !needed && v[k] != "" {
|
||||||
|
return nil, fmt.Errorf("%s does not apply to tls=%s", k, mode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if v["hosts"] != "" && !m.made {
|
||||||
|
return nil, fmt.Errorf("hosts does not apply to tls=%s", mode)
|
||||||
|
}
|
||||||
|
if mode == "off" {
|
||||||
|
return opts, nil
|
||||||
|
}
|
||||||
|
tls := map[string]any{"enabled": true}
|
||||||
|
maps.Copy(tls, m.set)
|
||||||
|
for k, option := range m.needs {
|
||||||
|
tls[option] = v[k]
|
||||||
|
}
|
||||||
|
if v["hosts"] != "" {
|
||||||
|
tls["hosts"] = list(v["hosts"])
|
||||||
|
}
|
||||||
|
opts["tls"] = tls
|
||||||
|
return opts, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func splitAddr(key, addr string) (string, int64, error) {
|
||||||
|
host, p, err := net.SplitHostPort(addr)
|
||||||
|
port, perr := strconv.ParseUint(p, 10, 16)
|
||||||
|
if err != nil || perr != nil || port == 0 {
|
||||||
|
return "", 0, fmt.Errorf("%s %q: want HOST:PORT, an IPv6 address in brackets", key, addr)
|
||||||
|
}
|
||||||
|
return host, int64(port), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func chainType(transport string) (string, error) {
|
||||||
|
if transport != "tcp" && transport != "http" {
|
||||||
|
return "", fmt.Errorf("transport %q (valid: tcp, http)", transport)
|
||||||
|
}
|
||||||
|
return transport + "_chain", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func list(s string) []any {
|
||||||
|
var out []any
|
||||||
|
for e := range strings.SplitSeq(s, ",") {
|
||||||
|
if e = strings.TrimSpace(e); e != "" {
|
||||||
|
out = append(out, e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"reflect"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// A preset starts its pipeline and names it unless --pipeline did; later
|
||||||
|
// sinks add to its own, a later format replaces its own.
|
||||||
|
func TestPresetStartsAndNamesItsPipeline(t *testing.T) {
|
||||||
|
isolateConfig(t)
|
||||||
|
got := loadPipelines(t, "preset", "tail,path=/var/log/app.log", "sink", "null", "format", "json",
|
||||||
|
"pipeline", "mine", "preset", "pipe")
|
||||||
|
want := []PipelineConfig{{
|
||||||
|
Name: "tail",
|
||||||
|
Flow: &FlowConfig{Format: &FormatConfig{Type: "json"}},
|
||||||
|
PluginSources: []PluginSourceConfig{{ID: "file", Type: "file", Config: map[string]any{"directory": "/var/log", "pattern": "app.log", "from": "end"}}},
|
||||||
|
PluginSinks: []PluginSinkConfig{
|
||||||
|
{ID: "stdout", Type: "console", Config: map[string]any{"color": "auto"}},
|
||||||
|
{ID: "null", Type: "null", Config: map[string]any{}},
|
||||||
|
},
|
||||||
|
}, {
|
||||||
|
Name: "mine",
|
||||||
|
Flow: &FlowConfig{Format: &FormatConfig{Type: "raw"}},
|
||||||
|
PluginSources: []PluginSourceConfig{{ID: "stdin", Type: "console", Config: map[string]any{}}},
|
||||||
|
PluginSinks: []PluginSinkConfig{{ID: "stdout", Type: "console", Config: map[string]any{"color": "auto"}}},
|
||||||
|
}}
|
||||||
|
if !reflect.DeepEqual(got, want) {
|
||||||
|
t.Fatalf("pipelines:\n got %+v\nwant %+v", got, want)
|
||||||
|
}
|
||||||
|
if _, err := Load(Args{Specs: specs("sink", "null", "preset", "pipe")}); err == nil || !strings.Contains(err.Error(), "must start its pipeline") {
|
||||||
|
t.Fatalf("preset after a sink: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keys are checked against the preset's own, required ones enforced, and
|
||||||
|
// the edge and aggregator refuse to run without authentication.
|
||||||
|
func TestPresetKeysAreChecked(t *testing.T) {
|
||||||
|
isolateConfig(t)
|
||||||
|
for spec, want := range map[string]string{
|
||||||
|
"nope": `no preset "nope" (valid: pipe, tail, serve, edge, aggregator)`,
|
||||||
|
"tail,path=x,colour=1": `preset tail has no key "colour" (valid: path, from, format)`,
|
||||||
|
"tail": "preset tail needs path",
|
||||||
|
"tail,path=x,format.type=json": "preset keys do not nest",
|
||||||
|
"tail,path=x,path=y": `key "path" takes one value`,
|
||||||
|
"serve,listen=8080": `listen "8080": want HOST:PORT`,
|
||||||
|
"serve,tls=self,cert=c": "cert does not apply to tls=self",
|
||||||
|
"serve,tls=files,cert=c": "tls=files needs key",
|
||||||
|
"serve,viewer=yes": `viewer "yes"`,
|
||||||
|
"serve,proxy=127.0.0.1": "proxy needs users",
|
||||||
|
"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"`,
|
||||||
|
"aggregator,users=u,tls=off": "the aggregator always serves TLS",
|
||||||
|
"aggregator": "it never receives unauthenticated",
|
||||||
|
} {
|
||||||
|
_, err := Load(Args{Specs: specs("preset", spec)})
|
||||||
|
if err == nil || !strings.Contains(err.Error(), want) {
|
||||||
|
t.Errorf("%s: err = %v, want %s", spec, err, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+79
-49
@@ -16,11 +16,28 @@ type specKind struct {
|
|||||||
flag string
|
flag string
|
||||||
typed bool // SPEC starts with TYPE
|
typed bool // SPEC starts with TYPE
|
||||||
many bool // repeatable within a pipeline; the environment adds _1.._N
|
many bool // repeatable within a pipeline; the environment adds _1.._N
|
||||||
|
first bool // starts its pipeline, and names it by TYPE unless --pipeline did
|
||||||
apply func(p *PipelineConfig, typ string, opts map[string]any) error
|
apply func(p *PipelineConfig, typ string, opts map[string]any) error
|
||||||
}
|
}
|
||||||
|
|
||||||
var specKinds = []specKind{
|
var specKinds = []specKind{
|
||||||
{"source", true, true, func(p *PipelineConfig, typ string, opts map[string]any) error {
|
{"preset", true, false, true, func(p *PipelineConfig, typ string, opts map[string]any) error {
|
||||||
|
values := map[string][]string{}
|
||||||
|
for k, v := range opts {
|
||||||
|
switch v := v.(type) {
|
||||||
|
case string:
|
||||||
|
values[k] = []string{v}
|
||||||
|
case []any: // a repeated key
|
||||||
|
for _, e := range v {
|
||||||
|
values[k] = append(values[k], e.(string))
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("preset keys do not nest: %q", k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return applyPreset(p, typ, values)
|
||||||
|
}},
|
||||||
|
{"source", true, true, false, func(p *PipelineConfig, typ string, opts map[string]any) error {
|
||||||
ids := make([]string, len(p.PluginSources))
|
ids := make([]string, len(p.PluginSources))
|
||||||
for i, s := range p.PluginSources {
|
for i, s := range p.PluginSources {
|
||||||
ids[i] = s.ID
|
ids[i] = s.ID
|
||||||
@@ -29,7 +46,7 @@ var specKinds = []specKind{
|
|||||||
p.PluginSources = append(p.PluginSources, PluginSourceConfig{ID: id, Type: typ, Config: opts})
|
p.PluginSources = append(p.PluginSources, PluginSourceConfig{ID: id, Type: typ, Config: opts})
|
||||||
return err
|
return err
|
||||||
}},
|
}},
|
||||||
{"sink", true, true, func(p *PipelineConfig, typ string, opts map[string]any) error {
|
{"sink", true, true, false, func(p *PipelineConfig, typ string, opts map[string]any) error {
|
||||||
ids := make([]string, len(p.PluginSinks))
|
ids := make([]string, len(p.PluginSinks))
|
||||||
for i, s := range p.PluginSinks {
|
for i, s := range p.PluginSinks {
|
||||||
ids[i] = s.ID
|
ids[i] = s.ID
|
||||||
@@ -38,24 +55,24 @@ var specKinds = []specKind{
|
|||||||
p.PluginSinks = append(p.PluginSinks, PluginSinkConfig{ID: id, Type: typ, Config: opts})
|
p.PluginSinks = append(p.PluginSinks, PluginSinkConfig{ID: id, Type: typ, Config: opts})
|
||||||
return err
|
return err
|
||||||
}},
|
}},
|
||||||
{"filter", true, true, func(p *PipelineConfig, typ string, opts map[string]any) error {
|
{"filter", true, true, false, func(p *PipelineConfig, typ string, opts map[string]any) error {
|
||||||
opts["type"] = typ
|
opts["type"] = typ
|
||||||
f := FilterConfig{}
|
f := FilterConfig{}
|
||||||
err := Scan(opts, &f)
|
err := Scan(opts, &f)
|
||||||
p.Flow.Filters = append(p.Flow.Filters, f)
|
p.Flow.Filters = append(p.Flow.Filters, f)
|
||||||
return err
|
return err
|
||||||
}},
|
}},
|
||||||
{"format", true, false, func(p *PipelineConfig, typ string, opts map[string]any) error {
|
{"format", true, false, false, func(p *PipelineConfig, typ string, opts map[string]any) error {
|
||||||
opts["type"] = typ
|
opts["type"] = typ
|
||||||
p.Flow.Format = &FormatConfig{}
|
p.Flow.Format = &FormatConfig{}
|
||||||
return Scan(opts, p.Flow.Format)
|
return Scan(opts, p.Flow.Format)
|
||||||
}},
|
}},
|
||||||
// Naming a stage turns it on: the file defaults (pass, disabled) would not.
|
// Naming a stage turns it on: the file defaults (pass, disabled) would not.
|
||||||
{"rate-limit", false, false, func(p *PipelineConfig, _ string, opts map[string]any) error {
|
{"rate-limit", false, false, false, func(p *PipelineConfig, _ string, opts map[string]any) error {
|
||||||
p.Flow.RateLimit = &RateLimitConfig{Policy: "drop"}
|
p.Flow.RateLimit = &RateLimitConfig{Policy: "drop"}
|
||||||
return Scan(opts, p.Flow.RateLimit)
|
return Scan(opts, p.Flow.RateLimit)
|
||||||
}},
|
}},
|
||||||
{"heartbeat", false, false, func(p *PipelineConfig, _ string, opts map[string]any) error {
|
{"heartbeat", false, false, false, func(p *PipelineConfig, _ string, opts map[string]any) error {
|
||||||
p.Flow.Heartbeat = &HeartbeatConfig{Enabled: true}
|
p.Flow.Heartbeat = &HeartbeatConfig{Enabled: true}
|
||||||
return Scan(opts, p.Flow.Heartbeat)
|
return Scan(opts, p.Flow.Heartbeat)
|
||||||
}},
|
}},
|
||||||
@@ -68,33 +85,36 @@ type pipelineSpec struct {
|
|||||||
value string
|
value string
|
||||||
}
|
}
|
||||||
|
|
||||||
// takePipelineSpecs consumes the pipeline flags, which lixenwraith/config
|
// Spec is one pipeline flag: its name without dashes, and its value.
|
||||||
// would report as unrecognized.
|
type Spec struct{ Flag, Value string }
|
||||||
func takePipelineSpecs(args []string) (specs []pipelineSpec, rest []string, err error) {
|
|
||||||
for i := 0; i < len(args); i++ {
|
// SpecFlags names the pipeline flags, for the command-line parser.
|
||||||
if args[i] == "--" {
|
func SpecFlags() []string {
|
||||||
return specs, append(rest, args[i:]...), nil
|
flags := []string{"pipeline"}
|
||||||
}
|
for _, k := range specKinds {
|
||||||
name, value, inline := strings.Cut(args[i], "=")
|
flags = append(flags, k.flag)
|
||||||
|
}
|
||||||
|
return flags
|
||||||
|
}
|
||||||
|
|
||||||
|
// cliSpecs looks up the kind of each command-line spec.
|
||||||
|
func cliSpecs(in []Spec) ([]pipelineSpec, error) {
|
||||||
|
var specs []pipelineSpec
|
||||||
|
for _, s := range in {
|
||||||
var kind *specKind
|
var kind *specKind
|
||||||
if name != "--pipeline" {
|
if s.Flag != "pipeline" {
|
||||||
k := slices.IndexFunc(specKinds, func(k specKind) bool { return "--"+k.flag == name })
|
k := slices.IndexFunc(specKinds, func(k specKind) bool { return k.flag == s.Flag })
|
||||||
if k < 0 {
|
if k < 0 {
|
||||||
rest = append(rest, args[i])
|
return nil, fmt.Errorf("--%s is no pipeline flag", s.Flag)
|
||||||
continue
|
|
||||||
}
|
}
|
||||||
kind = &specKinds[k]
|
kind = &specKinds[k]
|
||||||
}
|
}
|
||||||
if !inline && i+1 < len(args) && !strings.HasPrefix(args[i+1], "-") {
|
if s.Value == "" {
|
||||||
i++
|
return nil, fmt.Errorf("--%s requires a value", s.Flag)
|
||||||
value = args[i]
|
|
||||||
}
|
}
|
||||||
if value == "" {
|
specs = append(specs, pipelineSpec{"--" + s.Flag, kind, s.Value})
|
||||||
return nil, nil, fmt.Errorf("%s requires a value", name)
|
|
||||||
}
|
|
||||||
specs = append(specs, pipelineSpec{name, kind, value})
|
|
||||||
}
|
}
|
||||||
return specs, rest, nil
|
return specs, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// envPipelineSpecs reads the one-pipeline environment form. An empty variable
|
// envPipelineSpecs reads the one-pipeline environment form. An empty variable
|
||||||
@@ -131,48 +151,58 @@ func envPipelineSpecs() []pipelineSpec {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// buildPipelines turns specs, in order, into pipelines; specs before the first
|
// buildPipelines turns specs, in order, into pipelines; specs before the first
|
||||||
// --pipeline go to one named "cli". A pipeline without a source reads stdin,
|
// --pipeline go to one named "cli". Every call returns fresh maps.
|
||||||
// one without a sink writes stdout. Every call returns fresh maps.
|
|
||||||
func buildPipelines(specs []pipelineSpec) ([]PipelineConfig, error) {
|
func buildPipelines(specs []pipelineSpec) ([]PipelineConfig, error) {
|
||||||
var pipelines []PipelineConfig
|
var pipelines []PipelineConfig
|
||||||
var seen map[*specKind]bool
|
var seen map[*specKind]bool
|
||||||
for _, s := range specs {
|
for _, s := range specs {
|
||||||
if s.kind == nil || pipelines == nil {
|
if s.kind == nil {
|
||||||
name := "cli"
|
pipelines = append(pipelines, PipelineConfig{Name: s.value, Flow: &FlowConfig{}})
|
||||||
if s.kind == nil {
|
|
||||||
name = s.value
|
|
||||||
}
|
|
||||||
pipelines = append(pipelines, PipelineConfig{Name: name, Flow: &FlowConfig{}})
|
|
||||||
seen = map[*specKind]bool{}
|
seen = map[*specKind]bool{}
|
||||||
if s.kind == nil {
|
continue
|
||||||
continue
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
p := &pipelines[len(pipelines)-1]
|
|
||||||
if seen[s.kind] && !s.kind.many {
|
|
||||||
return nil, fmt.Errorf("%s %s: pipeline %q already has one", s.name, s.value, p.Name)
|
|
||||||
}
|
|
||||||
seen[s.kind] = true
|
|
||||||
typ, opts, err := parseSpec(s.value, s.kind.typed)
|
typ, opts, err := parseSpec(s.value, s.kind.typed)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("%s %w", s.name, err)
|
return nil, fmt.Errorf("%s %w", s.name, err)
|
||||||
}
|
}
|
||||||
|
if pipelines == nil {
|
||||||
|
name := "cli"
|
||||||
|
if s.kind.first {
|
||||||
|
name = typ
|
||||||
|
}
|
||||||
|
pipelines = append(pipelines, PipelineConfig{Name: name, Flow: &FlowConfig{}})
|
||||||
|
seen = map[*specKind]bool{}
|
||||||
|
}
|
||||||
|
p := &pipelines[len(pipelines)-1]
|
||||||
|
switch {
|
||||||
|
case seen[s.kind] && !s.kind.many:
|
||||||
|
return nil, fmt.Errorf("%s %s: pipeline %q already has one", s.name, s.value, p.Name)
|
||||||
|
case s.kind.first && len(seen) > 0:
|
||||||
|
return nil, fmt.Errorf("%s %s: must start its pipeline", s.name, s.value)
|
||||||
|
}
|
||||||
|
seen[s.kind] = true
|
||||||
if err := s.kind.apply(p, typ, opts); err != nil {
|
if err := s.kind.apply(p, typ, opts); err != nil {
|
||||||
return nil, fmt.Errorf("%s %s: %w", s.name, s.value, err)
|
return nil, fmt.Errorf("%s %s: %w", s.name, s.value, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for i := range pipelines {
|
for i := range pipelines {
|
||||||
pipe := pipeDefault(pipelines[i].Name)
|
useStdio(&pipelines[i])
|
||||||
if len(pipelines[i].PluginSources) == 0 {
|
|
||||||
pipelines[i].PluginSources = pipe.PluginSources
|
|
||||||
}
|
|
||||||
if len(pipelines[i].PluginSinks) == 0 {
|
|
||||||
pipelines[i].PluginSinks = pipe.PluginSinks
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return pipelines, nil
|
return pipelines, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// useStdio makes a pipeline without a source read stdin, and one without a
|
||||||
|
// sink write stdout, as a filter does.
|
||||||
|
func useStdio(p *PipelineConfig) {
|
||||||
|
pipe := pipeDefault(p.Name)
|
||||||
|
if len(p.PluginSources) == 0 {
|
||||||
|
p.PluginSources = pipe.PluginSources
|
||||||
|
}
|
||||||
|
if len(p.PluginSinks) == 0 {
|
||||||
|
p.PluginSinks = pipe.PluginSinks
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// parseSpec reads [TYPE,]key=value,...: dotted keys nest, a repeated key makes
|
// parseSpec reads [TYPE,]key=value,...: dotted keys nest, a repeated key makes
|
||||||
// a list, and a backslash escapes ',', '=' and '\'. Errors name the part.
|
// a list, and a backslash escapes ',', '=' and '\'. Errors name the part.
|
||||||
func parseSpec(spec string, typed bool) (typ string, opts map[string]any, err error) {
|
func parseSpec(spec string, typed bool) (typ string, opts map[string]any, err error) {
|
||||||
|
|||||||
@@ -10,9 +10,18 @@ import (
|
|||||||
"github.com/lixenwraith/logwisp/internal/testutil"
|
"github.com/lixenwraith/logwisp/internal/testutil"
|
||||||
)
|
)
|
||||||
|
|
||||||
func loadPipelines(t *testing.T, args ...string) []PipelineConfig {
|
// specs pairs flags with values: specs("sink", "null") is --sink null
|
||||||
|
func specs(pairs ...string) []Spec {
|
||||||
|
var out []Spec
|
||||||
|
for i := 0; i < len(pairs); i += 2 {
|
||||||
|
out = append(out, Spec{pairs[i], pairs[i+1]})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadPipelines(t *testing.T, pairs ...string) []PipelineConfig {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
m, err := Load(args)
|
m, err := Load(Args{Specs: specs(pairs...)})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -27,15 +36,14 @@ func loadPipelines(t *testing.T, args ...string) []PipelineConfig {
|
|||||||
func TestPipelineSpecGrammar(t *testing.T) {
|
func TestPipelineSpecGrammar(t *testing.T) {
|
||||||
isolateConfig(t)
|
isolateConfig(t)
|
||||||
got := loadPipelines(t,
|
got := loadPipelines(t,
|
||||||
"--source", `file,directory=/var/log/a\,b,pattern=*.log`,
|
"source", `file,directory=/var/log/a\,b,pattern=*.log`,
|
||||||
`--source=file,id=app,directory=x\=y`,
|
"source", `file,id=app,directory=x\=y`,
|
||||||
"--source", `file,directory=c:\\logs\\`,
|
"source", `file,directory=c:\\logs\\`,
|
||||||
"--sink", "http,port=8080,auth.type=scram,auth.credentials_file=users.toml,tls.cert_file=c",
|
"sink", "http,port=8080,auth.type=scram,auth.credentials_file=users.toml,tls.cert_file=c",
|
||||||
"--filter", "include,patterns=ERROR,patterns=WARN",
|
"filter", "include,patterns=ERROR,patterns=WARN",
|
||||||
"--filter", `exclude,patterns=\d{1\,3}`,
|
"filter", `exclude,patterns=\d{1\,3}`,
|
||||||
"--format", `txt,timestamp_format=Jan 2\, 2006`,
|
"format", `txt,timestamp_format=Jan 2\, 2006`,
|
||||||
"--pipeline", "b", "--source", "null", "--sink", "null", "--sink", "null", "--heartbeat", "interval_ms=1000",
|
"pipeline", "b", "source", "null", "sink", "null", "sink", "null", "heartbeat", "interval_ms=1000")
|
||||||
"--", "--source", "random")
|
|
||||||
want := []PipelineConfig{{
|
want := []PipelineConfig{{
|
||||||
Name: "cli",
|
Name: "cli",
|
||||||
Flow: &FlowConfig{
|
Flow: &FlowConfig{
|
||||||
@@ -67,17 +75,17 @@ func TestPipelineSpecGrammar(t *testing.T) {
|
|||||||
if !reflect.DeepEqual(got, want) {
|
if !reflect.DeepEqual(got, want) {
|
||||||
t.Fatalf("pipelines:\n got %+v\nwant %+v", got, want)
|
t.Fatalf("pipelines:\n got %+v\nwant %+v", got, want)
|
||||||
}
|
}
|
||||||
for want, args := range map[string][]string{
|
for want, pairs := range map[string][]string{
|
||||||
`--sink http,port: missing "="`: {"--sink", "http,port"},
|
`--sink http,port: missing "="`: {"sink", "http,port"},
|
||||||
`--source directory=/x: missing TYPE`: {"--source", "directory=/x"},
|
`--source directory=/x: missing TYPE`: {"source", "directory=/x"},
|
||||||
`--source file,type=x: TYPE already sets "type"`: {"--source", "file,type=x"},
|
`--source file,type=x: TYPE already sets "type"`: {"source", "file,type=x"},
|
||||||
`--sink http,tls.cert_file=c: "tls" is both a value and a table`: {"--sink", "http,tls=1,tls.cert_file=c"},
|
`--sink http,tls.cert_file=c: "tls" is both a value and a table`: {"sink", "http,tls=1,tls.cert_file=c"},
|
||||||
`--sink requires a value`: {"--sink="},
|
`--sink requires a value`: {"sink", ""},
|
||||||
`--format txt: pipeline "cli" already has one`: {"--format", "json", "--format", "txt"},
|
`--format txt: pipeline "cli" already has one`: {"format", "json", "format", "txt"},
|
||||||
`--rate-limit rate=1,polcy=drop: unknown key "polcy"`: {"--rate-limit", "rate=1,polcy=drop"},
|
`--rate-limit rate=1,polcy=drop: unknown key "polcy"`: {"rate-limit", "rate=1,polcy=drop"},
|
||||||
} {
|
} {
|
||||||
if _, err := Load(args); err == nil || err.Error() != want {
|
if _, err := Load(Args{Specs: specs(pairs...)}); err == nil || err.Error() != want {
|
||||||
t.Errorf("%q: err = %v, want %s", args, err, want)
|
t.Errorf("%q: err = %v, want %s", pairs, err, want)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -86,7 +94,7 @@ func TestPipelineSpecGrammar(t *testing.T) {
|
|||||||
// must not split into several, wider entries.
|
// must not split into several, wider entries.
|
||||||
func TestSpecValueIsOneListEntry(t *testing.T) {
|
func TestSpecValueIsOneListEntry(t *testing.T) {
|
||||||
isolateConfig(t)
|
isolateConfig(t)
|
||||||
sink := loadPipelines(t, "--source", "null", "--sink",
|
sink := loadPipelines(t, "source", "null", "sink",
|
||||||
`tcp,auth.type=mtls,auth.allow_patterns=^edge-\d{1\,3}$,auth.allow=CN=a\,O=b,auth.allow=CN=c`)[0].PluginSinks[0]
|
`tcp,auth.type=mtls,auth.allow_patterns=^edge-\d{1\,3}$,auth.allow=CN=a\,O=b,auth.allow=CN=c`)[0].PluginSinks[0]
|
||||||
var opts TCPSinkOptions
|
var opts TCPSinkOptions
|
||||||
if err := Scan(sink.Config, &opts); err != nil {
|
if err := Scan(sink.Config, &opts); err != nil {
|
||||||
@@ -115,7 +123,7 @@ func TestEnvironmentPipeline(t *testing.T) {
|
|||||||
} {
|
} {
|
||||||
t.Setenv(name, value)
|
t.Setenv(name, value)
|
||||||
}
|
}
|
||||||
m, err := Load(nil)
|
m, err := Load(Args{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -136,7 +144,7 @@ func TestCommandLinePipelinesIgnoreEnvironment(t *testing.T) {
|
|||||||
isolateConfig(t)
|
isolateConfig(t)
|
||||||
t.Setenv("LOGWISP_SOURCE", "random")
|
t.Setenv("LOGWISP_SOURCE", "random")
|
||||||
t.Setenv("LOGWISP_SINK", "console")
|
t.Setenv("LOGWISP_SINK", "console")
|
||||||
got := loadPipelines(t, "--source", "null", "--sink", "null")
|
got := loadPipelines(t, "source", "null", "sink", "null")
|
||||||
if len(got) != 1 || len(got[0].PluginSources) != 1 || got[0].PluginSources[0].Type != "null" ||
|
if len(got) != 1 || len(got[0].PluginSources) != 1 || got[0].PluginSources[0].Type != "null" ||
|
||||||
len(got[0].PluginSinks) != 1 || got[0].PluginSinks[0].Type != "null" {
|
len(got[0].PluginSinks) != 1 || got[0].PluginSinks[0].Type != "null" {
|
||||||
t.Fatalf("environment specs mixed into command-line pipelines: %+v", got)
|
t.Fatalf("environment specs mixed into command-line pipelines: %+v", got)
|
||||||
@@ -147,7 +155,7 @@ func TestCommandLinePipelinesIgnoreEnvironment(t *testing.T) {
|
|||||||
func TestSpecPipelinesReplaceFilePipelines(t *testing.T) {
|
func TestSpecPipelinesReplaceFilePipelines(t *testing.T) {
|
||||||
isolateConfig(t)
|
isolateConfig(t)
|
||||||
testutil.WriteFile(t, "file.toml", "[logging]\nlevel = \"warn\"\n[[pipelines]]\nname = \"file\"\n")
|
testutil.WriteFile(t, "file.toml", "[logging]\nlevel = \"warn\"\n[[pipelines]]\nname = \"file\"\n")
|
||||||
m, err := Load([]string{"-c", "file.toml", "--source", "null", "--sink", "null", "--status_reporter=false"})
|
m, err := Load(Args{File: "file.toml", Specs: specs("source", "null", "sink", "null"), Overrides: []string{"--status_reporter=false"}})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -165,12 +173,12 @@ func TestSpecPipelinesReplaceFilePipelines(t *testing.T) {
|
|||||||
// stdout, as a filter does; two pipelines cannot both read stdin.
|
// stdout, as a filter does; two pipelines cannot both read stdin.
|
||||||
func TestSpecPipelinesDefaultToStdio(t *testing.T) {
|
func TestSpecPipelinesDefaultToStdio(t *testing.T) {
|
||||||
isolateConfig(t)
|
isolateConfig(t)
|
||||||
got := loadPipelines(t, "--filter", "include,patterns=ERROR")
|
got := loadPipelines(t, "filter", "include,patterns=ERROR")
|
||||||
if p := got[0]; len(p.PluginSources) != 1 || p.PluginSources[0].Type != "console" || p.PluginSources[0].ID != "stdin" ||
|
if p := got[0]; len(p.PluginSources) != 1 || p.PluginSources[0].Type != "console" || p.PluginSources[0].ID != "stdin" ||
|
||||||
len(p.PluginSinks) != 1 || p.PluginSinks[0].Type != "console" || p.PluginSinks[0].ID != "stdout" {
|
len(p.PluginSinks) != 1 || p.PluginSinks[0].Type != "console" || p.PluginSinks[0].ID != "stdout" {
|
||||||
t.Fatalf("pipeline: %+v", p)
|
t.Fatalf("pipeline: %+v", p)
|
||||||
}
|
}
|
||||||
_, err := Load([]string{"--pipeline", "a", "--sink", "null", "--pipeline", "b", "--sink", "null"})
|
_, err := Load(Args{Specs: specs("pipeline", "a", "sink", "null", "pipeline", "b", "sink", "null")})
|
||||||
if err == nil || !strings.Contains(err.Error(), `console source already reads stdin in pipeline "a"`) {
|
if err == nil || !strings.Contains(err.Error(), `console source already reads stdin in pipeline "a"`) {
|
||||||
t.Fatalf("second stdin reader: %v", err)
|
t.Fatalf("second stdin reader: %v", err)
|
||||||
}
|
}
|
||||||
@@ -181,7 +189,7 @@ func TestSpecPipelinesDefaultToStdio(t *testing.T) {
|
|||||||
// removal fails the reload instead of keeping the removed file's values.
|
// removal fails the reload instead of keeping the removed file's values.
|
||||||
func TestReloadKeepsSpecPipelines(t *testing.T) {
|
func TestReloadKeepsSpecPipelines(t *testing.T) {
|
||||||
isolateConfig(t)
|
isolateConfig(t)
|
||||||
m, err := Load([]string{"--source", "random,special=true", "--sink", "null"})
|
m, err := Load(Args{Specs: specs("source", "random,special=true", "sink", "null")})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,6 +34,10 @@ func ValidateConfig(cfg *Config) error {
|
|||||||
names[p.Name] = struct{}{}
|
names[p.Name] = struct{}{}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if err := lconfig.OneOf("auto", "always", "never")(cfg.Color); err != nil {
|
||||||
|
return fmt.Errorf("color: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
if err := validateLogConfig(cfg.Logging); err != nil {
|
if err := validateLogConfig(cfg.Logging); err != nil {
|
||||||
return fmt.Errorf("logging: %w", err)
|
return fmt.Errorf("logging: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,6 +43,9 @@ const (
|
|||||||
|
|
||||||
ShutdownTimeout = 10 * time.Second
|
ShutdownTimeout = 10 * time.Second
|
||||||
|
|
||||||
|
// Bounds the flush of a network sink's queues at Stop
|
||||||
|
SinkFlushTimeout = 2 * time.Second
|
||||||
|
|
||||||
ConfigReloadTimeout = 30 * time.Second
|
ConfigReloadTimeout = 30 * time.Second
|
||||||
|
|
||||||
LoggerShutdownTimeout = 2 * time.Second
|
LoggerShutdownTimeout = 2 * time.Second
|
||||||
|
|||||||
@@ -18,8 +18,10 @@ import (
|
|||||||
"github.com/lixenwraith/logwisp/internal/session"
|
"github.com/lixenwraith/logwisp/internal/session"
|
||||||
"github.com/lixenwraith/logwisp/internal/sink"
|
"github.com/lixenwraith/logwisp/internal/sink"
|
||||||
|
|
||||||
|
"github.com/lixenwraith/color"
|
||||||
lconfig "github.com/lixenwraith/config"
|
lconfig "github.com/lixenwraith/config"
|
||||||
"github.com/lixenwraith/log"
|
"github.com/lixenwraith/log"
|
||||||
|
"github.com/lixenwraith/terminal/inline"
|
||||||
"golang.org/x/term"
|
"golang.org/x/term"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -38,8 +40,9 @@ type ConsoleSink struct {
|
|||||||
session *session.Session
|
session *session.Session
|
||||||
|
|
||||||
// Configuration
|
// Configuration
|
||||||
config *config.ConsoleSinkOptions
|
config *config.ConsoleSinkOptions
|
||||||
escape bool
|
escape bool
|
||||||
|
painter *inline.Printer // paints level names; nil when color is off
|
||||||
|
|
||||||
// Application
|
// Application
|
||||||
input chan core.TransportEvent
|
input chan core.TransportEvent
|
||||||
@@ -63,8 +66,23 @@ const (
|
|||||||
DefaultConsoleTarget = "stdout"
|
DefaultConsoleTarget = "stdout"
|
||||||
DefaultConsoleBufferSize = 1000
|
DefaultConsoleBufferSize = 1000
|
||||||
DefaultConsoleEscape = "auto"
|
DefaultConsoleEscape = "auto"
|
||||||
|
DefaultConsoleColor = "auto"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// levelColors paints the first of a level's names in an entry, ANSI 16 so
|
||||||
|
// the terminal's theme picks the shades and a text console renders them.
|
||||||
|
// Names run longest first; cyan, not blue, stays readable on black.
|
||||||
|
var levelColors = map[string]struct {
|
||||||
|
names []string
|
||||||
|
style inline.Style
|
||||||
|
}{
|
||||||
|
"ERROR": {[]string{"ERROR", "FATAL", "ERR"}, inline.FgANSI(color.ANSIRed).Bold()},
|
||||||
|
"WARN": {[]string{"WARNING", "WARN"}, inline.FgANSI(color.ANSIYellow)},
|
||||||
|
"INFO": {[]string{"INFO", "INF"}, inline.FgANSI(color.ANSIGreen)},
|
||||||
|
"DEBUG": {[]string{"DEBUG", "DBG"}, inline.FgANSI(color.ANSICyan)},
|
||||||
|
"TRACE": {[]string{"TRACE"}, inline.FgANSI(color.ANSIBrightBlack)},
|
||||||
|
}
|
||||||
|
|
||||||
// NewConsoleSinkPlugin creates a console sink through plugin factory
|
// NewConsoleSinkPlugin creates a console sink through plugin factory
|
||||||
func NewConsoleSinkPlugin(
|
func NewConsoleSinkPlugin(
|
||||||
id string,
|
id string,
|
||||||
@@ -72,7 +90,7 @@ func NewConsoleSinkPlugin(
|
|||||||
logger *log.Logger,
|
logger *log.Logger,
|
||||||
proxy *session.Proxy,
|
proxy *session.Proxy,
|
||||||
) (sink.Sink, error) {
|
) (sink.Sink, error) {
|
||||||
opts := &config.ConsoleSinkOptions{Target: DefaultConsoleTarget, Escape: DefaultConsoleEscape}
|
opts := &config.ConsoleSinkOptions{Target: DefaultConsoleTarget, Escape: DefaultConsoleEscape, Color: DefaultConsoleColor}
|
||||||
|
|
||||||
// Scan config map into struct
|
// Scan config map into struct
|
||||||
if err := config.Scan(configMap, opts); err != nil {
|
if err := config.Scan(configMap, opts); err != nil {
|
||||||
@@ -85,6 +103,9 @@ func NewConsoleSinkPlugin(
|
|||||||
if err := lconfig.OneOf("auto", "always", "never")(opts.Escape); err != nil {
|
if err := lconfig.OneOf("auto", "always", "never")(opts.Escape); err != nil {
|
||||||
return nil, fmt.Errorf("escape: %w", err)
|
return nil, fmt.Errorf("escape: %w", err)
|
||||||
}
|
}
|
||||||
|
if err := lconfig.OneOf("auto", "always", "never")(opts.Color); err != nil {
|
||||||
|
return nil, fmt.Errorf("color: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
output := os.Stdout
|
output := os.Stdout
|
||||||
if opts.Target == "stderr" {
|
if opts.Target == "stderr" {
|
||||||
@@ -95,17 +116,29 @@ func NewConsoleSinkPlugin(
|
|||||||
opts.BufferSize = DefaultConsoleBufferSize
|
opts.BufferSize = DefaultConsoleBufferSize
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// inline decides auto: a terminal, NO_COLOR unset, TERM not dumb. Paint
|
||||||
|
// returns its text unchanged exactly when color is off.
|
||||||
|
printer := inline.New(output)
|
||||||
|
if opts.Color != "auto" {
|
||||||
|
printer.SetColor(opts.Color == "always")
|
||||||
|
}
|
||||||
|
var painter *inline.Printer
|
||||||
|
if printer.Paint("x", inline.Style{}) != "x" {
|
||||||
|
painter = printer
|
||||||
|
}
|
||||||
|
|
||||||
// Create and return plugin instance
|
// Create and return plugin instance
|
||||||
cs := &ConsoleSink{
|
cs := &ConsoleSink{
|
||||||
id: id,
|
id: id,
|
||||||
proxy: proxy,
|
proxy: proxy,
|
||||||
config: opts,
|
config: opts,
|
||||||
escape: opts.Escape == "always" || opts.Escape == "auto" && term.IsTerminal(int(output.Fd())),
|
escape: opts.Escape == "always" || opts.Escape == "auto" && term.IsTerminal(int(output.Fd())),
|
||||||
input: make(chan core.TransportEvent, opts.BufferSize),
|
painter: painter,
|
||||||
output: output,
|
input: make(chan core.TransportEvent, opts.BufferSize),
|
||||||
done: make(chan struct{}),
|
output: output,
|
||||||
exited: make(chan struct{}),
|
done: make(chan struct{}),
|
||||||
logger: logger,
|
exited: make(chan struct{}),
|
||||||
|
logger: logger,
|
||||||
}
|
}
|
||||||
cs.lastProcessed.Store(time.Time{})
|
cs.lastProcessed.Store(time.Time{})
|
||||||
|
|
||||||
@@ -124,6 +157,7 @@ func NewConsoleSinkPlugin(
|
|||||||
"instance_id", id,
|
"instance_id", id,
|
||||||
"target", opts.Target,
|
"target", opts.Target,
|
||||||
"escape", cs.escape,
|
"escape", cs.escape,
|
||||||
|
"color", painter != nil,
|
||||||
)
|
)
|
||||||
|
|
||||||
return cs, nil
|
return cs, nil
|
||||||
@@ -187,6 +221,7 @@ func (cs *ConsoleSink) GetStats() sink.SinkStats {
|
|||||||
"target": cs.config.Target,
|
"target": cs.config.Target,
|
||||||
"buffer_size": cs.config.BufferSize,
|
"buffer_size": cs.config.BufferSize,
|
||||||
"escape": cs.escape,
|
"escape": cs.escape,
|
||||||
|
"color": cs.painter != nil,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -224,6 +259,9 @@ func (cs *ConsoleSink) write(event core.TransportEvent) {
|
|||||||
if cs.escape {
|
if cs.escape {
|
||||||
payload = escapeControls(payload)
|
payload = escapeControls(payload)
|
||||||
}
|
}
|
||||||
|
if cs.painter != nil {
|
||||||
|
payload = paintLevel(cs.painter, payload, event.Entry.Level)
|
||||||
|
}
|
||||||
if _, err := cs.output.Write(payload); err != nil {
|
if _, err := cs.output.Write(payload); err != nil {
|
||||||
cs.logger.Error("msg", "Failed to write to console",
|
cs.logger.Error("msg", "Failed to write to console",
|
||||||
"component", "console_sink",
|
"component", "console_sink",
|
||||||
@@ -235,6 +273,42 @@ func (cs *ConsoleSink) write(event core.TransportEvent) {
|
|||||||
cs.lastProcessed.Store(time.Now())
|
cs.lastProcessed.Store(time.Now())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// paintLevel colors the first whole-word, case-insensitive occurrence of a
|
||||||
|
// name of level, after escaping: the escape sequences are the sink's own.
|
||||||
|
func paintLevel(p *inline.Printer, payload []byte, level string) []byte {
|
||||||
|
lc, ok := levelColors[level]
|
||||||
|
if !ok {
|
||||||
|
return payload
|
||||||
|
}
|
||||||
|
for _, name := range lc.names {
|
||||||
|
for i := 0; i+len(name) <= len(payload); i++ {
|
||||||
|
j := i + len(name)
|
||||||
|
if !nameAt(payload[i:j], name) || i > 0 && word(payload[i-1]) || j < len(payload) && word(payload[j]) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out := make([]byte, 0, len(payload)+16)
|
||||||
|
out = append(out, payload[:i]...)
|
||||||
|
out = append(out, p.Paint(string(payload[i:j]), lc.style)...)
|
||||||
|
return append(out, payload[j:]...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return payload
|
||||||
|
}
|
||||||
|
|
||||||
|
// nameAt compares ASCII letters without case; name is upper case
|
||||||
|
func nameAt(b []byte, name string) bool {
|
||||||
|
for k := range len(name) {
|
||||||
|
if c := b[k]; c != name[k] && c != name[k]+'a'-'A' {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func word(c byte) bool {
|
||||||
|
return c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' || c == '_'
|
||||||
|
}
|
||||||
|
|
||||||
// escapeControls writes what a terminal would act on or reorder (C0 and C1
|
// escapeControls writes what a terminal would act on or reorder (C0 and C1
|
||||||
// controls but tab, DEL, bidi controls, line separators, invalid UTF-8) as
|
// controls but tab, DEL, bidi controls, line separators, invalid UTF-8) as
|
||||||
// <hex>, keeping the final newline: a log line cannot drive the terminal.
|
// <hex>, keeping the final newline: a log line cannot drive the terminal.
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
package console
|
package console
|
||||||
|
|
||||||
import "testing"
|
import (
|
||||||
|
"io"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/lixenwraith/terminal/inline"
|
||||||
|
)
|
||||||
|
|
||||||
// Everything a terminal would act on is escaped; tabs and the final newline,
|
// Everything a terminal would act on is escaped; tabs and the final newline,
|
||||||
// which it only lays out, are kept.
|
// which it only lays out, are kept.
|
||||||
@@ -23,3 +28,22 @@ func TestEscapeControlsKeepsOnlyLayout(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The entry's level is painted where its line first names it as a whole
|
||||||
|
// word, in any case, longest name first; other words and levels stay plain.
|
||||||
|
func TestPaintLevelColorsTheFirstWholeName(t *testing.T) {
|
||||||
|
p := inline.New(io.Discard)
|
||||||
|
p.SetColor(true)
|
||||||
|
red, yellow := "\x1b[0;1;31m", "\x1b[0;33m"
|
||||||
|
for _, c := range []struct{ in, level, want string }{
|
||||||
|
{"2026 ERROR boom ERROR\n", "ERROR", "2026 " + red + "ERROR\x1b[0m boom ERROR\n"},
|
||||||
|
{"[warning] disk\n", "WARN", "[" + yellow + "warning\x1b[0m] disk\n"},
|
||||||
|
{"terror_x ERR:1\n", "ERROR", "terror_x " + red + "ERR\x1b[0m:1\n"},
|
||||||
|
{"INFOS informal\n", "INFO", "INFOS informal\n"},
|
||||||
|
{"ERROR without level\n", "", "ERROR without level\n"},
|
||||||
|
} {
|
||||||
|
if got := string(paintLevel(p, []byte(c.in), c.level)); got != c.want {
|
||||||
|
t.Errorf("%q %s: got %q, want %q", c.in, c.level, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+59
-23
@@ -43,7 +43,6 @@ const (
|
|||||||
DefaultHTTPStreamPath = "/stream"
|
DefaultHTTPStreamPath = "/stream"
|
||||||
DefaultHTTPStatusPath = "/status"
|
DefaultHTTPStatusPath = "/status"
|
||||||
HTTPReadHeaderTimeout = 10 * time.Second
|
HTTPReadHeaderTimeout = 10 * time.Second
|
||||||
HTTPShutdownTimeout = 2 * time.Second
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// HTTPSink streams log entries via Server-Sent Events
|
// HTTPSink streams log entries via Server-Sent Events
|
||||||
@@ -80,8 +79,14 @@ type HTTPSink struct {
|
|||||||
auth *authz.Policy
|
auth *authz.Policy
|
||||||
web map[string]http.Handler // proxy mode: GET /auth/... browser files
|
web map[string]http.Handler // proxy mode: GET /auth/... browser files
|
||||||
|
|
||||||
// Runtime
|
// Runtime. Stop closes flush; the broker then moves what remains of its
|
||||||
|
// input to tail and closes flushed; each stream writes its queue, tail
|
||||||
|
// and the disconnect event on its own, until flushBy.
|
||||||
done chan struct{}
|
done chan struct{}
|
||||||
|
flush chan struct{}
|
||||||
|
flushed chan struct{}
|
||||||
|
flushBy time.Time
|
||||||
|
tail [][]byte // read only once flushed is closed
|
||||||
stopOnce sync.Once
|
stopOnce sync.Once
|
||||||
wg sync.WaitGroup
|
wg sync.WaitGroup
|
||||||
startTime time.Time
|
startTime time.Time
|
||||||
@@ -145,7 +150,7 @@ func NewHTTPSinkPlugin(
|
|||||||
if opts.ClientBufferSize <= 0 {
|
if opts.ClientBufferSize <= 0 {
|
||||||
opts.ClientBufferSize = DefaultHTTPClientBufferSize
|
opts.ClientBufferSize = DefaultHTTPClientBufferSize
|
||||||
}
|
}
|
||||||
tlsCfg, err := tlsx.Server(opts.TLS)
|
tlsCfg, err := tlsx.Server(opts.TLS, opts.Host)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -174,6 +179,8 @@ func NewHTTPSinkPlugin(
|
|||||||
network: network,
|
network: network,
|
||||||
input: make(chan core.TransportEvent, opts.BufferSize),
|
input: make(chan core.TransportEvent, opts.BufferSize),
|
||||||
done: make(chan struct{}),
|
done: make(chan struct{}),
|
||||||
|
flush: make(chan struct{}),
|
||||||
|
flushed: make(chan struct{}),
|
||||||
logger: logger,
|
logger: logger,
|
||||||
clients: make(map[uint64]*sseClient),
|
clients: make(map[uint64]*sseClient),
|
||||||
writeTimeout: time.Duration(opts.WriteTimeoutMS) * time.Millisecond,
|
writeTimeout: time.Duration(opts.WriteTimeoutMS) * time.Millisecond,
|
||||||
@@ -276,7 +283,7 @@ func (h *HTTPSink) serve(ctx context.Context, ln net.Listener) error {
|
|||||||
h.startTime = time.Now()
|
h.startTime = time.Now()
|
||||||
|
|
||||||
h.wg.Add(1)
|
h.wg.Add(1)
|
||||||
go h.brokerLoop(ctx)
|
go h.brokerLoop()
|
||||||
|
|
||||||
serve := h.server.Serve
|
serve := h.server.Serve
|
||||||
if h.tlsConfig != nil {
|
if h.tlsConfig != nil {
|
||||||
@@ -325,18 +332,21 @@ func (h *HTTPSink) Stop() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// shutdown funnels ctx-cancel and Stop() teardown through a single path.
|
// shutdown funnels ctx-cancel and Stop() teardown through a single path.
|
||||||
// done is closed first so SSE handlers exit and Shutdown can complete;
|
// Server.Shutdown refuses new streams and waits for the open ones, which end
|
||||||
// Server.Close force-closes any handler stalled in a deadline-free write.
|
// once they wrote what is queued, within sink.FlushBound; Server.Close cuts a
|
||||||
|
// stream stalled past it.
|
||||||
func (h *HTTPSink) shutdown() {
|
func (h *HTTPSink) shutdown() {
|
||||||
h.stopOnce.Do(func() {
|
h.stopOnce.Do(func() {
|
||||||
close(h.done)
|
|
||||||
if h.server != nil {
|
if h.server != nil {
|
||||||
sctx, cancel := context.WithTimeout(context.Background(), HTTPShutdownTimeout)
|
h.flushBy = time.Now().Add(sink.FlushBound(h.writeTimeout))
|
||||||
|
ctx, cancel := context.WithDeadline(context.Background(), h.flushBy)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
if err := h.server.Shutdown(sctx); err != nil {
|
close(h.flush)
|
||||||
|
if err := h.server.Shutdown(ctx); err != nil {
|
||||||
h.server.Close()
|
h.server.Close()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
close(h.done)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -356,19 +366,22 @@ func (h *HTTPSink) removeClient(id uint64) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// brokerLoop fans out transport events to all client queues, non-blocking,
|
// brokerLoop fans out transport events to all client queues, non-blocking,
|
||||||
// and evicts clients whose sessions were idle-expired by the session manager
|
// and evicts clients whose sessions were idle-expired by the session manager,
|
||||||
func (h *HTTPSink) brokerLoop(ctx context.Context) {
|
// until flush, which shutdown closes on Stop and on context cancellation.
|
||||||
|
func (h *HTTPSink) brokerLoop() {
|
||||||
defer h.wg.Done()
|
defer h.wg.Done()
|
||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
case <-ctx.Done():
|
case <-h.flush: // first: a select with both ready picks at random
|
||||||
|
h.flushInput()
|
||||||
return
|
return
|
||||||
case <-h.done:
|
default:
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-h.flush:
|
||||||
|
h.flushInput()
|
||||||
return
|
return
|
||||||
case event, ok := <-h.input:
|
case event := <-h.input:
|
||||||
if !ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
h.totalProcessed.Add(1)
|
h.totalProcessed.Add(1)
|
||||||
h.lastProcessed.Store(time.Now())
|
h.lastProcessed.Store(time.Now())
|
||||||
|
|
||||||
@@ -395,6 +408,13 @@ func (h *HTTPSink) brokerLoop(ctx context.Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// flushInput moves what remains of the input to tail for the streams
|
||||||
|
func (h *HTTPSink) flushInput() {
|
||||||
|
h.tail = sink.Drain(h.input)
|
||||||
|
h.totalProcessed.Add(uint64(len(h.tail)))
|
||||||
|
close(h.flushed)
|
||||||
|
}
|
||||||
|
|
||||||
// handleStream serves one client's SSE stream
|
// handleStream serves one client's SSE stream
|
||||||
func (h *HTTPSink) handleStream(w http.ResponseWriter, r *http.Request) {
|
func (h *HTTPSink) handleStream(w http.ResponseWriter, r *http.Request) {
|
||||||
if h.config.MaxConnections > 0 && h.activeClients.Load() >= h.config.MaxConnections {
|
if h.config.MaxConnections > 0 && h.activeClients.Load() >= h.config.MaxConnections {
|
||||||
@@ -483,6 +503,13 @@ func (h *HTTPSink) handleStream(w http.ResponseWriter, r *http.Request) {
|
|||||||
idle := time.NewTicker(h.keepalive)
|
idle := time.NewTicker(h.keepalive)
|
||||||
defer idle.Stop()
|
defer idle.Stop()
|
||||||
|
|
||||||
|
send := func(payload []byte) bool {
|
||||||
|
if writeSSE(w, payload) != nil || rc.Flush() != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
h.proxy.UpdateActivity(sess.ID)
|
||||||
|
return true
|
||||||
|
}
|
||||||
clientGone := r.Context().Done()
|
clientGone := r.Context().Done()
|
||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
@@ -491,13 +518,9 @@ func (h *HTTPSink) handleStream(w http.ResponseWriter, r *http.Request) {
|
|||||||
return // broker evicted (stale session)
|
return // broker evicted (stale session)
|
||||||
}
|
}
|
||||||
h.armWrite(rc)
|
h.armWrite(rc)
|
||||||
if err := writeSSE(w, payload); err != nil {
|
if !send(payload) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := rc.Flush(); err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
h.proxy.UpdateActivity(sess.ID)
|
|
||||||
case <-idle.C:
|
case <-idle.C:
|
||||||
h.armWrite(rc)
|
h.armWrite(rc)
|
||||||
if _, err := fmt.Fprint(w, ":\n\n"); err != nil {
|
if _, err := fmt.Fprint(w, ":\n\n"); err != nil {
|
||||||
@@ -509,7 +532,20 @@ func (h *HTTPSink) handleStream(w http.ResponseWriter, r *http.Request) {
|
|||||||
h.proxy.UpdateActivity(sess.ID)
|
h.proxy.UpdateActivity(sess.ID)
|
||||||
case <-clientGone:
|
case <-clientGone:
|
||||||
return
|
return
|
||||||
case <-h.done:
|
case <-h.flushed:
|
||||||
|
// Nothing more will be queued: write the queue and the tail, then
|
||||||
|
// say why the stream ends
|
||||||
|
_ = rc.SetWriteDeadline(h.flushBy)
|
||||||
|
for len(c.send) > 0 {
|
||||||
|
if payload, ok := <-c.send; !ok || !send(payload) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, payload := range h.tail {
|
||||||
|
if !send(payload) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
fmt.Fprintf(w, "event: disconnect\ndata: {\"reason\":\"server_shutdown\"}\n\n")
|
fmt.Fprintf(w, "event: disconnect\ndata: {\"reason\":\"server_shutdown\"}\n\n")
|
||||||
rc.Flush()
|
rc.Flush()
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"crypto/tls"
|
"crypto/tls"
|
||||||
"crypto/x509"
|
"crypto/x509"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"maps"
|
"maps"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -319,3 +320,73 @@ func TestSSEFramesEveryLineAsData(t *testing.T) {
|
|||||||
t.Fatalf("framed %q, want %q", got, want)
|
t.Fatalf("framed %q, want %q", got, want)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Stop writes what is queued, in the sink and per stream, then the
|
||||||
|
// disconnect event, to a client that keeps reading, whatever another that
|
||||||
|
// 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)})
|
||||||
|
client, baseURL := serveTestHTTPSink(t, httpSink)
|
||||||
|
var scanners []*bufio.Scanner
|
||||||
|
for range 2 {
|
||||||
|
resp, err := client.Get(baseURL + "/stream")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = resp.Body.Close() })
|
||||||
|
scanner := bufio.NewScanner(resp.Body)
|
||||||
|
scanner.Buffer(nil, 64*1024)
|
||||||
|
for scanner.Scan() && scanner.Text() != "event: connected" {
|
||||||
|
}
|
||||||
|
scanners = append(scanners, scanner)
|
||||||
|
}
|
||||||
|
for deadline := time.Now().Add(2 * time.Second); ; time.Sleep(10 * time.Millisecond) {
|
||||||
|
httpSink.clientsMu.Lock()
|
||||||
|
registered := len(httpSink.clients)
|
||||||
|
httpSink.clientsMu.Unlock()
|
||||||
|
if registered == 2 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if time.Now().After(deadline) {
|
||||||
|
t.Fatal("streams never registered")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
type result struct {
|
||||||
|
lines int
|
||||||
|
disconnect bool
|
||||||
|
}
|
||||||
|
done := make(chan result)
|
||||||
|
go func() {
|
||||||
|
var r result
|
||||||
|
for scanners[0].Scan() {
|
||||||
|
switch line := scanners[0].Text(); {
|
||||||
|
case strings.HasPrefix(line, "data: line "):
|
||||||
|
r.lines++
|
||||||
|
case line == "event: disconnect":
|
||||||
|
r.disconnect = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
done <- r
|
||||||
|
}()
|
||||||
|
// Held, the lock keeps the input queued until Stop has begun its flush
|
||||||
|
httpSink.clientsMu.Lock()
|
||||||
|
padding := strings.Repeat("x", 8192)
|
||||||
|
for i := range n {
|
||||||
|
httpSink.Input() <- core.TransportEvent{Payload: fmt.Appendf(nil, "line %d %s\n", i, padding)}
|
||||||
|
}
|
||||||
|
stopped := make(chan time.Duration)
|
||||||
|
go func() {
|
||||||
|
start := time.Now()
|
||||||
|
httpSink.Stop()
|
||||||
|
stopped <- time.Since(start)
|
||||||
|
}()
|
||||||
|
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 r := <-done; r.lines != n || !r.disconnect {
|
||||||
|
t.Fatalf("reading stream received %d of %d lines, disconnect %v", r.lines, n, r.disconnect)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -35,3 +35,27 @@ type SinkStats struct {
|
|||||||
LastProcessed time.Time
|
LastProcessed time.Time
|
||||||
Details map[string]any
|
Details map[string]any
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// FlushBound is how long a network sink's Stop gives its clients to take
|
||||||
|
// what is queued: write_timeout_ms, at most core.SinkFlushTimeout, which a
|
||||||
|
// reload also waits out for a client that stopped reading.
|
||||||
|
func FlushBound(writeTimeout time.Duration) time.Duration {
|
||||||
|
if writeTimeout > 0 && writeTimeout < core.SinkFlushTimeout {
|
||||||
|
return writeTimeout
|
||||||
|
}
|
||||||
|
return core.SinkFlushTimeout
|
||||||
|
}
|
||||||
|
|
||||||
|
// Drain takes the payloads queued in input without waiting: a network sink's
|
||||||
|
// last events, which each client then writes on its own.
|
||||||
|
func Drain(input <-chan core.TransportEvent) [][]byte {
|
||||||
|
var tail [][]byte
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case event := <-input:
|
||||||
|
tail = append(tail, event.Payload)
|
||||||
|
default:
|
||||||
|
return tail
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+89
-24
@@ -7,6 +7,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"maps"
|
"maps"
|
||||||
"net"
|
"net"
|
||||||
|
"slices"
|
||||||
"strconv"
|
"strconv"
|
||||||
"sync"
|
"sync"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
@@ -74,8 +75,15 @@ type TCPSink struct {
|
|||||||
// Authorization
|
// Authorization
|
||||||
auth *authz.Policy
|
auth *authz.Policy
|
||||||
|
|
||||||
// Runtime
|
// Runtime. Stop closes flush; the broadcast loop then moves what remains
|
||||||
|
// of its input to tail and closes flushed; each writer writes its queue
|
||||||
|
// and tail on its own, until flushBy. done tears down what is left.
|
||||||
done chan struct{}
|
done chan struct{}
|
||||||
|
flush chan struct{}
|
||||||
|
flushed chan struct{}
|
||||||
|
flushBy time.Time
|
||||||
|
tail [][]byte // read only once flushed is closed
|
||||||
|
flushing bool // under clientsMu: no client registers once set
|
||||||
stopOnce sync.Once
|
stopOnce sync.Once
|
||||||
wg sync.WaitGroup
|
wg sync.WaitGroup
|
||||||
startTime time.Time
|
startTime time.Time
|
||||||
@@ -91,11 +99,13 @@ type TCPSink struct {
|
|||||||
|
|
||||||
// tcpClient is a registered connection's bounded send queue.
|
// tcpClient is a registered connection's bounded send queue.
|
||||||
// send is written by the broadcast loop (non-blocking) and drained by the
|
// send is written by the broadcast loop (non-blocking) and drained by the
|
||||||
// writer goroutine; closed signals reader-detected disconnect.
|
// writer goroutine; closed signals reader-detected disconnect, exited the
|
||||||
|
// writer's end.
|
||||||
type tcpClient struct {
|
type tcpClient struct {
|
||||||
send chan []byte
|
send chan []byte
|
||||||
sessionID string
|
sessionID string
|
||||||
closed chan struct{}
|
closed chan struct{}
|
||||||
|
exited chan struct{}
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewTCPSinkPlugin creates a tcp sink through plugin factory
|
// NewTCPSinkPlugin creates a tcp sink through plugin factory
|
||||||
@@ -131,7 +141,7 @@ func NewTCPSinkPlugin(
|
|||||||
if opts.KeepAlivePeriodMS <= 0 {
|
if opts.KeepAlivePeriodMS <= 0 {
|
||||||
opts.KeepAlivePeriodMS = DefaultTCPKeepAlivePeriodMS
|
opts.KeepAlivePeriodMS = DefaultTCPKeepAlivePeriodMS
|
||||||
}
|
}
|
||||||
tlsCfg, err := tlsx.Server(opts.TLS)
|
tlsCfg, err := tlsx.Server(opts.TLS, opts.Host)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -148,6 +158,8 @@ func NewTCPSinkPlugin(
|
|||||||
network: network,
|
network: network,
|
||||||
input: make(chan core.TransportEvent, opts.BufferSize),
|
input: make(chan core.TransportEvent, opts.BufferSize),
|
||||||
done: make(chan struct{}),
|
done: make(chan struct{}),
|
||||||
|
flush: make(chan struct{}),
|
||||||
|
flushed: make(chan struct{}),
|
||||||
logger: logger,
|
logger: logger,
|
||||||
clients: make(map[uint64]*tcpClient),
|
clients: make(map[uint64]*tcpClient),
|
||||||
conns: make(map[net.Conn]struct{}),
|
conns: make(map[net.Conn]struct{}),
|
||||||
@@ -223,7 +235,7 @@ func (t *TCPSink) Start(ctx context.Context) error {
|
|||||||
|
|
||||||
t.wg.Add(2)
|
t.wg.Add(2)
|
||||||
go t.acceptLoop()
|
go t.acceptLoop()
|
||||||
go t.broadcastLoop(ctx)
|
go t.broadcastLoop()
|
||||||
|
|
||||||
// Pipeline context cancellation mirrors gnet engine stop: cease accepting
|
// Pipeline context cancellation mirrors gnet engine stop: cease accepting
|
||||||
// and tear down existing connections
|
// and tear down existing connections
|
||||||
@@ -258,13 +270,27 @@ func (t *TCPSink) Stop() {
|
|||||||
"total_processed", t.totalProcessed.Load())
|
"total_processed", t.totalProcessed.Load())
|
||||||
}
|
}
|
||||||
|
|
||||||
// shutdown funnels ctx-cancel and Stop() teardown through a single path
|
// shutdown funnels ctx-cancel and Stop() teardown through a single path.
|
||||||
|
// Clients first receive what is queued, within sink.FlushBound, so a finite
|
||||||
|
// input reaches them whole.
|
||||||
func (t *TCPSink) shutdown() {
|
func (t *TCPSink) shutdown() {
|
||||||
t.stopOnce.Do(func() {
|
t.stopOnce.Do(func() {
|
||||||
close(t.done)
|
|
||||||
if t.listener != nil {
|
if t.listener != nil {
|
||||||
t.listener.Close() // unblocks acceptLoop
|
t.listener.Close() // unblocks acceptLoop
|
||||||
|
t.flushBy = time.Now().Add(sink.FlushBound(t.writeTimeout))
|
||||||
|
close(t.flush)
|
||||||
|
t.clientsMu.Lock()
|
||||||
|
t.flushing = true
|
||||||
|
clients := slices.Collect(maps.Values(t.clients))
|
||||||
|
t.clientsMu.Unlock()
|
||||||
|
for _, c := range clients {
|
||||||
|
select {
|
||||||
|
case <-c.exited:
|
||||||
|
case <-time.After(time.Until(t.flushBy)):
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
close(t.done)
|
||||||
t.clientsMu.Lock()
|
t.clientsMu.Lock()
|
||||||
for conn := range t.conns {
|
for conn := range t.conns {
|
||||||
conn.Close() // unblocks handshakes and per-connection readers
|
conn.Close() // unblocks handshakes and per-connection readers
|
||||||
@@ -381,10 +407,17 @@ func (t *TCPSink) handleConn(conn net.Conn) {
|
|||||||
send: make(chan []byte, t.config.ClientBufferSize),
|
send: make(chan []byte, t.config.ClientBufferSize),
|
||||||
sessionID: sess.ID,
|
sessionID: sess.ID,
|
||||||
closed: make(chan struct{}),
|
closed: make(chan struct{}),
|
||||||
|
exited: make(chan struct{}),
|
||||||
}
|
}
|
||||||
|
defer close(c.exited)
|
||||||
id := t.nextClientID.Add(1)
|
id := t.nextClientID.Add(1)
|
||||||
|
|
||||||
t.clientsMu.Lock()
|
t.clientsMu.Lock()
|
||||||
|
if t.flushing {
|
||||||
|
t.clientsMu.Unlock()
|
||||||
|
t.proxy.RemoveSession(sess.ID)
|
||||||
|
return
|
||||||
|
}
|
||||||
t.clients[id] = c
|
t.clients[id] = c
|
||||||
t.clientsMu.Unlock()
|
t.clientsMu.Unlock()
|
||||||
|
|
||||||
@@ -422,23 +455,45 @@ func (t *TCPSink) handleConn(conn net.Conn) {
|
|||||||
|
|
||||||
// Writer: synchronous lib write with deadline. A failed write means
|
// Writer: synchronous lib write with deadline. A failed write means
|
||||||
// the kernel buffer stayed full for the full deadline - connection is
|
// the kernel buffer stayed full for the full deadline - connection is
|
||||||
// dead or hopelessly stalled, so disconnect immediately (no gnet-style
|
// dead or hopelessly stalled, so disconnect immediately.
|
||||||
// consecutive-error counter needed for transient async callback errors).
|
write := func(data []byte, deadline time.Time) bool {
|
||||||
|
if !deadline.IsZero() {
|
||||||
|
conn.SetWriteDeadline(deadline)
|
||||||
|
}
|
||||||
|
if _, err := conn.Write(data); err != nil {
|
||||||
|
t.writeErrors.Add(1)
|
||||||
|
t.logger.Debug("msg", "Write failed, closing client",
|
||||||
|
"component", "tcp_sink",
|
||||||
|
"remote_addr", remote,
|
||||||
|
"error", err)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
t.proxy.UpdateActivity(sess.ID)
|
||||||
|
return true
|
||||||
|
}
|
||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
case data := <-c.send:
|
case data := <-c.send:
|
||||||
|
var deadline time.Time
|
||||||
if t.writeTimeout > 0 {
|
if t.writeTimeout > 0 {
|
||||||
conn.SetWriteDeadline(time.Now().Add(t.writeTimeout))
|
deadline = time.Now().Add(t.writeTimeout)
|
||||||
}
|
}
|
||||||
if _, err := conn.Write(data); err != nil {
|
if !write(data, deadline) {
|
||||||
t.writeErrors.Add(1)
|
|
||||||
t.logger.Debug("msg", "Write failed, closing client",
|
|
||||||
"component", "tcp_sink",
|
|
||||||
"remote_addr", remote,
|
|
||||||
"error", err)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
t.proxy.UpdateActivity(sess.ID)
|
case <-t.flushed:
|
||||||
|
// Nothing more will be queued: write the queue, then the tail
|
||||||
|
for len(c.send) > 0 {
|
||||||
|
if !write(<-c.send, t.flushBy) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, data := range t.tail {
|
||||||
|
if !write(data, t.flushBy) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return
|
||||||
case <-c.closed:
|
case <-c.closed:
|
||||||
return
|
return
|
||||||
case <-t.done:
|
case <-t.done:
|
||||||
@@ -447,19 +502,22 @@ func (t *TCPSink) handleConn(conn net.Conn) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// broadcastLoop fans out transport events to all client queues, non-blocking
|
// broadcastLoop fans out transport events to all client queues until flush,
|
||||||
func (t *TCPSink) broadcastLoop(ctx context.Context) {
|
// which shutdown closes on Stop and on context cancellation alike.
|
||||||
|
func (t *TCPSink) broadcastLoop() {
|
||||||
defer t.wg.Done()
|
defer t.wg.Done()
|
||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
case <-ctx.Done():
|
case <-t.flush: // first: a select with both ready picks at random
|
||||||
|
t.flushInput()
|
||||||
return
|
return
|
||||||
case <-t.done:
|
default:
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-t.flush:
|
||||||
|
t.flushInput()
|
||||||
return
|
return
|
||||||
case event, ok := <-t.input:
|
case event := <-t.input:
|
||||||
if !ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
t.totalProcessed.Add(1)
|
t.totalProcessed.Add(1)
|
||||||
t.lastProcessed.Store(time.Now())
|
t.lastProcessed.Store(time.Now())
|
||||||
|
|
||||||
@@ -477,6 +535,13 @@ func (t *TCPSink) broadcastLoop(ctx context.Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// flushInput moves what remains of the input to tail for the writers
|
||||||
|
func (t *TCPSink) flushInput() {
|
||||||
|
t.tail = sink.Drain(t.input)
|
||||||
|
t.totalProcessed.Add(uint64(len(t.tail)))
|
||||||
|
close(t.flushed)
|
||||||
|
}
|
||||||
|
|
||||||
// GetStats returns sink statistics
|
// GetStats returns sink statistics
|
||||||
func (t *TCPSink) GetStats() sink.SinkStats {
|
func (t *TCPSink) GetStats() sink.SinkStats {
|
||||||
lastProc, _ := t.lastProcessed.Load().(time.Time)
|
lastProc, _ := t.lastProcessed.Load().(time.Time)
|
||||||
|
|||||||
@@ -1,11 +1,15 @@
|
|||||||
package tcp
|
package tcp
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bufio"
|
||||||
|
"fmt"
|
||||||
"net"
|
"net"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/lixenwraith/logwisp/internal/core"
|
||||||
"github.com/lixenwraith/logwisp/internal/session"
|
"github.com/lixenwraith/logwisp/internal/session"
|
||||||
"github.com/lixenwraith/logwisp/internal/testutil"
|
"github.com/lixenwraith/logwisp/internal/testutil"
|
||||||
|
|
||||||
@@ -57,3 +61,81 @@ func TestStopDoesNotWaitForSilentHandshake(t *testing.T) {
|
|||||||
t.Fatalf("Stop took %v with a silent peer in the handshake", d)
|
t.Fatalf("Stop took %v with a silent peer in the handshake", d)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Stop writes what is queued, in the sink and per client, to a client that
|
||||||
|
// keeps reading, whatever another that stopped reading does: the end of a
|
||||||
|
// finite input reaches it whole, and Stop returns at the flush bound.
|
||||||
|
func TestStopWritesQueuedEventsToReadingClients(t *testing.T) {
|
||||||
|
const n = 2000 // of 8 KiB: more than a silent peer's socket buffers hold
|
||||||
|
probe, err := net.Listen("tcp4", "127.0.0.1:0")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
addr := probe.Addr().String()
|
||||||
|
probe.Close()
|
||||||
|
_, port, _ := net.SplitHostPort(addr)
|
||||||
|
p, _ := strconv.ParseInt(port, 10, 64)
|
||||||
|
|
||||||
|
manager := session.NewManager(time.Hour)
|
||||||
|
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),
|
||||||
|
}, log.NewLogger(), session.NewProxy(manager, "out"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
sink := created.(*TCPSink)
|
||||||
|
if err := sink.Start(t.Context()); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var conns []net.Conn
|
||||||
|
for range 2 {
|
||||||
|
conn, err := net.Dial("tcp4", addr)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { conn.Close() })
|
||||||
|
conns = append(conns, conn)
|
||||||
|
}
|
||||||
|
for deadline := time.Now().Add(2 * time.Second); ; time.Sleep(10 * time.Millisecond) {
|
||||||
|
sink.clientsMu.Lock()
|
||||||
|
registered := len(sink.clients)
|
||||||
|
sink.clientsMu.Unlock()
|
||||||
|
if registered == 2 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if time.Now().After(deadline) {
|
||||||
|
t.Fatal("clients never registered")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lines := make(chan int)
|
||||||
|
go func() {
|
||||||
|
count, scanner := 0, bufio.NewScanner(conns[0])
|
||||||
|
scanner.Buffer(nil, 64*1024)
|
||||||
|
for scanner.Scan() {
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
lines <- count
|
||||||
|
}()
|
||||||
|
// Held, the lock keeps the input queued until Stop has begun its flush
|
||||||
|
sink.clientsMu.Lock()
|
||||||
|
padding := strings.Repeat("x", 8192)
|
||||||
|
for i := range n {
|
||||||
|
sink.Input() <- core.TransportEvent{Payload: fmt.Appendf(nil, "line %d %s\n", i, padding)}
|
||||||
|
}
|
||||||
|
stopped := make(chan time.Duration)
|
||||||
|
go func() {
|
||||||
|
start := time.Now()
|
||||||
|
sink.Stop()
|
||||||
|
stopped <- time.Since(start)
|
||||||
|
}()
|
||||||
|
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 got := <-lines; got != n {
|
||||||
|
t.Fatalf("reading client received %d of %d lines", got, n)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -329,8 +329,9 @@ func (t *TCPChainSink) deliver(ctx context.Context, line []byte) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
t.failures++
|
t.failures++
|
||||||
if errors.Is(err, authz.ErrRefused) {
|
if errors.Is(err, authz.ErrRefused) || errors.As(err, new(*tls.CertificateVerificationError)) {
|
||||||
// A refusal is configuration, not weather: show it by default
|
// A refusal, or a server failing its CA or pin, is
|
||||||
|
// configuration, not weather: show it by default
|
||||||
t.logger.Warn("msg", "Chain connect refused",
|
t.logger.Warn("msg", "Chain connect refused",
|
||||||
"component", "tcp_chain_sink",
|
"component", "tcp_chain_sink",
|
||||||
"target", t.addr,
|
"target", t.addr,
|
||||||
|
|||||||
@@ -112,7 +112,7 @@ func NewHTTPChainSourcePlugin(
|
|||||||
if opts.ReadTimeoutMS <= 0 {
|
if opts.ReadTimeoutMS <= 0 {
|
||||||
opts.ReadTimeoutMS = DefaultHTTPChainSourceReadTimeoutMS
|
opts.ReadTimeoutMS = DefaultHTTPChainSourceReadTimeoutMS
|
||||||
}
|
}
|
||||||
tlsCfg, err := tlsx.Server(opts.TLS)
|
tlsCfg, err := tlsx.Server(opts.TLS, opts.Host)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -97,7 +97,7 @@ func NewTCPChainSourcePlugin(
|
|||||||
if opts.HelloTimeoutMS <= 0 {
|
if opts.HelloTimeoutMS <= 0 {
|
||||||
opts.HelloTimeoutMS = DefaultChainSourceHelloTimeoutMS
|
opts.HelloTimeoutMS = DefaultChainSourceHelloTimeoutMS
|
||||||
}
|
}
|
||||||
tlsCfg, err := tlsx.Server(opts.TLS)
|
tlsCfg, err := tlsx.Server(opts.TLS, opts.Host)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"crypto/ecdsa"
|
"crypto/ecdsa"
|
||||||
"crypto/elliptic"
|
"crypto/elliptic"
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
|
"crypto/tls"
|
||||||
"crypto/x509"
|
"crypto/x509"
|
||||||
"crypto/x509/pkix"
|
"crypto/x509/pkix"
|
||||||
"encoding/pem"
|
"encoding/pem"
|
||||||
@@ -120,6 +121,40 @@ func (p *PKI) Leaf(t testing.TB, name, cn string, client bool) (certFile, keyFil
|
|||||||
return writePEM(t, p.dir, name+".crt", "CERTIFICATE", der), writePEM(t, p.dir, name+".key", "PRIVATE KEY", keyDER)
|
return writePEM(t, p.dir, name+".crt", "CERTIFICATE", der), writePEM(t, p.dir, name+".key", "PRIVATE KEY", keyDER)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Handshake runs a TLS handshake between the two configurations over
|
||||||
|
// loopback TCP, whose buffers let a failing side's alert go out without a
|
||||||
|
// reader, and returns the client's error, else the server's.
|
||||||
|
func Handshake(t testing.TB, server, client *tls.Config) error {
|
||||||
|
t.Helper()
|
||||||
|
ln, err := net.Listen("tcp4", "127.0.0.1:0")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer ln.Close()
|
||||||
|
serverErr := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
conn, err := ln.Accept()
|
||||||
|
if err != nil {
|
||||||
|
serverErr <- err
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer conn.Close()
|
||||||
|
conn.SetDeadline(time.Now().Add(5 * time.Second))
|
||||||
|
serverErr <- tls.Server(conn, server).Handshake()
|
||||||
|
}()
|
||||||
|
conn, err := net.Dial("tcp4", ln.Addr().String())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
conn.SetDeadline(time.Now().Add(5 * time.Second))
|
||||||
|
err = tls.Client(conn, client).Handshake()
|
||||||
|
conn.Close()
|
||||||
|
if serr := <-serverErr; err == nil {
|
||||||
|
err = serr
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
func writePEM(t testing.TB, dir, name, blockType string, der []byte) string {
|
func writePEM(t testing.TB, dir, name, blockType string, der []byte) string {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
path := filepath.Join(dir, name)
|
path := filepath.Join(dir, name)
|
||||||
|
|||||||
@@ -0,0 +1,231 @@
|
|||||||
|
package tlsx
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"cmp"
|
||||||
|
"crypto"
|
||||||
|
"crypto/ecdsa"
|
||||||
|
"crypto/elliptic"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/sha256"
|
||||||
|
"crypto/tls"
|
||||||
|
"crypto/x509"
|
||||||
|
"crypto/x509/pkix"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/pem"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"math/big"
|
||||||
|
"net"
|
||||||
|
"os"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
"unicode"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Certificates are ECDSA P-256, for lw tls and for listeners without files.
|
||||||
|
// A CA signs leaves only; a leaf lasts at most 397 days, the longest browsers
|
||||||
|
// accept, and never past its issuer.
|
||||||
|
const (
|
||||||
|
CAValidity = 10 * 365 * 24 * time.Hour
|
||||||
|
LeafValidity = 397 * 24 * time.Hour
|
||||||
|
pinPrefix = "sha256//"
|
||||||
|
)
|
||||||
|
|
||||||
|
// processKey is the key of every generated listener certificate: reloads
|
||||||
|
// reissue certificates and keep it, so a pin holds until lw restarts.
|
||||||
|
var processKey = sync.OnceValues(NewKey)
|
||||||
|
|
||||||
|
func NewKey() (*ecdsa.PrivateKey, error) { return ecdsa.GenerateKey(elliptic.P256(), rand.Reader) }
|
||||||
|
|
||||||
|
// NewCA makes a self-signed certificate authority that signs leaves only
|
||||||
|
func NewCA(name string, validity time.Duration) (*x509.Certificate, *ecdsa.PrivateKey, error) {
|
||||||
|
key, err := NewKey()
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
t, err := template(name, validity)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
t.IsCA, t.MaxPathLenZero = true, true
|
||||||
|
t.KeyUsage = x509.KeyUsageCertSign | x509.KeyUsageCRLSign
|
||||||
|
cert, err := sign(t, t, &key.PublicKey, key)
|
||||||
|
return cert, key, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Leaf describes a certificate to issue. Hosts become DNS or IP SANs, which
|
||||||
|
// hostname verification reads; Name is the subject CN, which mtls reads.
|
||||||
|
type Leaf struct {
|
||||||
|
Name string
|
||||||
|
Hosts []string
|
||||||
|
Server, Client bool
|
||||||
|
Validity time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
// Issue signs a certificate for pub; a nil issuer makes it self-signed.
|
||||||
|
func (l Leaf) Issue(pub crypto.PublicKey, issuer *x509.Certificate, issuerKey crypto.Signer) (*x509.Certificate, error) {
|
||||||
|
t, err := template(l.Name, l.Validity)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
t.KeyUsage = x509.KeyUsageDigitalSignature
|
||||||
|
if l.Server {
|
||||||
|
t.ExtKeyUsage = append(t.ExtKeyUsage, x509.ExtKeyUsageServerAuth)
|
||||||
|
}
|
||||||
|
if l.Client {
|
||||||
|
t.ExtKeyUsage = append(t.ExtKeyUsage, x509.ExtKeyUsageClientAuth)
|
||||||
|
}
|
||||||
|
for _, h := range l.Hosts {
|
||||||
|
if ip := net.ParseIP(h); ip != nil {
|
||||||
|
t.IPAddresses = append(t.IPAddresses, ip)
|
||||||
|
} else {
|
||||||
|
t.DNSNames = append(t.DNSNames, h)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if issuer == nil {
|
||||||
|
return sign(t, t, pub, issuerKey)
|
||||||
|
}
|
||||||
|
if !time.Now().Before(issuer.NotAfter) {
|
||||||
|
return nil, fmt.Errorf("tls: issuer %q expired on %s", issuer.Subject.CommonName, issuer.NotAfter.UTC().Format(time.DateOnly))
|
||||||
|
}
|
||||||
|
if t.NotAfter.After(issuer.NotAfter) {
|
||||||
|
t.NotAfter = issuer.NotAfter
|
||||||
|
}
|
||||||
|
return sign(t, issuer, pub, issuerKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
func template(name string, validity time.Duration) (*x509.Certificate, error) {
|
||||||
|
serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
now := time.Now()
|
||||||
|
return &x509.Certificate{
|
||||||
|
SerialNumber: serial,
|
||||||
|
Subject: pkix.Name{CommonName: name},
|
||||||
|
NotBefore: now.Add(-5 * time.Minute), // peers' clocks drift
|
||||||
|
NotAfter: now.Add(validity),
|
||||||
|
BasicConstraintsValid: true,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func sign(t, parent *x509.Certificate, pub crypto.PublicKey, key crypto.Signer) (*x509.Certificate, error) {
|
||||||
|
der, err := x509.CreateCertificate(rand.Reader, t, parent, pub, key)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("tls: sign certificate: %w", err)
|
||||||
|
}
|
||||||
|
return x509.ParseCertificate(der)
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoadIssuer reads a CA certificate and its key, as lw tls ca writes them
|
||||||
|
func LoadIssuer(certFile, keyFile string) (*x509.Certificate, crypto.Signer, error) {
|
||||||
|
pair, err := tls.LoadX509KeyPair(certFile, keyFile)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, fmt.Errorf("tls: load issuer: %w", err)
|
||||||
|
}
|
||||||
|
ca, err := x509.ParseCertificate(pair.Certificate[0]) // Leaf is nil under GODEBUG=x509keypairleaf=0
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, fmt.Errorf("tls: load issuer: %w", err)
|
||||||
|
}
|
||||||
|
signer, ok := pair.PrivateKey.(crypto.Signer)
|
||||||
|
if !ok || !ca.IsCA || ca.KeyUsage&x509.KeyUsageCertSign == 0 {
|
||||||
|
return nil, nil, fmt.Errorf("tls: %s is no certificate authority", certFile)
|
||||||
|
}
|
||||||
|
return ca, signer, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// generated issues a listener certificate on the process key, self-signed
|
||||||
|
// or from the issuer files, for hosts and the names this machine answers to.
|
||||||
|
func generated(issuerCert, issuerKey, host string, hosts []string) (tls.Certificate, error) {
|
||||||
|
key, err := processKey()
|
||||||
|
if err != nil {
|
||||||
|
return tls.Certificate{}, err
|
||||||
|
}
|
||||||
|
var issuer *x509.Certificate
|
||||||
|
var signer crypto.Signer = key
|
||||||
|
if issuerCert != "" {
|
||||||
|
if issuer, signer, err = LoadIssuer(issuerCert, issuerKey); err != nil {
|
||||||
|
return tls.Certificate{}, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
name, _ := os.Hostname()
|
||||||
|
all := slices.Clone(hosts)
|
||||||
|
if ip := net.ParseIP(host); host != "" && (ip == nil || !ip.IsUnspecified()) {
|
||||||
|
all = append(all, host)
|
||||||
|
}
|
||||||
|
if !strings.ContainsFunc(name, func(r rune) bool { return r > unicode.MaxASCII }) {
|
||||||
|
all = append(all, name) // a DNS SAN is ASCII; the CN keeps any name
|
||||||
|
}
|
||||||
|
all = append(all, "localhost", "127.0.0.1", "::1")
|
||||||
|
slices.Sort(all)
|
||||||
|
all = slices.Compact(slices.DeleteFunc(all, func(h string) bool { return h == "" }))
|
||||||
|
leaf, err := Leaf{Name: cmp.Or(name, "logwisp"), Hosts: all, Server: true, Validity: LeafValidity}.Issue(&key.PublicKey, issuer, signer)
|
||||||
|
if err != nil {
|
||||||
|
return tls.Certificate{}, err
|
||||||
|
}
|
||||||
|
chain := [][]byte{leaf.Raw}
|
||||||
|
if issuer != nil {
|
||||||
|
chain = append(chain, issuer.Raw) // an intermediate needs it to chain to its root
|
||||||
|
}
|
||||||
|
return tls.Certificate{Certificate: chain, PrivateKey: key, Leaf: leaf}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// PinSHA256 is the curl --pinnedpubkey form of a certificate's public key
|
||||||
|
func PinSHA256(c *x509.Certificate) string { return pin(c.RawSubjectPublicKeyInfo) }
|
||||||
|
|
||||||
|
func pinOfKey(k *ecdsa.PrivateKey) string {
|
||||||
|
der, _ := x509.MarshalPKIXPublicKey(&k.PublicKey) // P-256 always marshals
|
||||||
|
return pin(der)
|
||||||
|
}
|
||||||
|
|
||||||
|
func pin(spki []byte) string {
|
||||||
|
h := sha256.Sum256(spki)
|
||||||
|
return pinPrefix + base64.StdEncoding.EncodeToString(h[:])
|
||||||
|
}
|
||||||
|
|
||||||
|
// verifyPins replaces chain verification: the server's key must hash to one
|
||||||
|
// of the ';'-separated pins. Its name and validity are not checked. Go skips
|
||||||
|
// it on a resumed session, and no dialer keeps a ClientSessionCache.
|
||||||
|
func verifyPins(s string) (func([][]byte, [][]*x509.Certificate) error, error) {
|
||||||
|
var pins [][]byte
|
||||||
|
for p := range strings.SplitSeq(s, ";") {
|
||||||
|
b64, ok := strings.CutPrefix(strings.TrimSpace(p), pinPrefix)
|
||||||
|
h, err := base64.StdEncoding.DecodeString(b64)
|
||||||
|
if !ok || err != nil || len(h) != sha256.Size {
|
||||||
|
return nil, fmt.Errorf("tls: pin_sha256 %q: want %sBASE64 of a SHA-256, ';' between several", p, pinPrefix)
|
||||||
|
}
|
||||||
|
pins = append(pins, h)
|
||||||
|
}
|
||||||
|
return func(raw [][]byte, _ [][]*x509.Certificate) error {
|
||||||
|
if len(raw) == 0 {
|
||||||
|
return errors.New("tls: server sent no certificate")
|
||||||
|
}
|
||||||
|
c, err := x509.ParseCertificate(raw[0])
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
h := sha256.Sum256(c.RawSubjectPublicKeyInfo)
|
||||||
|
if slices.ContainsFunc(pins, func(p []byte) bool { return bytes.Equal(p, h[:]) }) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
// The type of a failed chain check: callers treat both as a refusal
|
||||||
|
return &tls.CertificateVerificationError{UnverifiedCertificates: []*x509.Certificate{c},
|
||||||
|
Err: fmt.Errorf("server key %s matches no tls.pin_sha256", PinSHA256(c))}
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// EncodeCert and EncodeKey render PEM, the key as PKCS #8
|
||||||
|
func EncodeCert(c *x509.Certificate) []byte {
|
||||||
|
return pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: c.Raw})
|
||||||
|
}
|
||||||
|
|
||||||
|
func EncodeKey(k crypto.PrivateKey) ([]byte, error) {
|
||||||
|
der, err := x509.MarshalPKCS8PrivateKey(k)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: der}), nil
|
||||||
|
}
|
||||||
+55
-11
@@ -26,16 +26,36 @@ const (
|
|||||||
expiryWindow = 30 * 24 * time.Hour
|
expiryWindow = 30 * 24 * time.Hour
|
||||||
)
|
)
|
||||||
|
|
||||||
// Server builds the *tls.Config for listener plugins
|
// Server builds the *tls.Config for listener plugins (tcp/http sinks,
|
||||||
// (tcp/http sinks, tcp_chain/http_chain sources). Returns (nil, nil) when disabled.
|
// tcp_chain/http_chain sources) from cert_file and key_file, or issues one on
|
||||||
func Server(o *config.TLSOptions) (*tls.Config, error) {
|
// the process key: self-signed or from the issuer files, for host, hosts and
|
||||||
|
// this machine's names. Returns (nil, nil) when disabled.
|
||||||
|
func Server(o *config.TLSOptions, host string) (*tls.Config, error) {
|
||||||
if o == nil || !o.Enabled {
|
if o == nil || !o.Enabled {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
if o.CertFile == "" || o.KeyFile == "" {
|
files, issuer := o.CertFile != "" || o.KeyFile != "", o.IssuerCertFile != "" || o.IssuerKeyFile != ""
|
||||||
return nil, fmt.Errorf("tls: cert_file and key_file are required for listeners")
|
switch {
|
||||||
|
case o.PinSHA256 != "":
|
||||||
|
return nil, fmt.Errorf("tls: pin_sha256 applies to dialers")
|
||||||
|
case files && (issuer || o.SelfSigned) || issuer && o.SelfSigned:
|
||||||
|
return nil, fmt.Errorf("tls: set one of cert_file and key_file, self_signed, or issuer_cert_file and issuer_key_file")
|
||||||
|
case len(o.Hosts) > 0 && !issuer && !o.SelfSigned:
|
||||||
|
return nil, fmt.Errorf("tls: hosts applies to self_signed and issuer certificates")
|
||||||
|
case files && (o.CertFile == "" || o.KeyFile == ""):
|
||||||
|
return nil, fmt.Errorf("tls: cert_file and key_file must be set together")
|
||||||
|
case issuer && (o.IssuerCertFile == "" || o.IssuerKeyFile == ""):
|
||||||
|
return nil, fmt.Errorf("tls: issuer_cert_file and issuer_key_file must be set together")
|
||||||
|
case !files && !issuer && !o.SelfSigned:
|
||||||
|
return nil, fmt.Errorf("tls: listeners need cert_file and key_file, self_signed, or issuer_cert_file and issuer_key_file")
|
||||||
|
}
|
||||||
|
var cert tls.Certificate
|
||||||
|
var err error
|
||||||
|
if files {
|
||||||
|
cert, err = tls.LoadX509KeyPair(o.CertFile, o.KeyFile)
|
||||||
|
} else {
|
||||||
|
cert, err = generated(o.IssuerCertFile, o.IssuerKeyFile, host, o.Hosts)
|
||||||
}
|
}
|
||||||
cert, err := tls.LoadX509KeyPair(o.CertFile, o.KeyFile)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("tls: load keypair: %w", err)
|
return nil, fmt.Errorf("tls: load keypair: %w", err)
|
||||||
}
|
}
|
||||||
@@ -64,11 +84,18 @@ func Server(o *config.TLSOptions) (*tls.Config, error) {
|
|||||||
// Client builds the *tls.Config for dialer plugins (tcp_chain/http_chain
|
// Client builds the *tls.Config for dialer plugins (tcp_chain/http_chain
|
||||||
// sinks). host seeds ServerName when no override is set; Go verifies IP SANs
|
// sinks). host seeds ServerName when no override is set; Go verifies IP SANs
|
||||||
// when host is an address, without its zone, which names a local interface
|
// when host is an address, without its zone, which names a local interface
|
||||||
// and no certificate carries. Returns (nil, nil) when disabled.
|
// and no certificate carries. pin_sha256 replaces chain and name checks.
|
||||||
|
// Returns (nil, nil) when disabled.
|
||||||
func Client(o *config.TLSOptions, host string) (*tls.Config, error) {
|
func Client(o *config.TLSOptions, host string) (*tls.Config, error) {
|
||||||
if o == nil || !o.Enabled {
|
if o == nil || !o.Enabled {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
switch {
|
||||||
|
case o.SelfSigned || o.IssuerCertFile != "" || o.IssuerKeyFile != "" || len(o.Hosts) > 0:
|
||||||
|
return nil, fmt.Errorf("tls: self_signed, issuer_cert_file, issuer_key_file and hosts apply to listeners")
|
||||||
|
case o.PinSHA256 != "" && (o.CAFile != "" || o.InsecureSkipVerify):
|
||||||
|
return nil, fmt.Errorf("tls: pin_sha256 replaces ca_file and insecure_skip_verify: set one")
|
||||||
|
}
|
||||||
mv, err := minVersion(o.MinVersion)
|
mv, err := minVersion(o.MinVersion)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -81,6 +108,12 @@ func Client(o *config.TLSOptions, host string) (*tls.Config, error) {
|
|||||||
if cfg.ServerName == "" {
|
if cfg.ServerName == "" {
|
||||||
cfg.ServerName, _, _ = strings.Cut(host, "%")
|
cfg.ServerName, _, _ = strings.Cut(host, "%")
|
||||||
}
|
}
|
||||||
|
if o.PinSHA256 != "" {
|
||||||
|
if cfg.VerifyPeerCertificate, err = verifyPins(o.PinSHA256); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
cfg.InsecureSkipVerify = true // the pin check above replaces it
|
||||||
|
}
|
||||||
if o.CAFile != "" {
|
if o.CAFile != "" {
|
||||||
pool, err := loadPool(o.CAFile)
|
pool, err := loadPool(o.CAFile)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -149,13 +182,22 @@ func LogWarnings(l *log.Logger, component, id string, o *config.TLSOptions, list
|
|||||||
if o == nil || !o.Enabled {
|
if o == nil || !o.Enabled {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if key, err := processKey(); listener && o.SelfSigned && err == nil {
|
||||||
|
// Without a CA, a dialer verifies this listener by its pin only
|
||||||
|
l.Warn("msg", "Serving a self-signed certificate; it changes when lw restarts",
|
||||||
|
"component", component,
|
||||||
|
"instance_id", id,
|
||||||
|
"pin_sha256", pinOfKey(key),
|
||||||
|
"hint", "dialers set tls.pin_sha256; for a stable identity use issuer_cert_file or cert_file")
|
||||||
|
}
|
||||||
var warnings []string
|
var warnings []string
|
||||||
if !listener && o.InsecureSkipVerify {
|
if !listener && o.InsecureSkipVerify {
|
||||||
warnings = append(warnings, "tls.insecure_skip_verify disables server verification: any peer can impersonate the server")
|
warnings = append(warnings, "tls.insecure_skip_verify disables server verification: any peer can impersonate the server")
|
||||||
}
|
}
|
||||||
files := []struct{ key, path string }{{"cert_file", o.CertFile}, {"ca_file", o.CAFile}}
|
type file struct{ key, path string }
|
||||||
|
files := []file{{"cert_file", o.CertFile}, {"ca_file", o.CAFile}}
|
||||||
if listener {
|
if listener {
|
||||||
files[1] = struct{ key, path string }{"client_ca_file", o.ClientCAFile}
|
files = []file{{"cert_file", o.CertFile}, {"client_ca_file", o.ClientCAFile}, {"issuer_cert_file", o.IssuerCertFile}}
|
||||||
}
|
}
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
for _, f := range files {
|
for _, f := range files {
|
||||||
@@ -165,8 +207,10 @@ func LogWarnings(l *log.Logger, component, id string, o *config.TLSOptions, list
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if w := SecretFileWarning("tls.key_file", o.KeyFile); w != "" {
|
for _, key := range []file{{"tls.key_file", o.KeyFile}, {"tls.issuer_key_file", o.IssuerKeyFile}} {
|
||||||
warnings = append(warnings, w)
|
if w := SecretFileWarning(key.key, key.path); w != "" {
|
||||||
|
warnings = append(warnings, w)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
for _, w := range warnings {
|
for _, w := range warnings {
|
||||||
l.Warn("msg", w, "component", component, "instance_id", id)
|
l.Warn("msg", w, "component", component, "instance_id", id)
|
||||||
|
|||||||
@@ -3,11 +3,15 @@ package tlsx
|
|||||||
import (
|
import (
|
||||||
"crypto/x509"
|
"crypto/x509"
|
||||||
"crypto/x509/pkix"
|
"crypto/x509/pkix"
|
||||||
|
"net"
|
||||||
|
"path/filepath"
|
||||||
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/lixenwraith/logwisp/internal/config"
|
"github.com/lixenwraith/logwisp/internal/config"
|
||||||
|
"github.com/lixenwraith/logwisp/internal/testutil"
|
||||||
)
|
)
|
||||||
|
|
||||||
// A dialer verifies an IPv6 target by its address: the zone names a local
|
// A dialer verifies an IPv6 target by its address: the zone names a local
|
||||||
@@ -48,3 +52,104 @@ func TestExpiryWarning(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A self-signed listener is verified by the pin of the process key, which a
|
||||||
|
// reload's reissued certificate keeps; any other pin, or none, fails.
|
||||||
|
func TestSelfSignedListenerIsVerifiedByItsPin(t *testing.T) {
|
||||||
|
o := &config.TLSOptions{Enabled: true, SelfSigned: true, Hosts: []string{"agg.example"}}
|
||||||
|
first, err := Server(o, "0.0.0.0")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
reloaded, err := Server(o, "0.0.0.0")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
leaf := reloaded.Certificates[0].Leaf
|
||||||
|
pin := PinSHA256(leaf)
|
||||||
|
if PinSHA256(first.Certificates[0].Leaf) != pin || !slices.Contains(leaf.DNSNames, "agg.example") ||
|
||||||
|
!slices.Contains(leaf.DNSNames, "localhost") || slices.ContainsFunc(leaf.IPAddresses, net.IP.IsUnspecified) {
|
||||||
|
t.Fatalf("pin %s, names %v %v", pin, leaf.DNSNames, leaf.IPAddresses)
|
||||||
|
}
|
||||||
|
other := "sha256//" + strings.Repeat("A", 43) + "="
|
||||||
|
for pins, want := range map[string]string{other + "; " + pin: "", other: "matches no tls.pin_sha256", "": "certificate"} {
|
||||||
|
client, err := Client(&config.TLSOptions{Enabled: true, PinSHA256: pins}, "agg.example")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
err = testutil.Handshake(t, reloaded, client)
|
||||||
|
if want == "" && err != nil || want != "" && (err == nil || !strings.Contains(err.Error(), want)) {
|
||||||
|
t.Errorf("pins %q: %v, want %q", pins, err, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// An issuer-signed listener chains to the CA file, for its host, and expires
|
||||||
|
// with its issuer; a certificate that is no CA cannot issue.
|
||||||
|
func TestIssuedListenerCertificateChainsToTheIssuer(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
ca, key, err := NewCA("test CA", time.Hour)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
keyPEM, err := EncodeKey(key)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
caFile, keyFile := filepath.Join(dir, "ca.crt"), filepath.Join(dir, "ca.key")
|
||||||
|
testutil.WriteFile(t, caFile, string(EncodeCert(ca)))
|
||||||
|
testutil.WriteFile(t, keyFile, string(keyPEM))
|
||||||
|
server, err := Server(&config.TLSOptions{Enabled: true, IssuerCertFile: caFile, IssuerKeyFile: keyFile}, "127.0.0.1")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if leaf := server.Certificates[0].Leaf; !leaf.NotAfter.Equal(ca.NotAfter) {
|
||||||
|
t.Fatalf("leaf expires %s, issuer %s", leaf.NotAfter, ca.NotAfter)
|
||||||
|
}
|
||||||
|
client, err := Client(&config.TLSOptions{Enabled: true, CAFile: caFile}, "127.0.0.1")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := testutil.Handshake(t, server, client); err != nil {
|
||||||
|
t.Fatalf("issued chain: %v", err)
|
||||||
|
}
|
||||||
|
leafFile := filepath.Join(dir, "leaf.crt")
|
||||||
|
testutil.WriteFile(t, leafFile, string(EncodeCert(server.Certificates[0].Leaf)))
|
||||||
|
if _, err := Server(&config.TLSOptions{Enabled: true, IssuerCertFile: leafFile, IssuerKeyFile: keyFile}, ""); err == nil {
|
||||||
|
t.Fatal("a leaf issued certificates")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Each role takes one source of trust and one certificate source; keys of the
|
||||||
|
// other role fail instead of doing nothing.
|
||||||
|
func TestTLSOptionConflicts(t *testing.T) {
|
||||||
|
pin := "sha256//" + strings.Repeat("A", 43) + "="
|
||||||
|
for _, c := range []struct {
|
||||||
|
o config.TLSOptions
|
||||||
|
server bool
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{config.TLSOptions{SelfSigned: true, CertFile: "c", KeyFile: "k"}, true, "set one of"},
|
||||||
|
{config.TLSOptions{SelfSigned: true, IssuerCertFile: "c"}, true, "set one of"},
|
||||||
|
{config.TLSOptions{IssuerCertFile: "c"}, true, "must be set together"},
|
||||||
|
{config.TLSOptions{CertFile: "c"}, true, "must be set together"},
|
||||||
|
{config.TLSOptions{Hosts: []string{"a"}, CertFile: "c", KeyFile: "k"}, true, "hosts applies to"},
|
||||||
|
{config.TLSOptions{SelfSigned: true, PinSHA256: pin}, true, "applies to dialers"},
|
||||||
|
{config.TLSOptions{}, true, "listeners need"},
|
||||||
|
{config.TLSOptions{SelfSigned: true}, false, "apply to listeners"},
|
||||||
|
{config.TLSOptions{PinSHA256: pin, CAFile: "ca"}, false, "set one"},
|
||||||
|
{config.TLSOptions{PinSHA256: pin, InsecureSkipVerify: true}, false, "set one"},
|
||||||
|
{config.TLSOptions{PinSHA256: "sha256//short"}, false, "want sha256//BASE64"},
|
||||||
|
} {
|
||||||
|
c.o.Enabled = true
|
||||||
|
var err error
|
||||||
|
if c.server {
|
||||||
|
_, err = Server(&c.o, "")
|
||||||
|
} else {
|
||||||
|
_, err = Client(&c.o, "host")
|
||||||
|
}
|
||||||
|
if err == nil || !strings.Contains(err.Error(), c.want) {
|
||||||
|
t.Errorf("%+v: %v, want %q", c.o, err, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Executable
+108
@@ -0,0 +1,108 @@
|
|||||||
|
#!/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.
|
||||||
|
# Ports: 15890-15896 on 127.0.0.1.
|
||||||
|
# Requires: bash 5+, coreutils, curl, openssl. Linux dev host only.
|
||||||
|
|
||||||
|
set -u
|
||||||
|
. "$(dirname -- "${BASH_SOURCE[0]}")/lib.sh"
|
||||||
|
|
||||||
|
RUN=$E2E_DIR/run/preset
|
||||||
|
e2e_init "$@"
|
||||||
|
|
||||||
|
section "Setup"
|
||||||
|
need curl openssl
|
||||||
|
ports_free 15890 15891 15892 15893 15894 15895 15896
|
||||||
|
rm -rf "$RUN"
|
||||||
|
mkdir -p "$RUN/out" "$LOG"
|
||||||
|
cd "$RUN" || abort "no run directory"
|
||||||
|
# no configuration file or LOGWISP_ variable applies
|
||||||
|
for v in $(compgen -e LOGWISP_); do unset "$v"; done
|
||||||
|
export HOME=$RUN
|
||||||
|
lw() { "$BIN" "$@"; }
|
||||||
|
seq 1 300 | sed 's/^/edge line /' >"$RUN/edge.log"
|
||||||
|
info "runs lw in $(short "$RUN") with no configuration file"
|
||||||
|
|
||||||
|
section "Color"
|
||||||
|
red=$'\e[0;1;31m'
|
||||||
|
check "--color paints the level name" "$(is "$(printf 'a ERROR b\n' | lw --color)" "a ${red}ERROR"$'\e[0m b')"
|
||||||
|
check "--color never, and auto on a pipe, leave it plain" \
|
||||||
|
"$(is "$(printf 'a ERROR b\n' | lw --color never)$(printf 'a ERROR b\n' | lw)" "a ERROR ba ERROR b")"
|
||||||
|
check "LOGWISP_COLOR=always, a sink's own color=never wins" \
|
||||||
|
"$(is "$(printf 'x WARN\n' | LOGWISP_COLOR=always lw --sink console,color=never)" "x WARN")"
|
||||||
|
|
||||||
|
section "Presets"
|
||||||
|
seq 1 5000 >"$RUN/seq.txt"
|
||||||
|
lw --preset pipe <"$RUN/seq.txt" >"$RUN/pipe.out"
|
||||||
|
check "--preset pipe copies stdin line for line" "$(cmp -s "$RUN/seq.txt" "$RUN/pipe.out" && echo 1 || echo 0)"
|
||||||
|
out=$(timeout 3 "$BIN" --preset "tail,path=$RUN/edge.log,from=start" </dev/null 2>/dev/null | head -2 | tr '\n' '|')
|
||||||
|
check "--preset tail follows a file" "$(is "$out" "edge line 1|edge line 2|")"
|
||||||
|
lw --preset "serve,path=$RUN/edge.log,listen=127.0.0.1:15890,tls=self,hosts=logs.example" --color --dump >"$RUN/dump.toml"
|
||||||
|
lw -c "$RUN/dump.toml" --dump >"$RUN/dump2.toml"
|
||||||
|
check "--dump prints a file that reads back to the same dump" "$(cmp -s "$RUN/dump.toml" "$RUN/dump2.toml" && echo 1 || echo 0)"
|
||||||
|
check "lw --check builds the dumped preset" "$(lw --check -c "$RUN/dump.toml" 2>"$LOG/check.err" | grep -q 'configuration ok' && echo 1 || echo 0)"
|
||||||
|
lw preset edge -to 127.0.0.1:1 >/dev/null 2>"$LOG/edge-noauth.err"
|
||||||
|
rc=$?
|
||||||
|
check "an edge without credentials is a usage error ($rc)" "$( ((rc == 2)) && grep -q 'never sends unauthenticated' "$LOG/edge-noauth.err" && echo 1 || echo 0)"
|
||||||
|
|
||||||
|
section "Certificates"
|
||||||
|
lw tls ca -dir "$RUN/pki" 2>"$LOG/tls.out" &&
|
||||||
|
lw tls cert -ca-dir "$RUN/pki" -name logs.example -server -host 127.0.0.1,logs.example 2>>"$LOG/tls.out"
|
||||||
|
check "lw tls ca and cert issue a chain openssl verifies" \
|
||||||
|
"$(openssl verify -CAfile "$RUN/pki/ca.crt" "$RUN/pki/logs.example.crt" 2>/dev/null | grep -q ': OK$' && echo 1 || echo 0)"
|
||||||
|
check "keys are private" "$(is "$(stat -c %a "$RUN/pki/ca.key" "$RUN/pki/logs.example.key" | tr '\n' ' ')" "600 600 ")"
|
||||||
|
lw tls ca -dir "$RUN/pki" 2>/dev/null
|
||||||
|
check "an existing CA is never replaced" "$(( $? == 1 ))"
|
||||||
|
|
||||||
|
AUTH=$RUN
|
||||||
|
add_users "$RUN/users.toml" edge-01
|
||||||
|
# pin DAEMON: the pin_sha256 a self-signed listener logged
|
||||||
|
pin() { wait_until 5 logged "$1" pin_sha256 && grep -o 'pin_sha256 "sha256//[^"]*"' "$LOG/$1.out" | head -1 | cut -d'"' -f2; }
|
||||||
|
received() { cat "$RUN/out/$1"/aggregate*.log 2>/dev/null | grep -c 'edge line'; }
|
||||||
|
|
||||||
|
spawn agg-self "$BIN" --preset "aggregator,listen=127.0.0.1:15893,users=$RUN/users.toml,out=$RUN/out/self"
|
||||||
|
wait_port 15893 || abort "the self-signed aggregator did not listen" agg-self
|
||||||
|
PIN=$(pin agg-self)
|
||||||
|
check "a self-signed aggregator logs its pin" "$(is_set "$PIN")"
|
||||||
|
spawn edge-pin "$BIN" --preset "edge,path=$RUN/edge.log,from=start,to=127.0.0.1:15893,pin=$PIN,user=edge-01,password_file=$RUN/edge-01.pass"
|
||||||
|
spawn edge-wrong "$BIN" --preset "edge,path=$RUN/edge.log,from=start,to=127.0.0.1:15893,pin=sha256//AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=,user=edge-01,password_file=$RUN/edge-01.pass"
|
||||||
|
wait_until 10 eval '(( $(received self) >= 300 ))'
|
||||||
|
check "an edge pinning it delivers every line over SCRAM ($(received self))" "$(( $(received self) == 300 ))"
|
||||||
|
wait_until 10 logged edge-wrong 'matches no tls.pin_sha256'
|
||||||
|
check "an edge with another pin is refused before it logs in" "$(logged edge-wrong 'matches no tls.pin_sha256' && echo 1 || echo 0)"
|
||||||
|
|
||||||
|
spawn agg-issuer "$BIN" --preset "aggregator,listen=127.0.0.1:15894,transport=http,tls=issuer,issuer_cert=$RUN/pki/ca.crt,issuer_key=$RUN/pki/ca.key,users=$RUN/users.toml,out=$RUN/out/issuer"
|
||||||
|
wait_port 15894 || abort "the issuing aggregator did not listen" agg-issuer
|
||||||
|
spawn edge-ca "$BIN" --preset "edge,path=$RUN/edge.log,from=start,to=127.0.0.1:15894,transport=http,ca=$RUN/pki/ca.crt,user=edge-01,password_file=$RUN/edge-01.pass"
|
||||||
|
wait_until 10 eval '(( $(received issuer) >= 300 ))'
|
||||||
|
check "an aggregator signing its own certificate is verified by the CA ($(received issuer))" "$(( $(received issuer) == 300 ))"
|
||||||
|
|
||||||
|
spawn serve "$BIN" --preset "serve,path=$RUN/edge.log,listen=127.0.0.1:15895,tls=self"
|
||||||
|
wait_port 15895 || abort "the serve preset did not listen" serve
|
||||||
|
SERVE_PIN=$(pin serve)
|
||||||
|
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 ))"
|
||||||
|
|
||||||
|
section "Flush at exit"
|
||||||
|
# feed SECONDS PORT: the lines, once a client had time to connect
|
||||||
|
feed() { wait_port "$2"; sleep "$1"; seq 1 20000 | sed 's/^/flush /'; }
|
||||||
|
queues='buffer_size=20000,client_buffer_size=20000'
|
||||||
|
feed 1 15891 | lw --sink "tcp,host=127.0.0.1,port=15891,$queues" 2>"$LOG/flush-tcp.err" &
|
||||||
|
fpid=$!
|
||||||
|
wait_port 15891
|
||||||
|
n=$(tcp_read 15891 15 | grep -c '^flush ')
|
||||||
|
wait "$fpid"
|
||||||
|
check "a tcp client gets all 20000 lines of a finite input ($n)" "$((n == 20000))"
|
||||||
|
feed 1 15892 | lw --sink "http,host=127.0.0.1,port=15892,$queues" 2>"$LOG/flush-http.err" &
|
||||||
|
fpid=$!
|
||||||
|
wait_port 15892
|
||||||
|
curl -sN --max-time 15 http://127.0.0.1:15892/stream >"$RUN/flush-http.out"
|
||||||
|
wait "$fpid"
|
||||||
|
n=$(grep -c '^data: flush ' "$RUN/flush-http.out")
|
||||||
|
check "an http stream gets all 20000 lines, then the disconnect event ($n)" \
|
||||||
|
"$( ((n == 20000)) && tail -3 "$RUN/flush-http.out" | grep -q '^event: disconnect' && echo 1 || echo 0)"
|
||||||
|
|
||||||
|
summary
|
||||||
Reference in new issue
Block a user