v0.21.0 scram password auth, cred and token management, browser tls-terminating proxy, viewer client
Test / test (push) Canceled after 0s
Test / test (push) Canceled after 0s
This commit is contained in:
1 parent
f192c26fa2
commit
9ab8c356d2
60 files changed
+6791
-747
No files matched your search
@@ -0,0 +1,395 @@
|
||||
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/tlsx"
|
||||
|
||||
"github.com/lixenwraith/auth"
|
||||
)
|
||||
|
||||
const reloadHint = "send SIGHUP to logwisp to apply (auto_reload does not watch the credentials file)"
|
||||
|
||||
// authCommand is one `logwisp 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 `logwisp 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, "logwisp auth: unknown command %q\n\n", args[0])
|
||||
printAuthUsage(stderr)
|
||||
return 2
|
||||
}
|
||||
c := authCommands[i]
|
||||
fs := flag.NewFlagSet("logwisp 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: logwisp auth <command> [flags]\n\n")
|
||||
for _, c := range authCommands {
|
||||
fmt.Fprintf(w, " %-12s %s\n", c.name, c.summary)
|
||||
}
|
||||
fmt.Fprint(w, "\nRun logwisp 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 (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")
|
||||
}
|
||||
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, "tcp4", 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 },
|
||||
}
|
||||
token, err := policy.Token(context.Background(), client, "https://"+u.Host+u.Path)
|
||||
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")
|
||||
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)
|
||||
if err != nil || host == "" {
|
||||
return usageError("-addr must be HOST:PORT")
|
||||
}
|
||||
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, "tcp4", *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
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
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: logwisp auth add-user -credentials FILE") {
|
||||
t.Fatalf("exit %d, stderr: %s", code, stderr)
|
||||
}
|
||||
}
|
||||
+4
-1
@@ -14,6 +14,10 @@ Usage:
|
||||
logwisp help | -h | --help
|
||||
logwisp --version
|
||||
|
||||
Subcommands:
|
||||
logwisp auth <command> SCRAM credentials files, bearer tokens and tcp
|
||||
sink viewing; logwisp auth -h lists commands
|
||||
|
||||
Scalar configuration keys are settable as flags using their TOML paths:
|
||||
--<path>=<value> e.g. --logging.level=debug
|
||||
|
||||
@@ -62,7 +66,6 @@ func handleHelp(args []string) {
|
||||
}
|
||||
}
|
||||
|
||||
// printHelp writes usage to stdout and exits with success
|
||||
func printHelp() {
|
||||
fmt.Printf(helpText, version.Short())
|
||||
os.Exit(0)
|
||||
|
||||
+6
-14
@@ -16,20 +16,21 @@ import (
|
||||
"github.com/lixenwraith/log"
|
||||
)
|
||||
|
||||
// logger is the global logger instance for the application
|
||||
var logger *log.Logger
|
||||
|
||||
// main is the entry point for the LogWisp application
|
||||
func main() {
|
||||
// Before handleHelp, so `logwisp 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)
|
||||
|
||||
// Help handled before config parsing; loader has no help flag.
|
||||
// Also the future dispatch point for subcommands (tls, etc.)
|
||||
// Before config parsing: the loader has no help flag
|
||||
handleHelp(os.Args[1:])
|
||||
|
||||
// Load configuration with automatic CLI parsing
|
||||
manager, err := config.Load(os.Args[1:])
|
||||
if err != nil {
|
||||
if errors.Is(err, config.ErrConfigNotFound) {
|
||||
@@ -46,27 +47,22 @@ func main() {
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Initialize output handler
|
||||
InitOutputHandler(cfg.Quiet)
|
||||
|
||||
// Handle version
|
||||
if cfg.ShowVersion {
|
||||
fmt.Println(version.String())
|
||||
os.Exit(0)
|
||||
}
|
||||
|
||||
// Initialize logger instance and apply configuration
|
||||
if err := initializeLogger(cfg); err != nil {
|
||||
FatalError(1, "Failed to initialize logger: %v\n", err)
|
||||
}
|
||||
defer shutdownLogger()
|
||||
|
||||
// Start the logger
|
||||
if err := logger.Start(); err != nil {
|
||||
FatalError(1, "Failed to start logger: %v\n", err)
|
||||
}
|
||||
|
||||
// Log startup information
|
||||
logger.Info("msg", "LogWisp starting",
|
||||
"version", version.String(),
|
||||
"config_file", cfg.ConfigFile,
|
||||
@@ -74,7 +70,6 @@ func main() {
|
||||
"status_reporter", cfg.StatusReporter,
|
||||
"auto_reload", cfg.ConfigAutoReload)
|
||||
|
||||
// Create context for shutdown
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
@@ -98,7 +93,6 @@ func main() {
|
||||
logger.Info("msg", "Config auto-reload disabled")
|
||||
}
|
||||
|
||||
// Service shutdown sequence
|
||||
defer func() {
|
||||
logger.Info("msg", "Shutdown initiated")
|
||||
if statusReporterCancel != nil {
|
||||
@@ -109,7 +103,6 @@ func main() {
|
||||
}
|
||||
manager.Close()
|
||||
logger.Info("msg", "Shutdown complete")
|
||||
// Deferred logger shutdown will run after this
|
||||
}()
|
||||
|
||||
reload := func(fromDisk bool) {
|
||||
@@ -164,7 +157,6 @@ func main() {
|
||||
}
|
||||
}
|
||||
|
||||
// shutdownLogger gracefully shuts down the global logger.
|
||||
func shutdownLogger() {
|
||||
if logger != nil {
|
||||
if err := logger.Shutdown(core.LoggerShutdownTimeout); err != nil {
|
||||
|
||||
+35
-30
@@ -69,36 +69,8 @@ func statusReporter(service *service.Service, ctx context.Context) {
|
||||
|
||||
// logStats recursively logs statistics with automatic field extraction
|
||||
func logStats(msg string, name string, stats any) {
|
||||
// Build base log fields
|
||||
fields := []any{
|
||||
"msg", msg,
|
||||
"name", name,
|
||||
}
|
||||
|
||||
// Extract and flatten important metrics from stats map
|
||||
if statsMap, ok := stats.(map[string]any); ok {
|
||||
// Add scalar values directly
|
||||
for key, value := range statsMap {
|
||||
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:
|
||||
// For arrays, just log the count
|
||||
fields = append(fields, fmt.Sprintf("%s_count", key), getArrayLength(value))
|
||||
}
|
||||
}
|
||||
|
||||
// Log the flattened stats
|
||||
logger.Debug(fields...)
|
||||
logger.Debug(statsFields(msg, name, statsMap)...)
|
||||
|
||||
// Recursively log nested structures with detail
|
||||
for key, value := range statsMap {
|
||||
@@ -122,6 +94,39 @@ func logStats(msg string, name string, stats any) {
|
||||
}
|
||||
}
|
||||
|
||||
// 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 {
|
||||
@@ -145,4 +150,4 @@ func getArrayLength(v any) int {
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user