v0.22.0 several enhancements and fixes for security, network, doc, test, packaging
Test / test (push) Canceled after 0s
Test / e2e (push) Canceled after 0s

This commit is contained in:
lixen committed 2026-10-03 14:52:16 -04:00
1 parent 9ab8c356d2
commit 71d1b82f83
102 files changed
+7102 -2178

No files matched your search

+404
View File
@@ -0,0 +1,404 @@
package main
import (
"context"
"crypto/rand"
"crypto/tls"
"errors"
"flag"
"fmt"
"io"
"net"
"net/http"
"net/url"
"os"
"os/signal"
"path/filepath"
"slices"
"strings"
"syscall"
"logwisp/internal/authz"
"logwisp/internal/config"
"logwisp/internal/core"
"logwisp/internal/tlsx"
"github.com/lixenwraith/auth"
)
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
// 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 a user to a credentials file, or replace its password",
[]string{"credentials", "user"}, defineAddUser},
{"remove-user", "-credentials FILE -user NAME",
"Remove a user from a credentials file",
[]string{"credentials", "user"}, defineRemoveUser},
{"token", "-url https://HOST:PORT -user NAME -password-file FILE [TLS flags]",
"Log in to an http sink or http_chain source and print a bearer token",
[]string{"url", "user", "password-file"}, defineToken},
{"stream", "-addr HOST:PORT -user NAME -password-file FILE [TLS flags]",
"Log in to a tcp sink and copy its stream to stdout until interrupted",
[]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 {
path := fs.String("credentials", "", "credentials `file`, created when missing")
user := fs.String("user", "", "user `name`")
passwordFile := fs.String("password-file", "", "read the password from `file` when it exists, else write a generated one there")
generate := fs.Bool("generate", false, "generate a new password, replacing any -password-file; needed to rotate without one")
return func(stdout, stderr io.Writer) error {
creds, err := authz.LoadCredentials(*path)
// An empty file is one created beforehand to choose its owner
if fi, serr := os.Stat(*path); errors.Is(err, os.ErrNotExist) || serr == nil && fi.Size() == 0 {
creds, err = &authz.Credentials{DecoyKey: make([]byte, 32)}, nil
rand.Read(creds.DecoyKey)
}
if err != nil {
return err
}
i := slices.IndexFunc(creds.Users, func(c *auth.Credential) bool { return c.Username == *user })
var password string
if *passwordFile != "" && !*generate {
password, err = authz.ReadPassword(*passwordFile)
switch {
case err == nil && len(password) < 8:
return fmt.Errorf("the password in %s is shorter than 8 bytes", *passwordFile)
case err != nil && !errors.Is(err, os.ErrNotExist):
return err
}
}
generated := password == ""
if generated && i >= 0 && !*generate {
// A mistyped -password-file must not replace a working password
return fmt.Errorf("user %q exists: replacing its password needs an existing -password-file or -generate", *user)
}
if generated {
password = rand.Text()
}
var cred *auth.Credential
if len(creds.Users) == 0 {
cred, err = auth.NewCredential(*user, password)
} else {
// One profile per file: mixed ones would tell which users exist
p := creds.Users[0]
salt := make([]byte, len(p.Salt))
rand.Read(salt)
cred, err = auth.DeriveCredential(*user, password, salt, p.ArgonTime, p.ArgonMemory, p.ArgonThreads)
}
if err != nil {
return err
}
done := "added user %q to %s\n"
if i >= 0 {
creds.Users[i] = cred
done = "replaced the password of user %q in %s\n"
} else {
creds.Users = append(creds.Users, cred)
}
data, err := encodeCredentials(creds)
if err != nil {
return err
}
if generated && *passwordFile != "" {
// First, so a failure leaves the verifiers untouched and a rerun
// without -generate completes the change
if err := writeAtomic(*passwordFile, []byte(password+"\n")); err != nil {
return err
}
fmt.Fprintf(stderr, "wrote the generated password to %s\n", *passwordFile)
}
if err := writeAtomic(*path, data); err != nil {
return err
}
if generated && *passwordFile == "" {
fmt.Fprintf(stderr, "generated password for %q, shown once:\n", *user)
fmt.Fprintln(stdout, password)
}
fmt.Fprintf(stderr, done+"%s\n", *user, *path, reloadHint)
return nil
}
}
func defineRemoveUser(fs *flag.FlagSet) func(stdout, stderr io.Writer) error {
path := fs.String("credentials", "", "credentials `file`")
user := fs.String("user", "", "user `name`")
return func(_, stderr io.Writer) error {
creds, err := authz.LoadCredentials(*path)
if err != nil {
return err
}
i := slices.IndexFunc(creds.Users, func(c *auth.Credential) bool { return c.Username == *user })
switch {
case i < 0:
return fmt.Errorf("no user %q in %s", *user, *path)
case len(creds.Users) == 1:
// The daemon rejects a file without users
return fmt.Errorf("refusing to remove %q, the last user: remove the listener's auth block instead", *user)
}
creds.Users = slices.Delete(creds.Users, i, i+1)
data, err := encodeCredentials(creds)
if err == nil {
err = writeAtomic(*path, data)
}
if err != nil {
return err
}
fmt.Fprintf(stderr, "removed user %q from %s\n%s\n", *user, *path, reloadHint)
return nil
}
}
func defineToken(fs *flag.FlagSet) func(stdout, stderr io.Writer) error {
rawURL := fs.String("url", "", "listener `URL`, https://HOST:PORT or https://[IPV6]:PORT (with -unbound, a proxy's mount URL may carry a path)")
user := fs.String("user", "", "user `name`")
passwordFile := fs.String("password-file", "", "`file` holding the password")
unbound := fs.Bool("unbound", false, "log in without channel binding, to an http sink behind a TLS-terminating proxy (auth.trusted_proxies)")
tlsOpts := tlsFlags(fs)
return func(stdout, _ io.Writer) error {
u, err := url.Parse(*rawURL)
if err != nil || u.Scheme != "https" || u.Host == "" || u.RawQuery != "" || u.Fragment != "" || u.User != nil {
return usageError("-url must be https://HOST:PORT")
}
if u.Path = strings.TrimSuffix(u.Path, "/"); u.Path != "" && !*unbound {
// TLS passthrough cannot route by path: only a terminating proxy can
return usageError("-url takes a path only with -unbound")
}
network, err := core.Network(u.Hostname())
if err != nil {
return usageError("-url: " + err.Error())
}
tlsCfg, policy, err := dialPolicy(tlsOpts, u.Hostname(), *user, *passwordFile, authz.HTTP)
if err != nil {
return err
}
if *unbound {
policy.Unbind()
}
client := &http.Client{
Transport: &http.Transport{
DialContext: func(ctx context.Context, _, addr string) (net.Conn, error) {
var d net.Dialer
return d.DialContext(ctx, network, addr)
},
TLSClientConfig: tlsCfg,
TLSHandshakeTimeout: tlsx.HandshakeTimeout,
},
// A redirect would carry the login to another endpoint
CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse },
}
// String escapes an IPv6 zone, which u.Host holds raw; the parts drop
// an empty "?" that would swallow the /auth path
base := url.URL{Scheme: u.Scheme, Host: u.Host, Path: u.Path, RawPath: u.RawPath}
token, err := policy.Token(context.Background(), client, base.String())
if err != nil {
return err
}
fmt.Fprintln(stdout, token)
return nil
}
}
func defineStream(fs *flag.FlagSet) func(stdout, stderr io.Writer) error {
addr := fs.String("addr", "", "tcp sink `address`, HOST:PORT or [IPV6]:PORT")
user := fs.String("user", "", "user `name`")
passwordFile := fs.String("password-file", "", "`file` holding the password")
tlsOpts := tlsFlags(fs)
return func(stdout, _ io.Writer) error {
host, _, err := net.SplitHostPort(*addr)
network, nerr := core.Network(host)
if err != nil || nerr != nil || host == "" {
return usageError("-addr must be HOST:PORT, with an IPv6 address in brackets")
}
tlsCfg, policy, err := dialPolicy(tlsOpts, host, *user, *passwordFile, authz.TCP)
if err != nil {
return err
}
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
dctx, cancel := context.WithTimeout(ctx, tlsx.HandshakeTimeout)
conn, err := (&tls.Dialer{Config: tlsCfg}).DialContext(dctx, network, *addr)
cancel()
if err == nil {
defer conn.Close()
var r io.Reader
if r, err = policy.Greet(ctx, conn, ""); err == nil {
defer context.AfterFunc(ctx, func() { conn.Close() })()
if _, err = io.Copy(stdout, r); err == nil {
err = errors.New("stream closed by server")
}
}
}
if ctx.Err() != nil {
return nil // interrupted: how a viewer normally ends
}
return err
}
}
// tlsFlags registers the dialer TLS flags. There is no insecure flag: an
// unverified server could relay the login.
func tlsFlags(fs *flag.FlagSet) *config.TLSOptions {
o := &config.TLSOptions{Enabled: true}
fs.StringVar(&o.CAFile, "ca-file", "", "CA `file` that verifies the server (default: system roots)")
fs.StringVar(&o.ServerName, "server-name", "", "`name` the server certificate must carry (default: the host)")
fs.StringVar(&o.CertFile, "cert-file", "", "client certificate `file`, for listeners with tls.client_auth")
fs.StringVar(&o.KeyFile, "key-file", "", "client key `file`")
return o
}
// dialPolicy builds what a scram dialer plugin builds from its tls and auth blocks
func dialPolicy(o *config.TLSOptions, host, user, passwordFile string, transport authz.Transport) (*tls.Config, *authz.Policy, error) {
tlsCfg, err := tlsx.Client(o, host)
if err != nil {
return nil, nil, err
}
policy, err := authz.New(&config.AuthOptions{Type: authz.MethodSCRAM, Username: user, PasswordFile: passwordFile},
tlsCfg, authz.RoleDialer, transport)
if err != nil {
return nil, nil, err
}
tlsCfg.VerifyConnection = policy.VerifyConnection
return tlsCfg, policy, nil
}
// encodeCredentials renders creds and refuses what the daemon would not load
func encodeCredentials(creds *authz.Credentials) ([]byte, error) {
data, err := creds.Marshal()
if err == nil {
_, err = authz.ParseCredentials(data)
}
return data, err
}
// writeAtomic replaces path (a symlink's target) by rename, so the daemon
// never reads a partial file, keeping an existing file's mode and owner (0600
// for a new one). A change that would lose the owner fails: the daemon could
// no longer read the file and would keep the old users.
func writeAtomic(path string, data []byte) error {
if resolved, err := filepath.EvalSymlinks(path); err == nil {
path = resolved
} else if !errors.Is(err, os.ErrNotExist) {
return err
} else if fi, err := os.Lstat(path); err == nil && fi.Mode()&os.ModeSymlink != 0 {
// Replacing the link would leave its target, what the daemon reads, unwritten
return fmt.Errorf("%s is a symlink to a missing file; create the target first", path)
}
mode, uid, gid := os.FileMode(0o600), -1, -1
if fi, err := os.Stat(path); err == nil {
mode = fi.Mode().Perm()
if st, ok := fi.Sys().(*syscall.Stat_t); ok {
uid, gid = int(st.Uid), int(st.Gid)
}
} else if !errors.Is(err, os.ErrNotExist) {
return err
}
tmp, err := os.CreateTemp(filepath.Dir(path), "."+filepath.Base(path)+".*")
if err != nil {
return err
}
_, err = tmp.Write(data)
if err == nil {
err = tmp.Chmod(mode)
}
if err == nil && uid >= 0 {
if err = tmp.Chown(uid, gid); err != nil {
err = fmt.Errorf("%s: cannot keep owner %d:%d (run as root or that owner): %w", path, uid, gid, err)
}
}
if err == nil {
err = tmp.Sync()
}
if cerr := tmp.Close(); err == nil {
err = cerr
}
if err == nil {
err = os.Rename(tmp.Name(), path)
}
if err != nil {
os.Remove(tmp.Name())
}
return err
}
+212
View File
@@ -0,0 +1,212 @@
package main
import (
"bytes"
"errors"
"os"
"path/filepath"
"strings"
"testing"
"logwisp/internal/authz"
"github.com/lixenwraith/auth"
)
func runAuthTest(t *testing.T, args ...string) (code int, stdout, stderr string) {
t.Helper()
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
// reuses the file's profile, so only a new file costs the default 64 MiB.
func writeCredentials(t *testing.T, path string, users ...string) *authz.Credentials {
t.Helper()
c := &authz.Credentials{DecoyKey: bytes.Repeat([]byte{7}, 32)}
for _, u := range users {
cred, err := auth.DeriveCredential(u, "password-"+u, bytes.Repeat([]byte{1}, 16), 1, 64, 1)
if err != nil {
t.Fatal(err)
}
c.Users = append(c.Users, cred)
}
data, err := c.Marshal()
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, data, 0o600); err != nil {
t.Fatal(err)
}
return c
}
func TestAddUserCreatesPrivateFilesWithAMatchingVerifier(t *testing.T) {
dir := t.TempDir()
creds, pass := filepath.Join(dir, "users.toml"), filepath.Join(dir, "edge-01.pass")
code, stdout, stderr := runAuthTest(t, "add-user", "-credentials", creds, "-user", "edge-01", "-password-file", pass)
if code != 0 || stdout != "" {
t.Fatalf("exit %d, stdout %q, stderr: %s", code, stdout, stderr)
}
for _, path := range []string{creds, pass} {
fi, err := os.Stat(path)
if err != nil {
t.Fatal(err)
}
if fi.Mode().Perm() != 0o600 {
t.Fatalf("%s has mode %v, want 0600", path, fi.Mode().Perm())
}
}
raw, err := os.ReadFile(pass)
if err != nil || !strings.HasSuffix(string(raw), "\n") {
t.Fatalf("password file %q, %v: want one line", raw, err)
}
c, err := authz.LoadCredentials(creds)
if err != nil {
t.Fatal(err)
}
password, err := authz.ReadPassword(pass)
if err != nil {
t.Fatal(err)
}
u := c.Users[0]
want, err := auth.DeriveCredential("edge-01", password, u.Salt, u.ArgonTime, u.ArgonMemory, u.ArgonThreads)
if err != nil {
t.Fatal(err)
}
if !bytes.Equal(want.StoredKey, u.StoredKey) || !bytes.Equal(want.ServerKey, u.ServerKey) {
t.Fatal("stored verifier does not match the written password")
}
}
func TestAddUserNeverReplacesAPasswordWithoutASource(t *testing.T) {
dir := t.TempDir()
creds, typo := filepath.Join(dir, "users.toml"), filepath.Join(dir, "edge01.pass")
writeCredentials(t, creds, "edge-01")
before, err := os.ReadFile(creds)
if err != nil {
t.Fatal(err)
}
for _, extra := range [][]string{nil, {"-password-file", typo}} {
args := append([]string{"add-user", "-credentials", creds, "-user", "edge-01"}, extra...)
if code, _, stderr := runAuthTest(t, args...); code != 1 {
t.Fatalf("%v: exit %d, want 1; stderr: %s", extra, code, stderr)
}
}
after, err := os.ReadFile(creds)
if err != nil || !bytes.Equal(before, after) {
t.Fatalf("credentials file changed (%v)", err)
}
if _, err := os.Stat(typo); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("mistyped password file was created (%v)", err)
}
}
// The daemon reads a link's target: a rewrite through the link updates it
func TestRewriteKeepsFileModeDecoyKeyAndLink(t *testing.T) {
dir := t.TempDir()
creds, target, pass := filepath.Join(dir, "users.toml"), filepath.Join(dir, "real.toml"), filepath.Join(dir, "edge-02.pass")
orig := writeCredentials(t, target, "edge-01")
if err := os.Chmod(target, 0o640); err != nil {
t.Fatal(err)
}
if err := os.Symlink(target, creds); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(pass, []byte("correct horse battery\n"), 0o600); err != nil {
t.Fatal(err)
}
if code, _, stderr := runAuthTest(t, "add-user", "-credentials", creds, "-user", "edge-02", "-password-file", pass); code != 0 {
t.Fatalf("exit %d; stderr: %s", code, stderr)
}
if fi, err := os.Lstat(creds); err != nil || fi.Mode()&os.ModeSymlink == 0 {
t.Fatalf("link replaced by a file (%v)", err)
}
fi, err := os.Stat(target)
if err != nil {
t.Fatal(err)
}
if fi.Mode().Perm() != 0o640 {
t.Fatalf("mode %v, want 0640", fi.Mode().Perm())
}
c, err := authz.LoadCredentials(target)
if err != nil {
t.Fatal(err)
}
if !bytes.Equal(c.DecoyKey, orig.DecoyKey) || len(c.Users) != 2 || c.Users[1].Username != "edge-02" {
t.Fatalf("decoy key kept: %v, users: %d", bytes.Equal(c.DecoyKey, orig.DecoyKey), len(c.Users))
}
}
// A dangling link is refused rather than replaced: its missing target is the
// file the daemon would read
func TestAddUserRefusesADanglingLink(t *testing.T) {
dir := t.TempDir()
creds := filepath.Join(dir, "users.toml")
if err := os.Symlink(filepath.Join(dir, "missing", "users.toml"), creds); err != nil {
t.Fatal(err)
}
if code, _, _ := runAuthTest(t, "add-user", "-credentials", creds, "-user", "edge-01"); code != 1 {
t.Fatalf("exit %d, want 1", code)
}
if fi, err := os.Lstat(creds); err != nil || fi.Mode()&os.ModeSymlink == 0 {
t.Fatalf("link replaced (%v)", err)
}
}
// An empty file is how an operator picks the owner before the first user
func TestAddUserFillsAnEmptyFile(t *testing.T) {
creds := filepath.Join(t.TempDir(), "users.toml")
if err := os.WriteFile(creds, nil, 0o640); err != nil {
t.Fatal(err)
}
if code, _, stderr := runAuthTest(t, "add-user", "-credentials", creds, "-user", "edge-01"); code != 0 {
t.Fatalf("exit %d; stderr: %s", code, stderr)
}
fi, err := os.Stat(creds)
if err != nil || fi.Mode().Perm() != 0o640 {
t.Fatalf("mode %v (%v), want 0640", fi.Mode().Perm(), err)
}
if c, err := authz.LoadCredentials(creds); err != nil || len(c.Users) != 1 {
t.Fatalf("credentials after add-user: %v", err)
}
}
func TestRemoveUserRefusesTheLastUser(t *testing.T) {
creds := filepath.Join(t.TempDir(), "users.toml")
writeCredentials(t, creds, "edge-01", "edge-02")
if code, _, stderr := runAuthTest(t, "remove-user", "-credentials", creds, "-user", "edge-01"); code != 0 {
t.Fatalf("exit %d; stderr: %s", code, stderr)
}
if code, _, _ := runAuthTest(t, "remove-user", "-credentials", creds, "-user", "edge-02"); code != 1 {
t.Fatalf("removing the last user: exit %d, want 1", code)
}
c, err := authz.LoadCredentials(creds)
if err != nil || len(c.Users) != 1 || c.Users[0].Username != "edge-02" {
t.Fatalf("users after refusal: %v", err)
}
}
func TestAuthCommandHelpPrintsAuthUsage(t *testing.T) {
code, _, stderr := runAuthTest(t, "add-user", "-h")
if code != 0 || !strings.Contains(stderr, "Usage: lw auth add-user -credentials FILE") {
t.Fatalf("exit %d, stderr: %s", code, stderr)
}
}
// The login is posted to the -url given plus /auth: a link-local URL keeps
// its zone escaped, not turned into one that no longer parses
func TestTokenLogsInAtTheURLGiven(t *testing.T) {
pass := filepath.Join(t.TempDir(), "edge-01.pass")
if err := os.WriteFile(pass, []byte("correct horse battery\n"), 0o600); err != nil {
t.Fatal(err)
}
// No such interface or listener: the dial fails at once, after the URL is formed
for _, rawURL := range []string{"https://[fe80::1%25nosuchif0]:1", "https://127.0.0.1:1?"} {
code, _, stderr := runAuthTest(t, "token", "-url", rawURL, "-user", "edge-01", "-password-file", pass)
if want := `"` + strings.TrimSuffix(rawURL, "?") + `/auth"`; code != 1 || !strings.Contains(stderr, want) {
t.Errorf("%s: exit %d, stderr: %s", rawURL, code, stderr)
}
}
}
+200
View File
@@ -0,0 +1,200 @@
package main
import (
"context"
"fmt"
"os"
_ "logwisp/internal/source/console"
_ "logwisp/internal/source/file"
_ "logwisp/internal/source/httpchain"
_ "logwisp/internal/source/null"
_ "logwisp/internal/source/random"
_ "logwisp/internal/source/tcpchain"
_ "logwisp/internal/sink/console"
_ "logwisp/internal/sink/file"
_ "logwisp/internal/sink/http"
_ "logwisp/internal/sink/httpchain"
_ "logwisp/internal/sink/null"
_ "logwisp/internal/sink/tcp"
_ "logwisp/internal/sink/tcpchain"
"logwisp/internal/config"
"logwisp/internal/service"
"logwisp/internal/version"
"github.com/lixenwraith/log"
"github.com/lixenwraith/log/sanitizer"
)
// bootstrapInitial handles initial service startup with status reporter
func bootstrapInitial(ctx context.Context, cfg *config.Config) (*service.Service, context.CancelFunc, error) {
svc, err := bootstrapService(ctx, cfg)
if err != nil {
return nil, nil, fmt.Errorf("failed to bootstrap service: %w", err)
}
if err := svc.Start(); err != nil {
svc.Shutdown()
return nil, nil, fmt.Errorf("failed to start service pipelines: %w", err)
}
var statusCancel context.CancelFunc
if cfg.StatusReporter {
statusCancel = startStatusReporter(ctx, svc)
}
return svc, statusCancel, nil
}
// handleReload orchestrates the entire hot-reload process including status reporter lifecycle
func handleReload(ctx context.Context, newCfg *config.Config, oldSvc *service.Service, statusCancel context.CancelFunc) (*service.Service, *config.Config, context.CancelFunc, error) {
logger.Info("msg", "Starting configuration hot reload")
if err := config.ValidateConfig(newCfg); err != nil {
logger.Error("msg", "Invalid reload configuration, keeping old service running", "error", err)
return nil, nil, nil, err
}
// Bootstrap a new service to ensure it's valid before touching the old one
logger.Debug("msg", "Bootstrapping new service with updated config")
newService, err := bootstrapService(ctx, newCfg)
if err != nil {
logger.Error("msg", "Failed to bootstrap new service, keeping old service running", "error", err)
return nil, nil, nil, err
}
// Gracefully shut down the old service
if statusCancel != nil {
statusCancel()
}
if oldSvc != nil {
logger.Info("msg", "Shutting down old service before activating new one")
oldSvc.Shutdown()
}
// Start the new service
if err := newService.Start(); err != nil {
newService.Shutdown()
logger.Error("msg", "Failed to start new service pipelines after reload. The application may be in a non-functional state.", "error", err)
return nil, nil, nil, fmt.Errorf("failed to start new service: %w", err)
}
// Manage status reporter lifecycle
var newStatusCancel context.CancelFunc
if newCfg.StatusReporter {
newStatusCancel = startStatusReporter(ctx, newService)
}
logger.Info("msg", "Configuration hot reload completed successfully")
return newService, newCfg, newStatusCancel, nil
}
// bootstrapService creates and initializes the main log transport service and its pipelines
func bootstrapService(ctx context.Context, cfg *config.Config) (*service.Service, error) {
// Create service with logger dependency injection
svc, err := service.NewService(ctx, cfg, logger)
if err != nil {
logger.Error("msg", "Failed to initialize service",
"component", "bootstrap",
)
return nil, err
}
logger.Info("msg", "LogWisp started",
"version", version.Short(),
)
return svc, nil
}
// checkConfig builds every pipeline and plugin as a start would, so TLS files,
// credentials and options are loaded and their warnings print, then exits
// before anything binds or reads: plugins start goroutines only in Start.
func checkConfig(cfg *config.Config) int {
cfg.Logging.Output, cfg.Logging.Level = "stderr", "warn"
if err := initializeLogger(cfg); err == nil && logger.Start() == nil {
defer shutdownLogger()
}
if _, err := service.NewService(context.Background(), cfg, logger); err != nil {
fmt.Fprintf(os.Stderr, "configuration invalid: %v\n", err)
return 1
}
fmt.Printf("configuration ok: %d pipeline(s)\n", len(cfg.Pipelines))
return 0
}
// initializeLogger sets up the global logger based on the application's configuration
func initializeLogger(cfg *config.Config) error {
logger = log.NewLogger()
logCfg := log.DefaultConfig()
if cfg.Quiet {
// In quiet mode, disable ALL logging output
logCfg.Level = 255 // A level that disables all output
logCfg.EnableFile = false
logCfg.EnableConsole = false
return logger.ApplyConfig(logCfg)
}
// Determine log level
levelValue, err := log.Level(cfg.Logging.Level)
if err != nil {
return fmt.Errorf("invalid log level: %w", err)
}
logCfg.Level = levelValue
// Configure log format
if cfg.Logging.Format != "" {
logCfg.Format = cfg.Logging.Format
}
if cfg.Logging.Sanitization != "" {
logCfg.Sanitization = sanitizer.PolicyPreset(cfg.Logging.Sanitization)
}
// Configure based on output mode
switch cfg.Logging.Output {
case "none":
logCfg.EnableFile = false
logCfg.EnableConsole = false
case "stdout":
logCfg.EnableFile = false
logCfg.EnableConsole = true
logCfg.ConsoleTarget = "stdout"
case "stderr":
logCfg.EnableFile = false
logCfg.EnableConsole = true
logCfg.ConsoleTarget = "stderr"
case "split":
logCfg.EnableFile = false
logCfg.EnableConsole = true
logCfg.ConsoleTarget = "split"
case "file":
logCfg.EnableFile = true
logCfg.EnableConsole = false
configureFileLogging(logCfg, cfg)
case "all":
logCfg.EnableFile = true
logCfg.EnableConsole = true
logCfg.ConsoleTarget = "split"
configureFileLogging(logCfg, cfg)
default:
return fmt.Errorf("invalid log output mode: %s", cfg.Logging.Output)
}
return logger.ApplyConfig(logCfg)
}
// configureFileLogging sets up file-based logging parameters from the configuration
func configureFileLogging(logCfg *log.Config, cfg *config.Config) {
if cfg.Logging.File != nil {
logCfg.Directory = cfg.Logging.File.Directory
logCfg.Name = cfg.Logging.File.Name
logCfg.MaxSizeKB = cfg.Logging.File.MaxSizeMB * 1000
logCfg.MaxTotalSizeKB = cfg.Logging.File.MaxTotalSizeMB * 1000
if cfg.Logging.File.RetentionHours > 0 {
logCfg.RetentionPeriodHrs = cfg.Logging.File.RetentionHours
}
}
}
+85
View File
@@ -0,0 +1,85 @@
package main
import (
"fmt"
"logwisp/internal/version"
"os"
)
// helpText is the CLI usage reference. Scalar flags map 1:1 to TOML config paths.
const helpText = `LogWisp %s - log collection, processing, and distribution
Usage:
lw [options]
lw --check [options] Build every pipeline and plugin, report, exit
lw help | -h | --help
lw --version
Subcommands:
lw auth <command> SCRAM credentials files, bearer tokens and tcp
sink viewing; lw auth -h lists commands
Scalar configuration keys are settable as flags using their TOML paths:
--<path>=<value> e.g. --logging.level=debug
Common options:
-c, --config <path> Configuration file (default: ./logwisp.toml)
--quiet Suppress console output
--status_reporter=<bool> Periodic status logging (default: true)
--auto_reload=<bool> Config hot reload on file change (default: false)
Logging:
--logging.output=<mode> file|stdout|stderr|split|all|none
--logging.level=<level> debug|info|warn|error
--logging.file.directory=<path>
Pipelines (replace the file's pipelines; see doc/cli.md):
--pipeline <name> Start a pipeline; earlier specs go to "cli"
--source <spec> TYPE[,key=value...], repeatable
--sink <spec> e.g. http,host=0.0.0.0,port=8080, repeatable
--filter <spec> include|exclude,patterns=RE, repeatable
--format <spec> json|txt|raw[,key=value...]
--rate-limit <spec> rate=N[,burst=N,policy=drop|pass]
--heartbeat <spec> interval_ms=N[,include_stats=true,...]
Keys nest with '.' (tls.cert_file=...), a repeated key makes a list, and
'\' escapes ',' '=' '\' in values. -- ends option parsing.
Environment:
LOGWISP_<PATH> Config path, '.' -> '_', uppercase
e.g. LOGWISP_LOGGING_LEVEL=debug
LOGWISP_CONFIG_FILE Configuration file path
LOGWISP_CONFIG_DIR Configuration directory
LOGWISP_PIPELINE, LOGWISP_SOURCE[_N], LOGWISP_SINK[_N], LOGWISP_FILTER[_N],
LOGWISP_FORMAT, LOGWISP_RATE_LIMIT, LOGWISP_HEARTBEAT
One pipeline, specs as the flags (_N adds
more); ignored when a pipeline flag is given
Signals:
SIGINT, SIGTERM Graceful shutdown
SIGHUP, SIGUSR1 Reload configuration
Exit codes:
0 success
1 general error
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() {
fmt.Printf(helpText, version.Short())
os.Exit(0)
}
+174
View File
@@ -0,0 +1,174 @@
package main
import (
"context"
"errors"
"fmt"
"os"
"os/signal"
"reflect"
"syscall"
"logwisp/internal/config"
"logwisp/internal/core"
"logwisp/internal/version"
"github.com/lixenwraith/log"
)
var logger *log.Logger
func main() {
// Before handleHelp, so `lw auth <command> -h` prints the auth usage
if len(os.Args) > 1 && os.Args[1] == "auth" {
os.Exit(runAuth(os.Args[2:], os.Stdout, os.Stderr))
}
// --- 1. Initial setup ---
// Emulates nohup
signal.Ignore(syscall.SIGHUP)
// Before config parsing: the loader has no help flag
handleHelp(os.Args[1:])
manager, err := config.Load(os.Args[1:])
if err != nil {
if errors.Is(err, config.ErrConfigNotFound) {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(2)
}
fmt.Fprintf(os.Stderr, "Error: Failed to load config: %v\n", err)
os.Exit(1)
}
defer manager.Close()
cfg, err := manager.Snapshot()
if err != nil {
fmt.Fprintf(os.Stderr, "Error: Failed to read config: %v\n", err)
os.Exit(1)
}
InitOutputHandler(cfg.Quiet)
if cfg.ShowVersion {
fmt.Println(version.String())
os.Exit(0)
}
if cfg.Check {
os.Exit(checkConfig(cfg))
}
if err := initializeLogger(cfg); err != nil {
FatalError(1, "Failed to initialize logger: %v\n", err)
}
defer shutdownLogger()
if err := logger.Start(); err != nil {
FatalError(1, "Failed to start logger: %v\n", err)
}
configFile := cfg.ConfigFile
if _, err := os.Stat(configFile); err != nil {
configFile = "none" // flags, environment and built-in defaults only
}
logger.Info("msg", "LogWisp starting",
"version", version.String(),
"config_file", configFile,
"log_output", cfg.Logging.Output,
"status_reporter", cfg.StatusReporter,
"auto_reload", cfg.ConfigAutoReload)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
// --- 2. Bootstrap initial service ---
svc, statusReporterCancel, err := bootstrapInitial(ctx, cfg)
if err != nil {
logger.Error("msg", "Failed to initialize service", "error", err)
shutdownLogger()
os.Exit(1)
}
// --- 3. Setup signals and shutdown ---
sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM, syscall.SIGHUP, syscall.SIGUSR1)
var configChanges <-chan string
if cfg.ConfigAutoReload {
configChanges = manager.Watch()
logger.Info("msg", "Config auto-reload enabled", "config_file", cfg.ConfigFile)
} else {
logger.Info("msg", "Config auto-reload disabled")
}
defer func() {
logger.Info("msg", "Shutdown initiated")
if statusReporterCancel != nil {
statusReporterCancel()
}
if svc != nil {
svc.Shutdown()
}
manager.Close()
logger.Info("msg", "Shutdown complete")
}()
reload := func(fromDisk bool) {
var next *config.Config
var err error
if fromDisk {
next, err = manager.Reload()
} else {
next, err = manager.Snapshot()
}
if err != nil {
logger.Error("msg", "Failed to read reload configuration", "error", err, "action", "keeping current service")
return
}
// Watch events are hints and may be duplicated or concern startup-only
// settings. Signals always rebuild, including for certificate rotation.
if !fromDisk && cfg.StatusReporter == next.StatusReporter && reflect.DeepEqual(cfg.Pipelines, next.Pipelines) {
return
}
newSvc, newCfg, newStatusCancel, err := handleReload(ctx, next, svc, statusReporterCancel)
if err == nil {
svc, cfg, statusReporterCancel = newSvc, newCfg, newStatusCancel
}
}
// --- 4. Main Application Event Loop ---
logger.Info("msg", "Application started, waiting for signals or config changes")
for {
select {
case sig := <-sigChan:
if sig == syscall.SIGHUP || sig == syscall.SIGUSR1 {
logger.Info("msg", "Reload signal received, triggering manual reload", "signal", sig)
reload(true)
} else {
logger.Info("msg", "Shutdown signal received", "signal", sig)
cancel() // Trigger service shutdown via context
}
case event, ok := <-configChanges:
if !ok {
logger.Warn("msg", "Configuration watch channel closed, disabling auto-reload")
configChanges = nil // Stop selecting on this channel
continue
}
if collectConfigChanges(event, configChanges) {
reload(false)
}
case <-ctx.Done():
return // Exit the loop and trigger deferred shutdown
}
}
}
func shutdownLogger() {
if logger != nil {
if err := logger.Shutdown(core.LoggerShutdownTimeout); err != nil {
// Best effort - can't log the shutdown error
Error("Logger shutdown error: %v\n", err)
}
}
}
+93
View File
@@ -0,0 +1,93 @@
package main
import (
"fmt"
"io"
"os"
"sync"
)
// OutputHandler manages all application output, respecting the global quiet mode
type OutputHandler struct {
quiet bool
mu sync.RWMutex
stdout io.Writer
stderr io.Writer
}
// output is the global instance of the OutputHandler
var output *OutputHandler
// InitOutputHandler initializes the global output handler
func InitOutputHandler(quiet bool) {
output = &OutputHandler{
quiet: quiet,
stdout: os.Stdout,
stderr: os.Stderr,
}
}
// Print writes to stdout
func Print(format string, args ...any) {
if output != nil {
output.Print(format, args...)
}
}
// Error writes to stderr
func Error(format string, args ...any) {
if output != nil {
output.Error(format, args...)
}
}
// FatalError writes to stderr and exits the application
func FatalError(code int, format string, args ...any) {
if output != nil {
output.FatalError(code, format, args...)
} else {
// Fallback if handler not initialized
fmt.Fprintf(os.Stderr, format, args...)
os.Exit(code)
}
}
// Print writes a formatted string to stdout if not in quiet mode
func (o *OutputHandler) Print(format string, args ...any) {
o.mu.RLock()
defer o.mu.RUnlock()
if !o.quiet {
fmt.Fprintf(o.stdout, format, args...)
}
}
// Error writes a formatted string to stderr if not in quiet mode
func (o *OutputHandler) Error(format string, args ...any) {
o.mu.RLock()
defer o.mu.RUnlock()
if !o.quiet {
fmt.Fprintf(o.stderr, format, args...)
}
}
// FatalError writes a formatted string to stderr and exits with the given code.
func (o *OutputHandler) FatalError(code int, format string, args ...any) {
o.Error(format, args...)
os.Exit(code)
}
// IsQuiet returns the current quiet mode status.
func (o *OutputHandler) IsQuiet() bool {
o.mu.RLock()
defer o.mu.RUnlock()
return o.quiet
}
// SetQuiet updates the quiet mode status.
func (o *OutputHandler) SetQuiet(quiet bool) {
o.mu.Lock()
defer o.mu.Unlock()
o.quiet = quiet
}
+36
View File
@@ -0,0 +1,36 @@
package main
import (
"strings"
lconfig "github.com/lixenwraith/config"
)
// collectConfigChanges folds the queued hints into one snapshot read. Error
// events describe an unpublished edit and must not restart a healthy service.
func collectConfigChanges(first string, changes <-chan string) bool {
changed := reportConfigEvent(first)
// Bound the drain to the currently queued events so a busy writer cannot
// starve signal handling. Later events remain queued for the next iteration.
for remaining := len(changes); remaining > 0; remaining-- {
select {
case event, ok := <-changes:
if !ok {
return changed
}
changed = reportConfigEvent(event) || changed
default:
return changed
}
}
return changed
}
func reportConfigEvent(event string) bool {
if event == lconfig.EventFileDeleted || event == lconfig.EventPermissionsChanged ||
event == lconfig.EventReloadTimeout || strings.HasPrefix(event, lconfig.EventReloadError+":") {
logger.Warn("msg", "Configuration update not applied", "event", event, "action", "keeping current service")
return false
}
return event != ""
}
+152
View File
@@ -0,0 +1,152 @@
package main
import (
"context"
"errors"
"io/fs"
"net"
"os"
"path/filepath"
"strings"
"testing"
"time"
"logwisp/internal/plugin"
"logwisp/internal/session"
"logwisp/internal/testutil"
lconfig "github.com/lixenwraith/config"
)
func TestWatchErrorsDoNotTriggerReloadAndChangesAreCoalesced(t *testing.T) {
testLogger(t)
for _, event := range []string{lconfig.EventFileDeleted, lconfig.EventPermissionsChanged, lconfig.EventReloadTimeout, lconfig.EventReloadError + ":invalid TOML"} {
if collectConfigChanges(event, nil) {
t.Fatalf("error event triggered service restart: %s", event)
}
}
changes := make(chan string, 3)
changes <- "pipelines"
changes <- "status_reporter"
changes <- lconfig.EventReloadError + ":partial edit"
close(changes)
if !collectConfigChanges("logging.level", changes) || len(changes) != 0 {
t.Fatal("pending path changes were not folded into a single reload")
}
if collectConfigChanges("", changes) {
t.Fatal("closed channel triggered a reload")
}
}
func TestShippedConfigurationBuildsWithCheckedPluginDecoder(t *testing.T) {
testLogger(t)
path, err := filepath.Abs(filepath.Join("..", "..", "config", "logwisp.toml"))
if err != nil {
t.Fatal(err)
}
cfg := loadTestConfig(t, path)
if len(cfg.Pipelines) != 1 || cfg.Pipelines[0].Name != "default" {
t.Fatalf("sample array-of-tables decoded incorrectly: %+v", cfg.Pipelines)
}
svc, err := bootstrapService(context.Background(), cfg)
if err != nil {
t.Fatal(err)
}
defer svc.Shutdown()
pl, err := svc.GetPipeline("default")
if err != nil || len(pl.Sources) != 1 || len(pl.Sinks) != 1 {
t.Fatalf("sample plugins failed to build: %v", err)
}
svc.Shutdown() // A later reload failure or process exit can close it again.
}
func TestInvalidReloadLeavesStatusReporterRunning(t *testing.T) {
testLogger(t)
path := filepath.Join(t.TempDir(), "config.toml")
testutil.WriteFile(t, path, "[logging]\nlevel = \"info\"\n")
for _, mode := range []string{"empty-pipelines", "duplicate-names", "fractional-plugin-integer", "plugin-overflow"} {
t.Run(mode, func(t *testing.T) {
cfg := loadTestConfig(t, path)
switch mode {
case "empty-pipelines":
cfg.Pipelines = nil
case "duplicate-names":
cfg.Pipelines = append(cfg.Pipelines, cfg.Pipelines[0])
case "fractional-plugin-integer":
cfg.Pipelines[0].PluginSinks[0].Config["buffer_size"] = 1.5
case "plugin-overflow":
cfg.Pipelines[0].PluginSinks[0].Config["buffer_size"] = ^uint64(0)
}
cancelled := false
svc, next, _, err := handleReload(context.Background(), cfg, nil, func() { cancelled = true })
if err == nil || svc != nil || next != nil || cancelled {
t.Fatalf("invalid reload affected running state: svc=%v cfg=%v cancelled=%v error=%v", svc, next, cancelled, err)
}
})
}
}
// Every registered plugin decodes through the checked decoder, so a typo in
// any plugin's config, security tables included, refuses to start.
func TestEveryPluginRejectsUnknownKeys(t *testing.T) {
testLogger(t)
manager := session.NewManager(time.Hour)
t.Cleanup(manager.Stop)
proxy := session.NewProxy(manager, "typo")
bad := map[string]any{"no_such_key": true}
check := func(kind, name string, err error) {
if err == nil || !strings.Contains(err.Error(), "no_such_key") {
t.Errorf("%s %q accepted an unknown key: %v", kind, name, err)
}
}
for _, name := range plugin.ListSources() {
factory, _ := plugin.GetSource(name)
_, err := factory("typo", bad, logger, proxy)
check("source", name, err)
}
for _, name := range plugin.ListSinks() {
factory, _ := plugin.GetSink(name)
_, err := factory("typo", bad, logger, proxy)
check("sink", name, err)
}
}
// A misspelled spec key reaches the plugin as the nested key it names, so the
// sink refuses to build instead of starting without the TLS it was asked for.
func TestMisspelledSpecKeyFailsPluginConstruction(t *testing.T) {
testLogger(t)
path := filepath.Join(t.TempDir(), "empty.toml")
testutil.WriteFile(t, path, "")
cfg := loadTestConfig(t, path, "--source", "null", "--sink", "http,host=127.0.0.1,port=15861,tls.enabeld=true")
svc, err := bootstrapService(context.Background(), cfg)
if err == nil {
svc.Shutdown()
}
if err == nil || !strings.Contains(err.Error(), `unknown key "tls.enabeld"`) {
t.Fatalf("misspelled spec key accepted: %v", err)
}
}
// lw --check builds every plugin, so a bad option fails it, but starts none:
// the port a valid sink names stays free and its log directory uncreated.
func TestCheckBuildsWithoutStarting(t *testing.T) {
testLogger(t)
dir := t.TempDir()
path, out := filepath.Join(dir, "empty.toml"), filepath.Join(dir, "out")
testutil.WriteFile(t, path, "")
if code := checkConfig(loadTestConfig(t, path, "--source", "null", "--sink", "http,host=127.0.0.1,port=15862",
"--sink", "file,name=check,directory="+out)); code != 0 {
t.Fatalf("valid configuration: exit %d", code)
}
ln, err := net.Listen("tcp4", "127.0.0.1:15862")
if err != nil {
t.Fatalf("check bound the sink's port: %v", err)
}
ln.Close()
if _, err := os.Stat(out); !errors.Is(err, fs.ErrNotExist) {
t.Fatalf("check created the file sink's directory: %v", err)
}
if code := checkConfig(loadTestConfig(t, path, "--source", "null", "--sink", "http,port=15862,tls.enabled=true")); code != 1 {
t.Fatalf("listener TLS without a certificate: exit %d, want 1", code)
}
}
+153
View File
@@ -0,0 +1,153 @@
package main
import (
"context"
"fmt"
"time"
"logwisp/internal/service"
)
// startStatusReporter starts a new status reporter for a service and returns its cancel function.
func startStatusReporter(ctx context.Context, svc *service.Service) context.CancelFunc {
reporterCtx, cancel := context.WithCancel(ctx)
go statusReporter(svc, reporterCtx)
logger.Debug("msg", "Started status reporter")
return cancel
}
// statusReporter periodically logs the health and statistics of the service
func statusReporter(service *service.Service, ctx context.Context) {
ticker := time.NewTicker(30 * time.Second)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
if service == nil {
logger.Warn("msg", "Status reporter: service is nil",
"component", "status_reporter")
return
}
// Safely get stats with recovery
func() {
defer func() {
if r := recover(); r != nil {
logger.Error("msg", "Panic in status reporter",
"component", "status_reporter",
"panic", r)
}
}()
stats := service.GetGlobalStats()
totalPipelines, ok := stats["total_pipelines"].(int)
if !ok || totalPipelines == 0 {
logger.Warn("msg", "No active pipelines in status report",
"component", "status_reporter")
return
}
// Log service-level summary
logger.Debug("msg", "Status report",
"component", "status_reporter",
"active_pipelines", totalPipelines,
"time", time.Now().Format("15:04:05"))
// Log each pipeline's stats recursively
if pipelines, ok := stats["pipelines"].(map[string]any); ok {
for name, pipelineStats := range pipelines {
logStats("Pipeline status", name, pipelineStats)
}
}
}()
}
}
}
// logStats recursively logs statistics with automatic field extraction
func logStats(msg string, name string, stats any) {
if statsMap, ok := stats.(map[string]any); ok {
logger.Debug(statsFields(msg, name, statsMap)...)
// Recursively log nested structures with detail
for key, value := range statsMap {
switch v := value.(type) {
case map[string]any:
// Log nested component stats
if key == "flow" || key == "rate_limiter" || key == "filters" {
logStats(fmt.Sprintf("%s %s", name, key), key, v)
}
case []map[string]any:
// Log array items (sources, sinks, filters)
for i, item := range v {
if itemName, ok := item["id"].(string); ok {
logStats(fmt.Sprintf("%s %s", name, key), itemName, item)
} else {
logStats(fmt.Sprintf("%s %s", name, key), fmt.Sprintf("%s[%d]", key, i), item)
}
}
}
}
}
}
// statsFields flattens one stats map into log fields. A plugin's details map
// is spread into the line: its auth and TLS rejection counters live there.
func statsFields(msg, name string, stats map[string]any) []any {
fields := []any{"msg", msg, "name", name}
add := func(key string, value any) {
switch v := value.(type) {
case string, bool, int, int64, uint64, float64:
fields = append(fields, key, v)
case time.Time:
if !v.IsZero() {
fields = append(fields, key, v.Format(time.RFC3339))
}
case map[string]any:
// For nested maps, log summary counts if they contain arrays/maps
if count := getItemCount(v); count > 0 {
fields = append(fields, fmt.Sprintf("%s_count", key), count)
}
case []any, []map[string]any:
fields = append(fields, fmt.Sprintf("%s_count", key), getArrayLength(value))
}
}
for key, value := range stats {
if details, ok := value.(map[string]any); ok && key == "details" {
for k, v := range details {
add(k, v)
}
continue
}
add(key, value)
}
return fields
}
// getItemCount returns the count of items in a map (for nested structures)
func getItemCount(m map[string]any) int {
for _, v := range m {
switch v.(type) {
case []any:
return len(v.([]any))
case []map[string]any:
return len(v.([]map[string]any))
}
}
return 0
}
// getArrayLength safely gets the length of various array types
func getArrayLength(v any) int {
switch arr := v.(type) {
case []any:
return len(arr)
case []map[string]any:
return len(arr)
default:
return 0
}
}
+20
View File
@@ -0,0 +1,20 @@
package main
import (
"slices"
"testing"
)
// A plugin's details map is spread into its status line: auth and TLS
// rejection counters live there and would otherwise never be reported.
func TestStatusLineCarriesPluginDetails(t *testing.T) {
fields := statsFields("Pipeline sources", "in", map[string]any{
"id": "in",
"details": map[string]any{"auth_rejected": uint64(3), "tls_handshake_errors": uint64(1)},
})
for _, key := range []string{"auth_rejected", "tls_handshake_errors"} {
if !slices.Contains(fields, any(key)) {
t.Errorf("status fields lack %s: %v", key, fields)
}
}
}
+32
View File
@@ -0,0 +1,32 @@
package main
import (
"testing"
"logwisp/internal/config"
"logwisp/internal/testutil"
"github.com/lixenwraith/log"
)
func testLogger(t *testing.T) {
t.Helper()
previous := logger
logger = log.NewLogger()
t.Cleanup(func() { logger = previous })
}
func loadTestConfig(t *testing.T, path string, args ...string) *config.Config {
t.Helper()
testutil.ClearEnvPrefix(t, "LOGWISP_")
m, err := config.Load(append([]string{"-c", path, "--quiet", "--status_reporter=false"}, args...))
if err != nil {
t.Fatal(err)
}
t.Cleanup(m.Close)
cfg, err := m.Snapshot()
if err != nil {
t.Fatal(err)
}
return cfg
}