v0.21.0 scram password auth, cred and token management, browser tls-terminating proxy, viewer client
Test / test (push) Canceled after 0s

This commit is contained in:
lixen committed 2026-10-02 15:25:51 -04:00
1 parent f192c26fa2
commit 9ab8c356d2
60 files changed
+6744 -700

No files matched your search

+2
View File
@@ -15,3 +15,5 @@ catalog.txt
combined.txt
test/run/
test/run-mtls/
test/run-scram/
test/run-proxy/
+14 -5
View File
@@ -74,14 +74,23 @@ change touches.
keys must fail at any depth. Set defaults on the options struct before the
call; `TestEveryPluginRejectsUnknownKeys` covers every registered plugin.
- Network security has two seams: `tlsx` (TLS configs, startup warnings) and
`authz` (every admission decision). Both return nil when disabled and every
method tolerates a nil receiver; keep call sites branch-free on config.
- A plugin starts goroutines in `Start`, never in its constructor: a rejected
reload discards constructed plugins without calling `Stop`. Reload is the only
rotation path for certificates and secrets.
`authz`. Listeners admit only through `Admit`/`AuthorizeRequest`, dialers
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.
- A plugin starts goroutines in `Start`, never in its constructor (the SCRAM
server too: `Policy.Start`/`Close`): a rejected reload discards constructed
plugins without calling `Stop`. Reload is the only rotation path for
certificates, credentials and token keys.
- The chain `Hello` is the protocol's extension point: optional fields only (old
peers ignore unknown JSON); anything a peer must understand bumps
`chain.ProtocolVersion`.
- TLS tests over `net.Pipe` close the raw pipe ends: `tls.Conn.Close` waits up
to 5 s writing close_notify nobody reads. authz tests lower the dialer's
Argon2 floor in `TestMain`; elsewhere every login costs the 64 MiB default.
- `internal/sink/http/web/` is embedded by the `http` sink. `scram.js` stays one
dependency-free ES module (sites copy it into their bundles) and the pages
stay free of inline script and style (their CSP); `node --test` there checks
it against Go's Argon2 and `auth`'s known answer.
- Listeners and dialers are IPv4-only (`tcp4`). E2E scripts in `test/` need
`bin/logwisp` and `--auto`, and each owns a port range and a gitignored run
directory.
+15 -7
View File
@@ -62,15 +62,23 @@ exponential backoff and jitter.
- Authorization by certificate identity: an `auth` block admits named peers
(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
- Password authentication (Argon2id-SCRAM) on the same block: listeners hold
verifiers, never passwords, and do no KDF work; every login outside proxy
mode is bound to the
listener's certificate, so no relay presenting another one can use it; HTTP
listeners issue short-lived bearer tokens. `logwisp auth` manages credentials
files and logs viewers in; behind a site's TLS-terminating proxy, browsers
log in through a shipped login page and a dependency-free JS client
- Node binding: a chain source can label entries from the sender's certificate
instead of from what the sender claims, so origin attribution is not forgeable
identity or username instead of from what the sender claims, so origin
attribution is not forgeable
- Fail-closed configuration: unknown keys are rejected, and startup warns about
expiring certificates, disabled verification and unanchored allow patterns
expiring certificates, disabled verification, unanchored allow patterns and
world-readable secrets
See [Security](doc/security.md) for configuration and the exact boundary, and
the [mTLS authentication design](doc/mtls-auth-plan.md) for the rationale and
what is deliberately left out. Password authentication (Argon2id-SCRAM) is
planned; see the [SCRAM design](doc/scram-auth-plan.md).
the [mTLS](doc/mtls-auth-plan.md) and [SCRAM](doc/scram-auth-plan.md)
authentication designs for the rationale and what is deliberately left out.
## Documentation
@@ -87,8 +95,8 @@ planned; see the [SCRAM design](doc/scram-auth-plan.md).
| [Networking](doc/networking.md) | Listeners, dialers, timeouts, connection limits |
| [Security](doc/security.md) | TLS, mTLS, and peer authorization; threat model and current limits |
| [mTLS Authentication](doc/mtls-auth-plan.md) | Design and rationale for certificate-based authorization |
| [Password Authentication](doc/scram-auth-plan.md) | Planned Argon2id-SCRAM design; mTLS hardening |
| [CLI](doc/cli.md) | Flags, signals, exit codes |
| [Password Authentication](doc/scram-auth-plan.md) | Design and rationale for Argon2id-SCRAM authentication; mTLS hardening |
| [CLI](doc/cli.md) | Flags, signals, exit codes, `logwisp auth` |
| [Operations](doc/operations.md) | Running, monitoring, tuning, troubleshooting |
A fully annotated configuration covering every option ships as
+395
View File
@@ -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
}
+196
View File
@@ -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
View File
@@ -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
View File
@@ -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 {
+34 -29
View File
@@ -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 {
+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)
}
}
}
+70 -46
View File
@@ -6,12 +6,12 @@
### Commented values are the built-in defaults unless marked "example".
### Uncommenting a default is a no-op.
###
### NOTE: password authentication (planned: doc/scram-auth-plan.md), network
### access control (ACL), http/tcp ingest sources, and http_client/tcp_client
### sinks are not available in this version. TLS and mTLS ARE available on
### every network source and sink; see the [...tls] blocks below, and the
### [...auth] blocks to authorize peers by certificate identity.
### Unknown keys in any plugin config are rejected at startup.
### NOTE: network access control (ACL), http/tcp ingest sources, and
### http_client/tcp_client sinks are not available in this version. TLS is
### available on every network source and sink (the [...tls] blocks below), and
### the [...auth] blocks authenticate peers by certificate (mtls) or password
### (scram; credentials via `logwisp auth`). Unknown keys anywhere in this file
### or in any plugin config are rejected at startup.
###
### Environment overrides use the LOGWISP_ prefix
### (LOGWISP_QUIET, LOGWISP_LOGGING_LEVEL, ...). Bare names are ignored.
@@ -115,25 +115,36 @@ name = "default"
###============================================================================
### AUTH (shared shape; sits beside [...tls] on every network source and sink)
###
### Both methods REQUIRE tls.enabled; dialers forbid tls.insecure_skip_verify.
### On the http sink the block gates stream_path AND status_path.
### Listeners (tcp/http sinks, tcp_chain/http_chain sources):
### authorize the client certificate. type = "mtls" REQUIRES tls.enabled and
### tls.client_auth. On the http sink it gates stream_path AND status_path.
### Chain sources additionally bind the node label to the identity.
### mtls authorizes the client certificate; REQUIRES tls.client_auth.
### scram admits the users in credentials_file (`logwisp auth add-user`).
### Dialers (tcp_chain/http_chain sinks):
### pin the server identity. type = "mtls" REQUIRES tls.enabled and forbids
### tls.insecure_skip_verify.
### mtls pins the server identity.
### scram logs in as username with the password in password_file.
### Chain sources additionally bind the node label to the identity or username.
### A block naming peers or credentials with type = "none" is refused.
###
### type = "none" none | mtls
### identity = "cn" cn | san_dns | san_uri | san_email
### allow = [] Exact identities. Empty allow AND allow_patterns
### type = "none" none | mtls | scram
### identity = "cn" cn | san_dns | san_uri | san_email. Unset under scram;
### set on a scram listener (needs tls.client_auth), the
### certificate's field must equal the username.
### allow = [] mtls: exact identities. Empty allow AND allow_patterns
### admits any identity the CA vouches for (logged WARN).
### allow_patterns = [] RE2 patterns; anchor them yourself (^...$)
### node_binding = "" Chain sources only, default "force" under mtls:
### allow_patterns = [] mtls: RE2 patterns; anchor them yourself (^...$)
### node_binding = "" Chain sources only, default "force" with mtls or scram:
### none - trust_node governs, as before
### assert - declared label must equal the identity;
### per-entry node labels still follow trust_node
### force - label AND every entry take the identity
### Overrides trust_node. See doc/security.md.
### credentials_file = "" scram listeners: verifiers; read at (re)load, not watched
### token_lifetime_ms = 0 scram, http sink and http_chain source: 10 s to 24 h, 0 = 15 minutes
### username = "" scram dialers
### password_file = "" scram dialers: one trailing line break is trimmed
### trusted_proxies = [] scram http sink: TLS-terminating reverse proxies
### (addresses or CIDRs); browsers log in through them
###============================================================================
###============================================================================
@@ -195,12 +206,13 @@ from = "end" # "end" or "start" of a newly discovered
# client_auth = true
# client_ca_file = "/etc/logwisp/tls/client-ca.crt"
# min_version = "1.3"
# [pipelines.plugin_sources.config.auth] # authorize senders by certificate
# type = "none" # none | mtls; mtls requires client_auth
# identity = "cn" # cn | san_dns | san_uri | san_email
# allow = [] # exact identities; empty = any the CA issued
# allow_patterns = [] # RE2, anchor them yourself
# [pipelines.plugin_sources.config.auth] # authenticate senders
# type = "none" # none | mtls | scram; mtls requires client_auth
# identity = "cn" # mtls default; under scram unset, set binds the cert to the user
# allow = [] # mtls: exact identities; empty = any the CA issued
# allow_patterns = [] # mtls: RE2, anchor them yourself
# node_binding = "force" # none | assert | force; overrides trust_node
# credentials_file = "" # scram: written by `logwisp auth add-user`
## HTTP chain source (stdlib listener; receives NDJSON batches from upstream http_chain sinks)
# [[pipelines.plugin_sources]]
@@ -220,12 +232,14 @@ from = "end" # "end" or "start" of a newly discovered
# key_file = "/etc/logwisp/tls/server.key"
# client_auth = true
# client_ca_file = "/etc/logwisp/tls/client-ca.crt"
# [pipelines.plugin_sources.config.auth] # authorize senders by certificate
# type = "none" # none | mtls; mtls requires client_auth
# identity = "cn" # cn | san_dns | san_uri | san_email
# allow = [] # exact identities; empty = any the CA issued
# allow_patterns = [] # RE2, anchor them yourself
# [pipelines.plugin_sources.config.auth] # authenticate senders
# type = "none" # none | mtls | scram; mtls requires client_auth
# identity = "cn" # mtls default; under scram unset, set binds the cert to the user
# allow = [] # mtls: exact identities; empty = any the CA issued
# allow_patterns = [] # mtls: RE2, anchor them yourself
# node_binding = "force" # none | assert | force; overrides trust_node
# credentials_file = "" # scram: written by `logwisp auth add-user`
# token_lifetime_ms = 0 # scram bearer tokens, 10 s to 24 h; 0 = 15 minutes
###============================================================================
### Sinks (1+ required, fan-out)
@@ -258,7 +272,7 @@ target = "stdout" # stdout|stderr ("split" NOT supported)
# flush_interval_ms = 100
## HTTP sink (SSE streaming server + JSON status endpoint; IPv4 clients only)
## Both endpoints are UNAUTHENTICATED and the stream sends
## Without an auth block both endpoints are UNAUTHENTICATED and the stream sends
## Access-Control-Allow-Origin: *. Bind to a trusted interface.
# [[pipelines.plugin_sinks]]
# id = "http_out"
@@ -272,6 +286,8 @@ target = "stdout" # stdout|stderr ("split" NOT supported)
# client_buffer_size = 256 # Per-client send queue
# write_timeout_ms = 0 # Per-event deadline, 0 = none
# max_connections = 0 # 0 = unlimited
# login_page = false # GET /auth/login; needs auth.trusted_proxies
# viewer_page = false # GET /auth/view; needs login_page
# [pipelines.plugin_sinks.config.tls]
# enabled = true # example
# cert_file = "/etc/logwisp/tls/server.crt"
@@ -279,10 +295,13 @@ target = "stdout" # stdout|stderr ("split" NOT supported)
# client_auth = false
# client_ca_file = ""
# [pipelines.plugin_sinks.config.auth] # gates BOTH stream_path and status_path
# type = "none" # none | mtls; mtls requires client_auth
# identity = "cn" # cn | san_dns | san_uri | san_email
# allow = [] # exact identities; empty = any the CA issued
# allow_patterns = [] # RE2, anchor them yourself
# type = "none" # none | mtls | scram; browsers: mtls, or scram behind a proxy
# identity = "cn" # mtls default; under scram unset, set binds the cert to the user
# allow = [] # mtls: exact identities; empty = any the CA issued
# allow_patterns = [] # mtls: RE2, anchor them yourself
# credentials_file = "" # scram: log in with `logwisp auth token`
# token_lifetime_ms = 0 # scram bearer tokens, 10 s to 24 h; 0 = 15 minutes
# trusted_proxies = [] # scram: proxies ending the browsers' TLS; tls may then be off
## TCP sink (streaming server, IPv4 clients only)
# [[pipelines.plugin_sinks]]
@@ -303,11 +322,12 @@ target = "stdout" # stdout|stderr ("split" NOT supported)
# key_file = "/etc/logwisp/tls/server.key"
# client_auth = true
# client_ca_file = "/etc/logwisp/tls/client-ca.crt"
# [pipelines.plugin_sinks.config.auth] # authorize stream readers
# type = "none" # none | mtls; mtls requires client_auth
# identity = "cn" # cn | san_dns | san_uri | san_email
# allow = [] # exact identities; empty = any the CA issued
# allow_patterns = [] # RE2, anchor them yourself
# [pipelines.plugin_sinks.config.auth] # authenticate stream readers
# type = "none" # none | mtls | scram; mtls requires client_auth
# identity = "cn" # mtls default; under scram unset, set binds the cert to the user
# allow = [] # mtls: exact identities; empty = any the CA issued
# allow_patterns = [] # mtls: RE2, anchor them yourself
# credentials_file = "" # scram: readers use `logwisp auth stream`
## TCP chain sink (stdlib client; forwards to downstream tcp_chain source)
## Do NOT point a chain sink at a chain source in the SAME pipeline: entries
@@ -334,11 +354,13 @@ target = "stdout" # stdout|stderr ("split" NOT supported)
# cert_file = "/etc/logwisp/tls/client.crt"
# key_file = "/etc/logwisp/tls/client.key"
# min_version = "1.3"
# [pipelines.plugin_sinks.config.auth] # pin the downstream server's identity
# type = "none" # none | mtls; mtls requires tls.enabled
# identity = "cn" # cn | san_dns | san_uri | san_email
# allow = [] # exact identities; empty = any the CA issued
# allow_patterns = [] # RE2, anchor them yourself
# [pipelines.plugin_sinks.config.auth] # pin the server (mtls) or log in (scram)
# type = "none" # none | mtls | scram; both require tls.enabled
# identity = "cn" # mtls: cn | san_dns | san_uri | san_email
# allow = [] # mtls: exact identities; empty = any the CA issued
# allow_patterns = [] # mtls: RE2, anchor them yourself
# username = "" # scram
# password_file = "" # scram
## HTTP chain sink (stdlib client; batched NDJSON POST to downstream http_chain source)
# [[pipelines.plugin_sinks]]
@@ -361,8 +383,10 @@ target = "stdout" # stdout|stderr ("split" NOT supported)
# ca_file = "/etc/logwisp/tls/ca.crt"
# cert_file = "/etc/logwisp/tls/client.crt"
# key_file = "/etc/logwisp/tls/client.key"
# [pipelines.plugin_sinks.config.auth] # pin the downstream server's identity
# type = "none" # none | mtls; mtls requires tls.enabled
# identity = "cn" # cn | san_dns | san_uri | san_email
# allow = [] # exact identities; empty = any the CA issued
# allow_patterns = [] # RE2, anchor them yourself
# [pipelines.plugin_sinks.config.auth] # pin the server (mtls) or log in (scram)
# type = "none" # none | mtls | scram; both require tls.enabled
# identity = "cn" # mtls: cn | san_dns | san_uri | san_email
# allow = [] # mtls: exact identities; empty = any the CA issued
# allow_patterns = [] # mtls: RE2, anchor them yourself
# username = "" # scram
# password_file = "" # scram
+6 -3
View File
@@ -20,8 +20,8 @@ streams, or downstream LogWisp nodes.
| [Networking](networking.md) | Listeners, dialers, timeouts, connection limits |
| [Security](security.md) | TLS, mTLS, and peer authorization; threat model and current limits |
| [mTLS Authentication](mtls-auth-plan.md) | Design and rationale for certificate-based authorization |
| [Password Authentication](scram-auth-plan.md) | Planned Argon2id-SCRAM design; mTLS hardening |
| [CLI](cli.md) | Flags, signals, exit codes |
| [Password Authentication](scram-auth-plan.md) | Design and rationale for Argon2id-SCRAM authentication; mTLS hardening |
| [CLI](cli.md) | Flags, signals, exit codes, `logwisp auth` |
| [Operations](operations.md) | Running, monitoring, tuning, troubleshooting |
A fully annotated configuration covering every option lives at
@@ -63,8 +63,11 @@ endpoint), `tcp` (broadcast server), `null`, and the chain forwarders
present a client identity
- Authorization by certificate identity, per listener: named peers rather than
everything the CA issued, with the `http` sink's endpoints gated too
- Password authentication (Argon2id-SCRAM) bound to the listener's certificate,
with bearer tokens on HTTP listeners and a `logwisp auth` CLI, and browser
logins behind a TLS-terminating proxy
- Node binding, so a chain source labels entries from the sender's certificate
rather than from what the sender claims
identity or username rather than from what the sender claims
See [Security](security.md) for what each layer does and does not give you.
+6 -5
View File
@@ -154,11 +154,12 @@ Idle sessions are reaped every 5 minutes against a 30-minute idle limit. The
HTTP sink's broker treats a vanished session as an eviction signal and closes
the corresponding SSE client.
Authorization decisions do not read session metadata — they are made from the
handshake by `internal/authz`, at the point of connection or request, and their
outcome is *recorded* in the session. That ordering matters: a session exists
only for a peer that was already admitted. See the
[mTLS authentication design](mtls-auth-plan.md).
Authorization decisions do not read session metadata — `internal/authz` makes
them from the handshake, the SCRAM exchange or a bearer token, at the point of
connection or request, and their outcome is *recorded* in the session. That
ordering matters: a session exists only for a peer that was already admitted.
See the [mTLS](mtls-auth-plan.md) and [SCRAM](scram-auth-plan.md)
authentication designs.
## Configuration Reload
+43 -9
View File
@@ -45,14 +45,15 @@ Chained entries carry a `node` label identifying where they originated.
Relays preserve `node`, so a label survives any number of hops and identifies
the original producer rather than the last relay.
Under mTLS the source can instead bind the label to the sender's certificate,
which overrides `trust_node` entirely:
With an `auth` block the source can instead bind the label to the sender's
authenticated identity — its certificate identity under `mtls`, its username
under `scram` — which overrides `trust_node` entirely:
| `auth.node_binding` | Connection label | Per-entry `node` field |
|---------------------|------------------|------------------------|
| `none` | `trust_node` governs | `trust_node` governs |
| `assert` | Must equal the certificate identity, or the peer is rejected | `trust_node` governs |
| `force` (default under `mtls`) | The certificate identity | Overwritten with the identity |
| `assert` | Must equal the identity, or the peer is rejected | `trust_node` governs |
| `force` (default with an `auth` block) | The identity | Overwritten with the identity |
Pick `force` at an ingest boundary you do not trust — it is the only setting
where a compromised edge cannot mislabel its entries, including through the
@@ -64,10 +65,11 @@ Formatters render node identity as a syslog-style prefix on the source field:
`edge-01/app.log`. In JSON output the node therefore appears inside the source
field, not as a separate top-level key.
> `trust_node = true` with no `auth` block means any peer the CA vouches for can
> `trust_node = true` with no `auth` block means any peer that can connect can
> claim **any** node label, including one belonging to another host. On an
> untrusted network set `auth.type = "mtls"` with `node_binding = "force"`;
> `trust_node = false` is the fallback when certificates are not an option.
> untrusted network set an `auth` block (`mtls` or `scram`) and keep
> `node_binding = "force"`; `trust_node = false` is the fallback when neither is
> an option.
## Wire Protocol
@@ -92,6 +94,24 @@ A persistent connection carrying newline-delimited JSON.
Line size is bounded at 1 MiB. An oversized line is a protocol violation and
terminates the connection, because the scanner cannot resynchronize afterwards.
Under `auth.type = "scram"` the hello also carries the SCRAM client-first
message, and three more lines precede the entries, each one JSON object of at
most 4 KiB:
```
dialer {"logwisp":1,"node":"edge-01","scram":{"username":"edge-01","client_nonce":"…"}}
listener {"challenge":{"full_nonce":"…","salt":"…","argon_time":3,"argon_memory":65536,"argon_threads":4}}
dialer {"proof":{"full_nonce":"…","client_proof":"…"},"binding":"<base64 SHA-256 of the server certificate>"}
listener {"final":{"server_signature":"…","username":"edge-01"}}
```
The whole exchange runs within `hello_timeout_ms`; the listener sends the final
line only after node binding agrees, and a refusal at any step is one
`{"error":"…"}` line before the connection closes. The field is additive, so
the protocol version stays `1`: a listener without `scram` answers a hello
carrying it with `{"error":"authentication not enabled"}`, while an older
binary ignores it and the dialer gives up after 10 s.
### HTTP transport
Batches of NDJSON delivered by `POST`, with the preamble expressed as headers.
@@ -101,10 +121,18 @@ Batches of NDJSON delivered by `POST`, with the preamble expressed as headers.
| `X-Logwisp-Protocol` | request | Protocol version; must be `1` |
| `X-Logwisp-Node` | request | Origin node label |
| `Content-Type` | request | `application/x-ndjson` |
| `Authorization` | request | `Bearer <token>`, under `scram` |
| `X-Logwisp-Accepted` | response | Number of entries ingested |
Responses: `204` on success, `400` for a bad protocol version or a malformed
body, `413` when the body cap is exceeded, `405` for a non-`POST` method.
body, `413` when the body cap is exceeded, `405` for a non-`POST` method, `401`
for a missing or expired token, `403` for a refused certificate or node label.
Under `scram` the sink first logs in with two `POST /auth` requests carrying the
same JSON as the TCP lines: the hello (`200` and the challenge), then the proof
(`200` with the final message, `token` and `expires_in` in seconds). A refusal is
`{"error":"…"}`: `401` for a failed proof, `429` when throttled, `503` when the
listener is busy, `400` or `413` for a malformed or oversized body.
### Entry encoding
@@ -127,7 +155,7 @@ in at ingest.
| Transport | Guarantee | Failure behaviour |
|-----------|-----------|-------------------|
| `tcp_chain` | Per-line, held across reconnects | Retries with exponential backoff plus ±20 % jitter until written or shutdown; back-pressure appears upstream as `total_dropped_by_sink` |
| `http_chain` | At-least-once per batch | Retries transport errors, `408`, `429`, `5xx`; drops on any other non-2xx (`dropped_batches`) |
| `http_chain` | At-least-once per batch | Retries transport errors, `408`, `429`, `5xx` and, under `scram`, failed logins and `401`; drops on any other non-2xx (`dropped_batches`) |
`http_chain` batches can be delivered twice when a successful request's response
is lost. There is no de-duplication downstream; design your consumers to
@@ -219,6 +247,12 @@ Entries arriving on this relay are labelled `edge-01` or `edge-02` because that
is what their certificates say, regardless of the `node` each edge configured.
`test/mtls-chain-test.sh` builds exactly this shape against a throwaway PKI.
To use passwords instead of client certificates, replace both `auth` blocks:
`type = "scram"` with `username` and `password_file` on the edge, and with a
`credentials_file` on the relay. `client_auth` and the edge's certificate become
optional, and entries are labelled with the username. See
[Password Authentication](security.md#password-authentication-scram).
## Operational Notes
- **Formatting is a relay decision.** Because chain links carry structured
+71 -4
View File
@@ -2,14 +2,14 @@
```
logwisp [options]
logwisp auth <command> [flags]
logwisp help | -h | --help
logwisp --version
```
LogWisp has no subcommands. Earlier releases shipped `logwisp auth` and
`logwisp tls` for credential and certificate generation; both were removed
during the restructure. Use `openssl` or your PKI tooling instead — see
[Security](security.md).
`logwisp auth` manages SCRAM credentials and logs in to `scram` listeners; see
[below](#logwisp-auth). There is no certificate-generation command: use
`openssl` or your PKI tooling — see [Security](security.md#enabling-mtls).
## Options
@@ -174,3 +174,70 @@ no `--background` flag; earlier releases had one and it was removed. See
kill -HUP $(pidof logwisp)
kill -USR1 $(pidof logwisp)
```
## `logwisp auth`
Manages the credentials files of `auth.type = "scram"` listeners and logs in to
them as a viewer. See
[Password Authentication](security.md#password-authentication-scram).
| Command | Does |
|---------|------|
| `add-user -credentials FILE -user NAME [-password-file FILE] [-generate]` | Adds a user, or replaces its password |
| `remove-user -credentials FILE -user NAME` | Removes a user; refuses the last one |
| `token -url https://HOST:PORT[/PATH] -user NAME -password-file FILE [-unbound] [TLS flags]` | Logs in to an `http` sink or `http_chain` source and prints a bearer token |
| `stream -addr HOST:PORT -user NAME -password-file FILE [TLS flags]` | Logs in to a `tcp` sink and copies its stream to stdout until interrupted |
`logwisp auth <command> -h` lists a command's flags. The exit status is `0` on
success, `1` on failure and `2` on a usage error.
**`add-user`** creates the credentials file, with a fresh `decoy_key`, when it
does not exist or is empty (create it empty first to choose its owner). The password comes from `-password-file` when that file exists
(at least 8 bytes; one trailing line break is trimmed). Otherwise a random
26-character password (130 bits) is generated and written to `-password-file`,
or printed once to stdout when there is none. Replacing an existing user's
password needs an existing `-password-file` or `-generate`, so a mistyped path
cannot replace a working password; `-generate` always generates, overwriting
`-password-file`. New users take the file's existing Argon2 profile.
Both file commands rewrite atomically (a temporary file in the same directory
as the file, or a symlink's target, then a rename), create files `0600`, keep an
existing file's mode and owner, and refuse to write anything the daemon would
not load. A change that cannot keep the owner fails: run it as root or as the
owner. A symlink to a missing file is refused: create the target first.
Neither touches a running LogWisp: send `SIGHUP`, since `auto_reload` does not
watch the credentials file.
**`token`** and **`stream`** build the same TLS and SCRAM client as a chain
sink. TLS flags: `-ca-file` (default: system roots), `-server-name` (default:
the host), and `-cert-file` / `-key-file` for a listener with `tls.client_auth`.
There is no flag to skip verification: an unverified server could relay the
login. Redirects are not followed. `-unbound` logs in to an `http` sink behind
a TLS-terminating proxy (`auth.trusted_proxies`), still pinning the proxy's
certificate across the two requests; only then may `-url` carry the path the
proxy mounts LogWisp at. `stream` exits `0`
on `SIGINT` or `SIGTERM`, and `1` when the server ends the stream (a reload or
shutdown).
```bash
# listener host: a file the service user can read, users, then apply
install -m 0640 -o root -g logwisp /dev/null /etc/logwisp/users.toml
logwisp auth add-user -credentials /etc/logwisp/users.toml -user edge-01 \
-password-file /etc/logwisp/edge-01.pass
logwisp auth add-user -credentials /etc/logwisp/users.toml -user viewer \
-password-file viewer.pass
kill -HUP $(pidof logwisp)
# rotate: new password into the file; SIGHUP, deploy the file, SIGHUP the edge
logwisp auth add-user -credentials /etc/logwisp/users.toml -user edge-01 \
-password-file /etc/logwisp/edge-01.pass -generate
# http sink status, with the token kept out of curl's argv
curl --cacert ca.crt -H @<(printf 'Authorization: Bearer %s\n' \
"$(logwisp auth token -url https://HOST:PORT -user viewer \
-password-file viewer.pass -ca-file ca.crt)") https://HOST:PORT/status
# follow a tcp sink
logwisp auth stream -addr HOST:PORT -user viewer -password-file viewer.pass \
-ca-file ca.crt
```
+15 -6
View File
@@ -232,6 +232,10 @@ Startup validation is intentionally split.
`internal/config` validates only global structure:
- no key the file schema does not declare, at any depth outside a plugin's
`config` table: `config file "…": unknown key
"pipelines[0].plugin_sinks[0].confg"`. A misspelled table path would otherwise
drop the whole table it heads. A top-level `config_file` is ignored
- at least one pipeline
- unique, non-empty pipeline names
- at least one source and one sink per pipeline
@@ -239,11 +243,15 @@ Startup validation is intentionally split.
and `logging.console.target` enum membership
Everything else is validated by the plugin constructor that owns it — port
range, required paths, path prefixes, enum values, regex compilation, TLS file
loading. A failure there aborts pipeline construction with a message naming the
pipeline, plugin id, and offending key. A key the plugin does not declare, at
any depth, is such a failure (`unknown key "tls.enabeld"`): a misspelled option
must not silently fall back to its default.
range, required paths, path prefixes, enum values, regex compilation, TLS and
credentials file loading. A failure there aborts pipeline construction with a
message naming the pipeline, plugin id, and offending key. A key the plugin does
not declare, at any depth, is such a failure (`unknown key "tls.enabeld"`): a
misspelled option must not silently fall back to its default. For the same
reason an `auth` block that names peers or credentials (`allow`,
`allow_patterns`, `credentials_file`, `token_lifetime_ms`, `username`,
`password_file`, `trusted_proxies`) while its `type` is `none` or unset is refused: auth was
intended and the type forgotten. See [Security](security.md#the-auth-block).
There is **no** cross-pipeline port-conflict detection. Two sinks bound to the
same port fail at listener bind time, when the pipeline starts.
@@ -255,7 +263,8 @@ auto_reload = true
```
or send `SIGHUP` / `SIGUSR1`. Signals reread the selected file even when watching
is disabled and always rebuild, allowing certificate rotation without TOML edits.
is disabled and always rebuild, allowing certificate and credentials rotation
without TOML edits; `auto_reload` watches only the configuration file.
CLI and environment overrides are captured at startup and keep their precedence.
Reload rebuilds the whole service: a new service is constructed from the new
+6 -3
View File
@@ -198,13 +198,16 @@ make
./test/chain-test.sh --auto # two independent relay pipelines
./test/chain-aggregate-test.sh --auto # fan-in: both edges into one pipeline
./test/mtls-chain-test.sh --auto # the same fan-in under mTLS
./test/scram-chain-test.sh --auto # SCRAM logins on every network plugin
./test/passthrough-test.sh # file source relays a wide envelope intact
```
Without `--auto` the chain scripts run the relay in the foreground for
interactive inspection. They need bash 5+, coreutils, and curl, and they bind
ports 15801–15804. The pass-through test binds nothing. Generated configuration
and logs land in `test/run/`.
interactive inspection. They need bash 5+, coreutils, and curl (the mTLS and
SCRAM scripts also openssl). The plain chain scripts bind ports 15801–15804 and
write to `test/run/`; the mTLS script uses 15811–15814 and `test/run-mtls/`, the
SCRAM script 15821–15825 and `test/run-scram/`. The pass-through test binds
nothing.
## Uninstall
+17 -58
View File
@@ -48,10 +48,10 @@ back to authenticated peers.
## Non-Goals
- Password authentication. It is designed separately in
[scram-auth-plan.md](scram-auth-plan.md), reusing the `auth` block and the
reserved hooks (`chain.Hello`, the `Authorization` header in the `http_chain`
sink, the "Future: password auth block" comments in the options structs).
- Password authentication. It is implemented separately as `type = "scram"`
([scram-auth-plan.md](scram-auth-plan.md)) on the same `auth` block and
`internal/authz` seam, carried by `chain.Hello` on TCP and a bearer token on
HTTP.
- IP allow/deny lists and per-peer rate limits. Related, but a separate feature
with its own config surface.
- OCSP. See [Revocation](#revocation) for what is done instead.
@@ -156,68 +156,25 @@ Errors follow existing style: `auth: type "mtls" requires tls.client_auth`.
### The `internal/authz` package
```go
package authz
// Policy is the compiled form of config.AuthOptions.
type Policy struct { /* role, identity mode, exact set, patterns, binding, counters */ }
// Role selects the validation and behavior appropriate to the call site.
const ( RoleListener Role = iota; RoleChainListener; RoleDialer )
// New compiles a policy. Returns (nil, nil) when auth is disabled, matching
// the tlsx.Server / tlsx.Client convention. tlsOpts is the sibling `tls`
// block, so an unenforceable policy fails here rather than at run time.
func New(o *config.AuthOptions, tlsOpts *config.TLSOptions, role Role) (*Policy, error)
// Identity is the outcome of a successful authorization.
type Identity struct {
Name string // the selected certificate field
Method string // "mtls"
}
// Apply stamps an identity onto session metadata.
func (id Identity) Apply(meta map[string]any)
// Authorize extracts and checks the peer identity from a completed handshake.
func (p *Policy) Authorize(cs *tls.ConnectionState) (Identity, error)
// VerifyConnection is assignable to tls.Config.VerifyConnection on a dialer.
func (p *Policy) VerifyConnection(cs tls.ConnectionState) error
// ResolveNode applies node_binding to the label a peer declared.
func (p *Policy) ResolveNode(declared, fallback string, trustNode bool, id Identity) (string, error)
// TrustsEntryNode reports whether per-entry node labels survive the policy.
func (p *Policy) TrustsEntryNode(trustNode bool) bool
// Stats reports counters for the sink/source stats map.
func (p *Policy) Stats() map[string]any
```
This mirrors `internal/tlsx`: one small package that is the single seam between
declarative config and a cross-cutting concern. `New` returns `(nil, nil)` for
the disabled case and **every method tolerates a nil receiver**, so a call site
reads identically whether or not auth is configured — no nil checks, no branch
on config:
```go
id, err := s.auth.Authorize(tlsState) // nil policy: (zero Identity, nil)
if err != nil { /* reject */ }
```
One small package, mirroring `internal/tlsx`, is the single seam between the
`auth` block and the plugins. `New(o, tlsCfg, role, transport)` compiles a policy
against the sibling TLS config and returns `(nil, nil)` when auth is disabled;
every method tolerates a nil receiver, so call sites never branch on config.
Listeners admit only through `Admit` (TCP) and `AuthorizeRequest` (HTTP); the
current API, including SCRAM, is in [scram-auth-plan.md](scram-auth-plan.md#the-authz-seam).
### Enforcement Points
**`tcp_chain` source** (`internal/source/tcpchain/tcpchain.go`, `handleConn`)
Handshake → **authorize** → read hello → `ResolveNode` → create session. The
authorization sits between the handshake and the hello read, so an unauthorized
peer never gets a preamble parsed on its behalf. `chain.DecodeEntry` is then
Handshake → `Admit` (authorize, then read hello) → `ResolveNode` → create
session. Under `mtls` the certificate is checked before the hello is read, so
an unauthorized peer never gets a preamble parsed on its behalf. `chain.DecodeEntry` is then
called with `auth.TrustsEntryNode(trust_node)` rather than `trust_node` itself.
**`http_chain` source** (`internal/source/httpchain/httpchain.go`, `handleIngest`)
Per request, from `r.TLS`, before the body is read — an unauthorized sender does
Per request, through `AuthorizeRequest`, before the body is read — an unauthorized sender does
not get to stream `max_body_bytes` into the process. Rejection is `403`,
distinct from the `400` used for protocol errors, so a sender can tell "you are
not allowed" from "your batch was malformed". `ResolveNode` then governs the
@@ -256,7 +213,9 @@ still reports `CapTLS`, the `mtls=true` field on the startup log line, and the
`Pipeline.initSourceCapabilities` and `initSinkCapabilities` treat this as a
cross-cutting check: a plugin advertising `CapAuth` without `CapTLS` is a
contradiction and fails pipeline construction rather than starting.
contradiction and fails pipeline construction rather than starting. An `http`
sink in SCRAM proxy mode also reports `CapProxyTLS`, which satisfies the check
without `tls`: TLS ends at a trusted proxy.
### Observability
+74 -16
View File
@@ -41,12 +41,12 @@ deadline" wherever the table says so.
|--------|--------|---------|--------|
| `tcp` sink | `write_timeout_ms` | `5000` | One write to one client; a miss disconnects that client |
| `http` sink | `write_timeout_ms` | `0` (none) | One SSE event write |
| `tcp_chain` source | `hello_timeout_ms` | `10000` | Reading the protocol preamble |
| `tcp_chain` source | `hello_timeout_ms` | `10000` | Reading the protocol preamble, and under `scram` the whole login |
| `tcp_chain` source | `read_timeout_ms` | `0` (none) | Idle time between entries |
| `http_chain` source | `read_timeout_ms` | `30000` | Reading a whole request body |
| `tcp_chain` sink | `dial_timeout_ms` | `5000` | TCP connect |
| `tcp_chain` sink | `write_timeout_ms` | `5000` | One line write |
| `http_chain` sink | `request_timeout_ms` | `10000` | Dial plus write plus response |
| `http_chain` sink | `request_timeout_ms` | `10000` | Dial plus write plus response, and a SCRAM login when one is due |
Fixed, non-configurable bounds:
@@ -56,6 +56,8 @@ Fixed, non-configurable bounds:
| HTTP read-header timeout | 10 s | `http` sink, `http_chain` source |
| HTTP server shutdown grace | 2 s | `http` sink, `http_chain` source |
| Max single entry line | 1 MiB | Chain listeners |
| SCRAM login | 10 s | `tcp` sink, chain sinks, `logwisp auth`, each `/auth` request |
| SCRAM line or `/auth` body | 4 KiB | `scram` listeners and dialers |
The `http` sink deliberately leaves the server's `WriteTimeout` unset, since it
would terminate long-lived SSE streams; per-event deadlines come from
@@ -76,9 +78,10 @@ sink, and the `tcp_chain` source. `0` means unlimited.
The `http_chain` source has no connection cap; it bounds work with
`max_body_bytes` and `read_timeout_ms` instead.
There is **no** per-IP limiting and no IP allow/deny list. `flow.rate_limit` is
a pipeline-wide entry rate limit, not a network-level one — it cannot
distinguish or throttle an individual peer.
Apart from SCRAM logins, which are throttled per address (see
[Security](security.md#throttling)), there is **no** per-IP limiting and no IP
allow/deny list. `flow.rate_limit` is a pipeline-wide entry rate limit, not a
network-level one — it cannot distinguish or throttle an individual peer.
## Keep-Alive
@@ -141,10 +144,12 @@ TLS is enabled. Only `GET` is routed to the stream and status paths. Each event
is framed as one `data:` line per newline in the payload, so multi-line entries
stream intact. Response headers set `Cache-Control: no-cache`,
`X-Accel-Buffering: no`, and, without an auth policy,
`Access-Control-Allow-Origin: *`.
`Access-Control-Allow-Origin: *`. Under `scram`, `POST /auth` serves the login
and both paths take `Authorization: Bearer <token>`.
**TCP sink** — raw payload bytes, no framing added by the sink. Whether entries
are newline-delimited depends on the formatter.
are newline-delimited depends on the formatter. Under `scram` the client's hello
and login lines come first, as on a [chain link](chaining.md#tcp-transport).
**Chain transports** — see [Chaining](chaining.md) for the hello preamble,
headers, and entry encoding.
@@ -183,25 +188,78 @@ headers, and entry encoding.
- `auth: node_binding "assert": declared node "..." does not match identity` —
the sender's `node` option and its certificate disagree. Fix one, or use
`node_binding = "force"` to let the certificate win silently.
- On a dialer, the same message inside `Chain connect failed` means the
*server* was refused: its certificate identity is not in the sink's
- On a `tcp_chain` sink the same message inside `Chain connect refused` means
the *server* was refused: its certificate identity is not in the sink's
`auth.allow`.
- Rejections appear as WARN and increment `auth_rejected`.
**SCRAM login fails**
- `authentication failed` — the dialer is not told why, by design. The
listener's WARN line (`Connection rejected by auth policy` on TCP,
`Login rejected` on HTTP) is: `invalid credentials` for a wrong password, an
unknown user, or one removed or rotated on the listener (compare the
`password_file` with the last `add-user`, and check the listener got
`SIGHUP`); `certificate cn "…" does not match user "…"` for the `identity`
binding.
- `the client saw another server certificate: TLS interception or a terminating
proxy` in the listener's log, with `auth_binding_mismatch` rising — something
between the peers terminates TLS. Pass TLS through to LogWisp; the login
cannot work otherwise, except on an `http` sink in proxy mode
(`auth.trusted_proxies`), where `the client bound its proof to the proxy's
certificate` means a client that needs `logwisp auth token -unbound`, and
`the client sent an unbound proof` the reverse. On an `http_chain` sink,
`server certificate differs from the one the SCRAM login was bound to` means
the certificate changed after the login: a rotation (the retry binds anew),
several backends, or interception.
- `403` from an `http` sink in proxy mode, with `not a trusted proxy` or
`X-Forwarded-Proto` in its WARN line — the request did not come from a listed
proxy, or the proxy did not forward `X-Forwarded-Proto: https` and
`X-Forwarded-For`.
- `too many attempts` — the address failed or abandoned logins faster than one
per second beyond a burst of 10, or has 4 unfinished; it clears within seconds
once the failing peer stops. Peers behind one NAT share the budget. `busy` —
4,096 logins in flight, or the listener is stopping.
- `authentication not enabled` — the dialer has `scram`; the listener's
`auth.type` is `none` or `mtls`.
- `no challenge within 10s (older logwisp, or not a scram listener)` — the
server read the hello and said nothing. Over HTTP the same case reads
`no auth endpoint at …/auth`.
- `Argon2 parameters below client minimum` — the credentials file holds a
cheaper Argon2 profile than the defaults; recreate it with `logwisp auth`.
- `peer offered no credentials` (`authentication required` on the wire) in the
listener's log — a peer without `scram` reached a `scram` listener; a client
that sends nothing logs `read hello: … i/o timeout` after 10 s instead. A
`tcp_chain` sink without `scram` logs WARN `Chain link refused` with that
reason and retries under growing backoff; what it wrote before reading the
refusal is lost. An `http_chain` sink without `scram` gets `401` and drops
every batch.
**`401` on HTTP under `scram`**
- An `http_chain` sink renews its token before it expires, so a `401` means
the listener reloaded (every reload revokes all tokens); the retry logs in
again.
- Continuous `401` means the token never sticks: several LogWisp processes
behind one address, a proxy that strips `Authorization`, or a
`token_lifetime_ms` shorter than a request takes. A token used with `curl`
dies with every listener reload.
- `403` is not the token: it is the `identity` binding, node binding or, in
proxy mode, the proxy gate.
**Entries not arriving over a chain link**
- Check the sink's `connected` statistic and its `reconnects` count.
- Check the source's `auth_rejected` — an allow-list miss looks exactly like a
network fault from the sender's side.
- Check the source's `auth_rejected` — an allow-list miss or a failed login
looks exactly like a network fault from the sender's side — and the sink's
`last_auth_error`.
- Check the source's `parse_errors` — a version skew shows up here.
- On `http_chain`, remember entries wait up to `flush_interval_ms` before a
batch is sent.
**Entries arriving under an unexpected node label**
- `auth.node_binding` defaults to `force` when `auth.type = "mtls"`, which
relabels every entry with the sender's certificate identity. If a dashboard
suddenly shows a different label, that is why. Use `node_binding = "assert"`
to keep upstream origin labels on relay-to-relay hops, or `"none"` to leave
`trust_node` in charge.
- `auth.node_binding` defaults to `force` with any `auth` block, which
relabels every entry with the sender's certificate identity or username. If
a dashboard suddenly shows a different label, that is why. Use
`node_binding = "assert"` to keep upstream origin labels on relay-to-relay
hops, or `"none"` to leave `trust_node` in charge.
**Clients connect but see nothing**
- The pipeline may be filtering everything out; check `flow.filters` stats.
+16 -12
View File
@@ -104,7 +104,8 @@ level = "debug"
It emits a service summary and then walks each pipeline, flattening scalar
statistics into log fields and recursing into flow, rate limiter, filter,
source, and sink stats.
source, and sink stats. Each plugin's `details` are spread into its line, so
the auth and TLS rejection counters show up there.
Disable with `status_reporter = false`.
@@ -142,8 +143,9 @@ curl -s http://127.0.0.1:8080/status | jq .
}
```
This endpoint is scoped to one sink, not to the whole process, and it is
**unauthenticated**. Bind it to a trusted interface.
This endpoint is scoped to one sink, not to the whole process, and without an
`auth` block it is **unauthenticated**. Bind it to a trusted interface, or
query it with a client certificate or a token from `logwisp auth token`.
### Metrics worth watching
@@ -280,20 +282,22 @@ environment or the command line at all.
openssl x509 -in /etc/logwisp/tls/relay.crt -noout -enddate
```
Certificates load at plugin construction, so rotation is: write the new files,
then `kill -HUP`. Startup and every reload warn at WARN once a certificate is
within 30 days of expiry; automate the check anyway.
Certificates and SCRAM credentials files load at plugin construction, so
rotation is: write the new files, then `kill -HUP`. Startup and every reload
warn at WARN once a certificate is within 30 days of expiry; automate the check
anyway. For passwords see
[rotation and revocation](security.md#rollout-rotation-and-revocation).
**Access review**
With `tls` alone, any certificate signed by the configured `client_ca_file` is
accepted, so "access review" means reviewing what your CA has issued. Add an
`auth` block with an explicit `allow` list and the review becomes the config
file itself: the identities listed there are the ones that can connect, and
removing one plus a `SIGHUP` is the revocation path. Authorized identities are
recorded in session metadata as `auth_identity`; rejections are counted in
`auth_rejected` and logged at WARN. See
[Security](security.md#the-auth-block).
`auth` block with an explicit `allow` list, or `scram` with its credentials
file, and the review becomes those files: the identities or users listed there
are the ones that can connect, and removing one plus a `SIGHUP` is the
revocation path. Authorized identities are recorded in session metadata as
`auth_identity`; rejections are counted in `auth_rejected` and logged at WARN.
See [Security](security.md#the-auth-block).
**Secret leakage**
+220 -81
View File
@@ -1,11 +1,12 @@
# Password Authentication (Argon2id-SCRAM)
**Status:** planned. Ships in two batches:
1. **Done:** `lixenwraith/auth` gained SCRAM channel binding, and the mTLS layer
was hardened (see [mTLS Hardening](#mtls-hardening)).
2. **Next:** the SCRAM integration described here, pinned to the tagged `auth`
release that carries channel binding.
**Status:** implemented, in three batches: `lixenwraith/auth` gained SCRAM
channel binding and the mTLS layer was hardened (see
[mTLS Hardening](#mtls-hardening)); then the SCRAM integration described here,
on the `auth` release that carries channel binding; then browser logins behind
a TLS-terminating proxy. Operator documentation lives in
[Security](security.md#password-authentication-scram) and
[`logwisp auth`](cli.md#logwisp-auth).
**Scope:** optional username/password authentication on every network plugin,
next to the existing certificate method, without weakening what mTLS gives.
@@ -24,8 +25,10 @@ next to the existing certificate method, without weakening what mTLS gives.
- Standard SASL SCRAM-SHA-256 interoperability. The `auth` exchange is
logwisp-to-logwisp (or logwisp CLI) only.
- Browser `EventSource` viewers under SCRAM: they cannot set `Authorization` or
run Argon2. mTLS remains the browser path.
- Browser viewers of a logwisp that terminates TLS itself: a browser cannot see
the server certificate, so its proof cannot be channel-bound. Browsers log in
through [proxy mode](#browser-viewers-behind-a-tls-terminating-proxy), or use
mTLS.
- Multiple backends behind a non-sticky L7 balancer: handshake state and the
token key live in one process.
@@ -35,23 +38,27 @@ next to the existing certificate method, without weakening what mTLS gives.
`StoredKey = SHA-256(HMAC(K, "Client Key"))`, `ServerKey = HMAC(K, "Server Key")`,
`K = Argon2id(password, salt)`. The Argon2id digest itself is never stored: it is
the salted password, from which both keys derive. Verifying a proof is HMAC only;
Argon2 runs on the dialer or CLI (64 MiB, t=3, the `auth` defaults).
Argon2 runs on the dialer or CLI (t=3, 64 MiB, 4 threads, the `auth` defaults).
The dialer refuses a challenge below that cost, so a rogue server cannot ask for
a cheaply guessable proof, and trusts the link only after the server's final
signature proves it holds the user's `ServerKey`.
**Channel binding.** Both peers pass `auth.WithChannelBinding(SHA-256(server leaf
DER))` at the proof step. The server hashes its configured certificate; the
client hashes the certificate it saw. A relay presenting any other certificate,
even one the CA trusts, makes the server reject the proof, so it never obtains a
link or a token. A mismatch is indistinguishable from a wrong password by design;
the client also sends its hash as a diagnostic field so the listener can log
"TLS interception or terminating proxy" and count it. TLS must terminate at
logwisp.
link or a token. A mismatch is indistinguishable from a wrong password on the
wire by design; the client also sends its hash as a diagnostic field, so the
listener logs "TLS interception or a terminating proxy" and counts it. TLS must
terminate at logwisp, except in [proxy mode](#browser-viewers-behind-a-tls-terminating-proxy).
**HTTP pinning.** HTTP auth takes two requests and every later request may use a
new pooled connection, so binding the exchange is not enough. After step 1 the
dialer pins the bound hash on its `VerifyConnection`: step 2 and every ingest
request must present the same leaf. A mismatch clears token and pin and
re-authenticates, which also covers certificate rotation. A token handed to curl
by the CLI is unpinned.
new pooled connection, so binding the exchange is not enough. Once the challenge
arrives the dialer pins the certificate that answered on its `VerifyConnection`:
the proof and every ingest request must present the same leaf, or the handshake
fails before anything is sent. The `http_chain` sink then clears token and pin
and logs in again, which also covers certificate rotation. A token handed to
curl by the CLI is unpinned.
## Configuration
@@ -72,86 +79,107 @@ password_file = "/etc/logwisp/edge-01.pass"
Validation at construction:
- `scram` requires TLS; a dialer also forbids `insecure_skip_verify`.
- Listeners require `credentials_file` and reject `allow`/`allow_patterns`: the
credentials file is the allow list. Use one file per listener when listeners
admit different users.
- Dialers require `username` + `password_file`, and reject `credentials_file`,
`allow*` and `identity` (server pinning stays an mTLS feature).
- `scram` requires TLS, except on an `http` sink in proxy mode; a dialer also
forbids `insecure_skip_verify`.
- Listeners require `credentials_file` and reject `allow`/`allow_patterns` (the
credentials file is the allow list), `username` and `password_file`. Use one
file per listener when listeners admit different users.
- Dialers require `username` + `password_file` (1–1024 bytes after trimming one
line break), and reject `credentials_file`, `token_lifetime_ms`, `allow*` and
`identity` (server pinning stays an mTLS feature).
- `identity` on a listener requires `tls.client_auth` and **binds the certificate
to the user**: the certificate's identity field must equal the SCRAM username,
so a peer needs its own certificate and its own password (PostgreSQL's
`clientcert=verify-full`). Without it the factors are independent: any
CA-issued certificate plus any valid password (`clientcert=verify-ca`).
- `token_lifetime_ms` only on HTTP listeners; default 15 minutes.
- `/auth` is reserved; no configured path may equal it.
- `/auth` and every path under it are reserved.
- `mtls` rejects the four SCRAM keys. A block that names peers or credentials
with `type = "none"` is refused: the type was forgotten, not the block.
**Credentials file** (TOML, written by the CLI, mode 0600):
```toml
decoy_key = "<base64, 32 random bytes>"
[[users]]
username = "edge-01"
salt = "<base64>"
argon_time = 3
argon_memory = 65536
argon_threads = 4
stored_key = "<base64>"
argon_time = 3
salt = "<base64>"
server_key = "<base64>"
stored_key = "<base64>"
username = "edge-01"
```
`decoy_key` keeps unknown-user challenges stable across restarts and edits, so
probing usernames reveals nothing. It is created by the first `add-user` and
preserved by every rewrite. An empty user set, a duplicate user, a missing
`decoy_key` or mixed KDF profiles are configuration errors. The file is read at
construction; the SCRAM server starts in the plugin's `Start` and stops in `Stop`,
so a rejected reload leaks nothing.
a single probe cannot tell a real user from an unknown one (a real user's salt
changes when it is added, rotated or removed). It is created by the first `add-user` and
preserved by every rewrite. An empty user set, a duplicate user, a missing or
short `decoy_key`, an unknown key, or users differing in KDF profile or salt
length are configuration errors. The file is read at construction; the SCRAM
server starts in the plugin's `Start` and stops in `Stop`, so a rejected reload
leaks nothing.
## Wire Protocol
`chain.Hello` gains `scram` (raw JSON, decoded by `authz`); the protocol version
stays 1 because the field is additive. Every message after the hello is one
`authStep`: `error`, `challenge`, `proof` (+ `binding`), `final` (+ `token`,
`expires_in` on HTTP). Pre-auth lines are capped at 4 KiB.
`expires_in` on HTTP). Pre-auth lines and `/auth` bodies are capped at 4 KiB.
**TCP** (`tcp_chain`, `tcp` sink): hello carrying the client-first message,
challenge, proof, final — all under one deadline (`hello_timeout_ms` on the
`tcp_chain` source, 10 s on the `tcp` sink and every dialer, with the dialer's
context able to cut it short). The final is written only after every admission
check (certificate binding, node binding); the stream then continues on the same
buffered reader. A source without SCRAM answers a hello carrying credentials with
`error: authentication not enabled`; only an old binary stays silent, and the
dialer's deadline covers that.
check (certificate binding, node binding); a later refusal is an `error` line
instead. The stream then continues on the same buffered reader. A listener
without SCRAM that reads a hello (the `tcp_chain` source) answers one carrying
credentials with `error: authentication not enabled`; an old binary stays
silent, and the dialer gives up after its deadline with "no challenge within
10s".
**HTTP** (`http_chain` source, `http` sink): `POST /auth`, outside the auth
middleware, with its own 10 s read/write deadline and a 4 KiB body limit.
middleware, with its own 10 s read/write deadline. It answers `404` unless the
policy is `scram`.
| Step | Request body | Success | Failure |
|------|--------------|---------|---------|
| 1 | hello | `200` challenge | `429` throttled, `503` handshake table full, `400` malformed |
| 2 | proof | `200` final, token, expires_in | `401 {"error":"authentication failed"}` |
| 1 | hello | `200` challenge | `429` throttled, `503` busy, `400` malformed, `413` oversized |
| 2 | proof | `200` final, token, expires_in | `401 {"error":"authentication failed"}`, `503` if the server stopped |
Protected endpoints take `Authorization: Bearer <token>`: missing or invalid is
`401` with `WWW-Authenticate: Bearer realm="logwisp"`, a certificate-binding miss
is `403`. Tokens are HS256 JWTs from `auth.NewJWT` with a random per-instance key,
so every reload revokes them. An SSE stream is checked at connect and outlives its
token; a reload ends it.
is `403`. Tokens are HS256 JWTs from `auth.NewJWT` (issuer `logwisp`, no leeway)
with a random per-instance key, so every reload revokes them. An SSE stream is
checked at connect and outlives its token; a reload ends it.
**Dialer outcomes** (`http_chain` sink): every `/auth` failure is transient (the
batch is held under backoff); `404`/`405` from `/auth` logs "no auth endpoint:
older logwisp or auth.type is not scram"; an ingest `401` clears the token and is
retried; `403` drops the batch; a pin mismatch clears token and pin. Rejections
are logged at WARN once per change of state and counted in `auth_failures` /
`last_auth_error`.
batch is held under backoff); `404`/`405` from `/auth` reads "no auth endpoint
at <url> (older logwisp, or not a scram listener)"; an ingest `401` clears the
token and is retried (tokens are renewed ahead of expiry, so this follows a
listener reload); `403` drops
the batch; a pin mismatch clears token and pin. Refusals are logged at WARN on
every attempt, paced by the backoff (`Chain connect refused` on `tcp_chain`,
`Chain batch delivery failed` on `http_chain`), and failed logins are counted
in `auth_failures` / `last_auth_error`.
## Throttling
Per remote IP (the socket address, never a forwarded header), on handshake
Per remote IP (the socket address; in proxy mode the forwarded client, an IPv6
one per /64), on handshake
starts: a token bucket (burst 10, 1/s) refunded on success, and at most 4
unfinished exchanges. The table is bounded and fails closed when full. Abandoned
TCP exchanges release their slot in the `auth` handshake table immediately.
Counters: `auth_allowed` and `auth_rejected` once per exchange outcome;
`auth_throttled`, `auth_busy` and `auth_binding_mismatch` separately.
unfinished exchanges. An unanswered HTTP challenge holds its slot for the
`auth` handshake timeout (30 s); abandoned TCP exchanges release their slot,
and their entry in the `auth` handshake table, immediately. The address table
holds 65,536 entries, drops one idle for a minute once its challenges have
expired, and fails closed when full; the SCRAM server itself caps in-flight
handshakes at 4,096 (`busy`).
Counters: `auth_allowed` counts logins; `auth_rejected` every refusal — failed
proofs, malformed requests, missing credentials and, on HTTP, refused tokens;
`auth_binding_mismatch` is the subset of failed proofs whose client saw another
certificate. `auth_throttled` and `auth_busy` count starts refused before a
challenge and are not in `auth_rejected`.
## The `authz` Seam
@@ -161,11 +189,13 @@ closed.
- `Admit(conn, cs, wantHello, timeout)` returns an admission (identity, hello,
reader) whose `Accept` or `Reject` writes the final message.
- `AuthorizeRequest(r)` covers mTLS certificates and bearer tokens;
`ServeAuth(w, r)` serves `/auth`.
- Dialers: `Greet(ctx, conn, node)` writes the hello (plain or SCRAM);
`Authenticate`, `SetAuthorization`, `ClearToken` and `VerifyConnection` serve
HTTP.
- `AuthorizeRequest(r)` covers mTLS certificates and bearer tokens, returning
the refusal status for `Refuse`; `ServeAuth(w, r)` serves `/auth`.
- Dialers: `Greet(ctx, conn, node)` writes the hello and, under SCRAM, runs the
TCP exchange. Over HTTP `Prepare` logs in when no token is held and sets the
bearer header, `Invalidate` drops the token after a `401` or a pin mismatch,
`Token` is the login itself (behind `Prepare`, and for the CLI), and
`VerifyConnection` pins.
- `Start`/`Close` bracket the SCRAM server and are nil-safe like everything else.
## CLI
@@ -173,24 +203,27 @@ closed.
```
logwisp auth add-user -credentials F -user U [-password-file P] [-generate]
logwisp auth remove-user -credentials F -user U
logwisp auth token -url https://host:port -user U -password-file P [TLS flags]
logwisp auth token -url https://host:port[/path] -user U -password-file P [-unbound] [TLS flags]
logwisp auth stream -addr host:port -user U -password-file P [TLS flags]
```
- `add-user` takes the password from `-password-file` when it exists; a new user
without one gets a generated 130-bit password, written to that file (0600) or
printed once. Rotating an existing user needs the file or `-generate`, so a
mistyped path cannot replace a password. The file's KDF profile and decoy key
are reused, the result is validated with the daemon's own loader, and the
rewrite (temp file in the same directory, rename) keeps the existing mode and
owner.
- `add-user` takes the password from `-password-file` when it exists (at least
8 bytes); a new user without one gets a generated 130-bit password, written to
that file (0600) or printed once. Rotating an existing user needs the file or
`-generate`, so a mistyped path cannot replace a password. The file's KDF
profile and decoy key are reused, the result is validated with the daemon's
own parser, and the rewrite (temp file beside the file or a symlink's target,
rename) keeps the existing mode and owner, or fails.
- `remove-user` refuses to remove the last user.
- Both print that changes apply on `SIGHUP`: `auto_reload` does not watch the
credentials file.
- `token` prints a bearer token for curl (`-H @<(...)` keeps it out of argv);
`stream` authenticates to a `tcp` sink and copies the stream to stdout.
`stream` authenticates to a `tcp` sink and copies the stream to stdout until
interrupted. `-url` takes a path only with `-unbound`, and redirects are not
followed.
- TLS flags: `-ca-file`, `-server-name`, `-cert-file`, `-key-file`. There is no
insecure flag.
- Exit status: 0 success, 1 failure, 2 usage error.
## Rollout and Rotation
@@ -202,18 +235,108 @@ logwisp auth stream -addr host:port -user U -password-file P [TLS flags]
between.
- Revocation: `remove-user`, `SIGHUP`. All tokens die with the reload.
## Browser Viewers Behind a TLS-Terminating Proxy
The deployer serves the log stream inside a website whose TLS ends at its
reverse proxy; LogWisp holds no private key and listens on a trusted hop.
Viewers log in from a browser with the client LogWisp ships, and the password
reaches neither the proxy nor LogWisp. Operator view and example:
[Security](security.md#browsers-behind-a-tls-terminating-proxy).
**Trust model.** `auth.trusted_proxies` (addresses or CIDRs) switches the `http`
sink into proxy mode; it is refused on every other plugin and with `identity`.
- Only listed peers may connect: `403` on every path, the pages included.
- `X-Forwarded-Proto` must be `https` on every hop, so a site served in
plaintext by mistake fails closed instead of exposing sessions.
- The client is the rightmost `X-Forwarded-For` hop that is not a proxy (hops to
its left are the client's own claims), or the leftmost when every hop is
inside a proxy range; throttling (an IPv6 client per /64, as one host holds
it whole), sessions and logs use it. Outside proxy mode forwarded headers
stay ignored.
- SCRAM runs unbound: a browser cannot read the site's certificate, and LogWisp
sees none. The proof and the session ride on the site's TLS; a party on the
proxy-to-LogWisp hop could relay a login, hence the address restriction and a
startup warning for a plaintext hop to a proxy off this host. `tls` may stay
off. Outside proxy mode nothing changes: proofs stay bound, and a bound proof
in proxy mode fails with a hint naming `-unbound`.
**Session.** The proof step may ask for a cookie (`"session": "cookie"`, proxy
mode only). The answer sets `logwisp_session` (`HttpOnly; Secure;
SameSite=Strict; Max-Age=<lifetime>`) and carries no token in the body. It has
no `Path`: RFC 6265 defaults it to the directory of `POST <mount>/auth`, which
is the mount behind any proxy prefix. Stream and status accept the cookie or
`Authorization: Bearer`; another `Authorization` scheme, such as the site's own
Basic auth, leaves the cookie in charge. Logout is `{"logout": true}` on `POST /auth` itself,
because a clearing cookie set from `/auth/logout` would default to another path
and miss the login cookie. It clears the cookie and revokes a valid token until
it expires (a set of at most 65,536 hashes, gone on reload like every token). An
open stream outlives its token; a reconnect after expiry gets `401`. `/auth`
takes only `application/json`, which a cross-origin page cannot send without a
preflight nobody answers.
**Endpoints.** `/auth` and every path under it are reserved; every URL in the
pages is relative, so a proxy prefix works unchanged.
| Path | Purpose |
|------|---------|
| `POST /auth` | SCRAM hello, proof, or logout; unbound in proxy mode |
| `GET /auth/scram.js` | the client library, always in proxy mode |
| `GET /auth/login`, `login.js`, `style.css` | login page (`login_page`) |
| `GET /auth/view`, `view.js` | minimal live viewer (`viewer_page`, needs `login_page`) |
Files carry `default-src 'none'; script-src 'self'; connect-src 'self';
style-src 'self'; form-action 'self'; frame-ancestors 'none'; base-uri 'none'`,
`nosniff` and `no-referrer`; nothing is inline. The viewer learns a custom
`status_path` from a meta tag the sink fills in, renders entries as text, and
sends a signed-out visitor to `login?next=view`; the login page follows `next`
only within its origin. The `proxy_tls` capability satisfies the pipeline's
"auth needs TLS" check.
**Client library.** `internal/sink/http/web/scram.js`, embedded with `go:embed`,
is one dependency-free ES module, so a site with its own CSP can copy it into
its bundle. It exports `login(base, username, password, {onProgress})` and
`logout(base)`. BLAKE2b and Argon2id are plain JS (no WebAssembly, which would
need `'wasm-unsafe-eval'`), yielding to the page every 50 ms; SHA-256 and HMAC
come from WebCrypto, so it needs a secure context. It checks the challenge as
the Go client does (nonce, salt, cost limits and the dialer's Argon2 floor),
refuses redirects, and reports success only after verifying the server's
signature. A 64 MiB login takes about 2 s on a desktop. The wire protocol above
is the contract: a site may implement its own client.
**CLI.** `logwisp auth token -unbound` logs in through the proxy, still pinning
its certificate across the two requests; only then may `-url` carry the mount
path.
## Verification
Go tests, one per rule: binding (match, mismatch, one-sided, length) in `auth`;
in `authz` the validation table, credential loading, decoy stability across
edits, TCP exchange over `net.Pipe` (success, wrong password equals unknown
user, binding mismatch, missing hello, silent peer within the deadline,
abandoned exchange frees its slot, final and first entry in one write, assert
mismatch gets an error rather than a final), HTTP exchange on a TLS test server
Go tests, one per rule: binding (match, mismatch, one-sided, length) in `auth`.
In `authz`: the validation table (each row valid but for its rule, the error
naming it) and credentials file validation; the TCP exchange over `net.Pipe`
(admission with the first entry in the same read, wrong password
indistinguishable from an unknown user, another certificate refused and counted,
hello and policy disagreeing either way, a dialer cut short by its context, an
abandoned exchange freeing its handshake and limiter slots, a refusal after the
exchange sent as a reason rather than a final, certificate-to-user binding for
logins and tokens); only failed attempts throttled, the limiter bounds
(reservation under concurrency, the sweep of abandoned challenges); pre-auth
input capped at 4 KiB; the decoy salt stable across restarts; a dialer refusing
a challenge below its Argon2 floor; the HTTP exchange on a TLS test server
(token accepted, garbage and foreign tokens `401`, forged final yields no token,
a different certificate after step 1 refused before the body is sent), the
limiter bound; `http_chain` sink re-authentication and outcome table; CLI
add/remove/rotate, mode preservation, last-user refusal, password trimming.
a different certificate after the challenge refused before the proof is sent,
renewal ahead of expiry); `Authorize` failing closed under SCRAM; password
trimming; refused hellos freeing their slot; unbound logins pinned too. Proxy
mode: trusted peers, https and the client hop; throttling on the forwarded
client and IPv6 /64; an unbound browser login with the cookie's attributes, the
cookie opening endpoints (beside Basic auth) until logout revokes it, cookie
sessions refused elsewhere; only unbound logins behind a proxy; `/auth` taking
only JSON. In the `http` sink, every SSE line break framed as data.
In the plugins: `/auth` outside the `http` sink's gate, the pages served with
their types and CSP, direct peers refused, pages only in proxy mode, and the
`http_chain` sink logging in again after a `401` and delivering once. In the
CLI: private files with a matching verifier, no password replaced without a
source, mode, decoy key and symlink kept across rewrites, an empty file filled,
last-user refusal. Under node, `scram.js` against the RFC 9106 vector, Go's
`argon2.IDKey`, and `auth`'s known-answer proof.
`test/scram-chain-test.sh --auto` (ports 15821-15825): a relay with SCRAM on all
four listener types plus one `client_auth` + SCRAM port; authorized edges over
@@ -222,7 +345,20 @@ user and no-auth edges deliver nothing; a `tcp` sink viewer through `logwisp aut
stream` held past the exchange deadline; a raw TLS client without a hello gets
nothing; the `http` sink through `logwisp auth token` and curl; missing and bad
tokens get `401`; `remove-user` + `SIGHUP` revokes token and login; throttling
last. The existing scripts keep passing.
last. `test/scram-proxy-test.sh --auto` (ports 15831-15832): a Go reverse proxy
ending TLS in front of a plaintext `http` sink mounted at `/logs/`; headless
Chromium sent to the login page, refused a wrong password, streaming events
after login, with the cookie scoped to `/logs` and hidden from scripts, cleared
by sign-out, and no CSP violations; `token -unbound` and curl through the
proxy; direct peers and plaintext-forwarded requests `403`. The existing
scripts keep passing.
## Not Implemented
1. **PROXY protocol behind TLS passthrough.** Every client then shares the
proxy's address, and so one throttling budget that a single client can
exhaust. Accepting PROXY v2 from listed proxies would restore per-client
throttling on the TCP listeners and on HTTP listeners outside proxy mode.
## mTLS Hardening
@@ -234,10 +370,13 @@ Shipped with this plan, independent of SCRAM:
- The `tcp` sink tracks connections from accept, so `Stop` and reloads no longer
wait out the 10 s handshake timeout of a silent peer.
- Unknown keys in any plugin's config, at any depth, fail construction: a typo
such as `[...tls] enabeld = true` used to switch protection off silently.
such as `[...tls] enabeld = true` used to switch protection off silently. The
configuration file itself is checked the same way, so a misspelled table path
above a plugin's config fails too.
- Startup and every reload warn about certificates that expired, are not yet
valid, or expire within 30 days; about `insecure_skip_verify`; about
`allow_patterns` that are not anchored at both ends of every alternative; and,
once per path, about key files every local user can read.
once per path, about key, credentials and password files every local user can
read.
- An `http` sink with an auth policy no longer sends
`Access-Control-Allow-Origin: *`.
+332 -84
View File
@@ -11,19 +11,21 @@ configure it, and — equally important — what it does not yet do.
| Server certificate verification by dialers | Implemented |
| Mutual TLS (client certificate required and verified) | Implemented at the transport layer |
| Peer identity recorded per session | Implemented |
| Authorization from peer identity (allow-lists, node binding) | Implemented — see [The Auth Block](#the-auth-block) |
| Authentication on the `http` sink's stream and status endpoints | Implemented, via the auth block |
| Server identity pinning by dialers | Implemented, via the auth block |
| Authorization from certificate identity (allow-lists, node binding) | Implemented — see [The Auth Block](#the-auth-block) |
| Password (Argon2id-SCRAM) authentication, bound to the TLS channel | Implemented — see [Password Authentication](#password-authentication-scram) |
| Authentication on the `http` sink's stream and status endpoints | Implemented: client certificate or bearer token |
| Browser logins behind a site's TLS-terminating proxy | Implemented — see [Browsers behind a TLS-terminating proxy](#browsers-behind-a-tls-terminating-proxy) |
| Server pinning by dialers | Implemented: certificate identity (`mtls`), bound certificate (`scram`) |
| Startup warnings for expiring certificates and risky settings | Implemented — see [Startup Warnings](#startup-warnings) |
| Unknown configuration keys rejected | Implemented — a typo in `tls` or `auth` fails startup |
| Certificate revocation lists (CRL) or OCSP | **Not implemented** — revoke by editing the allow-list |
| Password (Argon2id-SCRAM) authentication | **Planned** — see [scram-auth-plan.md](scram-auth-plan.md) |
| IP allow/deny lists, per-IP connection or request limits | **Not implemented** |
| Unknown configuration keys rejected | Implemented — a typo in `tls`, `auth` or a table path fails startup |
| Certificate revocation lists (CRL) or OCSP | **Not implemented** — revoke by editing the allow-list or credentials file |
| IP allow/deny lists, per-IP connection or request limits | **Not implemented** — only SCRAM logins are throttled per address |
Certificates are the credential the transport already carries, so they are what
authentication is built on today: the `tls` block establishes that a peer chains
to your CA, and the `auth` block decides which peers that CA vouches for may
actually do what.
Two credentials are supported. Certificates (`mtls`) are the one the transport
already carries: the `tls` block establishes that a peer chains to your CA, and
the `auth` block decides which of those peers may do what. Passwords (`scram`)
need no client PKI: the listener holds verifiers, never passwords, and every
login is bound to the listener's certificate.
## The TLS Block
@@ -56,13 +58,8 @@ min_version = "1.3"
| `insecure_skip_verify` | dialer | `false` | Disable server verification |
| `min_version` | both | `"1.3"` | `"1.2"` or `"1.3"` |
**Roles by plugin:**
| Plugin | Role | Keys that apply |
|--------|------|-----------------|
| `tcp` sink, `http` sink | Listener | `cert_file`, `key_file`, `client_auth`, `client_ca_file`, `min_version` |
| `tcp_chain` source, `http_chain` source | Listener | same as above |
| `tcp_chain` sink, `http_chain` sink | Dialer | `ca_file`, `server_name`, `insecure_skip_verify`, `cert_file`, `key_file`, `min_version` |
Listeners are the `tcp` and `http` sinks and the `tcp_chain` and `http_chain`
sources; dialers are the `tcp_chain` and `http_chain` sinks.
> `min_version` takes `"1.2"` or `"1.3"`. The older `"TLS1.2"` spelling from
> pre-restructure releases is rejected. There is no `max_version` and no
@@ -86,25 +83,34 @@ Misconfiguration fails at plugin construction, before the pipeline starts:
TLS answers "is this channel private, and does the peer chain to a CA". Auth
answers "may *this* peer do *this*". They are separate blocks because they are
separate questions, and because a later non-certificate method should be able to
reuse the second one.
separate questions.
```toml
[pipelines.plugin_sources.config.auth] # or plugin_sinks.config.auth
type = "none" # none | mtls
type = "none" # none | mtls | scram
identity = "cn" # cn | san_dns | san_uri | san_email
allow = []
allow_patterns = []
allow = [] # mtls
allow_patterns = [] # mtls
node_binding = "force" # chain sources only
credentials_file = "" # scram listeners
token_lifetime_ms = 0 # scram, HTTP listeners; 10 s to 24 h, 0 = 15 minutes
username = "" # scram dialers
password_file = "" # scram dialers
trusted_proxies = [] # scram http sink behind a TLS-terminating proxy
```
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `type` | string | `none` | `none` ignores the whole block; `mtls` authorizes by certificate identity |
| `identity` | string | `cn` | Which certificate field carries the identity |
| `allow` | []string | `[]` | Exact identities to admit |
| `allow_patterns` | []string | `[]` | RE2 patterns matched against the identity; anchor them yourself |
| `node_binding` | string | `force` under `mtls` | Chain sources only: `none`, `assert`, or `force` |
| Option | Applies to | Default | Description |
|--------|------------|---------|-------------|
| `type` | all | `none` | `none` ignores the block; `mtls` authorizes by certificate identity; `scram` by password |
| `identity` | `mtls`; `scram` listeners | `cn` (`mtls`), unset (`scram`) | Certificate field carrying the identity. Under `scram` it binds the client certificate to the user |
| `allow` | `mtls` | `[]` | Exact identities to admit |
| `allow_patterns` | `mtls` | `[]` | RE2 patterns matched against the identity; anchor them yourself |
| `node_binding` | chain sources | `force` | `none`, `assert`, or `force` |
| `credentials_file` | `scram` listeners | — | Verifiers written by [`logwisp auth add-user`](cli.md#logwisp-auth) |
| `token_lifetime_ms` | `scram` on the `http` sink and `http_chain` source | 15 minutes | Bearer token lifetime, 10 s to 24 h |
| `username` | `scram` dialers | — | User to log in as |
| `password_file` | `scram` dialers | — | File holding the password; one trailing line break is trimmed |
| `trusted_proxies` | `scram` on the `http` sink | `[]` | Addresses or CIDRs of the reverse proxies that end the browsers' TLS; see [Browsers behind a TLS-terminating proxy](#browsers-behind-a-tls-terminating-proxy) |
**Roles by plugin:**
@@ -112,13 +118,14 @@ node_binding = "force" # chain sources only
|--------|------|---------|
| `tcp_chain` source, `http_chain` source | Listener | Which senders may ingest, and what node label their entries carry |
| `tcp` sink, `http` sink | Listener | Which clients may read the stream (and, on `http`, the status endpoint) |
| `tcp_chain` sink, `http_chain` sink | Dialer | Which server identity to accept, beyond hostname verification |
| `tcp_chain` sink, `http_chain` sink | Dialer | `mtls`: which server identity to accept, beyond hostname verification. `scram`: which user to log in as |
### Identity
The identity is one string pulled from the peer's verified leaf certificate.
The handshake has already checked the chain, signature, and validity window, so
this is pure field selection.
Under `mtls` the identity is one string pulled from the peer's verified leaf
certificate; under `scram` it is the username. The handshake has already
checked the chain, signature, and validity window, so this is pure field
selection.
| Mode | Source | Typical use |
|------|--------|-------------|
@@ -133,7 +140,8 @@ identity is a refusal, not an empty match.
### The allow list
`allow` is an exact-match set; `allow_patterns` holds RE2 patterns. An identity
passes if it appears in either.
passes if it appears in either. Under `scram` the credentials file is the allow
list, and both keys are refused.
Leaving **both** empty under `type = "mtls"` admits any identity the CA vouches
for. That is deliberate — it is how you enable node binding without enumerating
@@ -161,7 +169,8 @@ reported at startup.
Use **`force`** on an ingest boundary you do not trust. Every entry is
relabelled, so a compromised edge cannot smuggle a foreign origin through the
per-entry `node` field either. It is the default under `type = "mtls"`.
per-entry `node` field either. It is the default whenever `type` is `mtls` or
`scram`.
Use **`assert`** on a relay-to-relay hop. The relay must prove its own identity —
a mismatch fails loudly instead of being silently corrected — but the entries it
@@ -177,7 +186,7 @@ INFO msg="Node labels bound to peer identity; trust_node is ignored"
### Dialer-side pinning
On a chain sink, the same block pins the *server's* identity. Hostname
Under `mtls`, the block on a chain sink pins the *server's* identity. Hostname
verification already proves the server holds a certificate valid for the address
you dialed; pinning additionally requires that certificate to name an identity
you listed.
@@ -190,20 +199,40 @@ allow = ["relay.internal"]
The check runs as part of the handshake, so a server the policy rejects never
receives an entry — the sink's normal backoff loop handles it like any other
connect failure. `insecure_skip_verify` is refused alongside `type = "mtls"`:
an identity read from an unverified chain is a claim, not a fact.
connect failure. Under `scram` the dialer binds its login to the certificate it
was shown instead; see [Channel binding](#channel-binding).
### Validation
Misconfiguration fails at plugin construction, before the pipeline starts:
- `type = "mtls"` on a listener without `tls.enabled` **and** `tls.client_auth`
- `type = "mtls"` on a dialer without `tls.enabled`, or with
`tls.insecure_skip_verify`
- `type` `mtls` or `scram` without `tls.enabled` (proxy mode aside), or on a dialer with
`tls.insecure_skip_verify`: an identity read from an unverified chain is a
claim, and an unverified server could relay a login
- `type = "mtls"` on a listener without `tls.client_auth`
- a block naming peers or credentials (`allow`, `allow_patterns`,
`credentials_file`, `token_lifetime_ms`, `username`, `password_file`,
`trusted_proxies`) whose
`type` is `none` or unset: auth was intended and the type forgotten
- a key of the other method: `allow` or `allow_patterns` under `scram`, a
`scram` key under `mtls`
- a `scram` listener without a loadable `credentials_file` (see
[Credentials file](#credentials-file)), with `username` or `password_file`,
with a `token_lifetime_ms` outside 10 s to 24 h or on a TCP listener, or with
`identity` but no `tls.client_auth`
- a `scram` dialer without `username` and `password_file`, with
`credentials_file`, `token_lifetime_ms`, `trusted_proxies` or `identity`
(server pinning is `mtls` only), or with a password file that is empty or
over 1024 bytes
- `trusted_proxies` on anything but the `http` sink, with `identity`, or with
an entry that is neither an address nor a CIDR; `login_page` or
`viewer_page` without `trusted_proxies`, `viewer_page` without `login_page`
- an `identity` that is not one of the four modes
- an `allow_patterns` entry that does not compile
- a `node_binding` that is not one of the three values, or one set on a plugin
that has no node concept
- an HTTP path (`stream_path`, `status_path`, `ingest_path`) equal to `/auth`,
which is reserved for logins
Errors read like `auth: type "mtls" requires tls.client_auth`.
@@ -217,15 +246,15 @@ when it is constructed, so startup and every reload repeat them:
- `insecure_skip_verify` on a dialer
- an `allow_patterns` entry not anchored at both ends of every alternative
(`^a|b$` admits `a…` and `…b`)
- a `key_file` every local user can read (once per path per process)
- a `key_file`, `credentials_file` or `password_file` every local user can read
(once per path per process)
## Enabling mTLS
### 1. Generate a CA and certificates
LogWisp no longer ships a certificate-generation subcommand; the `logwisp tls`
command was removed with the rest of the CLI restructure. Use `openssl`,
`cfssl`, `step-cli`, or your existing PKI.
LogWisp does not generate certificates; use `openssl`, `cfssl`, `step-cli`, or
your existing PKI.
```bash
# CA
@@ -299,48 +328,265 @@ INFO msg="TCP chain sink initialized" ... tls=true mtls=true
auth="mtls identity=cn allow=[1 exact, 0 pattern(s)] node_binding=none"
```
A client that presents no certificate is refused during the handshake:
```
WARN msg="TLS handshake failed" component=tcp_chain_source
remote_addr=127.0.0.1:53840 error="tls: client didn't provide a certificate"
```
A client that presents no certificate is refused during the handshake
(`tls: client didn't provide a certificate`, counted in `tls_handshake_errors`).
A client whose certificate is valid but whose identity is not authorized gets
past the handshake and is refused by the policy:
```
WARN msg="Connection rejected by auth policy" component=tcp_chain_source
remote_addr=127.0.0.1:33946 error="auth: identity \"edge-99\" is not allowed"
remote_addr=<addr> error="auth: refused: identity \"edge-99\" is not allowed"
```
Handshake failures are counted in `tls_handshake_errors`; policy rejections in
`auth_rejected`. Both appear in the status reporter and in the `http` sink's
status endpoint. Accepted peers are recorded in session metadata as
`auth_method` and `auth_identity`.
Policy rejections are counted in the plugin's `auth_rejected` statistic; the
`http` sink's status endpoint reports its own. Accepted peers are recorded in
session metadata as `auth_method` and `auth_identity`.
`test/mtls-chain-test.sh` builds a throwaway PKI and exercises the whole surface
end to end — run it with `--auto` to see each guarantee asserted.
## Password Authentication (SCRAM)
`type = "scram"` authenticates peers by username and password with
Argon2id-SCRAM from `lixenwraith/auth`. It needs TLS but no client
certificates, works on all six network plugins, and is LogWisp's own protocol,
not standard SASL: viewers use the [`logwisp auth`](cli.md#logwisp-auth) CLI or,
behind a TLS-terminating proxy, the shipped browser client.
```toml
# Listener: tcp_chain/http_chain source, tcp/http sink
[pipelines.plugin_sources.config.tls]
enabled = true
cert_file = "/etc/logwisp/tls/relay.crt"
key_file = "/etc/logwisp/tls/relay.key"
[pipelines.plugin_sources.config.auth]
type = "scram"
credentials_file = "/etc/logwisp/users.toml"
# Dialer: tcp_chain/http_chain sink
[pipelines.plugin_sinks.config.tls]
enabled = true
ca_file = "/etc/logwisp/tls/ca.crt"
[pipelines.plugin_sinks.config.auth]
type = "scram"
username = "edge-01"
password_file = "/etc/logwisp/edge-01.pass"
```
Startup reports `auth="scram users=2 certificate=independent node_binding=force"`
on such a chain source, and `auth="scram user=edge-01"` on the dialer.
### Mechanism
For each user the listener keeps a salt, the Argon2id cost, and two keys derived
from `K = Argon2id(password, salt)`: `StoredKey = SHA-256(HMAC(K, "Client
Key"))` and `ServerKey = HMAC(K, "Server Key")`. Neither logs anyone in, but
each password guess against them costs one Argon2id, so keep the file private.
Checking a login is HMAC work only; Argon2id (3 passes, 64 MiB, 4 threads) runs
on the dialer or the CLI. The proof is mutual: the dialer accepts the server's
final message only when it is signed with this user's `ServerKey`, and it
refuses a challenge asking for less Argon2 work than those defaults, so a rogue
server cannot request a cheaply crackable proof. An unknown user gets a stable
decoy challenge and fails exactly like a wrong password: `authentication
failed`.
### Channel binding
Both sides bind the proof to the SHA-256 of the listener's leaf certificate:
the listener hashes the certificate it serves, the dialer the one it was shown.
A TLS-terminating proxy or interceptor shows another certificate — CA-valid or
not — so the proof fails and the relay never obtains a link or a token. The
dialer is told `authentication failed` either way; the listener compares the
hash the dialer reports, logs `the client saw another server certificate: TLS
interception or a terminating proxy`, and counts `auth_binding_mismatch`.
An HTTP login takes two requests, and every later request may open a new pooled
connection. After the challenge the dialer pins the certificate it was bound to:
the proof and every ingest request must meet the same certificate, or the TLS
handshake fails before anything is sent. The `http_chain` sink then drops token
and pin and logs in again, which also covers a rotated server certificate. A
token printed by `logwisp auth token` is not pinned.
### Certificates and users
`tls.client_auth` is optional under `scram`, and on its own independent of the
password: a peer needs *some* certificate the CA issued and *some* valid
password, like PostgreSQL's `clientcert=verify-ca`. Setting `identity` (which
requires `tls.client_auth`) binds the two: the certificate's identity field must
equal the username, so every peer needs its own certificate *and* its own
password, like `clientcert=verify-full`. A mismatch fails the login; an HTTP
request whose certificate does not match its token's user gets `403`. Startup
shows the mode as `certificate=independent` or `certificate=cn=username`.
### Tokens
The `http` sink and `http_chain` source answer a login at `POST /auth` with a
bearer token: an HS256 JWT valid for `token_lifetime_ms` (default 15 minutes)
and signed with a key drawn when the plugin is built, so **every reload revokes
every token** (a rejected reload keeps the running service and its tokens). A
missing, invalid or expired token gets `401` with `WWW-Authenticate: Bearer
realm="logwisp"`. An SSE stream is checked when it connects and then outlives
its token; a reload ends it. TCP has no tokens: each connection runs its own
exchange.
### Throttling
Logins are throttled per remote socket address or, on an `http` sink in proxy
mode, per forwarded client (an IPv6 client per /64); forwarded headers are read
only from `trusted_proxies`. Each exchange takes a token from a bucket of 10 that refills at one per
second, and a successful login gives it back, so only failed or abandoned
attempts drain it. At most 4 exchanges per address may be unfinished: an HTTP
challenge never answered holds its slot for up to 30 s, a TCP connection that
ends mid-exchange frees it at once. A refused start answers `too many attempts`
(HTTP `429`) and counts `auth_throttled`; the table holds 65,536 addresses and
refuses new ones when full. Separately, at most 4,096 exchanges may be in flight
per listener; beyond that, or while the plugin stops, a login gets `busy` (HTTP
`503`) and counts `auth_busy`. Peers behind one NAT or one passthrough proxy
share a bucket: there, one client can exhaust every other client's logins.
### Credentials file
```toml
# logwisp SCRAM verifiers, written by `logwisp auth add-user`. Keep it private.
decoy_key = "<base64, 32 random bytes>"
[[users]]
argon_memory = 65536
argon_threads = 4
argon_time = 3
salt = "<base64>"
server_key = "<base64>"
stored_key = "<base64>"
username = "edge-01"
```
Write it with [`logwisp auth add-user`](cli.md#logwisp-auth) rather than by
hand. The whole file is validated when the plugin is built: a `decoy_key` of at
least 32 bytes, at least one user, unique names, no unknown keys, and one Argon2
profile and salt length for every user, since mixed profiles would tell a prober
which users exist. `decoy_key` keeps unknown-user challenges stable across
restarts and edits; the CLI creates it once and preserves it. A single probe
cannot tell a real user from an unknown one, but a prober polling a name sees
its salt change when the user is added, rotated or removed. The file is read
only when the plugin is built and `auto_reload` does not watch it: send
`SIGHUP` after every change. Use one file per listener when listeners admit
different users.
### Rollout, rotation and revocation
- **Rollout.** Turning `scram` on cuts off every dialer of that listener that
has no credentials. Upgrade every binary first, then add a second listener
with `scram` on another port, move the dialers to it, and remove the old one.
- **Password rotation.** `logwisp auth add-user -generate` with the user's
`-password-file` replaces verifier and password together. `SIGHUP` the
listener, deploy the password file, `SIGHUP` the dialer. Logins fail in
between and the dialer retries under backoff, holding its current entry or
batch while its input queue fills. To avoid the gap, add a second user, move
the dialer to it, then remove the first; under `node_binding = "force"` the
node label follows the username.
- **Revocation.** `logwisp auth remove-user`, then `SIGHUP`. The reload drops
every connection and revokes every token; other dialers log in again on
their own.
### Limits
- TLS must terminate at LogWisp, except on an `http` sink in proxy mode. Behind
any other terminating proxy or load balancer every login fails by design;
pass TLS through instead (TCP or SNI routing). All clients then share the
proxy's address, and so one throttling budget.
- One process per HTTP address. Handshake state and the token key live in one
instance, so behind a balancer the proof or the token can reach an instance
that never saw the login.
- Browsers log in only to an `http` sink in proxy mode, below; elsewhere use
`mtls` for browsers.
- `nc` and `openssl s_client` cannot read a `scram` `tcp` sink; use
`logwisp auth stream`.
`test/scram-chain-test.sh --auto` exercises these guarantees end to end.
### Browsers behind a TLS-terminating proxy
A site that serves the log stream inside its own pages ends TLS at its reverse
proxy and need not give LogWisp a private key. `trusted_proxies` puts the `http`
sink in proxy mode, where browsers log in with the client LogWisp ships; the
password reaches neither the proxy nor LogWisp.
```toml
[pipelines.plugin_sinks.config]
host = "127.0.0.1"
port = 8081
login_page = true # GET /auth/login
viewer_page = true # GET /auth/view, needs login_page
[pipelines.plugin_sinks.config.auth]
type = "scram"
credentials_file = "/etc/logwisp/users.toml"
trusted_proxies = ["127.0.0.1"] # addresses or CIDRs
```
```nginx
location /logs/ {
proxy_pass http://127.0.0.1:8081/;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
```
- **Trust.** Every other peer gets `403` on every path. `X-Forwarded-Proto`
must be `https` on every hop, so a site accidentally served in plaintext fails
closed. The client is the rightmost `X-Forwarded-For` hop that is not a
proxy; throttling, sessions and logs use it. List only the proxies: a client
inside a listed range is trusted to name its own hops.
- **Unbound logins.** The browser cannot see a certificate LogWisp could bind
to, so proofs are unbound and a party on the hop between proxy and LogWisp
could relay a login. Keep that hop on loopback or a trusted network, or
enable `tls` on the sink; a plaintext hop to a proxy off this host is
warned about at startup. `identity` cannot be combined with proxy mode.
- **Sessions.** The page asks for a cookie: `logwisp_session`, `HttpOnly`,
`Secure`, `SameSite=Strict`, `Max-Age` the token lifetime, and no `Path`, so
it scopes itself to the mount (`/logs` above). Stream and status accept it or
a bearer token, so `new EventSource("/logs/stream")` works on any page of the
site, beside the site's own Basic auth too, and `logwisp auth token -unbound`
keeps working. `POST /auth` with
`{"logout": true}` clears the cookie and revokes the token until it expires.
`/auth` takes only `application/json`, which a cross-origin page cannot send
without a preflight.
- **Pages.** Under `/auth/` the sink serves `scram.js` always, and with
`login_page` / `viewer_page` the login page and a minimal live viewer with
their script and style, under `default-src 'none'; script-src 'self';
connect-src 'self'; style-src 'self'; form-action 'self'; frame-ancestors
'none'; base-uri 'none'`. A site with its own CSP can copy `scram.js` from
`internal/sink/http/web/` into its bundle: one dependency-free ES module
exporting `login(base, username, password, {onProgress})` and
`logout(base)`, where `base` is the mount URL ending in `/`. It needs a
secure context for WebCrypto and takes about 2 s of Argon2 per login on a
desktop.
`test/scram-proxy-test.sh --auto` logs in from headless Chromium through such a
proxy.
## What Each Layer Enforces
**`tls` with `client_auth = true`** — a membership check. The peer holds a
certificate chaining to `client_ca_file`, within its validity window, and holds
the matching private key. An attacker without a CA-issued certificate cannot
connect at all. What it does *not* decide is which CA-issued certificate: every
one is equivalent at this layer.
the matching private key. Every CA-issued certificate is equivalent at this
layer.
**`auth` with `type = "mtls"`** — an identity check, per listener:
**`auth` with `type = "mtls"`** — an identity check, per listener: only the
identities you list may connect, so one CA can serve several trust domains and
a single peer can be withdrawn without touching the others.
- only the identities you list may connect, so one CA can serve several trust
domains and a single peer can be withdrawn without touching the others
- the chain `node` label is bound to the certificate, so a compromised edge
cannot attribute its entries to another host
- the `http` sink's stream and status endpoints stop being open to anyone who
can reach the port
**`auth` with `type = "scram"`** — a password check, per listener: only the
users in its credentials file may connect, the login cannot be relayed through
another certificate (proxy mode, where TLS ends at the site's proxy, aside),
and with `identity` each user is tied to its own certificate.
**Revocation** is the allow-list, not a CRL. Remove the identity from `allow` /
`allow_patterns` and send `SIGHUP`: the reload rebuilds every pipeline, so the
Under either method the chain `node` label can be bound to the identity, so a
compromised edge cannot attribute its entries to another host, and the `http`
sink's stream and status endpoints stop being open to anyone who can reach the
port.
**Revocation** is the allow-list or the credentials file, not a CRL. Remove the
identity or user and send `SIGHUP`: the reload rebuilds every pipeline, so the
change takes effect on the next connection and existing ones are dropped by the
rebuild. No network call on the handshake path, and no window between revocation
and the next CRL publication. See
@@ -349,32 +595,33 @@ would add.
## Surfaces Without Access Control
An `auth` block closes each of these. Without one, bind them to a trusted
interface or front them with an authenticating proxy.
An `auth` block (`mtls` or `scram`) closes each of these. Without one, bind them
to a trusted interface or front them with an authenticating proxy.
| Surface | Exposure when `auth.type = "none"` |
|---------|-----------------------------------|
| `http` sink `stream_path` | Full log stream, with `Access-Control-Allow-Origin: *`, so any browser origin can read it (the header is omitted once an auth policy is set) |
| `http` sink `status_path` | Host, port, TLS flag, uptime, client counts, throughput counters |
| `tcp` sink | Full log stream to any client that connects |
| `tcp_chain` / `http_chain` source | Ingest from any peer the CA vouches for, under any node label it claims |
| `tcp_chain` / `http_chain` source | Ingest from any peer that can connect (with `client_auth`, any the CA vouches for), under any node label it claims |
`max_connections` bounds concurrency on all of them but does not distinguish
callers.
Note that `auth` requires `client_auth = true`, which requires TLS. There is no
way to authenticate a plaintext listener.
Both methods require TLS on the listener, except an `http` sink in proxy mode,
where TLS ends at the trusted proxy; `mtls` also requires `client_auth`.
## Operational Guidance
**Certificates**
**Certificates and secrets**
- Use a dedicated CA for LogWisp so its trust decisions stay independent.
- Keep leaf lifetimes short (90–825 days) and automate renewal.
- Key files should be `0600` and owned by the service account; a world-readable
one is reported at startup.
- Rotation requires a reload (`SIGHUP`), because certificates are loaded once at
plugin construction; there is no on-disk watch for certificate files.
- Key, credentials and password files should be `0600` and owned by the service
account; a world-readable one is reported at startup. `logwisp auth` creates
them `0600` and keeps the mode of an existing file.
- Rotation requires a reload (`SIGHUP`), because certificates and credentials
are loaded once at plugin construction; there is no on-disk watch for them.
- Startup and every reload warn 30 days before a certificate lapses. Still
automate renewal; the warning only reaches someone reading the log.
- Keep the identity field you authorize on stable across rotations. Reissuing a
@@ -388,10 +635,11 @@ way to authenticate a plaintext listener.
verification entirely and makes the connection trivially interceptable. It is
reported at startup.
- Bind listeners to specific interfaces rather than `0.0.0.0` where you can.
- On any ingest port reachable from a network you do not fully control, set
`auth.type = "mtls"` with an explicit `allow` list. `trust_node = false` is the
fallback when certificates are not an option; it is unforgeable but labels
entries by remote address, which is useless behind NAT or a load balancer.
- On any ingest port reachable from a network you do not fully control, set an
`auth` block: `mtls` with an explicit `allow` list, or `scram`.
`trust_node = false` is the fallback when neither is an option; it is
unforgeable but labels entries by remote address, which is useless behind NAT
or a load balancer.
- Run LogWisp as an unprivileged user with write access only to its own log and
configuration directories.
+71 -21
View File
@@ -135,22 +135,37 @@ allow = ["viewer-01"]
| `client_buffer_size` | int | `256` | Per-client send queue depth |
| `write_timeout_ms` | int | `0` | Per-event write deadline; `0` = none |
| `max_connections` | int | `0` | Concurrent stream cap; `0` = unlimited |
| `login_page` | bool | `false` | Serve the browser login page at `/auth/login`; needs `auth.trusted_proxies` |
| `viewer_page` | bool | `false` | Serve a minimal live viewer at `/auth/view`; needs `login_page` |
| `tls` | table | — | Listener TLS; see [Security](security.md) |
| `auth` | table | — | Client authorization; see [Security](security.md#the-auth-block) |
| `auth` | table | — | Client authentication (`mtls` or `scram`); see [Security](security.md#the-auth-block) |
**Behaviour**
- Only `GET` is routed to either path; anything else gets `405`, `HEAD` on
`stream_path` included — a stream is a body, and a client registered to have
its body discarded never reads and never leaves.
- With an `auth` block, one middleware gates **both** endpoints: an
unauthorized client gets `403` with no body detail, and the rejection is
logged at WARN and counted in `auth_rejected`. The authorized identity is
recorded in the client's session as `auth_method` / `auth_identity`.
- With an `auth` block, one middleware gates **both** endpoints, with no body
detail in a refusal. Under `mtls` a refused certificate gets `403`. Under
`scram` a client logs in at `POST /auth`, which sits outside the gate (see
[`logwisp auth token`](cli.md#logwisp-auth)), and sends
`Authorization: Bearer <token>`; a missing, invalid or expired token gets
`401` with `WWW-Authenticate`, a certificate that does not match the token's
user `403`. A stream is checked when it connects and outlives its token; a
reload ends it. `/auth` and every path under it are reserved.
- With `auth.trusted_proxies` (proxy mode) the sink sits behind a site's
TLS-terminating reverse proxy: only the proxies may connect, browsers log in
through `/auth/login` or the site's own copy of `/auth/scram.js`, and stream
and status also accept the `logwisp_session` cookie. See
[Browsers behind a TLS-terminating proxy](security.md#browsers-behind-a-tls-terminating-proxy).
- Refusals are logged at WARN and counted in `auth_rejected`. The authorized
identity is recorded in the client's session as `auth_method` /
`auth_identity`.
- On connect the client receives an `event: connected` frame carrying its
client id, session id, sink instance id, endpoint paths, and buffer size.
- Payloads are framed per the SSE spec, one `data:` line per newline in the
payload, so multi-line entries stream correctly.
- Payloads are framed per the SSE spec, one `data:` line per line break in the
payload (CRLF, LF or a lone CR), so multi-line entries stream correctly and
no entry can inject an `event:`, `id:` or `retry:` field.
- The server sets no `WriteTimeout` (that would kill long-lived streams);
per-write deadlines come from `write_timeout_ms` via `http.ResponseController`
and cover the connected frame, every payload, and the idle comment.
@@ -176,10 +191,15 @@ connection limit, write timeout, uptime, endpoint paths, and the
`total_processed` / `dropped_writes` / `rejected_clients` / `auth_rejected`
counters.
**Statistics**: `dropped_writes`, `rejected_clients`, `auth`, `auth_allowed`,
`auth_rejected`; under `scram` also `auth_users`, `auth_throttled`,
`auth_busy`, `auth_binding_mismatch` and `auth_token_lifetime_ms`, and in
proxy mode `auth_trusted_proxies`.
> Without an `auth` block both endpoints are unauthenticated, and the stream
> response carries `Access-Control-Allow-Origin: *`, so any web origin can read
> it. With one, the header is omitted. Set `auth.type = "mtls"` (which requires `tls.client_auth`), bind to a
> trusted interface, or put an authenticating reverse proxy in front.
> it. With one, the header is omitted. Set an `auth` block, bind to a trusted
> interface, or put an authenticating reverse proxy in front.
---
@@ -224,13 +244,17 @@ allow = ["viewer-01"]
| `keep_alive_period_ms` | int | `30000` | Keep-alive idle period |
| `max_connections` | int | `0` | Concurrent connection cap; `0` = unlimited |
| `tls` | table | — | Listener TLS |
| `auth` | table | — | Client authorization; see [Security](security.md#the-auth-block) |
| `auth` | table | — | Client authentication (`mtls` or `scram`); see [Security](security.md#the-auth-block) |
**Behaviour**
- The sink is write-only. Each connection also runs a reader that discards
inbound bytes; it exists to detect disconnects and to refresh session
activity when a client sends anything.
activity when a client sends anything. Under `scram` the client must log in
first: a hello carrying the login, then challenge, proof and final, all
within 10 s, after which the stream follows on the same connection. `nc` and
`openssl s_client` cannot do this; use
[`logwisp auth stream`](cli.md#logwisp-auth).
- A write that misses its deadline means the kernel buffer stayed full for the
whole timeout, so the client is disconnected immediately rather than retried.
- A client whose send queue is full has that event dropped (`dropped_writes`)
@@ -242,6 +266,10 @@ allow = ["viewer-01"]
receives a broadcast. Its connection is closed, the rejection logged at WARN,
and `rejected_conns` incremented.
**Statistics**: `write_errors`, `dropped_writes`, `rejected_conns`,
`tls_handshake_errors`, `auth`, `auth_allowed`, `auth_rejected`; under `scram`
also `auth_users`, `auth_throttled`, `auth_busy` and `auth_binding_mismatch`.
---
## tcp_chain
@@ -285,15 +313,27 @@ key_file = "/etc/logwisp/tls/client.key"
| `keep_alive` | bool | `true` | Enable TCP keep-alive |
| `keep_alive_period_ms` | int | `30000` | Keep-alive idle period |
| `tls` | table | — | Dialer TLS; `cert_file`/`key_file` present a client identity |
| `auth` | table | — | Server identity pinning; see [Security](security.md#dialer-side-pinning) |
| `auth` | table | — | Server identity pinning (`mtls`) or a login (`scram`); see [Security](security.md#the-auth-block) |
**Behaviour**
- The connection is established lazily, so pipeline start does not depend on the
downstream being up.
- Under `scram` the hello carries the login, and the link counts as established
only once the server's final message proves it holds the user's verifier. The
exchange is bounded at 10 s and cut short by shutdown. A refusal —
`authentication failed`, `authentication not enabled`, `no challenge within
10s` — is logged at WARN as `Chain connect refused` on every attempt; other
connect failures log at DEBUG. Both retry under the normal backoff.
- Each entry is serialized as one canonical JSON line. Delivery holds the line
across reconnects until it is written or the process shuts down, with
exponential backoff plus ±20 % jitter between attempts.
exponential backoff plus ±20 % jitter between attempts. The delay keeps
growing until a link outlives `backoff_min_ms`, so a source that drops every
link right after accepting it is not retried in a tight loop.
- A source never writes once a link is up, so the sink watches each link: a
line from the source is a refusal (a sink without `scram` facing a `scram`
source), logged at WARN as `Chain link refused`; EOF ends the link at once
instead of at the next failed write.
- Because delivery blocks the sink's run loop during an outage, back-pressure
surfaces as a full input queue and is counted by the pipeline as
`total_dropped_by_sink`.
@@ -302,12 +342,12 @@ key_file = "/etc/logwisp/tls/client.key"
- Events arriving without a structured entry are wrapped from the formatted
payload and counted in `synthesized`.
An `auth` block on a dialer pins the server's identity: the policy runs as part
of the handshake, so a server it rejects is treated like any other connect
failure and retried under the normal backoff.
Under `mtls` the policy pins the server's identity as part of the handshake, so
a server it rejects is refused like a failed login above.
**Statistics**: `target`, `node`, `tls`, `auth`, `connected`, `reconnects`,
`write_errors`, `synthesized`.
`write_errors`, `synthesized`; under `scram` also `auth_username`,
`auth_failures` and `last_auth_error` (empty after a successful login).
---
@@ -354,7 +394,7 @@ key_file = "/etc/logwisp/tls/client.key"
| `backoff_min_ms` | int | `500` | Retry backoff floor |
| `backoff_max_ms` | int | `30000` | Retry backoff ceiling |
| `tls` | table | — | Dialer TLS; `cert_file`/`key_file` present a client identity |
| `auth` | table | — | Server identity pinning; see [Security](security.md#dialer-side-pinning) |
| `auth` | table | — | Server identity pinning (`mtls`) or a login (`scram`); see [Security](security.md#the-auth-block) |
**Behaviour**
@@ -362,18 +402,28 @@ key_file = "/etc/logwisp/tls/client.key"
the first attempt succeeded but the response was lost.
- Retries apply to transport errors, `408`, `429`, and `5xx`. Any other
non-2xx response is treated as permanent, and the batch is dropped and counted
in `dropped_batches`.
in `dropped_batches`. A retry is logged at WARN, a dropped batch at ERROR.
- Under `scram` the sink logs in at `POST /auth` whenever it holds no token and
sends `Authorization: Bearer <token>`. A failed login is retried like a
transport error, holding the batch. The token is renewed ahead of its expiry;
a `401` on ingest (the source reloaded) drops it and the retry logs in again.
A `403` is permanent. A
connection presenting a certificate other than the one the login was bound
to drops token and pin, and the retry logs in anew. A sink without `scram`
facing a `scram` source gets `401` and drops every batch.
- Redirects are never followed; a `3xx` is permanent too. Following one would
resend the batch wherever the response points, plaintext `http` included.
- HTTP/2 is off by design; batched NDJSON POSTs gain nothing from it.
- On shutdown a single best-effort flush of the pending batch is attempted.
**Statistics**: `target`, `node`, `tls`, `auth`, `batches_sent`,
`request_errors`, `dropped_batches`, `synthesized`.
`request_errors`, `dropped_batches`, `synthesized`; under `scram` also
`auth_username`, `auth_failures` and `last_auth_error`.
---
## Sink Statistics
Every sink reports: `id`, `type`, `total_processed`, `active_connections`,
`start_time`, `last_processed`, and a type-specific `details` map.
`start_time`, `last_processed`, and a type-specific `details` map. The DEBUG
status report spreads `details` into each plugin's line.
+31 -18
View File
@@ -190,22 +190,30 @@ node_binding = "force"
| `hello_timeout_ms` | int | `10000` | Deadline for the hello preamble |
| `trust_node` | bool | `true` | `false` overrides the sender's node label with its remote address. Ignored when `auth.node_binding` is active |
| `tls` | table | — | Listener TLS; see [Security](security.md) |
| `auth` | table | — | Peer authorization and node binding; see [Security](security.md#the-auth-block) |
| `auth` | table | — | Peer authentication (`mtls` or `scram`) and node binding; see [Security](security.md#the-auth-block) |
For passwords instead of certificates, the `auth` block takes `type = "scram"`
and a `credentials_file`, and `client_auth` becomes optional; see
[Password Authentication](security.md#password-authentication-scram).
**Behaviour**
- TLS handshakes run explicitly with a 10 s bound before the preamble is read,
after the `max_connections` admission check.
- Authorization runs between the handshake and the hello read, so an
unauthorized peer never gets a preamble parsed on its behalf. A rejection is
logged at WARN and counted in `rejected_conns`.
- A connection is rejected if the first line is not a valid hello with a
matching protocol version.
- Under `mtls` the certificate is checked before the hello is read, so an
unauthorized peer never gets a preamble parsed on its behalf. Under `scram`
the hello opens the login: challenge, proof and final follow on the same
connection, the whole exchange within `hello_timeout_ms`, and the final is
sent only once node binding agrees. A source without `scram` answers a hello
offering credentials with `authentication not enabled`. A rejection is logged
at WARN and counted in `rejected_conns`.
- The node label is then resolved: under `auth.node_binding` it comes from the
peer's certificate, otherwise `trust_node` governs. `force` also overrides the
`node` field on every individual entry; `assert` leaves per-entry labels to
`trust_node`, so a relay can forward other nodes' entries while proving its
own identity.
peer's certificate identity or username, otherwise `trust_node` governs.
`force` also overrides the `node` field on every individual entry; `assert`
leaves per-entry labels to `trust_node`, so a relay can forward other nodes'
entries while proving its own identity.
- Each accepted connection gets a session recording the remote address, node
label, — under TLS — `tls` and `tls_peer_cn`, and — under auth —
`auth_method` and `auth_identity`.
@@ -215,7 +223,8 @@ node_binding = "force"
**Statistics**: `active_connections`, `rejected_conns`, `parse_errors`,
`tls_handshake_errors`, `trust_node`, `auth`, `auth_allowed`, `auth_rejected`,
`node_binding`.
`node_binding`; under `scram` also `auth_users`, `auth_throttled`, `auth_busy`
and `auth_binding_mismatch`.
---
@@ -259,16 +268,19 @@ node_binding = "force"
| `read_timeout_ms` | int | `30000` | Full request read deadline |
| `trust_node` | bool | `true` | `false` overrides the sender's node label with its remote address. Ignored when `auth.node_binding` is active |
| `tls` | table | — | Listener TLS |
| `auth` | table | — | Peer authorization and node binding; see [Security](security.md#the-auth-block) |
| `auth` | table | — | Peer authentication (`mtls` or `scram`) and node binding; see [Security](security.md#the-auth-block) |
**Behaviour**
- Only `POST` to `ingest_path` is routed; other methods get `405` with an
`Allow` header, and other paths get `404`.
- Only `POST` to `ingest_path` and to `/auth` is routed; other methods get `405`
with an `Allow` header, and other paths get `404`. `/auth` is the SCRAM login
and answers `404` unless `auth.type = "scram"`.
- Authorization runs before the body is read, so an unauthorized sender does not
get to stream `max_body_bytes` into the process. Both a policy rejection and a
node-binding failure answer `403`, distinct from the `400` used for protocol
errors, so a sender can tell "not allowed" from "malformed batch".
get to stream `max_body_bytes` into the process. A missing, invalid or expired
bearer token (`scram`) answers `401`, so the sender logs in again; a refused
certificate, a certificate that does not match the token's user, and a
node-binding failure answer `403`. Both are distinct from the `400` used for
protocol errors, so a sender can tell "not allowed" from "malformed batch".
- A missing or mismatched `X-Logwisp-Protocol` header is rejected with `400`.
- Batch acceptance is atomic: entries are published only after the body reads
cleanly end to end. A transfer error rejects the whole batch (`400`, or `413`
@@ -282,12 +294,13 @@ node_binding = "force"
**Statistics**: `total_requests`, `rejected_requests`, `parse_errors`,
`cached_sessions`, `trust_node`, `auth`, `auth_allowed`, `auth_rejected`,
`node_binding`.
`node_binding`; under `scram` also `auth_users`, `auth_throttled`, `auth_busy`,
`auth_binding_mismatch` and `auth_token_lifetime_ms`.
---
## Source Statistics
Every source reports: `id`, `type`, `total_entries`, `dropped_entries`,
`start_time`, `last_entry_time`, and a type-specific `details` map. These appear
in the status reporter output and in the `http` sink's status endpoint.
`start_time`, `last_entry_time`, and a type-specific `details` map. The DEBUG
status report spreads `details` into each plugin's line.
+7 -1
View File
@@ -3,8 +3,14 @@ module logwisp
go 1.27.1
require (
github.com/lixenwraith/auth v0.0.0-20261002191639-3ed0e995807c
github.com/lixenwraith/config v0.2.1
github.com/lixenwraith/log v0.1.11
github.com/lixenwraith/toml v0.0.0-20261001212158-cede215365c0
)
require github.com/lixenwraith/toml v0.0.0-20261001212158-cede215365c0 // indirect
require (
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
golang.org/x/crypto v0.57.0 // indirect
golang.org/x/sys v0.48.0 // indirect
)
+8
View File
@@ -1,6 +1,14 @@
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/lixenwraith/auth v0.0.0-20261002191639-3ed0e995807c h1:zwpVa85g267vo13UCzCao4lfVgUPaU79CmxPW9OdF8w=
github.com/lixenwraith/auth v0.0.0-20261002191639-3ed0e995807c/go.mod h1:411JUqnbQgy9jgoFV+fH91TfVUEfzYA4dyPv7f4c264=
github.com/lixenwraith/config v0.2.1 h1:a1VLWhtAi1F7FWtRtFVpPkvemnBEf+4xrvDupvwHYtg=
github.com/lixenwraith/config v0.2.1/go.mod h1:yfJQwKS+/6Dg70GIoHeYc0eYztOPETpqe6nUIbakcyg=
github.com/lixenwraith/log v0.1.11 h1:dKWLnaR8dhXxj4l/aU5XZiFIsr8geaJQbOf8m7SiKhU=
github.com/lixenwraith/log v0.1.11/go.mod h1:EE8i5nSAW2yZBTytKuhdgehmgzrby6kW1k5DyH1SvBg=
github.com/lixenwraith/toml v0.0.0-20261001212158-cede215365c0 h1:ue1pILlQAovrxTMSaKII4AbZ7yY4mNs3cyivoSBwMmA=
github.com/lixenwraith/toml v0.0.0-20261001212158-cede215365c0/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/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA=
golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
+380 -99
View File
@@ -1,21 +1,25 @@
// Package authz turns a verified certificate into an authorization decision.
// It is the single seam between declarative auth config and the network
// plugins: each one compiles a Policy at construction and calls Authorize per
// connection (TCP) or per request (HTTP).
//
// New returns (nil, nil) when auth is disabled, mirroring tlsx.Server and
// tlsx.Client, and every method tolerates a nil receiver — so call sites read
// the same whether or not a policy is configured.
// Package authz is the single seam between declarative auth config and the
// network plugins. Listeners admit peers through Admit (TCP) or
// AuthorizeRequest (HTTP); dialers prove themselves through Greet or Prepare.
// New returns nil when auth is disabled and every method tolerates a nil
// receiver, so call sites read the same whether or not a policy is set.
package authz
import (
"bufio"
"context"
"crypto/tls"
"errors"
"fmt"
"net"
"net/http"
"regexp"
"regexp/syntax"
"strings"
"sync/atomic"
"time"
"logwisp/internal/chain"
"logwisp/internal/config"
"logwisp/internal/tlsx"
@@ -26,6 +30,7 @@ import (
const (
MethodNone = "none"
MethodMTLS = "mtls"
MethodSCRAM = "scram"
)
// Node label binding modes. See ResolveNode and TrustsEntryNode for the
@@ -40,35 +45,56 @@ const (
type Role int
const (
// RoleListener authorizes client certificates on a plugin with no node
// concept: the tcp and http sinks
// RoleListener authenticates peers of a plugin with no node concept: the
// tcp and http sinks
RoleListener Role = iota
// RoleChainListener authorizes client certificates and binds the node
// label a peer declares: the tcp_chain and http_chain sources
// RoleChainListener also binds the node label a peer declares: the
// tcp_chain and http_chain sources
RoleChainListener
// RoleDialer pins the server's identity beyond hostname verification:
// the tcp_chain and http_chain sinks
// RoleDialer proves itself to, or pins, the server: the tcp_chain and
// http_chain sinks
RoleDialer
)
// Transport tells HTTP plugins, which carry bearer tokens, from TCP ones
type Transport int
const (
TCP Transport = iota
HTTP
)
// ErrRefused marks a decision of the policy, on either side: a peer refused
// by this listener, or this dialer refused by its server. Anything else is a
// transport or protocol failure.
var ErrRefused = errors.New("auth: refused")
// Policy is the compiled form of config.AuthOptions
type Policy struct {
role Role
identity string
transport Transport
method string
identity string // mtls: identity field; scram: certificate-to-user binding, "" = none
allow map[string]struct{}
patterns []*regexp.Regexp
binding string
secrets []secretFile // reported at startup when every local user can read them
listener *scramListener // scram listener state
dialer *scramDialer // scram dialer state
// Statistics
allowed atomic.Uint64
rejected atomic.Uint64
}
type secretFile struct{ key, path string }
// Identity is the outcome of a successful authorization. The zero value is
// what a disabled policy yields.
type Identity struct {
Name string // the selected certificate field
Method string // MethodMTLS
Name string // certificate field (mtls) or username (scram)
Method string
}
// Apply stamps an authenticated identity onto session metadata. A zero
@@ -82,69 +108,100 @@ func (id Identity) Apply(meta map[string]any) {
}
// New compiles an auth policy, returning (nil, nil) when auth is disabled.
// tlsOpts is the sibling `tls` block: an auth policy the transport cannot
// enforce is rejected here rather than silently accepted, which is the
// failure mode worth designing out.
func New(o *config.AuthOptions, tlsOpts *config.TLSOptions, role Role) (*Policy, error) {
// tlsCfg is what tlsx built from the sibling `tls` block: a policy the
// transport cannot enforce is rejected here rather than silently accepted.
func New(o *config.AuthOptions, tlsCfg *tls.Config, role Role, transport Transport) (*Policy, error) {
if o == nil {
return nil, nil
}
switch o.Type {
case "", MethodNone:
// Tuning keys are ignored with the block, but one that names peers or
// credentials means auth was intended and the type was forgotten
if key := intentKey(o); key != "" {
return nil, fmt.Errorf("auth: %s is set but auth.type is %q", key, MethodNone)
}
return nil, nil
case MethodMTLS:
case MethodMTLS, MethodSCRAM:
default:
return nil, fmt.Errorf("auth: type %q (valid: %q, %q)", o.Type, MethodNone, MethodMTLS)
return nil, fmt.Errorf("auth: type %q (valid: %q, %q, %q)", o.Type, MethodNone, MethodMTLS, MethodSCRAM)
}
if tlsCfg == nil && (o.Type != MethodSCRAM || len(o.TrustedProxies) == 0) {
return nil, fmt.Errorf("auth: type %q requires tls.enabled", o.Type)
}
if role == RoleDialer && tlsCfg != nil && tlsCfg.InsecureSkipVerify {
// An unverified server makes identities claims and exposes credentials
return nil, fmt.Errorf("auth: type %q cannot be used with tls.insecure_skip_verify", o.Type)
}
if tlsOpts == nil || !tlsOpts.Enabled {
return nil, fmt.Errorf("auth: type %q requires tls.enabled", MethodMTLS)
binding, err := nodeBinding(o.NodeBinding, role)
if err != nil {
return nil, err
}
if role == RoleDialer {
// Identity from an unverified chain is a claim, not a fact
if tlsOpts.InsecureSkipVerify {
return nil, fmt.Errorf("auth: type %q cannot pin an identity with tls.insecure_skip_verify", MethodMTLS)
}
} else if !tlsOpts.ClientAuth {
return nil, fmt.Errorf("auth: type %q requires tls.client_auth", MethodMTLS)
}
identity := o.Identity
if identity == "" {
identity = tlsx.IdentityCN
}
switch identity {
case tlsx.IdentityCN, tlsx.IdentitySANDNS, tlsx.IdentitySANURI, tlsx.IdentitySANEmail:
default:
return nil, fmt.Errorf("auth: identity %q (valid: %q, %q, %q, %q)",
identity, tlsx.IdentityCN, tlsx.IdentitySANDNS, tlsx.IdentitySANURI, tlsx.IdentitySANEmail)
}
binding := o.NodeBinding
if role == RoleChainListener {
if binding == "" {
// The only setting under which a misconfigured or hostile edge
// cannot mislabel its entries
binding = BindingForce
}
} else if binding != "" && binding != BindingNone {
return nil, fmt.Errorf("auth: node_binding %q applies only to chain sources", binding)
p := &Policy{role: role, transport: transport, method: o.Type, binding: binding}
if o.Type == MethodMTLS {
err = p.compileMTLS(o, tlsCfg)
} else {
binding = BindingNone
err = p.compileSCRAM(o, tlsCfg)
}
if err != nil {
return nil, err
}
return p, nil
}
// intentKey names the first key that only makes sense with authentication on
func intentKey(o *config.AuthOptions) string {
switch {
case len(o.Allow) > 0:
return "allow"
case len(o.AllowPatterns) > 0:
return "allow_patterns"
}
return scramKey(o)
}
func nodeBinding(binding string, role Role) (string, error) {
if role != RoleChainListener {
if binding != "" && binding != BindingNone {
return "", fmt.Errorf("auth: node_binding %q applies only to chain sources", binding)
}
return BindingNone, nil
}
switch binding {
case "":
// The only setting under which a misconfigured or hostile edge cannot
// mislabel its entries
return BindingForce, nil
case BindingNone, BindingAssert, BindingForce:
default:
return nil, fmt.Errorf("auth: node_binding %q (valid: %q, %q, %q)",
binding, BindingNone, BindingAssert, BindingForce)
return binding, nil
}
return "", fmt.Errorf("auth: node_binding %q (valid: %q, %q, %q)", binding, BindingNone, BindingAssert, BindingForce)
}
p := &Policy{
role: role,
identity: identity,
binding: binding,
allow: make(map[string]struct{}, len(o.Allow)),
func identityMode(mode string) (string, error) {
switch mode {
case "":
return tlsx.IdentityCN, nil
case tlsx.IdentityCN, tlsx.IdentitySANDNS, tlsx.IdentitySANURI, tlsx.IdentitySANEmail:
return mode, nil
}
return "", fmt.Errorf("auth: identity %q (valid: %q, %q, %q, %q)",
mode, tlsx.IdentityCN, tlsx.IdentitySANDNS, tlsx.IdentitySANURI, tlsx.IdentitySANEmail)
}
func (p *Policy) compileMTLS(o *config.AuthOptions, tlsCfg *tls.Config) error {
if key := scramKey(o); key != "" {
return fmt.Errorf("auth: %s applies only to type %q", key, MethodSCRAM)
}
if p.role != RoleDialer && tlsCfg.ClientAuth != tls.RequireAndVerifyClientCert {
return fmt.Errorf("auth: type %q requires tls.client_auth", MethodMTLS)
}
var err error
if p.identity, err = identityMode(o.Identity); err != nil {
return err
}
p.allow = make(map[string]struct{}, len(o.Allow))
for _, a := range o.Allow {
if a = strings.TrimSpace(a); a != "" {
p.allow[a] = struct{}{}
@@ -153,47 +210,236 @@ func New(o *config.AuthOptions, tlsOpts *config.TLSOptions, role Role) (*Policy,
for i, pat := range o.AllowPatterns {
re, err := regexp.Compile(pat)
if err != nil {
return nil, fmt.Errorf("auth: allow_patterns[%d] %q: %w", i, pat, err)
return fmt.Errorf("auth: allow_patterns[%d] %q: %w", i, pat, err)
}
p.patterns = append(p.patterns, re)
}
return p, nil
return nil
}
// Authorize extracts and checks the peer identity from a completed handshake.
// A nil policy authorizes everything and yields the zero Identity, so callers
// need no branch on whether auth is configured.
func scramKey(o *config.AuthOptions) string {
switch {
case o.CredentialsFile != "":
return "credentials_file"
case o.TokenLifetimeMS != 0:
return "token_lifetime_ms"
case o.Username != "":
return "username"
case o.PasswordFile != "":
return "password_file"
case len(o.TrustedProxies) > 0:
return "trusted_proxies"
}
return ""
}
// Authorize checks the mtls certificate identity of a completed handshake. It
// refuses under scram, so a listener that skipped its exchange fails closed.
// A nil policy authorizes everything and yields the zero Identity.
func (p *Policy) Authorize(cs *tls.ConnectionState) (Identity, error) {
if p == nil {
return Identity{}, nil
}
if p.method != MethodMTLS {
p.rejected.Add(1)
return Identity{}, fmt.Errorf("%w: type %q admits peers only through its exchange", ErrRefused, p.method)
}
if cs == nil {
p.rejected.Add(1)
return Identity{}, fmt.Errorf("auth: peer is not on a TLS connection")
return Identity{}, fmt.Errorf("%w: peer is not on a TLS connection", ErrRefused)
}
name := tlsx.PeerIdentity(*cs, p.identity)
if name == "" {
// An unusable identity field is a rejection, not an empty match
p.rejected.Add(1)
return Identity{}, fmt.Errorf("auth: peer certificate carries no %s identity", p.identity)
return Identity{}, fmt.Errorf("%w: peer certificate carries no %s identity", ErrRefused, p.identity)
}
if !p.permits(name) {
p.rejected.Add(1)
return Identity{}, fmt.Errorf("auth: identity %q is not allowed", name)
return Identity{}, fmt.Errorf("%w: identity %q is not allowed", ErrRefused, name)
}
p.allowed.Add(1)
return Identity{Name: name, Method: MethodMTLS}, nil
}
// VerifyConnection is assignable to tls.Config.VerifyConnection on a dialer,
// so a server whose identity the policy rejects fails the handshake itself
// rather than after the first write. It runs after the standard chain and
// hostname checks, so the identity it reads is already verified.
// VerifyConnection is assignable to tls.Config.VerifyConnection on a dialer.
// It runs after the standard chain and hostname checks on every connection,
// resumed ones included: mtls pins the server identity, scram pins the
// certificate its last exchange was bound to.
func (p *Policy) VerifyConnection(cs tls.ConnectionState) error {
if p == nil {
return nil
}
if p.dialer != nil {
return p.dialer.verifyPin(cs)
}
_, err := p.Authorize(&cs)
return err
}
// Admission is a TCP peer that passed authentication and awaits the plugin's
// own checks. Under scram nothing is sent until Accept or Reject, so a later
// check such as node binding still decides what the peer is told.
type Admission struct {
Identity Identity
Hello chain.Hello // zero when no hello was read
Reader *bufio.Reader // the stream continues here, after the auth lines
conn net.Conn
final []byte // scram: the server-final line Accept sends
deadline bool
}
// Admit authenticates a TCP peer before it is served: the certificate under
// mtls (before anything is read), the hello's SCRAM exchange under scram.
// wantHello reads the chain hello without scram too. The whole exchange runs
// under one read/write deadline of timeout; Accept clears it.
func (p *Policy) Admit(conn net.Conn, cs *tls.ConnectionState, wantHello bool, timeout time.Duration) (*Admission, error) {
a := &Admission{conn: conn, Reader: bufio.NewReaderSize(conn, streamBuffer)}
scram := p != nil && p.listener != nil
if !scram {
id, err := p.Authorize(cs)
if err != nil {
return nil, err
}
a.Identity = id
}
if !wantHello && !scram {
return a, nil
}
conn.SetDeadline(time.Now().Add(timeout))
a.deadline = true
line, err := readLine(a.Reader)
if err != nil {
return nil, fmt.Errorf("read hello: %w", err)
}
if a.Hello, err = chain.DecodeHello(line); err != nil {
if scram {
p.rejected.Add(1)
writeStep(conn, authStep{Error: "malformed hello"})
}
return nil, err
}
switch {
case scram:
if len(a.Hello.Scram) == 0 {
p.rejected.Add(1)
writeStep(conn, authStep{Error: "authentication required"})
return nil, fmt.Errorf("%w: peer offered no credentials", ErrRefused)
}
a.Identity, a.final, err = p.exchangeTCP(a, cs)
if err != nil {
return nil, err
}
case len(a.Hello.Scram) > 0:
// Without this answer a SCRAM dialer would wait out its deadline
writeStep(conn, authStep{Error: "authentication not enabled"})
return nil, fmt.Errorf("%w: peer offered credentials, auth.type is not %q", ErrRefused, MethodSCRAM)
}
return a, nil
}
// Accept tells a scram peer it is admitted and lifts the exchange deadline
func (a *Admission) Accept() error {
if a.final != nil {
if _, err := a.conn.Write(a.final); err != nil {
return err
}
}
if a.deadline {
return a.conn.SetDeadline(time.Time{})
}
return nil
}
// Reject tells a scram peer why it is turned away after authenticating
func (a *Admission) Reject(reason string) {
if a.final != nil {
writeStep(a.conn, authStep{Error: reason})
}
}
// AuthorizeRequest admits one HTTP request to a protected endpoint: the
// client certificate under mtls, the bearer token under scram. status is the
// answer for a refusal (401 or 403); see Refuse.
func (p *Policy) AuthorizeRequest(r *http.Request) (Identity, int, error) {
switch {
case p == nil:
return Identity{}, 0, nil
case p.listener == nil:
id, err := p.Authorize(r.TLS)
if err != nil {
return Identity{}, http.StatusForbidden, err
}
return id, 0, nil
}
return p.authorizeToken(r)
}
// Refuse answers a request AuthorizeRequest turned away, with no detail
func Refuse(w http.ResponseWriter, status int) {
if status == http.StatusUnauthorized {
w.Header().Set("WWW-Authenticate", `Bearer realm="logwisp"`)
}
http.Error(w, strings.ToLower(http.StatusText(status)), status)
}
// Greet opens a dialer's connection: it writes the hello and, under scram,
// runs the whole exchange under ExchangeTimeout, cut short by ctx. The
// returned reader holds whatever the server sent after the exchange.
func (p *Policy) Greet(ctx context.Context, conn net.Conn, node string) (*bufio.Reader, error) {
r := bufio.NewReaderSize(conn, streamBuffer)
conn.SetDeadline(time.Now().Add(ExchangeTimeout))
defer context.AfterFunc(ctx, func() { conn.SetDeadline(time.Now()) })()
if p == nil || p.dialer == nil {
line, err := chain.EncodeHello(chain.Hello{Node: node})
if err == nil {
_, err = conn.Write(line)
}
if err != nil {
return nil, fmt.Errorf("hello: %w", err)
}
} else if err := p.dialer.exchangeTCP(conn, r, node); err != nil {
if ctx.Err() != nil {
return nil, ctx.Err()
}
return nil, err
}
return r, conn.SetDeadline(time.Time{})
}
// Prepare readies a request for a protected HTTP endpoint: under scram it
// authenticates when no token is held, then sets the bearer header. baseURL
// is scheme://host:port; client must carry VerifyConnection. No-op otherwise.
func (p *Policy) Prepare(ctx context.Context, client *http.Client, baseURL string, req *http.Request) error {
if p == nil || p.dialer == nil {
return nil
}
token := p.dialer.token.Load()
if token == nil || time.Now().UnixNano() >= p.dialer.renewAt.Load() {
t, err := p.Token(ctx, client, baseURL)
if err != nil {
return err
}
token = &t
}
req.Header.Set("Authorization", "Bearer "+*token)
return nil
}
// Invalidate drops the scram token after the server refused it (status 401)
// or a connection presented a certificate other than the bound one (err),
// and reports whether a retry can succeed by authenticating again.
func (p *Policy) Invalidate(status int, err error) bool {
if p == nil || p.dialer == nil {
return false
}
if status != http.StatusUnauthorized && !errors.Is(err, errPinMismatch) {
return false
}
p.dialer.dropToken()
return true
}
// permits reports whether an identity satisfies the allow list. An empty list
// admits any identity the CA vouches for; that is the documented default, and
// constructors log it at startup rather than leaving it silent.
@@ -272,20 +518,42 @@ func (p *Policy) NodeBinding() string {
// Enabled reports whether a policy is in force
func (p *Policy) Enabled() bool { return p != nil }
// Unrestricted reports whether the policy admits any identity the CA vouches
// for. Constructors log this at startup: it is a deliberate default, and a
// silent one would be a footgun.
// Unrestricted reports whether an mtls policy admits any identity the CA
// vouches for. Under scram the credentials file is the allow list.
func (p *Policy) Unrestricted() bool {
return p != nil && len(p.allow) == 0 && len(p.patterns) == 0
return p != nil && p.method == MethodMTLS && len(p.allow) == 0 && len(p.patterns) == 0
}
// LogStartup reports, once per plugin construction, what a listener policy
// admits, how it labels nodes, and allow patterns that admit more than they
// appear to. trustNode matters only for chain sources.
// LogStartup reports, once per plugin construction, what the policy admits,
// how it labels nodes, allow patterns that admit more than they appear to,
// and secret files every local user can read. trustNode: chain sources only.
func (p *Policy) LogStartup(l *log.Logger, component, id string, trustNode bool) {
if p == nil || p.role == RoleDialer {
if p == nil {
return
}
for _, re := range p.patterns {
if !anchored(re.String()) {
l.Warn("msg", "auth.allow_patterns entry is not anchored and matches any identity containing it",
"component", component,
"instance_id", id,
"pattern", re.String(),
"hint", "anchor every alternative, e.g. ^(edge-01|edge-02)$")
}
}
for _, f := range p.secrets {
if w := tlsx.SecretFileWarning(f.key, f.path); w != "" {
l.Warn("msg", w, "component", component, "instance_id", id)
}
}
if p.role == RoleDialer {
return
}
if p.BehindProxy() && p.listener.proxy.exposedHop() {
l.Warn("msg", "Trusted proxies may be on other hosts and the hop from them is plaintext: anyone on that path can relay logins and read sessions",
"component", component,
"instance_id", id,
"hint", "keep the proxies on loopback, or enable tls on this listener")
}
if p.Unrestricted() {
l.Warn("msg", "Auth policy admits any identity the configured CA vouches for",
"component", component,
@@ -299,15 +567,6 @@ func (p *Policy) LogStartup(l *log.Logger, component, id string, trustNode bool)
"node_binding", p.binding,
"trust_node", trustNode)
}
for _, re := range p.patterns {
if !anchored(re.String()) {
l.Warn("msg", "auth.allow_patterns entry is not anchored and matches any identity containing it",
"component", component,
"instance_id", id,
"pattern", re.String(),
"hint", "anchor every alternative, e.g. ^(edge-01|edge-02)$")
}
}
}
// anchored reports whether every match of pattern spans the whole identity:
@@ -348,15 +607,26 @@ func anchoredAt(re *syntax.Regexp, anchor syntax.Op) bool {
// Describe renders the policy for a startup log line
func (p *Policy) Describe() string {
if p == nil {
switch {
case p == nil:
return MethodNone
case p.dialer != nil:
return fmt.Sprintf("%s user=%s", MethodSCRAM, p.dialer.username)
case p.listener != nil:
cert := "independent"
if p.identity != "" {
cert = p.identity + "=username"
}
if p.BehindProxy() {
return fmt.Sprintf("%s users=%d unbound behind %d trusted proxy range(s)", MethodSCRAM, len(p.listener.creds.Users), len(p.listener.proxy.trusted))
}
return fmt.Sprintf("%s users=%d certificate=%s node_binding=%s", MethodSCRAM, len(p.listener.creds.Users), cert, p.binding)
}
scope := fmt.Sprintf("%d exact, %d pattern(s)", len(p.allow), len(p.patterns))
if p.Unrestricted() {
scope = "any identity issued by the configured CA"
}
return fmt.Sprintf("%s identity=%s allow=[%s] node_binding=%s",
MethodMTLS, p.identity, scope, p.binding)
return fmt.Sprintf("%s identity=%s allow=[%s] node_binding=%s", MethodMTLS, p.identity, scope, p.binding)
}
// Rejected returns the number of authorization failures
@@ -369,18 +639,29 @@ func (p *Policy) Rejected() uint64 {
// Stats reports policy state for a plugin's stats details map. Merge it in
// with maps.Copy so rejections surface in the status reporter and in the
// http sink's status endpoint.
// http sink's status endpoint. allowed counts admissions (certificate checks,
// SCRAM logins); rejected counts every refusal.
func (p *Policy) Stats() map[string]any {
if p == nil {
return map[string]any{"auth": MethodNone}
}
d := map[string]any{
"auth": MethodMTLS,
"auth_identity": p.identity,
"auth_unrestricted": p.Unrestricted(),
"auth": p.method,
"auth_allowed": p.allowed.Load(),
"auth_rejected": p.rejected.Load(),
}
switch {
case p.dialer != nil:
p.dialer.stats(d)
case p.listener != nil:
p.listener.stats(d)
if p.identity != "" {
d["auth_identity"] = p.identity
}
default:
d["auth_identity"] = p.identity
d["auth_unrestricted"] = p.Unrestricted()
}
if p.role == RoleChainListener {
d["node_binding"] = p.binding
}
+67 -23
View File
@@ -5,6 +5,7 @@ import (
"crypto/x509"
"crypto/x509/pkix"
"net/url"
"strings"
"testing"
"logwisp/internal/config"
@@ -21,8 +22,8 @@ func leafCN(cn string) *x509.Certificate {
return &x509.Certificate{Subject: pkix.Name{CommonName: cn}}
}
func mtlsListenerTLS() *config.TLSOptions {
return &config.TLSOptions{Enabled: true, ClientAuth: true}
func mtlsListenerTLS() *tls.Config {
return &tls.Config{ClientAuth: tls.RequireAndVerifyClientCert}
}
func TestPeerIdentityModes(t *testing.T) {
@@ -65,7 +66,7 @@ func TestPeerIdentityModes(t *testing.T) {
func TestNewDisabled(t *testing.T) {
for _, o := range []*config.AuthOptions{nil, {}, {Type: MethodNone}} {
p, err := New(o, nil, RoleListener)
p, err := New(o, nil, RoleListener, TCP)
if err != nil {
t.Fatalf("New(%+v) error: %v", o, err)
}
@@ -106,35 +107,78 @@ func TestNilPolicyIsTransparent(t *testing.T) {
}
}
// Each row is valid but for its own rule, and the error names that rule
func TestNewValidation(t *testing.T) {
f := newFixture(t)
pw := f.write(t, "pw", "edge-01-secret\n")
scram := func(o config.AuthOptions) *config.AuthOptions { o.Type = MethodSCRAM; return &o }
listener := func(o config.AuthOptions) *config.AuthOptions { o.CredentialsFile = f.creds; return scram(o) }
proxies := []string{"127.0.0.1", "10.0.0.0/8"}
dialer := func(o config.AuthOptions) *config.AuthOptions {
o.Username, o.PasswordFile = "edge-01", pw
return scram(o)
}
tests := []struct {
name string
auth *config.AuthOptions
tls *config.TLSOptions
tls *tls.Config
role Role
transport Transport
want string
}{
{"unknown type", &config.AuthOptions{Type: "kerberos"}, mtlsListenerTLS(), RoleListener},
{"no tls", &config.AuthOptions{Type: MethodMTLS}, nil, RoleListener},
{"tls disabled", &config.AuthOptions{Type: MethodMTLS}, &config.TLSOptions{}, RoleListener},
{"no client_auth", &config.AuthOptions{Type: MethodMTLS}, &config.TLSOptions{Enabled: true}, RoleListener},
{"unknown identity", &config.AuthOptions{Type: MethodMTLS, Identity: "serial"}, mtlsListenerTLS(), RoleListener},
{"bad pattern", &config.AuthOptions{Type: MethodMTLS, AllowPatterns: []string{"^edge-("}}, mtlsListenerTLS(), RoleListener},
{"unknown binding", &config.AuthOptions{Type: MethodMTLS, NodeBinding: "maybe"}, mtlsListenerTLS(), RoleChainListener},
{"binding on plain listener", &config.AuthOptions{Type: MethodMTLS, NodeBinding: BindingForce}, mtlsListenerTLS(), RoleListener},
{"binding on dialer", &config.AuthOptions{Type: MethodMTLS, NodeBinding: BindingForce}, &config.TLSOptions{Enabled: true}, RoleDialer},
{"dialer skips verify", &config.AuthOptions{Type: MethodMTLS}, &config.TLSOptions{Enabled: true, InsecureSkipVerify: true}, RoleDialer},
{"unknown type", &config.AuthOptions{Type: "kerberos"}, mtlsListenerTLS(), RoleListener, TCP, `type "kerberos"`},
{"allow list without a type", &config.AuthOptions{Allow: []string{"edge-01"}}, mtlsListenerTLS(), RoleListener, TCP, "allow is set"},
{"credentials without a type", &config.AuthOptions{CredentialsFile: f.creds}, mtlsListenerTLS(), RoleListener, TCP, "credentials_file is set"},
{"no tls", &config.AuthOptions{Type: MethodMTLS}, nil, RoleListener, TCP, "requires tls.enabled"},
{"no client_auth", &config.AuthOptions{Type: MethodMTLS}, &tls.Config{}, RoleListener, TCP, "requires tls.client_auth"},
{"unknown identity", &config.AuthOptions{Type: MethodMTLS, Identity: "serial"}, mtlsListenerTLS(), RoleListener, TCP, `identity "serial"`},
{"bad pattern", &config.AuthOptions{Type: MethodMTLS, AllowPatterns: []string{"^edge-("}}, mtlsListenerTLS(), RoleListener, TCP, "allow_patterns[0]"},
{"unknown binding", &config.AuthOptions{Type: MethodMTLS, NodeBinding: "maybe"}, mtlsListenerTLS(), RoleChainListener, TCP, `node_binding "maybe"`},
{"binding on plain listener", &config.AuthOptions{Type: MethodMTLS, NodeBinding: BindingForce}, mtlsListenerTLS(), RoleListener, TCP, "only to chain sources"},
{"binding on dialer", &config.AuthOptions{Type: MethodMTLS, NodeBinding: BindingForce}, &tls.Config{}, RoleDialer, TCP, "only to chain sources"},
{"dialer skips verify", &config.AuthOptions{Type: MethodMTLS}, &tls.Config{InsecureSkipVerify: true}, RoleDialer, TCP, "insecure_skip_verify"},
{"mtls with a password", &config.AuthOptions{Type: MethodMTLS, PasswordFile: pw}, &tls.Config{}, RoleDialer, TCP, "password_file applies only"},
{"scram without tls", listener(config.AuthOptions{}), nil, RoleListener, TCP, "requires tls.enabled"},
{"scram without credentials", scram(config.AuthOptions{}), f.serverTLS, RoleListener, TCP, "requires credentials_file"},
{"scram with an allow list", listener(config.AuthOptions{Allow: []string{"edge-01"}}), f.serverTLS, RoleListener, TCP, "credentials file is the allow list"},
{"scram listener with a password", listener(config.AuthOptions{Username: "edge-01"}), f.serverTLS, RoleListener, TCP, "only to dialers"},
{"scram certificate binding without client_auth", listener(config.AuthOptions{Identity: "cn"}), f.serverTLS, RoleListener, TCP, "requires tls.client_auth"},
{"token lifetime on tcp", listener(config.AuthOptions{TokenLifetimeMS: 60000}), f.serverTLS, RoleListener, TCP, "only to HTTP listeners"},
{"token lifetime too short", listener(config.AuthOptions{TokenLifetimeMS: 900}), f.serverTLS, RoleListener, HTTP, "outside"},
{"token lifetime too long", listener(config.AuthOptions{TokenLifetimeMS: 1e13}), f.serverTLS, RoleListener, HTTP, "outside"},
{"scram listener without a certificate", listener(config.AuthOptions{}), &tls.Config{}, RoleListener, TCP, "listener certificate"},
{"scram dialer without a password", scram(config.AuthOptions{Username: "edge-01"}), &tls.Config{}, RoleDialer, TCP, "requires username and password_file"},
{"scram dialer with credentials", dialer(config.AuthOptions{CredentialsFile: f.creds}), &tls.Config{}, RoleDialer, TCP, "only to listeners"},
{"scram dialer skips verify", dialer(config.AuthOptions{}), &tls.Config{InsecureSkipVerify: true}, RoleDialer, TCP, "insecure_skip_verify"},
{"trusted proxies without a type", &config.AuthOptions{TrustedProxies: proxies}, nil, RoleListener, HTTP, "trusted_proxies is set"},
{"trusted proxies under mtls", &config.AuthOptions{Type: MethodMTLS, TrustedProxies: proxies}, mtlsListenerTLS(), RoleListener, HTTP, "trusted_proxies applies only"},
{"trusted proxies on a chain source", listener(config.AuthOptions{TrustedProxies: proxies}), nil, RoleChainListener, HTTP, "only to the http sink"},
{"trusted proxies on tcp", listener(config.AuthOptions{TrustedProxies: proxies}), nil, RoleListener, TCP, "only to the http sink"},
{"trusted proxies with certificate binding", listener(config.AuthOptions{TrustedProxies: proxies, Identity: "cn"}), f.serverTLS, RoleListener, HTTP, "TLS-terminating proxy"},
{"malformed trusted proxy", listener(config.AuthOptions{TrustedProxies: []string{"localhost"}}), nil, RoleListener, HTTP, "neither an address"},
{"trusted proxies on a dialer", dialer(config.AuthOptions{TrustedProxies: proxies}), &tls.Config{}, RoleDialer, HTTP, "only to listeners"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
if _, err := New(tc.auth, tc.tls, tc.role); err == nil {
t.Fatal("expected an error, got nil")
if _, err := New(tc.auth, tc.tls, tc.role, tc.transport); err == nil || !strings.Contains(err.Error(), tc.want) {
t.Fatalf("error %v, want one naming %q", err, tc.want)
}
})
}
// A dialer needs TLS but not client_auth: it pins the server's identity
if _, err := New(&config.AuthOptions{Type: MethodMTLS}, &config.TLSOptions{Enabled: true}, RoleDialer); err != nil {
t.Fatalf("dialer policy rejected: %v", err)
// The rows' bases are valid: a dialer needs TLS but not client_auth, as it
// pins the server's identity
for _, o := range []*config.AuthOptions{{Type: MethodMTLS}, dialer(config.AuthOptions{})} {
if _, err := New(o, &tls.Config{}, 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 {
t.Fatalf("scram listener rejected: %v", err)
}
// Behind proxies TLS ends at the proxy, so the hop may be plaintext
if _, err := New(listener(config.AuthOptions{TrustedProxies: proxies}), nil, RoleListener, HTTP); err != nil {
t.Fatalf("proxy-mode listener rejected: %v", err)
}
}
@@ -143,7 +187,7 @@ func TestAuthorizeMatching(t *testing.T) {
Type: MethodMTLS,
Allow: []string{"edge-01", " edge-02 "},
AllowPatterns: []string{`^relay-\d{2}$`},
}, mtlsListenerTLS(), RoleListener)
}, mtlsListenerTLS(), RoleListener, TCP)
if err != nil {
t.Fatalf("New: %v", err)
}
@@ -185,7 +229,7 @@ func TestAuthorizeMatching(t *testing.T) {
// Empty allow and allow_patterns admits any CA-vouched identity, but still
// records it and still refuses a certificate with no usable identity field
func TestAuthorizeUnrestricted(t *testing.T) {
p, err := New(&config.AuthOptions{Type: MethodMTLS}, mtlsListenerTLS(), RoleListener)
p, err := New(&config.AuthOptions{Type: MethodMTLS}, mtlsListenerTLS(), RoleListener, TCP)
if err != nil {
t.Fatalf("New: %v", err)
}
@@ -209,7 +253,7 @@ func TestAuthorizeUnrestricted(t *testing.T) {
func TestResolveNodeBindings(t *testing.T) {
newChain := func(binding string) *Policy {
p, err := New(&config.AuthOptions{Type: MethodMTLS, NodeBinding: binding}, mtlsListenerTLS(), RoleChainListener)
p, err := New(&config.AuthOptions{Type: MethodMTLS, NodeBinding: binding}, mtlsListenerTLS(), RoleChainListener, TCP)
if err != nil {
t.Fatalf("New(%q): %v", binding, err)
}
@@ -285,7 +329,7 @@ func TestIdentityApply(t *testing.T) {
func TestVerifyConnectionPinsServer(t *testing.T) {
p, err := New(&config.AuthOptions{Type: MethodMTLS, Allow: []string{"relay.internal"}},
&config.TLSOptions{Enabled: true}, RoleDialer)
&tls.Config{}, RoleDialer, TCP)
if err != nil {
t.Fatalf("New: %v", err)
}
+204
View File
@@ -0,0 +1,204 @@
package authz
import (
"crypto/sha256"
"errors"
"fmt"
"net/http"
"net/netip"
"slices"
"strings"
"sync"
"time"
"github.com/lixenwraith/auth"
)
// SessionCookie carries a browser's token in proxy mode
const SessionCookie = "logwisp_session"
const maxRevoked = 65536 // logged-out tokens remembered until they expire
// proxyMode is an http sink behind TLS-terminating reverse proxies: only they
// may connect, X-Forwarded-For names the client, logins are unbound (logwisp
// holds no certificate the browser sees) and sessions may be cookies.
type proxyMode struct {
trusted []netip.Prefix
plaintext bool // no tls on the hop from the proxies
mu sync.Mutex
revoked map[[32]byte]time.Time
}
func parseProxies(entries []string) (*proxyMode, error) {
m := &proxyMode{}
for _, e := range entries {
e = strings.TrimSpace(e)
prefix, err := netip.ParsePrefix(e)
if err != nil {
addr, aerr := netip.ParseAddr(e)
if aerr != nil {
return nil, fmt.Errorf("auth: trusted_proxies entry %q is neither an address nor a CIDR", e)
}
addr = addr.Unmap()
prefix = netip.PrefixFrom(addr, addr.BitLen())
}
m.trusted = append(m.trusted, prefix.Masked())
}
return m, nil
}
func (m *proxyMode) trusts(addr netip.Addr) bool {
return slices.ContainsFunc(m.trusted, func(p netip.Prefix) bool { return p.Contains(addr) })
}
// parseHop reads a forwarded or peer address, with or without a port
func parseHop(s string) (netip.Addr, bool) {
if ap, err := netip.ParseAddrPort(s); err == nil {
return ap.Addr().Unmap(), true
}
addr, err := netip.ParseAddr(strings.TrimSuffix(strings.TrimPrefix(s, "["), "]"))
return addr.Unmap(), err == nil
}
// exposedHop reports a plaintext hop from a proxy that may be on another host
func (m *proxyMode) exposedHop() bool {
return m.plaintext && slices.ContainsFunc(m.trusted, func(p netip.Prefix) bool { return !p.Addr().IsLoopback() })
}
// client is the rightmost X-Forwarded-For hop that is not a trusted proxy:
// hops to its left come from the client and prove nothing. When every hop is
// trusted, the client sits inside a proxy range and the leftmost hop is it.
func (m *proxyMode) client(peer string, h http.Header) (string, error) {
if addr, ok := parseHop(peer); !ok || !m.trusts(addr) {
return "", fmt.Errorf("%s is not a trusted proxy", peer)
}
protos := headerList(h, "X-Forwarded-Proto")
if len(protos) == 0 || slices.ContainsFunc(protos, func(v string) bool { return !strings.EqualFold(v, "https") }) {
return "", fmt.Errorf("proxy forwarded X-Forwarded-Proto %q; sessions need https", strings.Join(protos, ","))
}
hops := headerList(h, "X-Forwarded-For")
if len(hops) == 0 {
return "", errors.New("proxy sent no client address in X-Forwarded-For")
}
for i := len(hops) - 1; ; i-- {
addr, ok := parseHop(hops[i])
if !ok {
return "", fmt.Errorf("malformed X-Forwarded-For hop %q", hops[i])
}
if i == 0 || !m.trusts(addr) {
return addr.String(), nil
}
}
}
func headerList(h http.Header, key string) []string {
var out []string
for _, v := range h.Values(key) {
for e := range strings.SplitSeq(v, ",") {
if e = strings.TrimSpace(e); e != "" {
out = append(out, e)
}
}
}
return out
}
func (m *proxyMode) revoke(token string, until time.Time) bool {
key := sha256.Sum256([]byte(token))
now := time.Now()
m.mu.Lock()
defer m.mu.Unlock()
if m.revoked == nil {
m.revoked = make(map[[32]byte]time.Time)
}
if len(m.revoked) >= maxRevoked {
for k, expiry := range m.revoked {
if now.After(expiry) {
delete(m.revoked, k)
}
}
if len(m.revoked) >= maxRevoked {
return false
}
}
m.revoked[key] = until
return true
}
func (m *proxyMode) isRevoked(token string) bool {
key := sha256.Sum256([]byte(token))
m.mu.Lock()
defer m.mu.Unlock()
_, ok := m.revoked[key]
return ok
}
// BehindProxy reports proxy mode (auth.trusted_proxies on an http sink)
func (p *Policy) BehindProxy() bool {
return p != nil && p.listener != nil && p.listener.proxy != nil
}
// ClientAddr is the address throttling, sessions and logs name: the socket
// peer, or in proxy mode the forwarded client. A proxy-mode request from an
// untrusted peer, or one the proxy did not receive over https, is refused.
func (p *Policy) ClientAddr(r *http.Request) (string, error) {
peer := remoteIP(r.RemoteAddr)
if !p.BehindProxy() {
return peer, nil
}
addr, err := p.listener.proxy.client(peer, r.Header)
if err != nil {
p.rejected.Add(1)
return "", fmt.Errorf("%w: %w", ErrRefused, err)
}
return addr, nil
}
// presentedToken is the bearer token or, in proxy mode, the session cookie.
// There another scheme, such as the site's own Basic auth, leaves the cookie
// in charge.
func (p *Policy) presentedToken(r *http.Request) (string, error) {
h := r.Header.Get("Authorization")
if scheme, _, _ := strings.Cut(h, " "); p.BehindProxy() && !strings.EqualFold(scheme, "Bearer") {
h = ""
}
if h != "" {
token, err := auth.ParseBearerToken(h)
if err != nil {
return "", errors.New("malformed bearer token")
}
return token, nil
}
if p.BehindProxy() {
if c, err := r.Cookie(SessionCookie); err == nil && c.Value != "" {
return c.Value, nil
}
}
return "", errors.New("no bearer token")
}
// sessionCookie sets no Path: it defaults to the directory of /auth, which is
// the mount point behind any proxy prefix, so stream and status receive it.
func sessionCookie(token string, maxAge int) *http.Cookie {
return &http.Cookie{Name: SessionCookie, Value: token, MaxAge: maxAge,
HttpOnly: true, Secure: true, SameSite: http.SameSiteStrictMode}
}
// logout clears the cookie and revokes a valid presented token until it
// expires. It answers on /auth itself: a clearing cookie set from another path
// would default to another Path and miss the login cookie.
func (p *Policy) logout(w http.ResponseWriter, r *http.Request) error {
l := p.listener
http.SetCookie(w, sessionCookie("", -1))
token, err := p.presentedToken(r)
if err == nil {
_, _, err = l.tokens.ValidateToken(token)
}
if err == nil && !l.proxy.revoke(token, time.Now().Add(l.lifetime)) {
l.busy.Add(1)
writeJSON(w, http.StatusServiceUnavailable, authStep{Error: "busy"})
return errors.New("auth: logout revocation list is full; the token stays valid until it expires")
}
w.WriteHeader(http.StatusNoContent)
return nil
}
+257
View File
@@ -0,0 +1,257 @@
package authz
import (
"bytes"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
"logwisp/internal/chain"
"logwisp/internal/config"
"github.com/lixenwraith/auth"
)
// forwarding is the proxy's half of every request: the client and https
type forwarding struct{ next http.RoundTripper }
func (f forwarding) RoundTrip(r *http.Request) (*http.Response, error) {
r = r.Clone(r.Context())
r.Header.Set("X-Forwarded-For", "203.0.113.7")
r.Header.Set("X-Forwarded-Proto", "https")
return f.next.RoundTrip(r)
}
// proxyListener is a plaintext http sink policy behind a proxy on loopback
func (f *fixture) proxyListener(t *testing.T) (*Policy, *httptest.Server) {
t.Helper()
l := f.listener(t, config.AuthOptions{TrustedProxies: []string{"127.0.0.1"}}, nil, RoleListener, HTTP)
mux := http.NewServeMux()
mux.HandleFunc("POST "+chain.AuthPath, func(w http.ResponseWriter, r *http.Request) { l.ServeAuth(w, r) })
mux.HandleFunc("GET /protected", func(w http.ResponseWriter, r *http.Request) {
if _, status, err := l.AuthorizeRequest(r); err != nil {
Refuse(w, status)
}
})
srv := httptest.NewServer(mux)
t.Cleanup(srv.Close)
return l, srv
}
// Behind trusted proxies only they may connect, only requests they received
// over https pass, and the client is the rightmost forwarded hop that is not
// a proxy (hops to its left are the client's own claims), or the leftmost
// when every hop is inside a proxy range.
func TestProxyModeTrustsOnlyItsProxies(t *testing.T) {
f := newFixture(t)
l := f.listener(t, config.AuthOptions{TrustedProxies: []string{"127.0.0.1", "10.0.0.0/8"}}, nil, RoleListener, HTTP)
for _, tc := range []struct{ name, peer, xff, proto, want string }{
{"untrusted peer", "192.0.2.1:5000", "203.0.113.7", "https", ""},
{"no proto", "127.0.0.1:5000", "203.0.113.7", "", ""},
{"plaintext site", "127.0.0.1:5000", "203.0.113.7", "http", ""},
{"one plaintext hop", "127.0.0.1:5000", "203.0.113.7", "https, http", ""},
{"no client", "127.0.0.1:5000", "", "https", ""},
{"malformed client", "127.0.0.1:5000", "not-an-address", "https", ""},
{"spoofed hop", "127.0.0.1:5000", "198.51.100.1, 203.0.113.7", "https", "203.0.113.7"},
{"proxy chain", "127.0.0.1:5000", "203.0.113.7, 10.1.2.3", "https", "203.0.113.7"},
{"proxy hop with a port", "127.0.0.1:5000", "203.0.113.7, 10.1.2.3:443", "https", "203.0.113.7"},
{"bracketed IPv6 client", "127.0.0.1:5000", "[2001:db8::1]", "https", "2001:db8::1"},
{"client inside a proxy range", "127.0.0.1:5000", "10.9.9.9, 10.1.2.3", "https", "10.9.9.9"},
} {
r := httptest.NewRequest(http.MethodGet, "/status", nil)
r.RemoteAddr = tc.peer
if tc.xff != "" {
r.Header.Set("X-Forwarded-For", tc.xff)
}
if tc.proto != "" {
r.Header.Set("X-Forwarded-Proto", tc.proto)
}
got, err := l.ClientAddr(r)
switch {
case tc.want == "" && !errors.Is(err, ErrRefused):
t.Errorf("%s: client %q, %v; want a refusal", tc.name, got, err)
case tc.want != "" && (err != nil || got != tc.want):
t.Errorf("%s: client %q, %v; want %s", tc.name, got, err, tc.want)
}
}
}
// A browser logs in unbound and gets its session only as a cookie that
// scopes itself to the mount; the cookie opens the endpoints, beside the
// site's own Basic auth too, until logout revokes it. Outside proxy mode
// cookie sessions and logout are refused.
func TestBrowserSessionBehindProxy(t *testing.T) {
f := newFixture(t)
l, srv := f.proxyListener(t)
client := &http.Client{Transport: forwarding{http.DefaultTransport}}
post := func(url string, body any, cookie *http.Cookie) (*http.Response, authStep) {
data, _ := json.Marshal(body)
req, _ := http.NewRequest(http.MethodPost, url+chain.AuthPath, bytes.NewReader(data))
req.Header.Set("Content-Type", "application/json")
if cookie != nil {
req.AddCookie(cookie)
}
resp, err := client.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
var step authStep
json.NewDecoder(resp.Body).Decode(&step)
return resp, step
}
login := func(url string) (*http.Response, authStep) {
c := auth.NewScramClient("edge-01", "edge-01-secret", auth.WithMinArgonCost(1, 64))
first, _ := c.StartAuthentication()
scram, _ := json.Marshal(first)
_, step := post(url, chain.Hello{LogWisp: chain.ProtocolVersion, Scram: scram}, nil)
if step.Challenge == nil {
t.Fatalf("no challenge: %q", step.Error)
}
proof, err := c.ProcessServerFirstMessage(*step.Challenge)
if err != nil {
t.Fatal(err)
}
return post(url, authStep{Proof: &proof, Session: "cookie"}, nil)
}
open := func(cookie *http.Cookie, basic bool) int {
req, _ := http.NewRequest(http.MethodGet, srv.URL+"/protected", nil)
req.AddCookie(cookie)
if basic {
req.SetBasicAuth("site-user", "site-password")
}
resp, err := client.Do(req)
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
return resp.StatusCode
}
resp, step := login(srv.URL)
cookies := resp.Cookies()
if resp.StatusCode != http.StatusOK || step.Token != "" || len(cookies) != 1 {
t.Fatalf("login: %d, body token %q, %d cookies", resp.StatusCode, step.Token, len(cookies))
}
c := cookies[0]
if c.Name != SessionCookie || !c.HttpOnly || !c.Secure || c.SameSite != http.SameSiteStrictMode ||
c.Path != "" || c.MaxAge != int(DefaultTokenLifetime.Seconds()) {
t.Fatalf("session cookie %+v", c)
}
for _, basic := range []bool{false, true} {
if got := open(c, basic); got != http.StatusOK {
t.Fatalf("with the cookie (Basic auth %v): %d", basic, got)
}
}
req, _ := http.NewRequest(http.MethodPost, srv.URL+chain.AuthPath, strings.NewReader(`{"logout":true}`))
req.Header.Set("Content-Type", "application/json")
req.SetBasicAuth("site-user", "site-password")
req.AddCookie(c)
resp, err := client.Do(req)
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
if cleared := resp.Cookies(); resp.StatusCode != http.StatusNoContent || len(cleared) != 1 || cleared[0].MaxAge >= 0 {
t.Fatalf("logout: %d, cookies %+v", resp.StatusCode, cleared)
}
if got := open(c, false); got != http.StatusUnauthorized {
t.Fatalf("after logout: %d, want 401", got)
}
if l.allowed.Load() != 1 {
t.Fatalf("logins = %d", l.allowed.Load())
}
direct := f.listener(t, config.AuthOptions{}, f.serverTLS, RoleListener, HTTP)
ds, _ := f.httpListener(t, direct, f.serverTLS)
client = f.httpClient(f.dialer(t, "edge-01", "edge-01-secret"))
proof := &auth.ClientFinalRequest{FullNonce: strings.Repeat("n", 32), ClientProof: "AAAA"}
for _, step := range []authStep{{Proof: proof, Session: "cookie"}, {Logout: true}} {
if resp, _ := post(ds.URL, step, nil); resp.StatusCode != http.StatusBadRequest {
t.Fatalf("%+v outside proxy mode: %d, want 400", step, resp.StatusCode)
}
}
}
// Behind a proxy logwisp never sees the certificate a client binds to, so
// only unbound proofs (logwisp auth token -unbound) log in.
func TestOnlyUnboundLoginsBehindProxy(t *testing.T) {
f := newFixture(t)
l := f.listener(t, config.AuthOptions{TrustedProxies: []string{"127.0.0.1"}}, f.serverTLS, RoleListener, HTTP)
srv, _ := f.httpListener(t, l, f.serverTLS)
for _, unbound := range []bool{false, true} {
d := f.dialer(t, "edge-01", "edge-01-secret")
if unbound {
d.Unbind()
}
client := f.httpClient(d)
client.Transport = forwarding{client.Transport}
_, err := d.Token(t.Context(), client, srv.URL)
if unbound != (err == nil) {
t.Errorf("unbound %v: %v", unbound, err)
}
}
if l.allowed.Load() != 1 || l.Rejected() != 1 {
t.Fatalf("logins %d, refusals %d; want 1 and 1", l.allowed.Load(), l.Rejected())
}
}
// Behind a proxy the limiter counts the forwarded client, and an IPv6 client
// by its /64, so neither one proxy address nor one host's many addresses
// share or escape a budget.
func TestProxyModeThrottlesTheForwardedClient(t *testing.T) {
f := newFixture(t)
_, srv := f.proxyListener(t)
hello := func(client string) int {
first, _ := auth.NewScramClient("edge-01", "edge-01-secret").StartAuthentication()
scram, _ := json.Marshal(first)
body, _ := json.Marshal(chain.Hello{LogWisp: chain.ProtocolVersion, Scram: scram})
req, _ := http.NewRequest(http.MethodPost, srv.URL+chain.AuthPath, bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Forwarded-For", client)
req.Header.Set("X-Forwarded-Proto", "https")
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
return resp.StatusCode
}
for _, tc := range []struct {
name string
clients []string
next, another string
}{
{"one client", []string{"203.0.113.7"}, "203.0.113.7", "198.51.100.1"},
{"one IPv6 /64", []string{"2001:db8::1", "2001:db8::2", "2001:db8::3", "2001:db8::4"}, "2001:db8::5", "2001:db8:0:1::1"},
} {
for i := range limitPending {
hello(tc.clients[i%len(tc.clients)]) // unanswered: each holds a slot
}
if got := hello(tc.next); got != http.StatusTooManyRequests {
t.Errorf("%s past its pending cap: %d, want 429", tc.name, got)
}
if got := hello(tc.another); got != http.StatusOK {
t.Errorf("%s: another client %s: %d, want 200", tc.name, tc.another, got)
}
}
}
// /auth takes only JSON: a cross-origin page cannot send it without a
// preflight nobody answers, so it cannot drive a login or logout.
func TestAuthTakesOnlyJSON(t *testing.T) {
f := newFixture(t)
_, srv := f.proxyListener(t)
client := &http.Client{Transport: forwarding{http.DefaultTransport}}
resp, err := client.Post(srv.URL+chain.AuthPath, "text/plain", strings.NewReader(`{"logout":true}`))
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
if resp.StatusCode != http.StatusUnsupportedMediaType {
t.Fatalf("text/plain /auth: %d, want 415", resp.StatusCode)
}
}
+996
View File
@@ -0,0 +1,996 @@
package authz
import (
"bufio"
"bytes"
"context"
"crypto/rand"
"crypto/sha256"
"crypto/tls"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"mime"
"net"
"net/http"
"net/netip"
"os"
"strings"
"sync"
"sync/atomic"
"time"
"logwisp/internal/chain"
"logwisp/internal/config"
"logwisp/internal/tlsx"
"logwisp/internal/tokenbucket"
"github.com/lixenwraith/auth"
"github.com/lixenwraith/toml"
)
const (
// ExchangeTimeout bounds a whole SCRAM exchange on either side
ExchangeTimeout = 10 * time.Second
// DefaultTokenLifetime is how long a bearer token from /auth stays valid
DefaultTokenLifetime = 15 * time.Minute
// Token lifetime bounds: exp has whole seconds, a sub-second expires_in
// reads as unknown, and 10 s leaves room for renewal ahead of expiry
MinTokenLifetime = 10 * time.Second
MaxTokenLifetime = 24 * time.Hour
maxAuthLine = 4096 // pre-auth lines and /auth bodies come from unauthenticated peers
streamBuffer = 64 * 1024
limitBurst = 10 // failed or abandoned exchanges per address before throttling
limitRate = 1 // per second
limitPending = 4 // unfinished exchanges per address
limitPeers = 65536
limitIdle = time.Minute
)
// The dialer's floor on the Argon2 cost a server may ask for. A hostile server
// could otherwise request a cheaply guessable proof; tests lower it.
var minArgonTime, minArgonMemory uint32 = auth.DefaultArgonTime, auth.DefaultArgonMemory
var errPinMismatch = errors.New("server certificate differs from the one the SCRAM login was bound to")
// authStep is every SCRAM message after the hello: TCP lines and HTTP bodies.
// Binding is the client's view of the server certificate, used only to tell
// TLS interception apart from a wrong password in the listener's log.
type authStep struct {
Error string `json:"error,omitempty"`
Challenge *auth.ServerFirstMessage `json:"challenge,omitempty"`
Proof *auth.ClientFinalRequest `json:"proof,omitempty"`
Binding string `json:"binding,omitempty"`
Final *auth.ServerFinalMessage `json:"final,omitempty"`
Token string `json:"token,omitempty"`
ExpiresIn int64 `json:"expires_in,omitempty"`
Session string `json:"session,omitempty"` // "cookie": proxy mode, token in a cookie
Logout bool `json:"logout,omitempty"`
}
// Credentials is a parsed credentials file: the verifiers a listener accepts,
// never passwords, and the key that keeps unknown-user challenges stable.
type Credentials struct {
DecoyKey []byte
Users []*auth.Credential
}
var credentialKeys = map[string]bool{
"username": true, "salt": true, "argon_time": true, "argon_memory": true,
"argon_threads": true, "stored_key": true, "server_key": true,
}
// LoadCredentials reads and validates a credentials file
func LoadCredentials(path string) (*Credentials, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("auth: credentials_file: %w", err)
}
c, err := ParseCredentials(data)
if err != nil {
return nil, fmt.Errorf("auth: credentials_file %s: %w", path, err)
}
return c, nil
}
// ParseCredentials validates a whole file: a decoy key, at least one user,
// unique names, one shared KDF profile (mixed profiles would reveal which
// accounts exist) and well-formed verifiers.
func ParseCredentials(data []byte) (*Credentials, error) {
root, err := toml.NewParser(data).Parse()
if err != nil {
return nil, err
}
for k := range root {
if k != "decoy_key" && k != "users" {
return nil, fmt.Errorf("unknown key %q", k)
}
}
key, _ := root["decoy_key"].(string)
decoy, err := base64.StdEncoding.Strict().DecodeString(key)
if err != nil || len(decoy) < 32 {
return nil, errors.New("decoy_key must hold at least 32 base64-encoded bytes")
}
var users []map[string]any
switch v := root["users"].(type) {
case []map[string]any:
users = v
case []any:
for _, e := range v {
if u, ok := e.(map[string]any); ok {
users = append(users, u)
}
}
}
if len(users) == 0 {
return nil, errors.New("no users")
}
c := &Credentials{DecoyKey: decoy}
seen := make(map[string]bool, len(users))
for i, u := range users {
for k := range u {
if !credentialKeys[k] {
return nil, fmt.Errorf("users[%d]: unknown key %q", i, k)
}
}
cred, err := auth.ImportCredential(u)
if err != nil {
return nil, fmt.Errorf("users[%d]: %w", i, err)
}
if seen[cred.Username] {
return nil, fmt.Errorf("users[%d]: duplicate user %q", i, cred.Username)
}
seen[cred.Username] = true
if f := c.Users; len(f) > 0 && (f[0].ArgonTime != cred.ArgonTime || f[0].ArgonMemory != cred.ArgonMemory ||
f[0].ArgonThreads != cred.ArgonThreads || len(f[0].Salt) != len(cred.Salt)) {
return nil, fmt.Errorf("users[%d] %q: Argon2 profile differs from %q's; all users must share one", i, cred.Username, f[0].Username)
}
c.Users = append(c.Users, cred)
}
return c, nil
}
// Marshal renders the file in its canonical form
func (c *Credentials) Marshal() ([]byte, error) {
users := make([]map[string]any, len(c.Users))
for i, u := range c.Users {
users[i] = u.Export()
}
body, err := toml.Marshal(map[string]any{
"decoy_key": base64.StdEncoding.EncodeToString(c.DecoyKey),
"users": users,
})
if err != nil {
return nil, err
}
header := "# logwisp SCRAM verifiers, written by `logwisp auth add-user`. Keep it private.\n"
return append([]byte(header), body...), nil
}
// ReadPassword reads a password file, trimming one trailing line break so a
// file from an editor and one from `logwisp auth add-user` agree.
func ReadPassword(path string) (string, error) {
data, err := os.ReadFile(path)
if err != nil {
return "", fmt.Errorf("auth: password_file: %w", err)
}
pw := strings.TrimSuffix(strings.TrimSuffix(string(data), "\n"), "\r")
if pw == "" || len(pw) > auth.MaxPasswordLen {
return "", fmt.Errorf("auth: password_file %s: password must be 1-%d bytes", path, auth.MaxPasswordLen)
}
return pw, nil
}
// --- Listener ---
type scramListener struct {
creds *Credentials
cb []byte // SHA-256 of this listener's certificate, the channel binding; nil behind proxies
tokens *auth.JWT // HTTP listeners; a per-instance key, so a reload revokes every token
lifetime time.Duration
proxy *proxyMode
server atomic.Pointer[auth.ScramServer]
limit limiter
throttled atomic.Uint64
busy atomic.Uint64
bindingMismatch atomic.Uint64
}
func (p *Policy) compileSCRAM(o *config.AuthOptions, tlsCfg *tls.Config) error {
if len(o.Allow) > 0 || len(o.AllowPatterns) > 0 {
return fmt.Errorf("auth: allow and allow_patterns apply only to type %q; the credentials file is the allow list", MethodMTLS)
}
if p.role == RoleDialer {
return p.compileSCRAMDialer(o)
}
switch {
case o.Username != "" || o.PasswordFile != "":
return errors.New("auth: username and password_file apply only to dialers")
case o.CredentialsFile == "":
return fmt.Errorf("auth: type %q requires credentials_file", MethodSCRAM)
case o.TokenLifetimeMS != 0 && (o.TokenLifetimeMS < MinTokenLifetime.Milliseconds() || o.TokenLifetimeMS > MaxTokenLifetime.Milliseconds()):
return fmt.Errorf("auth: token_lifetime_ms %d is outside %d (%s) to %d (%s)", o.TokenLifetimeMS,
MinTokenLifetime.Milliseconds(), MinTokenLifetime, MaxTokenLifetime.Milliseconds(), MaxTokenLifetime)
case o.TokenLifetimeMS > 0 && p.transport != HTTP:
return errors.New("auth: token_lifetime_ms applies only to HTTP listeners")
case len(o.TrustedProxies) > 0 && (p.role != RoleListener || p.transport != HTTP):
return errors.New("auth: trusted_proxies applies only to the http sink")
case len(o.TrustedProxies) > 0 && o.Identity != "":
return errors.New("auth: identity binds a client certificate, which a TLS-terminating proxy does not pass on; drop it or trusted_proxies")
}
if o.Identity != "" {
// Binds the certificate to the user: a peer needs its own of both
if tlsCfg.ClientAuth != tls.RequireAndVerifyClientCert {
return fmt.Errorf("auth: identity under type %q binds the client certificate to the user and requires tls.client_auth", MethodSCRAM)
}
var err error
if p.identity, err = identityMode(o.Identity); err != nil {
return err
}
}
l := &scramListener{}
var err error
if len(o.TrustedProxies) > 0 {
if l.proxy, err = parseProxies(o.TrustedProxies); err != nil {
return err
}
l.proxy.plaintext = tlsCfg == nil
} else {
if len(tlsCfg.Certificates) == 0 || len(tlsCfg.Certificates[0].Certificate) == 0 {
return errors.New("auth: type scram needs the listener certificate for channel binding")
}
cb := sha256.Sum256(tlsCfg.Certificates[0].Certificate[0])
l.cb = cb[:]
}
if l.creds, err = LoadCredentials(o.CredentialsFile); err != nil {
return err
}
if p.transport == HTTP {
l.lifetime = DefaultTokenLifetime
if o.TokenLifetimeMS > 0 {
l.lifetime = time.Duration(o.TokenLifetimeMS) * time.Millisecond
}
key := make([]byte, 32)
rand.Read(key)
if l.tokens, err = auth.NewJWT(key, auth.WithIssuer("logwisp"),
auth.WithTokenLifetime(l.lifetime), auth.WithLeeway(0)); err != nil {
return err
}
}
p.listener = l
p.secrets = append(p.secrets, secretFile{"auth.credentials_file", o.CredentialsFile})
return nil
}
// Start brings up the SCRAM server. Plugins call it from Start, so a policy
// that is constructed but never started holds no goroutine.
func (p *Policy) Start() error {
if p == nil || p.listener == nil {
return nil
}
l := p.listener
s, err := auth.NewScramServerWithDecoyKey(l.creds.DecoyKey)
if err != nil {
return err
}
for _, c := range l.creds.Users {
if err := s.AddCredential(c); err != nil {
s.Stop()
return fmt.Errorf("auth: user %q: %w", c.Username, err)
}
}
l.server.Store(s)
return nil
}
// Close stops the SCRAM server; exchanges in flight fail
func (p *Policy) Close() {
if p == nil || p.listener == nil {
return
}
if s := p.listener.server.Swap(nil); s != nil {
s.Stop()
}
}
// begin opens an exchange for a peer: throttling, then the challenge. On
// failure, status is the HTTP answer and public the only detail sent.
func (p *Policy) begin(ip string, first json.RawMessage) (challenge auth.ServerFirstMessage, status int, public string, err error) {
l := p.listener
var req auth.ClientFirstRequest
if err := json.Unmarshal(first, &req); err != nil {
p.rejected.Add(1)
return challenge, http.StatusBadRequest, "malformed request", fmt.Errorf("%w: malformed client-first message", ErrRefused)
}
if !l.limit.start(ip) {
l.throttled.Add(1)
return challenge, http.StatusTooManyRequests, "too many attempts", fmt.Errorf("%w: %s throttled", ErrRefused, ip)
}
s := l.server.Load()
if s == nil {
l.limit.release(ip)
l.busy.Add(1)
return challenge, http.StatusServiceUnavailable, "busy", errors.New("auth: scram server is not running")
}
challenge, err = s.ProcessClientFirstMessage(req.Username, req.ClientNonce)
if err != nil {
l.limit.release(ip)
}
switch {
case errors.Is(err, auth.ErrSCRAMTooManyHandshakes), errors.Is(err, auth.ErrSCRAMStopped):
l.busy.Add(1)
return challenge, http.StatusServiceUnavailable, "busy", err
case err != nil:
p.rejected.Add(1)
return challenge, http.StatusBadRequest, "malformed request", fmt.Errorf("%w: %w", ErrRefused, err)
}
l.limit.track(ip, challenge.FullNonce)
return challenge, 0, "", nil
}
// finish verifies a proof, binding it to this listener's certificate, then
// the client certificate to the user when configured.
func (p *Policy) finish(ip, nonce string, step authStep, cs *tls.ConnectionState) (auth.ServerFinalMessage, error) {
l := p.listener
l.limit.done(ip, nonce)
s := l.server.Load()
if s == nil {
l.busy.Add(1)
return auth.ServerFinalMessage{}, errors.New("auth: scram server is not running")
}
var bind auth.ExchangeOption // nil, so unbound, behind proxies
if l.cb != nil {
bind = auth.WithChannelBinding(l.cb)
}
final, err := s.ProcessClientFinalMessage(nonce, step.Proof.ClientProof, bind)
if err == nil {
err = p.bindCertificate(cs, final.Username)
}
if err != nil {
p.rejected.Add(1)
switch {
case step.Binding != "" && l.proxy != nil:
err = fmt.Errorf("%w; the client bound its proof to the proxy's certificate: behind trusted_proxies, log in unbound (logwisp auth token -unbound)", err)
case step.Binding == "" && l.cb != nil:
err = fmt.Errorf("%w; the client sent an unbound proof (-unbound), but this listener binds logins to its certificate", err)
case step.Binding != "" && step.Binding != base64.StdEncoding.EncodeToString(l.cb):
l.bindingMismatch.Add(1)
err = fmt.Errorf("%w; the client saw another server certificate: TLS interception or a terminating proxy", err)
}
return auth.ServerFinalMessage{}, fmt.Errorf("%w: %w", ErrRefused, err)
}
p.allowed.Add(1)
l.limit.succeeded(ip)
return final, nil
}
func (p *Policy) bindCertificate(cs *tls.ConnectionState, username string) error {
if p.identity == "" {
return nil
}
if cs == nil {
return errors.New("no client certificate")
}
if got := tlsx.PeerIdentity(*cs, p.identity); got != username {
return fmt.Errorf("certificate %s %q does not match user %q", p.identity, got, username)
}
return nil
}
// exchangeTCP runs the listener side after the hello and returns the final
// line for Accept. An exchange abandoned after its challenge is released from
// the auth table at once rather than holding a slot for its timeout.
func (p *Policy) exchangeTCP(a *Admission, cs *tls.ConnectionState) (Identity, []byte, error) {
ip := throttleKey(remoteIP(a.conn.RemoteAddr().String()))
challenge, _, public, err := p.begin(ip, a.Hello.Scram)
if err != nil {
writeStep(a.conn, authStep{Error: public})
return Identity{}, nil, err
}
settled := false
defer func() {
if !settled {
p.listener.limit.done(ip, challenge.FullNonce)
if s := p.listener.server.Load(); s != nil {
s.ProcessClientFinalMessage(challenge.FullNonce, "")
}
}
}()
if err := writeStep(a.conn, authStep{Challenge: &challenge}); err != nil {
return Identity{}, nil, err
}
line, err := readLine(a.Reader)
if err != nil {
return Identity{}, nil, fmt.Errorf("read proof: %w", err)
}
var step authStep
if err := json.Unmarshal(line, &step); err != nil || step.Proof == nil {
p.rejected.Add(1)
writeStep(a.conn, authStep{Error: "malformed proof"})
return Identity{}, nil, fmt.Errorf("%w: malformed proof", ErrRefused)
}
settled = true
final, err := p.finish(ip, challenge.FullNonce, step, cs)
if err != nil {
writeStep(a.conn, authStep{Error: "authentication failed"})
return Identity{}, nil, err
}
out, err := json.Marshal(authStep{Final: &final})
if err != nil {
return Identity{}, nil, err
}
return Identity{Name: final.Username, Method: MethodSCRAM}, append(out, '\n'), nil
}
// ServeAuth answers POST /auth on an HTTP scram listener: a hello gets a
// challenge, a proof gets the server-final message and a bearer token (in
// proxy mode, a session cookie on request), a logout ends a session. It
// returns the identity of a completed login, or the refusal, for the
// plugin's log; other steps return neither.
func (p *Policy) ServeAuth(w http.ResponseWriter, r *http.Request) (Identity, error) {
if p == nil || p.listener == nil || p.listener.tokens == nil {
http.NotFound(w, r)
return Identity{}, nil
}
l := p.listener
client, err := p.ClientAddr(r)
if err != nil {
writeJSON(w, http.StatusForbidden, authStep{Error: "forbidden"})
return Identity{}, err
}
ip := throttleKey(client)
// A cross-origin page cannot send JSON without a preflight nobody answers
if mt, _, _ := mime.ParseMediaType(r.Header.Get("Content-Type")); mt != "application/json" {
p.rejected.Add(1)
writeJSON(w, http.StatusUnsupportedMediaType, authStep{Error: "malformed request"})
return Identity{}, errors.New("auth request: Content-Type is not application/json")
}
rc := http.NewResponseController(w)
rc.SetReadDeadline(time.Now().Add(ExchangeTimeout))
rc.SetWriteDeadline(time.Now().Add(ExchangeTimeout))
var req struct {
chain.Hello
authStep
}
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, maxAuthLine)).Decode(&req); err != nil {
p.rejected.Add(1)
var tooLarge *http.MaxBytesError
if errors.As(err, &tooLarge) {
writeJSON(w, http.StatusRequestEntityTooLarge, authStep{Error: "request too large"})
} else {
writeJSON(w, http.StatusBadRequest, authStep{Error: "malformed request"})
}
return Identity{}, fmt.Errorf("auth request: %w", err)
}
cookie := req.Session == "cookie"
switch {
case cookie && l.proxy == nil, req.Session != "" && !cookie, req.Logout && l.proxy == nil:
p.rejected.Add(1)
writeJSON(w, http.StatusBadRequest, authStep{Error: "sessions and logout need trusted_proxies"})
return Identity{}, errors.New("auth request: a browser session outside proxy mode")
case req.Logout:
return Identity{}, p.logout(w, r)
case len(req.Scram) > 0 && req.LogWisp == chain.ProtocolVersion:
challenge, status, public, err := p.begin(ip, req.Scram)
if err != nil {
writeJSON(w, status, authStep{Error: public})
return Identity{}, err
}
writeJSON(w, http.StatusOK, authStep{Challenge: &challenge})
return Identity{}, nil
case req.Proof != nil:
final, err := p.finish(ip, req.Proof.FullNonce, req.authStep, r.TLS)
if err != nil {
status := http.StatusUnauthorized
if !errors.Is(err, ErrRefused) {
status = http.StatusServiceUnavailable
}
writeJSON(w, status, authStep{Error: "authentication failed"})
return Identity{}, err
}
token, err := l.tokens.GenerateToken(final.Username, nil)
if err != nil {
writeJSON(w, http.StatusInternalServerError, authStep{Error: "token unavailable"})
return Identity{}, err
}
answer := authStep{Final: &final, ExpiresIn: int64(l.lifetime / time.Second)}
if cookie {
http.SetCookie(w, sessionCookie(token, int(l.lifetime/time.Second)))
} else {
answer.Token = token
}
writeJSON(w, http.StatusOK, answer)
return Identity{Name: final.Username, Method: MethodSCRAM}, nil
}
p.rejected.Add(1)
writeJSON(w, http.StatusBadRequest, authStep{Error: "malformed request"})
return Identity{}, errors.New("auth request: neither a hello nor a proof")
}
func (p *Policy) authorizeToken(r *http.Request) (Identity, int, error) {
l := p.listener
if l.tokens == nil {
p.rejected.Add(1)
return Identity{}, http.StatusUnauthorized, fmt.Errorf("%w: no token issuer on this listener", ErrRefused)
}
if _, err := p.ClientAddr(r); err != nil {
return Identity{}, http.StatusForbidden, err
}
token, err := p.presentedToken(r)
if err != nil {
p.rejected.Add(1)
return Identity{}, http.StatusUnauthorized, fmt.Errorf("%w: %w", ErrRefused, err)
}
user, _, err := l.tokens.ValidateToken(token)
if err == nil && l.proxy != nil && l.proxy.isRevoked(token) {
err = errors.New("token ended by logout")
}
if err != nil {
p.rejected.Add(1)
return Identity{}, http.StatusUnauthorized, fmt.Errorf("%w: bearer %w", ErrRefused, err)
}
if err := p.bindCertificate(r.TLS, user); err != nil {
p.rejected.Add(1)
return Identity{}, http.StatusForbidden, fmt.Errorf("%w: %w", ErrRefused, err)
}
return Identity{Name: user, Method: MethodSCRAM}, 0, nil
}
func (l *scramListener) stats(d map[string]any) {
d["auth_users"] = len(l.creds.Users)
d["auth_throttled"] = l.throttled.Load()
d["auth_busy"] = l.busy.Load()
d["auth_binding_mismatch"] = l.bindingMismatch.Load()
if l.tokens != nil {
d["auth_token_lifetime_ms"] = l.lifetime.Milliseconds()
}
if l.proxy != nil {
proxies := make([]string, len(l.proxy.trusted))
for i, p := range l.proxy.trusted {
proxies[i] = p.String()
}
d["auth_trusted_proxies"] = proxies
}
}
// --- Dialer ---
type scramDialer struct {
username string
password string
unbound bool // HTTP logins to a listener behind a TLS-terminating proxy
token atomic.Pointer[string]
renewAt atomic.Int64 // unix nanoseconds; Prepare logs in again from then
pin atomic.Pointer[[]byte] // HTTP: certificate the token's login was bound to
failures atomic.Uint64
lastErr atomic.Pointer[string]
}
func (p *Policy) compileSCRAMDialer(o *config.AuthOptions) error {
switch {
case o.CredentialsFile != "", o.TokenLifetimeMS != 0, len(o.TrustedProxies) > 0:
return errors.New("auth: credentials_file, token_lifetime_ms and trusted_proxies apply only to listeners")
case o.Identity != "":
return fmt.Errorf("auth: identity on a dialer pins the server and applies only to type %q", MethodMTLS)
case o.Username == "" || o.PasswordFile == "":
return fmt.Errorf("auth: type %q on a dialer requires username and password_file", MethodSCRAM)
}
password, err := ReadPassword(o.PasswordFile)
if err != nil {
return err
}
d := &scramDialer{username: o.Username, password: password}
if _, err := d.client().StartAuthentication(); err != nil {
return fmt.Errorf("auth: username %q: %w", o.Username, err)
}
p.dialer = d
p.secrets = append(p.secrets, secretFile{"auth.password_file", o.PasswordFile})
return nil
}
// Unbind makes a dialer's HTTP logins unbound, for an http sink behind a
// TLS-terminating proxy whose certificate logwisp never sees
func (p *Policy) Unbind() {
if p != nil && p.dialer != nil {
p.dialer.unbound = true
}
}
func (d *scramDialer) client() *auth.ScramClient {
return auth.NewScramClient(d.username, d.password, auth.WithMinArgonCost(minArgonTime, minArgonMemory))
}
// exchangeTCP runs the dialer side on conn; nothing is trusted until the
// server's final signature proves it holds this user's verifier.
func (d *scramDialer) exchangeTCP(conn net.Conn, r *bufio.Reader, node string) (err error) {
defer func() { d.record(err) }()
tc, ok := conn.(*tls.Conn)
if !ok {
return errors.New("auth: scram requires TLS")
}
c := d.client()
first, err := c.StartAuthentication()
if err != nil {
return err
}
scram, err := json.Marshal(first)
if err != nil {
return err
}
line, err := chain.EncodeHello(chain.Hello{Node: node, Scram: scram})
if err != nil {
return err
}
if _, err := conn.Write(line); err != nil {
return fmt.Errorf("hello: %w", err)
}
step, err := readStep(r)
if err != nil {
var ne net.Error
if errors.As(err, &ne) && ne.Timeout() {
return fmt.Errorf("%w: no challenge within %v (older logwisp, or not a scram listener)", ErrRefused, ExchangeTimeout)
}
return err
}
if step.Challenge == nil {
return errors.New("auth: server sent no challenge")
}
cb := certHash(tc.ConnectionState())
proof, err := c.ProcessServerFirstMessage(*step.Challenge, auth.WithChannelBinding(cb))
if err != nil {
return fmt.Errorf("%w: %w", ErrRefused, err)
}
if err := writeStep(conn, authStep{Proof: &proof, Binding: base64.StdEncoding.EncodeToString(cb)}); err != nil {
return err
}
if step, err = readStep(r); err != nil {
return err
}
return d.verifyFinal(c, step)
}
func (d *scramDialer) verifyFinal(c *auth.ScramClient, step authStep) error {
if step.Final == nil {
return errors.New("auth: server sent no final message")
}
if err := c.VerifyServerFinalMessage(*step.Final); err != nil {
return fmt.Errorf("%w: the server could not prove it holds this user's verifier: %w", ErrRefused, err)
}
return nil
}
// Token logs in over HTTP and returns a fresh bearer token: for the CLI, and
// behind Prepare. The certificate of the first answer is pinned before the
// proof is sent, so neither the proof nor the token reaches another server.
func (p *Policy) Token(ctx context.Context, client *http.Client, baseURL string) (token string, err error) {
if p == nil || p.dialer == nil {
return "", fmt.Errorf("auth: tokens need type %q on a dialer", MethodSCRAM)
}
d := p.dialer
defer func() { d.record(err) }()
d.dropToken() // a rotated server certificate must be able to bind anew
ctx, cancel := context.WithTimeout(ctx, ExchangeTimeout)
defer cancel()
url := baseURL + chain.AuthPath
c := d.client()
first, err := c.StartAuthentication()
if err != nil {
return "", err
}
scram, err := json.Marshal(first)
if err != nil {
return "", err
}
hello, err := json.Marshal(chain.Hello{LogWisp: chain.ProtocolVersion, Scram: scram})
if err != nil {
return "", err
}
step, resp, err := postStep(ctx, client, url, hello)
if err != nil {
return "", err
}
if step.Challenge == nil || resp.TLS == nil || len(resp.TLS.PeerCertificates) == 0 {
return "", errors.New("auth: no challenge over TLS")
}
cb := certHash(*resp.TLS)
d.pin.Store(&cb) // unbound too: both requests must reach the same server
var bind auth.ExchangeOption
var binding string
if !d.unbound {
bind, binding = auth.WithChannelBinding(cb), base64.StdEncoding.EncodeToString(cb)
}
proof, err := c.ProcessServerFirstMessage(*step.Challenge, bind)
if err != nil {
return "", fmt.Errorf("%w: %w", ErrRefused, err)
}
body, err := json.Marshal(authStep{Proof: &proof, Binding: binding})
if err != nil {
return "", err
}
if step, _, err = postStep(ctx, client, url, body); err != nil {
return "", err
}
if err := d.verifyFinal(c, step); err != nil {
return "", err
}
if step.Token == "" {
return "", errors.New("auth: server issued no token")
}
d.renewAt.Store(renewAt(time.Now(), time.Duration(step.ExpiresIn)*time.Second).UnixNano())
d.token.Store(&step.Token)
return step.Token, nil
}
// renewAt schedules a fresh login ahead of expiry, so a healthy link never
// pays a refused request each lifetime; 401 stays the fallback for reloads.
func renewAt(now time.Time, lifetime time.Duration) time.Time {
if lifetime <= 0 {
return now.Add(100 * 365 * 24 * time.Hour) // lifetime unknown: renew on 401 only
}
margin := min(max(5*time.Second, lifetime/10), lifetime/2)
return now.Add(lifetime - margin)
}
func (d *scramDialer) dropToken() {
d.token.Store(nil)
d.pin.Store(nil)
}
func (d *scramDialer) verifyPin(cs tls.ConnectionState) error {
pin := d.pin.Load()
if pin == nil {
return nil
}
if len(cs.PeerCertificates) == 0 || !bytes.Equal(certHash(cs), *pin) {
return errPinMismatch
}
return nil
}
func (d *scramDialer) record(err error) {
msg := ""
if err != nil {
d.failures.Add(1)
msg = err.Error()
}
d.lastErr.Store(&msg)
}
func (d *scramDialer) stats(m map[string]any) {
m["auth_username"] = d.username
m["auth_failures"] = d.failures.Load()
if e := d.lastErr.Load(); e != nil {
m["last_auth_error"] = *e
}
}
// postStep sends one /auth request; any answer but 200 is a refusal
func postStep(ctx context.Context, client *http.Client, url string, body []byte) (authStep, *http.Response, error) {
var step authStep
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
if err != nil {
return step, nil, err
}
req.Header.Set("Content-Type", "application/json")
resp, err := client.Do(req)
if err != nil {
return step, nil, err
}
defer resp.Body.Close()
data, err := io.ReadAll(io.LimitReader(resp.Body, maxAuthLine))
if err != nil {
return step, resp, err
}
decodeErr := json.Unmarshal(data, &step)
switch {
case resp.StatusCode == http.StatusNotFound || resp.StatusCode == http.StatusMethodNotAllowed:
return step, resp, fmt.Errorf("%w: no auth endpoint at %s (older logwisp, or not a scram listener)", ErrRefused, url)
case resp.StatusCode != http.StatusOK:
msg := step.Error
if msg == "" {
msg = resp.Status
}
return step, resp, fmt.Errorf("%w: server: %s", ErrRefused, msg)
case decodeErr != nil:
return step, resp, fmt.Errorf("auth: malformed answer: %w", decodeErr)
}
return step, resp, nil
}
// AwaitClose blocks until a listener ends a link after its admission. Chain
// sources never write once a link is up, so a line is a refusal (ErrRefused,
// sent to dialers without scram that never read it) and EOF a close.
func AwaitClose(r *bufio.Reader) error {
line, err := readLine(r)
if err != nil {
return err
}
var step authStep
if json.Unmarshal(line, &step) == nil && step.Error != "" {
return fmt.Errorf("%w: server: %s", ErrRefused, step.Error)
}
return errors.New("unexpected data from server")
}
// --- Wire helpers ---
// readLine refuses a line over maxAuthLine as soon as it arrives: r buffers
// far more, for the stream after the exchange.
func readLine(r *bufio.Reader) ([]byte, error) {
for {
b, _ := r.Peek(r.Buffered())
if i := bytes.IndexByte(b, '\n'); i >= 0 && i < maxAuthLine {
line := bytes.Clone(bytes.TrimRight(b[:i+1], "\r\n"))
r.Discard(i + 1)
return line, nil
}
if len(b) >= maxAuthLine {
return nil, fmt.Errorf("line exceeds %d bytes", maxAuthLine)
}
if _, err := r.Peek(len(b) + 1); err != nil {
return nil, err
}
}
}
func readStep(r *bufio.Reader) (authStep, error) {
var step authStep
line, err := readLine(r)
if err != nil {
return step, err
}
if err := json.Unmarshal(line, &step); err != nil {
return step, fmt.Errorf("auth: malformed answer: %w", err)
}
if step.Error != "" {
return step, fmt.Errorf("%w: server: %s", ErrRefused, step.Error)
}
return step, nil
}
func writeStep(w io.Writer, step authStep) error {
line, err := json.Marshal(step)
if err != nil {
return err
}
_, err = w.Write(append(line, '\n'))
return err
}
func writeJSON(w http.ResponseWriter, status int, step authStep) {
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "no-store")
w.WriteHeader(status)
writeStep(w, step)
}
func certHash(cs tls.ConnectionState) []byte {
if len(cs.PeerCertificates) == 0 {
return nil
}
h := sha256.Sum256(cs.PeerCertificates[0].Raw)
return h[:]
}
func remoteIP(addr string) string {
if host, _, err := net.SplitHostPort(addr); err == nil {
return host
}
return addr
}
// --- Throttling ---
// throttleKey is what the limiter counts: an address, or for IPv6 its /64,
// which one host usually holds whole
func throttleKey(ip string) string {
addr, err := netip.ParseAddr(ip)
if err != nil || addr.Is4() {
return ip
}
return netip.PrefixFrom(addr, 64).Masked().String()
}
// limiter bounds SCRAM attempts per remote address: failed or abandoned
// exchanges drain a token bucket (successes are refunded) and at most
// limitPending may be unfinished. A full table fails closed.
type limiter struct {
mu sync.Mutex
peers map[string]*peerLimit
lastSweep time.Time
}
type peerLimit struct {
bucket *tokenbucket.TokenBucket
pending map[string]time.Time // full nonce -> expiry
reserved int // started, challenge not yet issued
seen time.Time
}
func (l *limiter) start(ip string) bool {
now := time.Now()
l.mu.Lock()
defer l.mu.Unlock()
if l.peers == nil {
l.peers = make(map[string]*peerLimit)
}
if now.Sub(l.lastSweep) > limitIdle/6 {
l.sweep(now)
}
pl := l.peers[ip]
if pl == nil {
if len(l.peers) >= limitPeers {
return false
}
pl = &peerLimit{bucket: tokenbucket.New(limitBurst, limitRate), pending: make(map[string]time.Time)}
l.peers[ip] = pl
}
pl.seen = now
pl.expire(now)
// Reserved under this lock: concurrent hellos cannot all pass the check
if len(pl.pending)+pl.reserved >= limitPending || !pl.bucket.Allow() {
return false
}
pl.reserved++
return true
}
// track turns start's reservation into the exchange's nonce
func (l *limiter) track(ip, nonce string) {
l.mu.Lock()
defer l.mu.Unlock()
if pl := l.peers[ip]; pl != nil {
pl.reserved--
pl.pending[nonce] = time.Now().Add(auth.ScramHandshakeTimeout)
}
}
// release frees start's reservation when no challenge was issued
func (l *limiter) release(ip string) {
l.mu.Lock()
defer l.mu.Unlock()
if pl := l.peers[ip]; pl != nil {
pl.reserved--
}
}
func (l *limiter) done(ip, nonce string) {
l.mu.Lock()
defer l.mu.Unlock()
if pl := l.peers[ip]; pl != nil {
delete(pl.pending, nonce)
}
}
func (l *limiter) succeeded(ip string) {
l.mu.Lock()
defer l.mu.Unlock()
if pl := l.peers[ip]; pl != nil {
pl.bucket.Refund(1)
}
}
func (pl *peerLimit) expire(now time.Time) {
for nonce, expiry := range pl.pending {
if now.After(expiry) {
delete(pl.pending, nonce)
}
}
}
// sweep drops addresses idle long enough for their bucket to be full again
func (l *limiter) sweep(now time.Time) {
l.lastSweep = now
for ip, pl := range l.peers {
pl.expire(now) // an abandoned HTTP challenge has no done
if now.Sub(pl.seen) > limitIdle && len(pl.pending) == 0 && pl.reserved == 0 {
delete(l.peers, ip)
}
}
}
+736
View File
@@ -0,0 +1,736 @@
package authz
import (
"bufio"
"bytes"
"context"
"crypto/tls"
"crypto/x509"
"encoding/json"
"errors"
"fmt"
"io"
"net"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"regexp"
"strings"
"sync/atomic"
"testing"
"time"
"logwisp/internal/chain"
"logwisp/internal/config"
"logwisp/internal/testutil"
"github.com/lixenwraith/auth"
)
func TestMain(m *testing.M) {
minArgonTime, minArgonMemory = 1, 64 // test verifiers use a cheap profile
os.Exit(m.Run())
}
type fixture struct {
pki *testutil.PKI
dir string
creds string // credentials file holding edge-01 and edge-02
serverTLS *tls.Config
clientTLS *tls.Config
}
func cheapCredential(t *testing.T, user, password string) *auth.Credential {
t.Helper()
c, err := auth.NewCredential(user, password, auth.WithTime(1), auth.WithMemory(64), auth.WithThreads(1))
if err != nil {
t.Fatal(err)
}
return c
}
func newFixture(t *testing.T) *fixture {
t.Helper()
f := &fixture{pki: testutil.NewPKI(t, "edge-01"), dir: t.TempDir()}
data, err := (&Credentials{
DecoyKey: bytes.Repeat([]byte{1}, 32),
Users: []*auth.Credential{cheapCredential(t, "edge-01", "edge-01-secret"), cheapCredential(t, "edge-02", "edge-02-secret")},
}).Marshal()
if err != nil {
t.Fatal(err)
}
f.creds = f.write(t, "users.toml", string(data))
f.serverTLS = &tls.Config{Certificates: []tls.Certificate{f.keyPair(t, f.pki.ServerCert, f.pki.ServerKey)}, ClientCAs: f.pool(t)}
f.clientTLS = &tls.Config{RootCAs: f.pool(t), ServerName: "127.0.0.1",
Certificates: []tls.Certificate{f.keyPair(t, f.pki.ClientCert, f.pki.ClientKey)}}
return f
}
func (f *fixture) write(t *testing.T, name, contents string) string {
path := filepath.Join(f.dir, name)
testutil.WriteFile(t, path, contents)
return path
}
func (f *fixture) keyPair(t *testing.T, cert, key string) tls.Certificate {
kp, err := tls.LoadX509KeyPair(cert, key)
if err != nil {
t.Fatal(err)
}
return kp
}
// serverLeaf is another server certificate from the same CA
func (f *fixture) serverLeaf(t *testing.T, name string) tls.Certificate {
cert, key := f.pki.Leaf(t, name, name+".internal", false)
return f.keyPair(t, cert, key)
}
func (f *fixture) pool(t *testing.T) *x509.CertPool {
pem, err := os.ReadFile(f.pki.CA)
if err != nil {
t.Fatal(err)
}
pool := x509.NewCertPool()
pool.AppendCertsFromPEM(pem)
return pool
}
func (f *fixture) listener(t *testing.T, o config.AuthOptions, tlsCfg *tls.Config, role Role, transport Transport) *Policy {
t.Helper()
o.Type, o.CredentialsFile = MethodSCRAM, f.creds
p, err := New(&o, tlsCfg, role, transport)
if err != nil {
t.Fatal(err)
}
if err := p.Start(); err != nil {
t.Fatal(err)
}
t.Cleanup(p.Close)
return p
}
func (f *fixture) dialer(t *testing.T, user, password string) *Policy {
t.Helper()
pw := f.write(t, user+"-"+strings.ReplaceAll(password, "/", "_")+".pass", password+"\n")
p, err := New(&config.AuthOptions{Type: MethodSCRAM, Username: user, PasswordFile: pw}, f.clientTLS, RoleDialer, TCP)
if err != nil {
t.Fatal(err)
}
return p
}
type admitted struct {
adm *Admission
err error
}
// connect runs a TLS pair over net.Pipe: the server side admits with
// listener and hands the admission to decide (default Accept); the client
// side greets.
func (f *fixture) connect(t *testing.T, listener *Policy, serverTLS *tls.Config, dialer *Policy, decide func(*Admission)) (admitted, *bufio.Reader, error) {
t.Helper()
if decide == nil {
decide = func(a *Admission) { a.Accept() }
}
sc, cc := net.Pipe()
server, client := tls.Server(sc, serverTLS), tls.Client(cc, f.clientTLS)
// Raw ends: a tls.Conn would wait out close_notify on a pipe nobody reads
t.Cleanup(func() { sc.Close(); cc.Close() })
done := make(chan admitted, 1)
go func() {
if err := server.Handshake(); err != nil {
done <- admitted{err: err}
return
}
cs := server.ConnectionState()
adm, err := listener.Admit(server, &cs, true, 5*time.Second)
if err == nil && decide != nil {
decide(adm)
}
done <- admitted{adm, err}
}()
if err := client.Handshake(); err != nil {
t.Fatal(err)
}
r, err := dialer.Greet(t.Context(), client, "declared-node")
if err != nil || dialer == nil || dialer.dialer == nil {
cc.Close() // a dialer that does not read must not block the server's answer
}
return <-done, r, err
}
// A full TCP exchange admits the user, and bytes arriving in the same read
// as the final message reach the dialer through the reader Greet returns.
func TestTCPExchangeAdmitsAndKeepsTheStream(t *testing.T) {
f := newFixture(t)
l := f.listener(t, config.AuthOptions{}, f.serverTLS, RoleChainListener, TCP)
res, r, err := f.connect(t, l, f.serverTLS, f.dialer(t, "edge-01", "edge-01-secret"), func(a *Admission) {
a.final = append(a.final, "first entry\n"...) // as TCP would coalesce them
if err := a.Accept(); err != nil {
t.Error(err)
}
})
if err != nil || res.err != nil {
t.Fatalf("exchange: dialer %v, listener %v", err, res.err)
}
if res.adm.Identity != (Identity{Name: "edge-01", Method: MethodSCRAM}) || res.adm.Hello.Node != "declared-node" {
t.Fatalf("admission = %+v / %+v", res.adm.Identity, res.adm.Hello)
}
line, err := r.ReadString('\n')
if err != nil || line != "first entry\n" {
t.Fatalf("stream after the exchange = %q, %v", line, err)
}
}
// A wrong password and an unknown user fail identically on the wire, so a
// probe cannot tell which accounts exist.
func TestTCPExchangeRefusesWrongPasswordAndUnknownUserAlike(t *testing.T) {
f := newFixture(t)
l := f.listener(t, config.AuthOptions{}, f.serverTLS, RoleListener, TCP)
var seen []string
for _, d := range []*Policy{f.dialer(t, "edge-01", "not-the-secret"), f.dialer(t, "edge-99", "edge-01-secret")} {
res, _, err := f.connect(t, l, f.serverTLS, d, nil)
if !errors.Is(err, ErrRefused) || !errors.Is(res.err, ErrRefused) {
t.Fatalf("dialer %v, listener %v; want both refused", err, res.err)
}
seen = append(seen, err.Error())
}
if seen[0] != seen[1] {
t.Fatalf("wrong password and unknown user differ: %q vs %q", seen[0], seen[1])
}
}
// A proof bound to a certificate other than the listener's own fails, even
// though that certificate chains to the trusted CA, and the listener counts
// the mismatch so interception is not mistaken for a wrong password.
func TestTCPExchangeRefusesAnotherCertificate(t *testing.T) {
f := newFixture(t)
other := f.serverTLS.Clone()
other.Certificates = []tls.Certificate{f.serverLeaf(t, "relay-b")}
l := f.listener(t, config.AuthOptions{}, other, RoleListener, TCP) // binds to relay-b
res, _, err := f.connect(t, l, f.serverTLS, f.dialer(t, "edge-01", "edge-01-secret"), nil)
if !errors.Is(err, ErrRefused) || !errors.Is(res.err, ErrRefused) {
t.Fatalf("dialer %v, listener %v; want both refused", err, res.err)
}
if n := l.listener.bindingMismatch.Load(); n != 1 {
t.Fatalf("binding mismatches = %d, want 1", n)
}
}
// A scram listener refuses a hello without credentials, and a listener
// without scram answers credentials instead of leaving the dialer waiting.
func TestHelloAndPolicyMustAgree(t *testing.T) {
f := newFixture(t)
l := f.listener(t, config.AuthOptions{}, f.serverTLS, RoleChainListener, TCP)
if res, _, _ := f.connect(t, l, f.serverTLS, nil, nil); !errors.Is(res.err, ErrRefused) {
t.Fatalf("scram listener admitted a plain hello: %v", res.err)
}
if _, _, err := f.connect(t, nil, f.serverTLS, f.dialer(t, "edge-01", "edge-01-secret"), nil); !errors.Is(err, ErrRefused) ||
!strings.Contains(err.Error(), "not enabled") {
t.Fatalf("dialer against a plain listener: %v", err)
}
}
// The dialer's exchange ends when its context does, so a silent server never
// holds a sink's Stop.
func TestGreetEndsWithContext(t *testing.T) {
f := newFixture(t)
sc, cc := net.Pipe()
server, client := tls.Server(sc, f.serverTLS), tls.Client(cc, f.clientTLS)
t.Cleanup(func() { sc.Close(); cc.Close() })
go func() {
server.Handshake()
bufio.NewReader(server).ReadString('\n') // swallow the hello, never answer
}()
if err := client.Handshake(); err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithTimeout(t.Context(), 200*time.Millisecond)
defer cancel()
start := time.Now()
if _, err := f.dialer(t, "edge-01", "edge-01-secret").Greet(ctx, client, ""); err == nil {
t.Fatal("Greet succeeded against a silent server")
}
if d := time.Since(start); d > 2*time.Second {
t.Fatalf("Greet took %v after its context ended", d)
}
}
// An exchange abandoned after its challenge leaves the auth handshake table
// and the limiter at once instead of holding a slot until it times out.
func TestAbandonedExchangeReleasesItsSlot(t *testing.T) {
f := newFixture(t)
l := f.listener(t, config.AuthOptions{}, f.serverTLS, RoleListener, TCP)
sc, cc := net.Pipe()
server, client := tls.Server(sc, f.serverTLS), tls.Client(cc, f.clientTLS)
t.Cleanup(func() { sc.Close(); cc.Close() })
done := make(chan error, 1)
go func() {
server.Handshake()
cs := server.ConnectionState()
_, err := l.Admit(server, &cs, false, 5*time.Second)
done <- err
}()
if err := client.Handshake(); err != nil {
t.Fatal(err)
}
first, err := auth.NewScramClient("edge-01", "edge-01-secret").StartAuthentication()
if err != nil {
t.Fatal(err)
}
scram, _ := json.Marshal(first)
hello, _ := chain.EncodeHello(chain.Hello{Scram: scram})
client.Write(hello)
step, err := readStep(bufio.NewReader(client))
if err != nil || step.Challenge == nil {
t.Fatalf("challenge: %+v, %v", step, err)
}
cc.Close()
if err := <-done; err == nil {
t.Fatal("abandoned exchange was admitted")
}
if _, err := l.listener.server.Load().ProcessClientFinalMessage(step.Challenge.FullNonce, "AAAA"); !errors.Is(err, auth.ErrSCRAMInvalidNonce) {
t.Fatalf("abandoned handshake still pending: %v", err)
}
lim := &l.listener.limit
lim.mu.Lock()
defer lim.mu.Unlock()
if pl := lim.peers["pipe"]; pl == nil || len(pl.pending) != 0 || pl.reserved != 0 {
t.Fatalf("limiter still holds the abandoned exchange: %+v", pl)
}
}
// A refusal after authentication, such as node binding, reaches the dialer as
// a reason and never as a final message it could take for admission.
func TestRejectAfterExchangeSendsReason(t *testing.T) {
f := newFixture(t)
l := f.listener(t, config.AuthOptions{}, f.serverTLS, RoleChainListener, TCP)
_, _, err := f.connect(t, l, f.serverTLS, f.dialer(t, "edge-01", "edge-01-secret"), func(a *Admission) {
a.Reject("node label rejected")
})
if !errors.Is(err, ErrRefused) || !strings.Contains(err.Error(), "node label rejected") {
t.Fatalf("dialer after Reject: %v", err)
}
}
// With identity set under scram, the client certificate must name the user:
// a peer needs its own certificate and its own password, and a token works
// only with the certificate of its login.
func TestCertificateBindsToUser(t *testing.T) {
f := newFixture(t)
mtls := f.serverTLS.Clone()
mtls.ClientAuth = tls.RequireAndVerifyClientCert
l := f.listener(t, config.AuthOptions{Identity: "cn"}, mtls, RoleListener, TCP)
if res, _, err := f.connect(t, l, mtls, f.dialer(t, "edge-01", "edge-01-secret"), nil); err != nil || res.err != nil {
t.Fatalf("own certificate and password: dialer %v, listener %v", err, res.err)
}
if res, _, _ := f.connect(t, l, mtls, f.dialer(t, "edge-02", "edge-02-secret"), nil); !errors.Is(res.err, ErrRefused) {
t.Fatalf("edge-01's certificate admitted user edge-02: %v", res.err)
}
h := f.listener(t, config.AuthOptions{Identity: "cn"}, mtls, RoleListener, HTTP)
srv, _ := f.httpListener(t, h, mtls)
d := f.dialer(t, "edge-01", "edge-01-secret")
token, err := d.Token(t.Context(), f.httpClient(d), srv.URL)
if err != nil {
t.Fatal(err)
}
other := f.clientTLS.Clone()
cert, key := f.pki.Leaf(t, "edge-02", "edge-02", true)
other.Certificates = []tls.Certificate{f.keyPair(t, cert, key)}
client := &http.Client{Transport: &http.Transport{TLSClientConfig: other}}
resp := get(t, client, srv.URL+"/protected", func(r *http.Request) { r.Header.Set("Authorization", "Bearer "+token) })
if resp.StatusCode != http.StatusForbidden {
t.Fatalf("edge-01's token with edge-02's certificate: %d, want 403", resp.StatusCode)
}
}
// An unknown user's challenge salt comes from the file's decoy key, so it is
// the same after a restart and a prober learns nothing from restarts.
func TestUnknownUserSaltSurvivesRestart(t *testing.T) {
f := newFixture(t)
var salts []string
for range 2 {
l := f.listener(t, config.AuthOptions{}, f.serverTLS, RoleListener, TCP)
c, err := l.listener.server.Load().ProcessClientFirstMessage("nobody", strings.Repeat("n", 32))
if err != nil {
t.Fatal(err)
}
salts = append(salts, c.Salt)
}
if salts[0] != salts[1] {
t.Fatalf("unknown user salts differ across restarts: %q, %q", salts[0], salts[1])
}
}
// Unauthenticated peers get 4 KiB: a longer hello line or /auth body is
// refused before it is parsed.
func TestPreAuthInputIsCapped(t *testing.T) {
f := newFixture(t)
l := f.listener(t, config.AuthOptions{}, f.serverTLS, RoleListener, TCP)
sc, cc := net.Pipe()
server, client := tls.Server(sc, f.serverTLS), tls.Client(cc, f.clientTLS)
t.Cleanup(func() { sc.Close(); cc.Close() })
done := make(chan error, 1)
go func() {
server.Handshake()
cs := server.ConnectionState()
_, err := l.Admit(server, &cs, true, 5*time.Second)
done <- err
}()
if err := client.Handshake(); err != nil {
t.Fatal(err)
}
go client.Write(bytes.Repeat([]byte("x"), 2*maxAuthLine))
if err := <-done; err == nil || !strings.Contains(err.Error(), "exceeds") {
t.Fatalf("oversized hello: %v", err)
}
h := f.listener(t, config.AuthOptions{}, f.serverTLS, RoleListener, HTTP)
srv, _ := f.httpListener(t, h, f.serverTLS)
hc := &http.Client{Transport: &http.Transport{TLSClientConfig: f.clientTLS}}
resp, err := hc.Post(srv.URL+chain.AuthPath, "application/json", bytes.NewReader(bytes.Repeat([]byte(" "), 2*maxAuthLine)))
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
if resp.StatusCode != http.StatusRequestEntityTooLarge {
t.Fatalf("oversized /auth body: %d, want 413", resp.StatusCode)
}
}
// A dialer refuses a challenge below its Argon2 floor before computing a
// proof, so a hostile server cannot obtain a cheaply guessable one.
func TestDialerRefusesACheapChallenge(t *testing.T) {
f := newFixture(t) // verifiers at t=1, m=64 KiB
l := f.listener(t, config.AuthOptions{}, f.serverTLS, RoleListener, TCP)
d := f.dialer(t, "edge-01", "edge-01-secret")
minArgonTime, minArgonMemory = auth.DefaultArgonTime, auth.DefaultArgonMemory
t.Cleanup(func() { minArgonTime, minArgonMemory = 1, 64 })
if _, _, err := f.connect(t, l, f.serverTLS, d, nil); !errors.Is(err, auth.ErrSCRAMParamsTooSmall) {
t.Fatalf("cheap challenge: %v", err)
}
if n := l.allowed.Load(); n != 0 {
t.Fatalf("logins = %d, want 0", n)
}
}
// Only failed attempts drain an address's budget; beyond it the listener
// answers "too many attempts" without running the exchange.
func TestOnlyFailedAttemptsAreThrottled(t *testing.T) {
f := newFixture(t)
l := f.listener(t, config.AuthOptions{}, f.serverTLS, RoleListener, TCP)
right := f.dialer(t, "edge-01", "edge-01-secret")
for i := range limitBurst + 1 {
if _, _, err := f.connect(t, l, f.serverTLS, right, nil); err != nil {
t.Fatalf("successful login %d: %v", i, err)
}
}
wrong := f.dialer(t, "edge-01", "not-the-secret")
for range limitBurst {
f.connect(t, l, f.serverTLS, wrong, nil)
}
_, _, err := f.connect(t, l, f.serverTLS, right, nil)
if err == nil || !strings.Contains(err.Error(), "too many attempts") || l.listener.throttled.Load() != 1 {
t.Fatalf("attempt over budget: %v, throttled %d", err, l.listener.throttled.Load())
}
}
// Successes are refunded; unfinished exchanges are capped per address from
// the moment they start, so concurrent hellos cannot all pass; a full table
// refuses new addresses, and the sweep frees addresses whose challenges were
// abandoned.
func TestLimiterBounds(t *testing.T) {
var l limiter
for i := range 2 * limitBurst {
if !l.start("10.0.0.1") {
t.Fatalf("successful attempt %d throttled", i)
}
l.track("10.0.0.1", "n")
l.done("10.0.0.1", "n")
l.succeeded("10.0.0.1")
}
for i := range limitPending {
if !l.start("10.0.0.2") {
t.Fatalf("pending exchange %d throttled", i)
}
}
if l.start("10.0.0.2") {
t.Fatal("an exchange beyond the pending cap was admitted before any challenge")
}
l.release("10.0.0.2")
if !l.start("10.0.0.2") {
t.Fatal("a released reservation still counts")
}
for i := len(l.peers); i < limitPeers; i++ {
l.peers[net.IPv4(10, 1, byte(i>>8), byte(i)).String()] = &peerLimit{
seen: time.Now(), pending: map[string]time.Time{"abandoned": time.Now().Add(time.Second)},
}
}
if l.start("192.0.2.1") {
t.Fatal("a full table admitted a new address")
}
l.sweep(time.Now().Add(2 * limitIdle))
if len(l.peers) != 1 { // 10.0.0.2 still holds its reservations
t.Fatalf("%d addresses left after their challenges expired, want 1", len(l.peers))
}
}
// httpListener serves /auth and one protected endpoint over TLS
func (f *fixture) httpListener(t *testing.T, p *Policy, tlsCfg *tls.Config) (*httptest.Server, *atomic.Int64) {
t.Helper()
var hits atomic.Int64
mux := http.NewServeMux()
mux.HandleFunc("POST "+chain.AuthPath, func(w http.ResponseWriter, r *http.Request) { p.ServeAuth(w, r) })
mux.HandleFunc("GET /protected", func(w http.ResponseWriter, r *http.Request) {
hits.Add(1)
if _, status, err := p.AuthorizeRequest(r); err != nil {
Refuse(w, status)
}
})
srv := httptest.NewUnstartedServer(mux)
srv.TLS = tlsCfg
srv.StartTLS()
t.Cleanup(srv.Close)
return srv, &hits
}
func (f *fixture) httpClient(d *Policy) *http.Client {
cfg := f.clientTLS.Clone()
cfg.VerifyConnection = d.VerifyConnection
return &http.Client{Transport: &http.Transport{TLSClientConfig: cfg}}
}
func get(t *testing.T, client *http.Client, url string, prepare func(*http.Request)) *http.Response {
t.Helper()
req, _ := http.NewRequest(http.MethodGet, url, nil)
prepare(req)
resp, err := client.Do(req)
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
return resp
}
// A login over HTTP yields a bearer token the endpoint accepts; a garbage
// token, or one another listener instance issued, gets 401 and a challenge
// header; a refused token can be dropped and replaced by a fresh login.
func TestHTTPTokenFlow(t *testing.T) {
f := newFixture(t)
l := f.listener(t, config.AuthOptions{}, f.serverTLS, RoleListener, HTTP)
srv, _ := f.httpListener(t, l, f.serverTLS)
d := f.dialer(t, "edge-01", "edge-01-secret")
client := f.httpClient(d)
resp := get(t, client, srv.URL+"/protected", func(r *http.Request) {
if err := d.Prepare(t.Context(), client, srv.URL, r); err != nil {
t.Fatal(err)
}
})
if resp.StatusCode != http.StatusOK {
t.Fatalf("with a token: %d", resp.StatusCode)
}
other := f.listener(t, config.AuthOptions{}, f.serverTLS, RoleListener, HTTP)
foreignSrv, _ := f.httpListener(t, other, f.serverTLS)
foreign, err := f.dialer(t, "edge-01", "edge-01-secret").Token(t.Context(), f.httpClient(d), foreignSrv.URL)
if err != nil {
t.Fatal(err)
}
for name, header := range map[string]string{"garbage": "Bearer not-a-token", "foreign": "Bearer " + foreign, "missing": ""} {
resp := get(t, client, srv.URL+"/protected", func(r *http.Request) { r.Header.Set("Authorization", header) })
if resp.StatusCode != http.StatusUnauthorized || !strings.HasPrefix(resp.Header.Get("WWW-Authenticate"), "Bearer") {
t.Errorf("%s token: %d %q", name, resp.StatusCode, resp.Header.Get("WWW-Authenticate"))
}
}
logins := l.allowed.Load()
if !d.Invalidate(http.StatusUnauthorized, nil) || d.dialer.token.Load() != nil {
t.Fatal("a refused token was not dropped")
}
resp = get(t, client, srv.URL+"/protected", func(r *http.Request) { d.Prepare(t.Context(), client, srv.URL, r) })
if resp.StatusCode != http.StatusOK || l.allowed.Load() != logins+1 {
t.Fatalf("after Invalidate: %d, logins %d -> %d", resp.StatusCode, logins, l.allowed.Load())
}
}
// A server that cannot sign the final message with the user's verifier gets
// no token stored, whatever token it offers.
func TestForgedFinalYieldsNoToken(t *testing.T) {
f := newFixture(t)
l := f.listener(t, config.AuthOptions{}, f.serverTLS, RoleListener, HTTP)
mux := http.NewServeMux()
mux.HandleFunc("POST "+chain.AuthPath, func(w http.ResponseWriter, r *http.Request) {
body := new(bytes.Buffer)
body.ReadFrom(r.Body)
if bytes.Contains(body.Bytes(), []byte(`"proof"`)) {
writeJSON(w, http.StatusOK, authStep{Final: &auth.ServerFinalMessage{ServerSignature: "AAAA"}, Token: "forged"})
return
}
r.Body = io.NopCloser(body)
l.ServeAuth(w, r)
})
srv := httptest.NewUnstartedServer(mux)
srv.TLS = f.serverTLS
srv.StartTLS()
t.Cleanup(srv.Close)
d := f.dialer(t, "edge-01", "edge-01-secret")
if _, err := d.Token(t.Context(), f.httpClient(d), srv.URL); err == nil {
t.Fatal("forged final accepted")
}
if d.dialer.token.Load() != nil {
t.Fatal("token stored from a forged final")
}
}
// After the first answer every connection must present the bound certificate:
// a second, equally CA-valid server never sees the proof, unbound logins
// (through a proxy) included.
func TestPinnedCertificateAcrossConnections(t *testing.T) {
f := newFixture(t)
l := f.listener(t, config.AuthOptions{}, f.serverTLS, RoleListener, HTTP)
a, _ := f.httpListener(t, l, f.serverTLS)
otherTLS := f.serverTLS.Clone()
otherTLS.Certificates = []tls.Certificate{f.serverLeaf(t, "relay-b")}
b, bHits := f.httpListener(t, l, otherTLS)
for _, unbound := range []bool{false, true} {
d := f.dialer(t, "edge-01", "edge-01-secret")
if unbound {
d.Unbind()
}
client := f.httpClient(d)
var dials atomic.Int64
tr := client.Transport.(*http.Transport)
tr.DisableKeepAlives = true
tr.DialContext = func(ctx context.Context, network, _ string) (net.Conn, error) {
target := a.Listener.Addr().String()
if dials.Add(1) > 1 {
target = b.Listener.Addr().String() // every later connection lands on b
}
return (&net.Dialer{}).DialContext(ctx, network, target)
}
_, err := d.Token(t.Context(), client, a.URL)
if !errors.Is(err, errPinMismatch) {
t.Fatalf("unbound %v: login across certificates: %v", unbound, err)
}
if !d.Invalidate(0, err) {
t.Fatalf("unbound %v: a pin mismatch did not drop the login", unbound)
}
}
if bHits.Load() != 0 || l.allowed.Load() != 0 {
t.Fatal("the proof reached a server with another certificate")
}
}
// A hello refused after it was admitted, such as a malformed username, frees
// its pending slot: otherwise four of them would lock the address out.
func TestRefusedHellosFreeTheirSlot(t *testing.T) {
f := newFixture(t)
l := f.listener(t, config.AuthOptions{}, f.serverTLS, RoleListener, HTTP)
srv, _ := f.httpListener(t, l, f.serverTLS)
client := &http.Client{Transport: &http.Transport{TLSClientConfig: f.clientTLS}}
hello := func(user string) int {
body := fmt.Sprintf(`{"logwisp":1,"scram":{"username":%q,"client_nonce":%q}}`, user, strings.Repeat("n", 32))
resp, err := client.Post(srv.URL+chain.AuthPath, "application/json", strings.NewReader(body))
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
return resp.StatusCode
}
for range limitPending + 1 {
if got := hello("edge,01"); got != http.StatusBadRequest {
t.Fatalf("malformed username: %d, want 400", got)
}
}
if got := hello("edge-01"); got != http.StatusOK {
t.Fatalf("a valid hello after refused ones: %d, want 200", got)
}
}
// Password files from editors and from the CLI read the same.
func TestReadPasswordTrimsOneLineBreak(t *testing.T) {
f := newFixture(t)
for _, contents := range []string{"secret", "secret\n", "secret\r\n"} {
if pw, err := ReadPassword(f.write(t, "pw", contents)); err != nil || pw != "secret" {
t.Errorf("ReadPassword(%q) = %q, %v", contents, pw, err)
}
}
if _, err := ReadPassword(f.write(t, "pw", "\n")); err == nil {
t.Error("an empty password was accepted")
}
}
// A credentials file is validated whole, and Marshal round-trips. Each case
// breaks one rule of an otherwise valid file.
func TestCredentialsFileValidation(t *testing.T) {
f := newFixture(t)
data, err := os.ReadFile(f.creds)
if err != nil {
t.Fatal(err)
}
c, err := ParseCredentials(data)
if err != nil || len(c.Users) != 2 || c.Users[1].Username != "edge-02" {
t.Fatalf("round trip: %+v, %v", c, err)
}
valid := string(data)
decoyLine := regexp.MustCompile(`(?m)^decoy_key = .*\n`)
stronger := cheapCredential(t, "edge-03", "edge-03-secret")
stronger.ArgonTime = 2
mixed, _ := (&Credentials{DecoyKey: c.DecoyKey, Users: []*auth.Credential{c.Users[0], stronger}}).Marshal()
for name, tc := range map[string]struct{ contents, want string }{
"no decoy key": {decoyLine.ReplaceAllString(valid, ""), "decoy_key must hold"},
"short decoy": {decoyLine.ReplaceAllString(valid, "decoy_key = \"AAAA\"\n"), "decoy_key must hold"},
"no users": {decoyLine.FindString(valid), "no users"},
"duplicate": {strings.Replace(valid, `"edge-02"`, `"edge-01"`, 1), "duplicate user"},
"unknown user key": {strings.Replace(valid, "argon_time", "comment = 1\nargon_time", 1), `unknown key "comment"`},
"unknown file key": {"comment = 1\n" + valid, `unknown key "comment"`},
"mixed profile": {string(mixed), "profile differs"},
} {
if _, err := ParseCredentials([]byte(tc.contents)); err == nil || !strings.Contains(err.Error(), tc.want) {
t.Errorf("%s: error %v, want %q", name, err, tc.want)
}
}
}
// A scram listener's Authorize refuses, so a call site that skipped the
// exchange admits nobody rather than everybody.
func TestScramListenerAuthorizeFailsClosed(t *testing.T) {
f := newFixture(t)
l := f.listener(t, config.AuthOptions{}, f.serverTLS, RoleListener, HTTP)
if _, err := l.Authorize(&tls.ConnectionState{}); !errors.Is(err, ErrRefused) {
t.Fatalf("Authorize under scram = %v, want a refusal", err)
}
}
// A token is renewed ahead of its expiry, so a healthy link never pays a
// refused request each lifetime.
func TestTokenIsRenewedBeforeExpiry(t *testing.T) {
f := newFixture(t)
l := f.listener(t, config.AuthOptions{TokenLifetimeMS: MinTokenLifetime.Milliseconds()}, f.serverTLS, RoleListener, HTTP)
srv, _ := f.httpListener(t, l, f.serverTLS)
d := f.dialer(t, "edge-01", "edge-01-secret")
client := f.httpClient(d)
prepare := func() {
req, _ := http.NewRequest(http.MethodGet, srv.URL+"/protected", nil)
if err := d.Prepare(t.Context(), client, srv.URL, req); err != nil {
t.Fatal(err)
}
}
prepare()
prepare()
if n := l.allowed.Load(); n != 1 {
t.Fatalf("logins within the lifetime = %d, want 1", n)
}
ahead := time.Until(time.Unix(0, d.dialer.renewAt.Load()))
if ahead <= 0 || ahead > MinTokenLifetime-5*time.Second {
t.Fatalf("renewal scheduled %v ahead, want within lifetime - 5 s", ahead)
}
d.dialer.renewAt.Store(time.Now().UnixNano()) // the renewal point arrives
prepare()
if n := l.allowed.Load(); n != 2 {
t.Fatalf("logins after the renewal point = %d, want 2", n)
}
}
+11 -9
View File
@@ -11,28 +11,30 @@ import (
// ProtocolVersion is declared in the hello preamble
const ProtocolVersion = 1
// Hello is the first NDJSON line sent by the dialing side after connect.
// Reserved for future revisions: auth credential, feature flags (ack, compression).
// Hello is the first NDJSON line a dialer sends after connect. Fields are
// optional so older peers ignore what they do not know; a field a peer must
// understand needs a ProtocolVersion bump. Scram opens an authentication
// exchange (authz owns its content), so chain need not know the auth library.
type Hello struct {
LogWisp int `json:"logwisp"`
Node string `json:"node,omitempty"`
// Auth string `json:"auth,omitempty"`
// Features []string `json:"features,omitempty"`
Scram json.RawMessage `json:"scram,omitempty"`
}
// HTTP transport mapping of the chain protocol.
// Hello preamble equivalent: protocol + node carried as request headers.
// Reserved extension point: Authorization header for auth, TLS at transport.
// HTTP transport mapping of the chain protocol: protocol and node travel as
// request headers, SCRAM runs on AuthPath and yields a bearer token.
const (
HeaderProtocol = "X-Logwisp-Protocol"
HeaderNode = "X-Logwisp-Node"
HeaderAccepted = "X-Logwisp-Accepted"
ContentTypeNDJSON = "application/x-ndjson"
AuthPath = "/auth"
)
// EncodeHello serializes a newline-terminated hello preamble
func EncodeHello(node string) ([]byte, error) {
b, err := json.Marshal(Hello{LogWisp: ProtocolVersion, Node: node})
func EncodeHello(h Hello) ([]byte, error) {
h.LogWisp = ProtocolVersion
b, err := json.Marshal(h)
if err != nil {
return nil, err
}
+32 -49
View File
@@ -172,15 +172,6 @@ type PluginSourceConfig struct {
ConfigFile string `toml:"config_file,omitempty"` // TODO: support for include/source mechanism for nested config
}
// // SourceConfig is a polymorphic struct representing a single data source
// type SourceConfig struct {
// Type string `toml:"type"`
//
// // Polymorphic - only one populated based on type
// File *FileSourceOptions `toml:"file,omitempty"`
// Console *ConsoleSourceOptions `toml:"console,omitempty"`
// }
// NullSourceOptions defines settings for a null source (no configuration needed)
type NullSourceOptions struct{}
@@ -219,7 +210,6 @@ type TCPChainSourceOptions struct {
HelloTimeoutMS int64 `toml:"hello_timeout_ms"` // preamble deadline
TrustNode bool `toml:"trust_node"` // false: force node label from remote address
Auth *AuthOptions `toml:"auth"`
// Future: password auth block
}
// HTTPChainSourceOptions defines settings for a stdlib HTTP listener ingesting
@@ -234,7 +224,6 @@ type HTTPChainSourceOptions struct {
ReadTimeoutMS int64 `toml:"read_timeout_ms"` // full request read deadline
TrustNode bool `toml:"trust_node"` // false: force node label from remote address
Auth *AuthOptions `toml:"auth"`
// Future: password auth block
}
// --- Sink Options ---
@@ -247,15 +236,6 @@ type PluginSinkConfig struct {
ConfigFile string `toml:"config_file,omitempty"` // TODO: support for include/source mechanism for nested config
}
// // SinkConfig is a polymorphic struct representing a single data sink
// type SinkConfig struct {
// Type string `toml:"type"`
//
// // Polymorphic - only one populated based on type
// Console *ConsoleSinkOptions `toml:"console,omitempty"`
// File *FileSinkOptions `toml:"file,omitempty"`
// }
// NullSinkOptions defines settings for a null sink (no configuration needed)
type NullSinkOptions struct{}
@@ -289,7 +269,6 @@ type TCPSinkOptions struct {
KeepAlivePeriodMS int64 `toml:"keep_alive_period_ms"`
MaxConnections int64 `toml:"max_connections"` // 0 = unlimited
Auth *AuthOptions `toml:"auth"`
// Future: password auth block
}
// HTTPSinkOptions defines settings for an HTTP SSE server sink
@@ -303,8 +282,9 @@ type HTTPSinkOptions struct {
ClientBufferSize int64 `toml:"client_buffer_size"` // per-client send queue
WriteTimeoutMS int64 `toml:"write_timeout_ms"` // per-SSE-write deadline, 0 = none
MaxConnections int64 `toml:"max_connections"` // 0 = unlimited
LoginPage bool `toml:"login_page"` // GET /auth/login, needs auth.trusted_proxies
ViewerPage bool `toml:"viewer_page"` // GET /auth/view, needs login_page
Auth *AuthOptions `toml:"auth"`
// Future: password auth block
}
// TCPChainSinkOptions defines settings for a stdlib TCP client forwarding
@@ -322,7 +302,6 @@ type TCPChainSinkOptions struct {
KeepAlivePeriodMS int64 `toml:"keep_alive_period_ms"`
KeepAlive bool `toml:"keep_alive"`
Auth *AuthOptions `toml:"auth"`
// Future: password auth block
}
// HTTPChainSinkOptions defines settings for a stdlib HTTP client posting
@@ -341,49 +320,53 @@ type HTTPChainSinkOptions struct {
BackoffMinMS int64 `toml:"backoff_min_ms"`
BackoffMaxMS int64 `toml:"backoff_max_ms"`
Auth *AuthOptions `toml:"auth"`
// Future: password auth block
}
// --- Auth Options ---
// AuthOptions defines certificate-based authorization for network plugins.
// It sits beside `tls` rather than inside it: TLS answers "is this channel
// private and does the peer chain to a CA", auth answers "may *this* peer do
// *this*". One shape serves both roles:
// - Listeners (tcp/http sinks, tcp_chain/http_chain sources) authorize the
// peer's client certificate; type "mtls" requires tls.client_auth.
// - Dialers (tcp_chain/http_chain sinks) pin the server's identity beyond
// hostname verification.
// AuthOptions selects how a network plugin authenticates its peer. It sits
// beside `tls`: TLS answers "is this channel private", auth answers "may this
// peer do this". Listeners verify (mtls: certificate identity; scram: password
// via credentials_file); dialers prove themselves (scram) or pin the server
// (mtls). Validation is per role in internal/authz.
type AuthOptions struct {
// Method: "none" (default, preserves pre-auth behavior) | "mtls"
// Method: "none" (default) | "mtls" | "scram"
Type string `toml:"type"`
// Certificate field carrying the identity:
// "cn" (default) | "san_dns" | "san_uri" | "san_email"
// Certificate field carrying the identity: "cn" (default) | "san_dns" |
// "san_uri" | "san_email". Under scram it binds the client certificate to
// the user: the field must equal the SCRAM username.
Identity string `toml:"identity"`
// Exact identity matches. Empty Allow *and* AllowPatterns means "any
// identity the CA vouches for" - today's behavior, but with the identity
// recorded and node binding available.
// mtls only: exact identities and RE2 patterns (anchor them yourself).
// Both empty admits any identity the CA vouches for.
Allow []string `toml:"allow"`
// RE2 patterns matched against the identity; anchor them yourself
AllowPatterns []string `toml:"allow_patterns"`
// Chain sources only: "none" | "assert" | "force" (default "force" when
// Type is "mtls"). Overrides trust_node.
// Chain sources only: "none" | "assert" | "force" (default "force").
// Overrides trust_node.
NodeBinding string `toml:"node_binding"`
// scram listeners: verifier file written by `logwisp auth add-user`, and
// the bearer token lifetime on HTTP listeners (default 15 minutes)
CredentialsFile string `toml:"credentials_file"`
TokenLifetimeMS int64 `toml:"token_lifetime_ms"`
// scram http sink: addresses or CIDRs of the reverse proxies that end the
// browsers' TLS; logins are then unbound and sessions may be cookies
TrustedProxies []string `toml:"trusted_proxies"`
// scram dialers: the identity presented and the file holding its password
Username string `toml:"username"`
PasswordFile string `toml:"password_file"`
}
// --- TLS Options ---
// TLSOptions defines transport security for network sources and sinks.
// One shape serves both roles so the config block is uniform:
// - Listeners (tcp/http sinks, tcp_chain/http_chain sources) use
// cert_file/key_file as server identity; client_auth/client_ca_file
// require and verify peer certificates (mTLS).
// - Dialers (tcp_chain/http_chain sinks) use ca_file/server_name to verify
// the server; cert_file/key_file present a client identity (mTLS).
// 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
// verify the server with ca_file and server_name and may present
// cert_file and key_file.
type TLSOptions struct {
Enabled bool `toml:"enabled"`
+3
View File
@@ -65,6 +65,9 @@ func (m *Manager) Snapshot() (*Config, error) {
}
cfg := value.(*Config)
cfg.ConfigFile = m.path
if err := checkFileKeys(m.path); err != nil {
return nil, fmt.Errorf("config file %q: %w", m.path, err)
}
if err := ValidateConfig(cfg); err != nil {
return nil, fmt.Errorf("validate configuration: %w", err)
}
+32
View File
@@ -228,3 +228,35 @@ port = 9000
}
}
}
// A misspelled table path above a plugin's config drops that whole table, so
// the file itself is checked too; only config_file is tolerated, as documented.
func TestConfigFileRejectsUnknownKeys(t *testing.T) {
isolateConfig(t)
for key, body := range map[string]string{
"pipelines[0].plugin_sinks[0].confg": "[pipelines.plugin_sinks.confg.tls]\nenabled = true",
"logging.levle": "[logging]\nlevle = \"debug\"",
} {
testutil.WriteFile(t, "typo.toml", `config_file = "ignored.toml"
[[pipelines]]
name = "typo"
[[pipelines.plugin_sources]]
id = "in"
type = "null"
[[pipelines.plugin_sinks]]
id = "out"
type = "tcp"
[pipelines.plugin_sinks.config]
port = 9000
`+body+"\n")
m, err := Load([]string{"-c", "typo.toml"})
if err != nil {
t.Fatal(err)
}
_, err = m.Snapshot()
m.Close()
if err == nil || !strings.Contains(err.Error(), `"`+key+`"`) {
t.Errorf("unknown key %s: err = %v", key, err)
}
}
}
+28 -3
View File
@@ -1,13 +1,17 @@
package config
import (
"errors"
"fmt"
"io/fs"
"maps"
"os"
"reflect"
"slices"
"strings"
lconfig "github.com/lixenwraith/config"
"github.com/lixenwraith/toml"
)
// ValidateConfig validates top-level structure only
@@ -119,7 +123,9 @@ func unknownKeys(m map[string]any, t reflect.Type, prefix string) error {
var tables []map[string]any
switch v := m[key].(type) {
case map[string]any:
tables = []map[string]any{v}
if err := unknownKeys(v, ft, prefix+key+"."); err != nil {
return err
}
case []map[string]any:
tables = v
case []any:
@@ -129,11 +135,30 @@ func unknownKeys(m map[string]any, t reflect.Type, prefix string) error {
}
}
}
for _, table := range tables {
if err := unknownKeys(table, ft, prefix+key+"."); err != nil {
for i, table := range tables {
if err := unknownKeys(table, ft, fmt.Sprintf("%s%s[%d].", prefix, key, i)); err != nil {
return err
}
}
}
return nil
}
// checkFileKeys rejects keys the configuration file declares that Config does
// not: a misspelled table path such as plugin_sinks.confg.tls would otherwise
// drop the whole table. Plugin config maps are checked by Scan instead.
func checkFileKeys(path string) error {
data, err := os.ReadFile(path)
if errors.Is(err, fs.ErrNotExist) {
return nil
}
if err != nil {
return err
}
root, err := toml.NewParser(data).Parse()
if err != nil {
return err
}
delete(root, "config_file") // runtime metadata, documented as ignored in the file
return unknownKeys(root, reflect.TypeOf(Config{}), "")
}
+1
View File
@@ -7,6 +7,7 @@ const (
// Network capabilities
CapNetLimit Capability = "netlimit"
CapTLS Capability = "tls"
CapProxyTLS Capability = "proxy_tls" // TLS ends at a trusted reverse proxy
CapAuth Capability = "auth"
// Session capabilities
+2 -2
View File
@@ -161,7 +161,7 @@ func (p *Pipeline) initSourceCapabilities(s source.Source, cfg config.PluginSour
// Network capabilities
case core.CapNetLimit:
continue // No-op for now, placeholder
case core.CapTLS:
case core.CapTLS, core.CapProxyTLS:
hasTLS = true
case core.CapAuth:
hasAuth = true
@@ -202,7 +202,7 @@ func (p *Pipeline) initSinkCapabilities(s sink.Sink, cfg config.PluginSinkConfig
// Network capabilities
case core.CapNetLimit:
continue // No-op for now, placeholder
case core.CapTLS:
case core.CapTLS, core.CapProxyTLS:
hasTLS = true
case core.CapAuth:
hasAuth = true
+89 -28
View File
@@ -1,6 +1,7 @@
package http
import (
"bytes"
"context"
"crypto/tls"
"encoding/json"
@@ -16,6 +17,7 @@ import (
"time"
"logwisp/internal/authz"
"logwisp/internal/chain"
"logwisp/internal/config"
"logwisp/internal/core"
"logwisp/internal/plugin"
@@ -75,6 +77,7 @@ type HTTPSink struct {
// Authorization
auth *authz.Policy
web map[string]http.Handler // proxy mode: GET /auth/... browser files
// Runtime
done chan struct{}
@@ -126,6 +129,11 @@ func NewHTTPSinkPlugin(
if opts.StreamPath == opts.StatusPath {
return nil, fmt.Errorf("stream_path and status_path must differ")
}
for _, p := range []string{opts.StreamPath, opts.StatusPath} {
if p == chain.AuthPath || strings.HasPrefix(p, chain.AuthPath+"/") {
return nil, fmt.Errorf("%s and the paths under it are reserved for authentication", chain.AuthPath)
}
}
if opts.BufferSize <= 0 {
opts.BufferSize = DefaultHTTPBufferSize
}
@@ -136,10 +144,22 @@ func NewHTTPSinkPlugin(
if err != nil {
return nil, err
}
authPolicy, err := authz.New(opts.Auth, opts.TLS, authz.RoleListener)
authPolicy, err := authz.New(opts.Auth, tlsCfg, authz.RoleListener, authz.HTTP)
if err != nil {
return nil, err
}
switch {
case (opts.LoginPage || opts.ViewerPage) && !authPolicy.BehindProxy():
return nil, errors.New("login_page and viewer_page need auth.trusted_proxies: browsers log in only behind a TLS-terminating proxy")
case opts.ViewerPage && !opts.LoginPage:
return nil, errors.New("viewer_page needs login_page, where it sends a signed-out viewer")
}
var web map[string]http.Handler
if authPolicy.BehindProxy() {
if web, err = webHandlers(opts); err != nil {
return nil, err
}
}
h := &HTTPSink{
id: id,
@@ -154,6 +174,7 @@ func NewHTTPSinkPlugin(
keepalive: core.StreamKeepaliveInterval,
tlsConfig: tlsCfg,
auth: authPolicy,
web: web,
}
h.lastProcessed.Store(time.Time{})
@@ -166,7 +187,9 @@ func NewHTTPSinkPlugin(
"status_path", opts.StatusPath,
"tls", tlsCfg != nil,
"mtls", tlsCfg != nil && tlsCfg.ClientAuth == tls.RequireAndVerifyClientCert,
"auth", authPolicy.Describe())
"auth", authPolicy.Describe(),
"login_page", opts.LoginPage,
"viewer_page", opts.ViewerPage)
tlsx.LogWarnings(logger, "http_sink", id, opts.TLS, true)
authPolicy.LogStartup(logger, "http_sink", id, false)
return h, nil
@@ -178,6 +201,9 @@ func (h *HTTPSink) Capabilities() []core.Capability {
if h.tlsConfig != nil {
caps = append(caps, core.CapTLS)
}
if h.auth.BehindProxy() {
caps = append(caps, core.CapProxyTLS)
}
if h.auth.Enabled() {
caps = append(caps, core.CapAuth) // authorizes clients, not just the CA
}
@@ -204,6 +230,10 @@ func (h *HTTPSink) Start(ctx context.Context) error {
// serve owns an already-bound listener, allowing tests to reserve an ephemeral
// port and exercise the same routing and worker lifecycle as Start.
func (h *HTTPSink) serve(ctx context.Context, ln net.Listener) error {
if err := h.auth.Start(); err != nil {
ln.Close()
return err
}
mux := http.NewServeMux()
// Method-scoped patterns: mux answers 405 with Allow header on non-GET
mux.HandleFunc(http.MethodGet+" "+h.config.StreamPath, h.handleStream)
@@ -214,10 +244,20 @@ func (h *HTTPSink) serve(ctx context.Context, ln net.Listener) error {
mux.HandleFunc(http.MethodHead+" "+h.config.StreamPath, streamHeadNotAllowed)
// One wrapper covers stream and status, and keeps the handlers themselves
// unaware of authorization
// unaware of authorization. Login and its browser files sit outside it;
// in proxy mode everything sits behind the proxy gate.
var handler http.Handler = mux
if h.auth.Enabled() {
handler = h.authMiddleware(handler)
outer := http.NewServeMux()
outer.HandleFunc(http.MethodPost+" "+chain.AuthPath, h.handleAuth)
for p, file := range h.web {
outer.Handle(http.MethodGet+" "+p, file)
}
outer.Handle("/", h.authMiddleware(mux))
handler = outer
if h.auth.BehindProxy() {
handler = h.proxyGate(outer)
}
}
h.server = &http.Server{
@@ -271,6 +311,7 @@ func (h *HTTPSink) Stop() {
h.shutdown()
h.wg.Wait()
h.auth.Close()
h.logger.Info("msg", " HTTP sink stopped",
"component", "http_sink",
@@ -358,7 +399,7 @@ func (h *HTTPSink) handleStream(w http.ResponseWriter, r *http.Request) {
}
rc := http.NewResponseController(w)
remote := r.RemoteAddr
remote := clientAddr(r)
meta := map[string]any{
"type": "http_client",
@@ -549,34 +590,53 @@ func (h *HTTPSink) GetStats() sink.SinkStats {
// handlers; absent when auth is disabled
type identityKey struct{}
// authMiddleware gates every endpoint on the client certificate policy.
// The rejection carries no detail: the status endpoint already exposes host,
// port, and throughput counters, so a 403 should not add the shape of the
// policy on top of that.
// authMiddleware gates every endpoint on the policy: a client certificate
// or a bearer token. The rejection carries no detail: the status endpoint
// already exposes host, port, and throughput counters, so a refusal should
// not add the shape of the policy on top of that.
func (h *HTTPSink) authMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ident, err := h.auth.Authorize(r.TLS)
ident, status, err := h.auth.AuthorizeRequest(r)
if err != nil {
h.logger.Warn("msg", "Request rejected by auth policy",
"component", "http_sink",
"instance_id", h.id,
"remote_addr", r.RemoteAddr,
"remote_addr", clientAddr(r),
"path", r.URL.Path,
"error", err)
http.Error(w, "forbidden", http.StatusForbidden)
authz.Refuse(w, status)
return
}
next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), identityKey{}, ident)))
})
}
// handleAuth runs one step of a SCRAM login and logs its outcome
func (h *HTTPSink) handleAuth(w http.ResponseWriter, r *http.Request) {
ident, err := h.auth.ServeAuth(w, r)
switch {
case err != nil:
h.logger.Warn("msg", "Login rejected",
"component", "http_sink",
"instance_id", h.id,
"remote_addr", clientAddr(r),
"error", err)
case ident.Name != "":
h.logger.Info("msg", "Login accepted",
"component", "http_sink",
"instance_id", h.id,
"remote_addr", clientAddr(r),
"auth_identity", ident.Name)
}
}
// streamHeadNotAllowed refuses a body-less read of a stream that is only a body
func streamHeadNotAllowed(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Allow", http.MethodGet)
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
}
// writeSSE frames a payload per the W3C SSE spec (multi-line safe)
// writeSSE frames a payload per the W3C SSE spec, one data: line per line
func writeSSE(w http.ResponseWriter, payload []byte) error {
for _, line := range splitLines(payload) {
if _, err := fmt.Fprintf(w, "data: %s\n", line); err != nil {
@@ -587,27 +647,28 @@ func writeSSE(w http.ResponseWriter, payload []byte) error {
return err
}
// splitLines splits payload by newlines, trimming a single trailing newline
// splitLines splits on every break SSE recognises (CRLF, LF and a lone CR),
// so no payload byte can end a data: line and start an event: or retry:
// field. One trailing break is dropped.
func splitLines(data []byte) [][]byte {
if len(data) == 0 {
return nil
}
if data[len(data)-1] == '\n' {
if bytes.HasSuffix(data, []byte("\r\n")) {
data = data[:len(data)-2]
} else if data[len(data)-1] == '\n' || data[len(data)-1] == '\r' {
data = data[:len(data)-1]
}
var lines [][]byte
start := 0
for i := 0; i < len(data); i++ {
if data[i] == '\n' {
lines = append(lines, data[start:i])
start = i + 1
for {
i := bytes.IndexAny(data, "\r\n")
if i < 0 {
return append(lines, data)
}
lines = append(lines, data[:i])
if data[i] == '\r' && i+1 < len(data) && data[i+1] == '\n' {
i++
}
data = data[i+1:]
}
}
if start < len(data) {
lines = append(lines, data[start:])
}
if len(lines) == 0 {
return [][]byte{data}
}
return lines
}
+150
View File
@@ -2,18 +2,26 @@ package http
import (
"bufio"
"bytes"
"crypto/tls"
"crypto/x509"
"encoding/json"
"io"
"maps"
"net/http"
"net/http/httptest"
"os"
"slices"
"strings"
"testing"
"time"
"logwisp/internal/core"
"logwisp/internal/sink"
"logwisp/internal/testutil"
"github.com/lixenwraith/auth"
"github.com/lixenwraith/log"
)
func TestStatusReportsQueueAndConnectionBounds(t *testing.T) {
@@ -169,3 +177,145 @@ func TestWildcardCORSOnlyWithoutAuth(t *testing.T) {
}
}
}
// Under scram the login endpoint sits outside the gate it opens: a hello
// earns a challenge without a token, while stream and status demand one.
func TestLoginEndpointBypassesTheGate(t *testing.T) {
pki := testutil.NewPKI(t, "viewer-01")
creds := scramCredentials(t)
gated, _ := newTestHTTPSink(t, map[string]any{
"tls": map[string]any{"enabled": true, "cert_file": pki.ServerCert, "key_file": pki.ServerKey},
"auth": map[string]any{"type": "scram", "credentials_file": creds},
})
client, baseURL := serveTestHTTPSink(t, gated)
caPEM, err := os.ReadFile(pki.CA)
if err != nil {
t.Fatal(err)
}
roots := x509.NewCertPool()
roots.AppendCertsFromPEM(caPEM)
client.Transport = &http.Transport{TLSClientConfig: &tls.Config{RootCAs: roots}}
baseURL = "https" + strings.TrimPrefix(baseURL, "http")
hello := `{"logwisp":1,"scram":{"username":"viewer-01","client_nonce":"abcdefgh"}}`
resp, err := client.Post(baseURL+"/auth", "application/json", strings.NewReader(hello))
if err != nil {
t.Fatal(err)
}
var step struct {
Challenge *auth.ServerFirstMessage `json:"challenge"`
}
json.NewDecoder(resp.Body).Decode(&step)
resp.Body.Close()
if resp.StatusCode != http.StatusOK || step.Challenge == nil {
t.Fatalf("POST /auth without a token = %d, challenge %v", resp.StatusCode, step.Challenge)
}
for _, path := range []string{"/status", "/stream"} {
resp, err := client.Get(baseURL + path)
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
if resp.StatusCode != http.StatusUnauthorized {
t.Errorf("GET %s without a token = %d, want 401", path, resp.StatusCode)
}
}
}
// proxied sends what a TLS-terminating proxy would: the client and https
func proxied(req *http.Request) *http.Request {
req.Header.Set("X-Forwarded-For", "203.0.113.7")
req.Header.Set("X-Forwarded-Proto", "https")
return req
}
func proxySink(t *testing.T, proxies []any, overrides map[string]any) *HTTPSink {
t.Helper()
opts := map[string]any{"auth": map[string]any{"type": "scram", "credentials_file": scramCredentials(t), "trusted_proxies": proxies}}
maps.Copy(opts, overrides)
h, _ := newTestHTTPSink(t, opts)
return h
}
// Behind a proxy the sink serves the client library and the enabled pages
// under /auth/, each with its type and the pages' CSP; the viewer learns a
// custom status path from its meta tag.
func TestProxyModeServesBrowserFiles(t *testing.T) {
h := proxySink(t, []any{"127.0.0.1"}, map[string]any{"login_page": true, "viewer_page": true, "status_path": "/api/status"})
if !slices.Contains(h.Capabilities(), core.CapProxyTLS) {
t.Fatal("a proxy-mode sink does not report proxy_tls")
}
client, baseURL := serveTestHTTPSink(t, h)
for file, ctype := range map[string]string{
"scram.js": "text/javascript", "login": "text/html", "login.js": "text/javascript",
"style.css": "text/css", "view": "text/html", "view.js": "text/javascript",
} {
req, _ := http.NewRequest(http.MethodGet, baseURL+"/auth/"+file, nil)
resp, err := client.Do(proxied(req))
if err != nil {
t.Fatal(err)
}
body, _ := io.ReadAll(resp.Body)
resp.Body.Close()
if resp.StatusCode != http.StatusOK || !strings.HasPrefix(resp.Header.Get("Content-Type"), ctype) ||
!strings.Contains(resp.Header.Get("Content-Security-Policy"), "script-src 'self'") {
t.Errorf("/auth/%s: %d %q %q", file, resp.StatusCode, resp.Header.Get("Content-Type"), resp.Header.Get("Content-Security-Policy"))
}
if file == "view" && !bytes.Contains(body, []byte(`content="api/status"`)) {
t.Error("the viewer page does not carry the custom status path")
}
}
}
// Behind a proxy nothing answers a peer outside trusted_proxies, not even the
// login page or the challenge.
func TestProxyModeRefusesDirectPeers(t *testing.T) {
h := proxySink(t, []any{"192.0.2.1"}, map[string]any{"login_page": true})
client, baseURL := serveTestHTTPSink(t, h)
for _, target := range []struct{ method, path string }{
{http.MethodGet, "/auth/login"}, {http.MethodGet, "/status"}, {http.MethodPost, "/auth"},
} {
req, _ := http.NewRequest(target.method, baseURL+target.path, strings.NewReader(`{"logwisp":1}`))
req.Header.Set("Content-Type", "application/json")
resp, err := client.Do(proxied(req))
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
if resp.StatusCode != http.StatusForbidden {
t.Errorf("%s %s from an untrusted peer: %d, want 403", target.method, target.path, resp.StatusCode)
}
}
}
// Pages exist only behind a proxy, where browsers can log in; the viewer
// needs the login page; /auth and the paths under it are reserved.
func TestPagesNeedProxyMode(t *testing.T) {
scram := map[string]any{"type": "scram", "credentials_file": scramCredentials(t)}
pki := testutil.NewPKI(t, "viewer-01")
tlsOn := map[string]any{"enabled": true, "cert_file": pki.ServerCert, "key_file": pki.ServerKey}
proxy := map[string]any{"type": "scram", "credentials_file": scram["credentials_file"], "trusted_proxies": []any{"127.0.0.1"}}
for name, opts := range map[string]map[string]any{
"login page without proxies": {"tls": tlsOn, "auth": scram, "login_page": true},
"viewer without login": {"auth": proxy, "viewer_page": true},
"stream under /auth": {"auth": proxy, "stream_path": "/auth/stream"},
} {
maps.Copy(opts, map[string]any{"host": "127.0.0.1", "port": int64(8081)})
if _, err := NewHTTPSinkPlugin("stream", opts, log.NewLogger(), nil); err == nil {
t.Errorf("%s: accepted", name)
}
}
}
// Every line break SSE recognises, a lone CR included, starts another data:
// line: an entry cannot inject an event:, retry: or id: field.
func TestSSEFramesEveryLineAsData(t *testing.T) {
rec := httptest.NewRecorder()
if err := writeSSE(rec, []byte("a\revent: disconnect\r\nretry: 99999999\nid: x\r")); err != nil {
t.Fatal(err)
}
want := "data: a\ndata: event: disconnect\ndata: retry: 99999999\ndata: id: x\n\n"
if got := rec.Body.String(); got != want {
t.Fatalf("framed %q, want %q", got, want)
}
}
+21
View File
@@ -5,11 +5,15 @@ import (
"maps"
"net"
"net/http"
"path/filepath"
"testing"
"time"
"logwisp/internal/authz"
"logwisp/internal/session"
"logwisp/internal/testutil"
"github.com/lixenwraith/auth"
"github.com/lixenwraith/log"
)
@@ -46,3 +50,20 @@ func serveTestHTTPSink(t *testing.T, h *HTTPSink) (*http.Client, string) {
t.Cleanup(client.CloseIdleConnections)
return client, "http://" + listener.Addr().String()
}
// scramCredentials writes a credentials file holding viewer-01 under a cheap
// Argon2 profile
func scramCredentials(t *testing.T) string {
t.Helper()
cred, err := auth.NewCredential("viewer-01", "viewer-01-secret", auth.WithTime(1), auth.WithMemory(64), auth.WithThreads(1))
if err != nil {
t.Fatal(err)
}
data, err := (&authz.Credentials{DecoyKey: make([]byte, 32), Users: []*auth.Credential{cred}}).Marshal()
if err != nil {
t.Fatal(err)
}
path := filepath.Join(t.TempDir(), "users.toml")
testutil.WriteFile(t, path, string(data))
return path
}
+110
View File
@@ -0,0 +1,110 @@
package http
import (
"bytes"
"context"
"embed"
"html"
"net/http"
"path"
"strings"
"logwisp/internal/authz"
"logwisp/internal/chain"
"logwisp/internal/config"
)
//go:embed web/*.js web/*.html web/*.css
var webFiles embed.FS
// pageCSP confines the pages to their own scripts and styles: no inline code,
// no other origin, no framing
const pageCSP = "default-src 'none'; script-src 'self'; connect-src 'self'; style-src 'self'; " +
"form-action 'self'; frame-ancestors 'none'; base-uri 'none'"
var webTypes = map[string]string{
".js": "text/javascript; charset=utf-8",
".css": "text/css; charset=utf-8",
".html": "text/html; charset=utf-8",
}
func loginOn(o *config.HTTPSinkOptions) bool { return o.LoginPage }
func viewerOn(o *config.HTTPSinkOptions) bool { return o.ViewerPage }
// webRoutes are served under /auth/ in proxy mode: the client library always,
// so a site can load it, and the pages when enabled
var webRoutes = []struct {
name, file string
on func(*config.HTTPSinkOptions) bool
}{
{"scram.js", "scram.js", func(*config.HTTPSinkOptions) bool { return true }},
{"style.css", "style.css", loginOn},
{"login", "login.html", loginOn},
{"login.js", "login.js", loginOn},
{"view", "view.html", viewerOn},
{"view.js", "view.js", viewerOn},
}
// webHandlers maps each enabled GET path to its file. The viewer learns a
// custom status path from a meta tag, as the CSP allows no inline script.
func webHandlers(o *config.HTTPSinkOptions) (map[string]http.Handler, error) {
handlers := make(map[string]http.Handler)
for _, r := range webRoutes {
if !r.on(o) {
continue
}
data, err := webFiles.ReadFile("web/" + r.file)
if err != nil {
return nil, err
}
if r.file == "view.html" {
status := html.EscapeString(strings.TrimPrefix(o.StatusPath, "/"))
data = bytes.Replace(data, []byte(`content="status"`), []byte(`content="`+status+`"`), 1)
}
handlers[chain.AuthPath+"/"+r.name] = serveWebFile(data, webTypes[path.Ext(r.file)])
}
return handlers, nil
}
func serveWebFile(data []byte, contentType string) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
hd := w.Header()
hd.Set("Content-Type", contentType)
hd.Set("Content-Security-Policy", pageCSP)
hd.Set("X-Content-Type-Options", "nosniff")
hd.Set("Referrer-Policy", "no-referrer")
hd.Set("Cache-Control", "no-cache")
w.Write(data)
})
}
// clientKey carries the forwarded client address from proxyGate
type clientKey struct{}
// proxyGate admits only the trusted proxies, and records the client they
// forward for logs and sessions. ServeAuth and AuthorizeRequest check again.
func (h *HTTPSink) proxyGate(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
addr, err := h.auth.ClientAddr(r)
if err != nil {
h.logger.Warn("msg", "Request refused: not through a trusted proxy over https",
"component", "http_sink",
"instance_id", h.id,
"remote_addr", r.RemoteAddr,
"path", r.URL.Path,
"error", err)
authz.Refuse(w, http.StatusForbidden)
return
}
next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), clientKey{}, addr)))
})
}
// clientAddr names the client in logs and sessions: the forwarded client in
// proxy mode, else the socket peer
func clientAddr(r *http.Request) string {
if addr, ok := r.Context().Value(clientKey{}).(string); ok {
return addr
}
return r.RemoteAddr
}
+22
View File
@@ -0,0 +1,22 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>LogWisp sign-in</title>
<link rel="stylesheet" href="style.css">
<script type="module" src="login.js"></script>
</head>
<body>
<main class="card">
<h1>LogWisp</h1>
<form id="login" method="post">
<label>Username <input name="username" autocomplete="username" required></label>
<label>Password <input name="password" type="password" autocomplete="current-password" required></label>
<button type="submit" disabled>Sign in</button>
</form>
<progress id="progress" max="1" hidden></progress>
<p id="status" role="status"></p>
</main>
</body>
</html>
+48
View File
@@ -0,0 +1,48 @@
import { login } from "./scram.js";
const base = new URL("..", location.href);
const form = document.getElementById("login");
const button = form.querySelector("button");
const progress = document.getElementById("progress");
const status = document.getElementById("status");
// Same origin only: an open redirect would lend this page to phishing
function nextTarget() {
const next = new URLSearchParams(location.search).get("next");
if (!next) return null;
try {
const url = new URL(next, location.href);
return url.origin === location.origin ? url : null;
} catch {
return null;
}
}
form.addEventListener("submit", async (event) => {
event.preventDefault();
const fields = new FormData(form);
button.disabled = true;
progress.removeAttribute("value");
progress.hidden = false;
status.textContent = "Deriving the key, this takes a few seconds…";
try {
const session = await login(base, fields.get("username"), fields.get("password"), {
onProgress: (fraction) => { progress.value = fraction; },
});
form.elements.password.value = "";
const target = nextTarget();
if (target) {
location.replace(target.href);
return;
}
status.textContent = `Signed in as ${session.username}.`;
} catch (err) {
status.textContent = err.message;
} finally {
progress.hidden = true;
button.disabled = false;
}
});
// Enabled only once this script runs, so a failed load cannot submit the form natively
button.disabled = false;
+484
View File
@@ -0,0 +1,484 @@
const MAX_USERNAME = 256;
const MAX_PASSWORD = 1024;
const MAX_FULL_NONCE = 512;
const MIN_SALT = 16;
const MAX_SALT = 64;
// lixenwraith/auth's budget for an Argon2 profile it did not choose
const MAX_TIME = 16;
const MAX_MEMORY = 256 * 1024;
const MAX_THREADS = 16;
const MAX_WORK = 4 * 64 * 1024 * 3;
// The Go dialer's floor: a hostile server must not obtain a cheaply guessable proof
const MIN_TIME = 3;
const MIN_MEMORY = 64 * 1024;
const encoder = new TextEncoder();
/**
* Logs in to the logwisp http sink mounted at base (a URL ending in "/"), per
* the wire protocol in logwisp's doc/scram-auth-plan.md. The session arrives as
* an HttpOnly cookie; the promise resolves only once the server has proved it
* holds this user's verifier. onProgress(fraction) follows the Argon2 pass.
*/
export async function login(base, username, password, { onProgress } = {}) {
const pw = checkCredentials(username, password);
subtle(); // an insecure page fails before anything is sent
const url = new URL("auth", base);
const clientNonce = base64(crypto.getRandomValues(new Uint8Array(24)))
.replace(/\+/g, "-").replace(/\//g, "_");
const first = await post(url, { logwisp: 1, scram: { username, client_nonce: clientNonce } });
const { proof, serverSignature } = await prove(username, pw, clientNonce, first.challenge,
MIN_TIME, MIN_MEMORY, onProgress);
const step = await post(url, { proof, session: "cookie" });
const final = step.final;
if (!final || typeof final !== "object") {
throw new Error("server sent no final message");
}
const name = final.username ?? "";
const signature = decodeBase64(final.server_signature, 32);
if ((name !== "" && name !== username) || !signature || !equal(signature, serverSignature)) {
throw new Error("server could not prove it holds this user's verifier");
}
const expiresIn = Number.isSafeInteger(step.expires_in) && step.expires_in > 0 ? step.expires_in : 0;
return { username, expiresIn };
}
// Logout posts to auth itself: a clearing cookie from any other path would
// get another default path and miss the session cookie
export async function logout(base) {
const res = await send(new URL("auth", base), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ logout: true }),
});
if (res.status !== 200 && res.status !== 204) {
throw new Error(`logout failed: ${res.status}`);
}
}
// Test hook: the proof for a fixed client nonce, under an explicit Argon2 floor
export async function _proofForTest(username, password, clientNonce, challenge, minTime, minMemory) {
const { proof, serverSignature } = await prove(username, checkCredentials(username, password),
clientNonce, challenge, minTime, minMemory);
return { client_proof: proof.client_proof, server_signature: base64(serverSignature) };
}
function checkCredentials(username, password) {
if (typeof username !== "string" || typeof password !== "string") {
throw new Error("username and password must be text");
}
const name = encoder.encode(username);
if (name.length === 0 || name.length > MAX_USERNAME || /[,=\p{Cc}\p{Cs}]/u.test(username)) {
throw new Error("invalid username");
}
// TextEncoder would silently turn a lone surrogate into U+FFFD
if (/\p{Cs}/u.test(password)) {
throw new Error("password contains invalid characters");
}
const pw = encoder.encode(password);
if (pw.length === 0 || pw.length > MAX_PASSWORD) {
throw new Error(`password must be 1-${MAX_PASSWORD} bytes`);
}
return pw;
}
// Everything the challenge says is checked before Argon2 runs on it
async function prove(username, pw, clientNonce, challenge, minTime, minMemory, onProgress) {
if (!challenge || typeof challenge !== "object") {
throw new Error("server sent no challenge");
}
const { full_nonce: fullNonce, salt, argon_time: t, argon_memory: m, argon_threads: p } = challenge;
if (typeof fullNonce !== "string" || fullNonce.length > MAX_FULL_NONCE || !/^[\x21-\x2b\x2d-\x7e]+$/.test(fullNonce) ||
!fullNonce.startsWith(clientNonce) || fullNonce.length <= clientNonce.length) {
throw new Error("invalid server nonce");
}
const saltBytes = decodeBase64(salt, MAX_SALT);
if (!saltBytes || saltBytes.length < MIN_SALT) {
throw new Error("invalid salt");
}
if (![t, m, p].every(Number.isSafeInteger) || t < 1 || p < 1 || m < 8 * p ||
t > MAX_TIME || m > MAX_MEMORY || p > MAX_THREADS || m * t > MAX_WORK) {
throw new Error("invalid Argon2 parameters");
}
if (t < minTime || m < minMemory) {
throw new Error("server asked for a weaker Argon2 cost than allowed");
}
const salted = await argon2id(pw, saltBytes, t, m, p, 32, { onProgress });
const clientKey = await hmac(salted, "Client Key");
const serverKey = await hmac(salted, "Server Key");
const storedKey = new Uint8Array(await subtle().digest("SHA-256", clientKey));
salted.fill(0);
// Unbound: the browser cannot see the certificate, so no c= part
const authMessage = `u=${username},n=${clientNonce},r=${fullNonce},s=${salt},t=${t},m=${m},p=${p},r=${fullNonce}`;
const proof = await hmac(storedKey, authMessage);
for (let i = 0; i < proof.length; i++) proof[i] ^= clientKey[i];
clientKey.fill(0);
return {
proof: { full_nonce: fullNonce, client_proof: base64(proof) },
serverSignature: await hmac(serverKey, authMessage),
};
}
async function post(url, body) {
const res = await send(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
const step = (await res.json().catch(() => null)) ?? {};
if (res.status === 404 || res.status === 405) {
throw new Error("no SCRAM login at this address");
}
if (res.status !== 200) {
throw new Error(`login refused: ${typeof step.error === "string" ? step.error : res.status}`);
}
return step;
}
// A redirect would replay the proof to wherever it points
async function send(url, init) {
try {
return await fetch(url, { ...init, credentials: "same-origin", cache: "no-store", redirect: "error" });
} catch {
throw new Error(`cannot reach ${url.pathname}`);
}
}
function subtle() {
const s = globalThis.crypto?.subtle;
if (!s) throw new Error("WebCrypto unavailable: the page must be served over HTTPS");
return s;
}
async function hmac(key, message) {
const k = await subtle().importKey("raw", key, { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
return new Uint8Array(await subtle().sign("HMAC", k, encoder.encode(message)));
}
function equal(a, b) {
if (a.length !== b.length) return false;
let d = 0;
for (let i = 0; i < a.length; i++) d |= a[i] ^ b[i];
return d === 0;
}
function base64(bytes) {
let s = "";
for (const b of bytes) s += String.fromCharCode(b);
return btoa(s);
}
// Strict like Go's base64.StdEncoding.Strict(): the round trip rejects stray padding bits
function decodeBase64(s, maxBytes) {
if (typeof s !== "string" || s.length > 4 * Math.ceil(maxBytes / 3) ||
!/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(s)) {
return null;
}
const out = Uint8Array.from(atob(s), (c) => c.charCodeAt(0));
return out.length <= maxBytes && base64(out) === s ? out : null;
}
// --- Argon2id (RFC 9106, version 0x13) ---
// Uint64s are (lo, hi) pairs of uint32 words; a 1 KiB block is 256 words.
const BLAKE2B_IV = new Uint32Array([
0xf3bcc908, 0x6a09e667, 0x84caa73b, 0xbb67ae85, 0xfe94f82b, 0x3c6ef372, 0x5f1d36f1, 0xa54ff53a,
0xade682d1, 0x510e527f, 0x2b3e6c1f, 0x9b05688c, 0xfb41bd6b, 0x1f83d9ab, 0x137e2179, 0x5be0cd19,
]);
const SIGMA = [
[0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15],
[14, 10, 4, 8, 9, 15, 13, 6, 1, 12, 0, 2, 11, 7, 5, 3],
[11, 8, 12, 0, 5, 2, 15, 13, 10, 14, 3, 6, 7, 1, 9, 4],
[7, 9, 3, 1, 13, 12, 11, 14, 2, 6, 5, 10, 4, 0, 15, 8],
[9, 0, 5, 7, 2, 4, 10, 15, 14, 1, 11, 12, 6, 8, 3, 13],
[2, 12, 6, 10, 0, 11, 8, 3, 4, 13, 7, 5, 15, 14, 1, 9],
[12, 5, 1, 15, 14, 13, 4, 10, 0, 7, 6, 3, 9, 2, 8, 11],
[13, 11, 7, 14, 12, 1, 3, 9, 5, 0, 15, 4, 8, 6, 2, 10],
[6, 15, 14, 9, 11, 3, 0, 8, 12, 2, 13, 7, 1, 4, 10, 5],
[10, 2, 8, 4, 7, 6, 1, 5, 15, 11, 9, 14, 3, 12, 13, 0],
].map((row) => row.map((i) => 2 * i));
/**
* Argon2id over a password (string or bytes) and salt, as Uint8Array of keyLen
* bytes. Yields to the event loop every ~50 ms, so a page stays responsive.
*/
export async function argon2id(password, salt, time, memoryKiB, threads, keyLen,
{ secret = new Uint8Array(0), data = new Uint8Array(0), onProgress } = {}) {
const pw = typeof password === "string" ? encoder.encode(password) : password;
if (![time, memoryKiB, threads, keyLen].every(Number.isSafeInteger) || time < 1 || threads < 1 ||
threads > 0xffffff || memoryKiB < 8 * threads || memoryKiB > 0xffffffff || keyLen < 4 || keyLen > 0xffffffff) {
throw new Error("invalid Argon2 parameters");
}
const h0 = new Uint8Array(72);
h0.set(blake2b(concat(le32(threads), le32(keyLen), le32(memoryKiB), le32(time), le32(0x13), le32(2),
le32(pw.length), pw, le32(salt.length), salt, le32(secret.length), secret, le32(data.length), data), 64));
const laneLen = Math.floor(memoryKiB / (4 * threads)) * 4;
const st = {
mem: new Uint32Array(laneLen * threads * 256), lanes: threads, laneLen, segLen: laneLen / 4,
blocks: laneLen * threads, time, r: new Uint32Array(256), q: new Uint32Array(256),
input: new Uint32Array(256), addr: new Uint32Array(256),
};
for (let lane = 0; lane < threads; lane++) {
h0.set(le32(lane), 68);
for (let i = 0; i < 2; i++) {
h0.set(le32(i), 64);
const block = hprime(h0, 1024);
for (let w = 0; w < 256; w++) st.mem[(lane * laneLen + i) * 256 + w] = readLE32(block, 4 * w);
}
}
let done = 0, yielded = Date.now();
for (let pass = 0; pass < time; pass++) {
for (let slice = 0; slice < 4; slice++) {
for (let lane = 0; lane < threads; lane++) {
fillSegment(st, pass, slice, lane);
done++;
if (Date.now() - yielded > 50) {
onProgress?.(done / (time * 4 * threads));
await new Promise((resolve) => setTimeout(resolve, 0));
yielded = Date.now();
}
}
}
}
const last = st.mem.slice((laneLen - 1) * 256, laneLen * 256);
for (let lane = 1; lane < threads; lane++) {
const o = (lane * laneLen + laneLen - 1) * 256;
for (let w = 0; w < 256; w++) last[w] ^= st.mem[o + w];
}
const bytes = new Uint8Array(1024);
for (let w = 0; w < 256; w++) writeLE32(bytes, 4 * w, last[w]);
return hprime(bytes, keyLen);
}
function fillSegment(st, pass, slice, lane) {
const { mem, lanes, laneLen, segLen, r, q, input, addr } = st;
const independent = pass === 0 && slice < 2;
let index = pass === 0 && slice === 0 ? 2 : 0;
if (independent) {
input.fill(0);
input[0] = pass;
input[2] = lane;
input[4] = slice;
input[6] = st.blocks;
input[8] = st.time;
input[10] = 2;
if (index !== 0) nextAddresses(input, addr, q);
}
for (let cur = lane * laneLen + slice * segLen + index; index < segLen; index++, cur++) {
const prev = index === 0 && slice === 0 ? cur + laneLen - 1 : cur - 1;
let j1, j2;
if (independent) {
if (index % 128 === 0) nextAddresses(input, addr, q);
j1 = addr[2 * (index % 128)];
j2 = addr[2 * (index % 128) + 1];
} else {
j1 = mem[prev * 256];
j2 = mem[prev * 256 + 1];
}
const refLane = pass === 0 && slice === 0 ? lane : j2 % lanes;
const same = refLane === lane;
let area, start = 0;
if (pass === 0) {
area = slice * segLen + (slice === 0 || same ? index : 0);
} else {
area = 3 * segLen + (same ? index : 0);
start = ((slice + 1) % 4) * segLen;
}
if (index === 0 || same) area--;
const ref = refLane * laneLen + (start + area - 1 - mulHi(mulHi(j1, j1), area)) % laneLen;
const c = cur * 256, x = prev * 256, y = ref * 256;
for (let i = 0; i < 256; i++) r[i] = mem[x + i] ^ mem[y + i];
q.set(r);
permute(q);
for (let i = 0; i < 256; i++) mem[c + i] ^= r[i] ^ q[i];
}
}
// Data-independent addressing: two compressions of the counter block against zero
function nextAddresses(input, addr, q) {
input[12]++;
for (const src of [input, addr]) {
q.set(src);
permute(q);
for (let i = 0; i < 256; i++) addr[i] = src[i] ^ q[i];
}
}
function permute(v) {
for (let x = 0; x < 256; x += 32) {
blamka(v, x, x + 8, x + 16, x + 24);
blamka(v, x + 2, x + 10, x + 18, x + 26);
blamka(v, x + 4, x + 12, x + 20, x + 28);
blamka(v, x + 6, x + 14, x + 22, x + 30);
blamka(v, x, x + 10, x + 20, x + 30);
blamka(v, x + 2, x + 12, x + 22, x + 24);
blamka(v, x + 4, x + 14, x + 16, x + 26);
blamka(v, x + 6, x + 8, x + 18, x + 28);
}
for (let w = 0; w < 32; w += 4) {
blamka(v, w, w + 64, w + 128, w + 192);
blamka(v, w + 2, w + 66, w + 130, w + 194);
blamka(v, w + 32, w + 96, w + 160, w + 224);
blamka(v, w + 34, w + 98, w + 162, w + 226);
blamka(v, w, w + 66, w + 160, w + 226);
blamka(v, w + 2, w + 96, w + 162, w + 192);
blamka(v, w + 32, w + 98, w + 128, w + 194);
blamka(v, w + 34, w + 64, w + 130, w + 224);
}
}
// G of Argon2's P on four uint64s, in locals; the products are split so
// every intermediate stays exact in a double
function blamka(v, a, b, c, d) {
let al = v[a], ah = v[a + 1], bl = v[b], bh = v[b + 1];
let cl = v[c], ch = v[c + 1], dl = v[d], dh = v[d + 1];
let pl, ph, s, xl, xh;
pl = Math.imul(al, bl); ph = mulHi(al, bl);
s = al + bl + ((pl << 1) >>> 0);
ah = (ah + bh + ((ph << 1) | (pl >>> 31)) + Math.floor(s / 4294967296)) >>> 0; al = s >>> 0;
xl = dl ^ al; dl = (dh ^ ah) >>> 0; dh = xl >>> 0;
pl = Math.imul(cl, dl); ph = mulHi(cl, dl);
s = cl + dl + ((pl << 1) >>> 0);
ch = (ch + dh + ((ph << 1) | (pl >>> 31)) + Math.floor(s / 4294967296)) >>> 0; cl = s >>> 0;
xl = bl ^ cl; xh = bh ^ ch; bl = ((xl >>> 24) | (xh << 8)) >>> 0; bh = ((xh >>> 24) | (xl << 8)) >>> 0;
pl = Math.imul(al, bl); ph = mulHi(al, bl);
s = al + bl + ((pl << 1) >>> 0);
ah = (ah + bh + ((ph << 1) | (pl >>> 31)) + Math.floor(s / 4294967296)) >>> 0; al = s >>> 0;
xl = dl ^ al; xh = dh ^ ah; dl = ((xl >>> 16) | (xh << 16)) >>> 0; dh = ((xh >>> 16) | (xl << 16)) >>> 0;
pl = Math.imul(cl, dl); ph = mulHi(cl, dl);
s = cl + dl + ((pl << 1) >>> 0);
ch = (ch + dh + ((ph << 1) | (pl >>> 31)) + Math.floor(s / 4294967296)) >>> 0; cl = s >>> 0;
xl = bl ^ cl; xh = bh ^ ch; bl = (xl << 1) | (xh >>> 31); bh = (xh << 1) | (xl >>> 31);
v[a] = al; v[a + 1] = ah; v[b] = bl; v[b + 1] = bh;
v[c] = cl; v[c + 1] = ch; v[d] = dl; v[d + 1] = dh;
}
// High word of the 64-bit product of two uint32s
function mulHi(a, b) {
const a0 = a & 0xffff, a1 = a >>> 16, b0 = b & 0xffff, b1 = b >>> 16;
const m01 = a0 * b1, m10 = a1 * b0;
const carry = ((a0 * b0) >>> 16) + (m01 & 0xffff) + (m10 & 0xffff);
return a1 * b1 + (m01 >>> 16) + (m10 >>> 16) + (carry >>> 16);
}
function xorRotr(v, d, s, n) {
const lo = v[d] ^ v[s], hi = v[d + 1] ^ v[s + 1];
if (n === 32) {
v[d] = hi;
v[d + 1] = lo;
} else if (n === 63) {
v[d] = (lo << 1) | (hi >>> 31);
v[d + 1] = (hi << 1) | (lo >>> 31);
} else {
v[d] = (lo >>> n) | (hi << (32 - n));
v[d + 1] = (hi >>> n) | (lo << (32 - n));
}
}
// H' of RFC 9106: BLAKE2b stretched to any length
function hprime(input, outLen) {
const x = concat(le32(outLen), input);
if (outLen <= 64) return blake2b(x, outLen);
const out = new Uint8Array(outLen);
let v = blake2b(x, 64), pos = 32;
out.set(v.subarray(0, 32));
for (; outLen - pos > 64; pos += 32) {
v = blake2b(v, 64);
out.set(v.subarray(0, 32), pos);
}
out.set(blake2b(v, outLen - pos), pos);
return out;
}
// Unkeyed BLAKE2b (RFC 7693) of a short input
function blake2b(input, outLen) {
const h = BLAKE2B_IV.slice();
h[0] ^= 0x01010000 ^ outLen;
const v = new Uint32Array(32), m = new Uint32Array(32), block = new Uint8Array(128);
const blocks = Math.max(1, Math.ceil(input.length / 128));
for (let b = 0; b < blocks; b++) {
const last = b === blocks - 1;
block.fill(0);
block.set(input.subarray(b * 128, b * 128 + 128));
for (let w = 0; w < 32; w++) m[w] = readLE32(block, 4 * w);
const count = last ? input.length : (b + 1) * 128;
v.set(h);
v.set(BLAKE2B_IV, 16);
v[24] ^= count;
v[25] ^= count / 4294967296;
if (last) {
v[28] = ~v[28];
v[29] = ~v[29];
}
for (let round = 0; round < 12; round++) {
const s = SIGMA[round % 10];
b2mix(v, m, 0, 8, 16, 24, s[0], s[1]);
b2mix(v, m, 2, 10, 18, 26, s[2], s[3]);
b2mix(v, m, 4, 12, 20, 28, s[4], s[5]);
b2mix(v, m, 6, 14, 22, 30, s[6], s[7]);
b2mix(v, m, 0, 10, 20, 30, s[8], s[9]);
b2mix(v, m, 2, 12, 22, 24, s[10], s[11]);
b2mix(v, m, 4, 14, 16, 26, s[12], s[13]);
b2mix(v, m, 6, 8, 18, 28, s[14], s[15]);
}
for (let w = 0; w < 16; w++) h[w] ^= v[w] ^ v[w + 16];
}
const out = new Uint8Array(64);
for (let w = 0; w < 16; w++) writeLE32(out, 4 * w, h[w]);
return out.slice(0, outLen);
}
function b2mix(v, m, a, b, c, d, x, y) {
add64(v, a, v, b);
add64(v, a, m, x);
xorRotr(v, d, a, 32);
add64(v, c, v, d);
xorRotr(v, b, c, 24);
add64(v, a, v, b);
add64(v, a, m, y);
xorRotr(v, d, a, 16);
add64(v, c, v, d);
xorRotr(v, b, c, 63);
}
function add64(v, x, w, y) {
const lo = v[x] + w[y];
v[x + 1] = v[x + 1] + w[y + 1] + (lo > 0xffffffff ? 1 : 0);
v[x] = lo;
}
function concat(...parts) {
const out = new Uint8Array(parts.reduce((n, p) => n + p.length, 0));
let o = 0;
for (const p of parts) {
out.set(p, o);
o += p.length;
}
return out;
}
function le32(n) {
const b = new Uint8Array(4);
writeLE32(b, 0, n);
return b;
}
function readLE32(b, i) {
return (b[i] | (b[i + 1] << 8) | (b[i + 2] << 16) | (b[i + 3] << 24)) >>> 0;
}
function writeLE32(b, i, n) {
b[i] = n;
b[i + 1] = n >>> 8;
b[i + 2] = n >>> 16;
b[i + 3] = n >>> 24;
}
+44
View File
@@ -0,0 +1,44 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { argon2id, _proofForTest } from "./scram.js";
const hex = (b) => Buffer.from(b).toString("hex");
const unhex = (s) => new Uint8Array(Buffer.from(s, "hex"));
test("Argon2id matches RFC 9106 section 5.3", async () => {
const tag = await argon2id(new Uint8Array(32).fill(1), new Uint8Array(16).fill(2), 3, 32, 4, 32,
{ secret: new Uint8Array(8).fill(3), data: new Uint8Array(12).fill(4) });
assert.equal(hex(tag), "0d640df58d78766c08c037a34a8b53c9d01ef0452d75b65eb52520e96b01e659");
});
// From golang.org/x/crypto/argon2.IDKey; m=1020 and m=37 exercise lane rounding,
// keyLen 100 the long form of H'.
test("Argon2id matches Go's argon2.IDKey", async () => {
const vectors = [
{ pw: "70617373776f7264", salt: "736f6d6573616c74", t: 1, m: 8, p: 1, keyLen: 32, want: "f137f8e186a403a679ccd0606e5ab5dcdafe43c1640855ac8c6e33e9bd63eeb3" },
{ pw: "", salt: "01080f161d242b323940474e555c636a", t: 2, m: 64, p: 2, keyLen: 32, want: "044879f042c4211727f27afa4e8fd18f95c199373cd03402f1f3dc4f07622d28" },
{ pw: "030a11181f262d343b424950575e656c737a81888f969da4abb2b9c0c7ced5dce3eaf1f8ff060d141b222930373e454c535a61686f767d848b9299a0a7aeb5bcc3cad1d8dfe6edf4fb020910171e252c333a41484f565d646b727980878e959ca3aab1b8", salt: "050c131a21282f363d444b525960676e757c838a91989fa6adb4bbc2c9d0d7de", t: 3, m: 256, p: 4, keyLen: 32, want: "a71f8b4acd16b613a2706aafb7ec23b1ddf1790b3aec1911f52152083dda79c9" },
{ pw: "70c3a4737377c3b6726420e29c93", salt: "0910171e252c333a41484f565d646b727980878e959ca3aab1b8bfc6cdd4dbe2e9f0f7fe050c131a21282f363d444b525960676e757c838a91989fa6adb4bbc2", t: 1, m: 1024, p: 3, keyLen: 32, want: "b069833fc8b42b81ad8c0baad6fa131f498fc2787a549809afe7459847029bc9" },
{ pw: "636f727265637420686f727365206261747465727920737461706c65", salt: "0b121920272e353c434a51585f666d74", t: 2, m: 4096, p: 1, keyLen: 32, want: "7d4729ce2bb0a9113eb50b1517b19172c47a61cf9b2c5c0bc6724997ecc52ad7" },
{ pw: "78", salt: "0d141b222930373e454c535a61686f767d848b92", t: 3, m: 37, p: 1, keyLen: 32, want: "f98a0cfc10228157b77cac1d45816f36447239e88bb5ed37e5a4f269f470607e" },
{ pw: "6c6f6e67206f7574707574", salt: "11181f262d343b424950575e656c737a", t: 1, m: 128, p: 2, keyLen: 100, want: "436511e64b4986f59fe6b2e86166a2cd2d543c2f221816780a7698284e0a81529fa25000a5da5fefc062ef70f0f8a94d87dbec1f292091e5fc14fdd7f155f29f4dd0073b604e3a7c5b77433e482337761a9c1dd454872e0e00cc22483b8b0513fbda54e4" },
];
for (const v of vectors) {
const key = await argon2id(unhex(v.pw), unhex(v.salt), v.t, v.m, v.p, v.keyLen);
assert.equal(hex(key), v.want, `t=${v.t} m=${v.m} p=${v.p} keyLen=${v.keyLen}`);
}
});
// lixenwraith/auth's unbound known answer; the server signature comes from the
// same AuthMessage under Go's DeriveCredential.
test("proof and server signature match lixenwraith/auth", async () => {
const challenge = {
full_nonce: "client-nonceserver-nonce", salt: Buffer.alloc(16, 7).toString("base64"),
argon_time: 1, argon_memory: 8, argon_threads: 1,
};
const got = await _proofForTest("alice", "password123", "client-nonce", challenge, 1, 8);
assert.deepEqual(got, {
client_proof: "JCVOdKAYwVu3YZx+6nfRfBUcnsTCgydOchnQq1G7Ry0=",
server_signature: "0HKxosIZvdoAzstS4dEkNgoYvcDpFnOgwBjyhiSsK00=",
});
});
+117
View File
@@ -0,0 +1,117 @@
:root {
color-scheme: light dark;
--bg: #f6f7f9;
--fg: #1d2330;
--muted: #5b6475;
--panel: #ffffff;
--line: #d6dae2;
--accent: #2f6fdb;
font: 15px/1.45 system-ui, sans-serif;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #14171d;
--fg: #e3e7ef;
--muted: #9aa3b5;
--panel: #1c2028;
--line: #2e3440;
--accent: #6ea0ff;
}
}
body {
margin: 0;
background: var(--bg);
color: var(--fg);
}
h1 {
margin: 0;
font-size: 1.25rem;
}
button {
font: inherit;
padding: 0.45rem 1rem;
border: 0;
border-radius: 6px;
background: var(--accent);
color: #fff;
cursor: pointer;
}
button:disabled {
opacity: 0.5;
cursor: default;
}
.card {
max-width: 22rem;
margin: 12vh auto 0;
padding: 1.5rem;
background: var(--panel);
border: 1px solid var(--line);
border-radius: 10px;
}
form {
display: grid;
gap: 0.9rem;
margin-top: 1.2rem;
}
label {
display: grid;
gap: 0.3rem;
color: var(--muted);
}
input {
font: inherit;
padding: 0.45rem 0.6rem;
border: 1px solid var(--line);
border-radius: 6px;
background: var(--bg);
color: var(--fg);
}
progress {
width: 100%;
margin-top: 1rem;
}
#status {
min-height: 1.4em;
margin: 0.8rem 0 0;
color: var(--muted);
}
.viewer {
display: flex;
flex-direction: column;
height: 100vh;
}
.viewer header {
display: flex;
align-items: center;
gap: 1rem;
padding: 0.6rem 1rem;
background: var(--panel);
border-bottom: 1px solid var(--line);
}
#state {
flex: 1;
color: var(--muted);
}
#log {
flex: 1;
overflow: auto;
padding: 0.5rem 1rem;
font: 13px/1.4 ui-monospace, monospace;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
+19
View File
@@ -0,0 +1,19 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>LogWisp stream</title>
<meta name="logwisp-status" content="status">
<link rel="stylesheet" href="style.css">
<script type="module" src="view.js"></script>
</head>
<body class="viewer">
<header>
<h1>LogWisp</h1>
<span id="state" role="status">connecting…</span>
<button id="logout" type="button">Sign out</button>
</header>
<main id="log"></main>
</body>
</html>
+67
View File
@@ -0,0 +1,67 @@
import { logout } from "./scram.js";
const base = new URL("..", location.href);
// The server writes its status path here, relative to the mount
const statusPath = document.querySelector('meta[name="logwisp-status"]').content;
const MAX_LINES = 5000;
const log = document.getElementById("log");
const state = document.getElementById("state");
let source = null;
function append(line) {
const atBottom = log.scrollTop + log.clientHeight >= log.scrollHeight - 4;
const row = document.createElement("div");
row.textContent = line;
log.append(row);
while (log.childElementCount > MAX_LINES) log.firstElementChild.remove();
if (atBottom) log.scrollTop = log.scrollHeight;
}
// The status answer names the stream path; its 401 is the only way to tell an
// expired session apart, since EventSource hides the status of a failure.
async function connect() {
let res;
try {
res = await fetch(new URL("./" + statusPath, base), { credentials: "same-origin", cache: "no-store", redirect: "error" });
} catch {
return retry("server unreachable");
}
if (res.status === 401) {
location.replace(new URL("login?next=view", location.href).href);
return;
}
const path = res.ok ? (await res.json().catch(() => ({})))?.endpoints?.stream : null;
if (typeof path !== "string" || !path.startsWith("/")) {
return retry(`status unavailable (${res.status})`);
}
// Stream paths are absolute in logwisp; the proxy may mount it under a prefix
source = new EventSource(new URL("./" + path.replace(/^\/+/, ""), base));
source.addEventListener("connected", () => { state.textContent = "live"; });
source.addEventListener("disconnect", () => {
source.close();
retry("server shut down");
});
source.onmessage = (event) => event.data.split("\n").forEach(append);
source.onerror = () => {
if (source.readyState === EventSource.CLOSED) retry("disconnected");
else state.textContent = "reconnecting…";
};
}
function retry(reason) {
state.textContent = `${reason}, retrying…`;
setTimeout(connect, 5000);
}
document.getElementById("logout").addEventListener("click", async () => {
source?.close();
try {
await logout(base);
} catch (err) {
state.textContent = err.message;
return;
}
location.replace(new URL("login", location.href).href);
});
connect();
+15 -2
View File
@@ -56,6 +56,7 @@ type HTTPChainSink struct {
config *config.HTTPChainSinkOptions
node string
baseURL string // scheme://host:port, where /auth lives
url string
tlsEnabled bool
@@ -106,6 +107,8 @@ func NewHTTPChainSinkPlugin(
opts.IngestPath = DefaultHTTPChainSinkIngestPath
} else if !strings.HasPrefix(opts.IngestPath, "/") {
return nil, fmt.Errorf("ingest_path: must start with '/'")
} else if opts.IngestPath == chain.AuthPath {
return nil, fmt.Errorf("ingest_path: %s is reserved for authentication", chain.AuthPath)
}
if opts.BufferSize <= 0 {
opts.BufferSize = DefaultHTTPChainSinkBufferSize
@@ -142,7 +145,7 @@ func NewHTTPChainSinkPlugin(
if err != nil {
return nil, err
}
authPolicy, err := authz.New(opts.Auth, opts.TLS, authz.RoleDialer)
authPolicy, err := authz.New(opts.Auth, tlsCfg, authz.RoleDialer, authz.HTTP)
if err != nil {
return nil, err
}
@@ -182,6 +185,7 @@ func NewHTTPChainSinkPlugin(
tlsEnabled: tlsCfg != nil,
mtls: tlsCfg != nil && len(tlsCfg.Certificates) > 0,
auth: authPolicy,
baseURL: scheme + "://" + addr,
url: scheme + "://" + addr + opts.IngestPath,
// A redirect would resend the batch, even from https to plaintext http
client: &http.Client{
@@ -214,6 +218,7 @@ func NewHTTPChainSinkPlugin(
"mtls", t.mtls,
"auth", authPolicy.Describe())
tlsx.LogWarnings(logger, "http_chain_sink", id, opts.TLS, false)
authPolicy.LogStartup(logger, "http_chain_sink", id, false)
return t, nil
}
@@ -435,10 +440,15 @@ func (t *HTTPChainSink) post(ctx context.Context, body []byte) (transient bool,
req.Header.Set("Content-Type", chain.ContentTypeNDJSON)
req.Header.Set(chain.HeaderProtocol, strconv.Itoa(chain.ProtocolVersion))
req.Header.Set(chain.HeaderNode, t.node)
// Future: Authorization header for auth
// Under scram: log in when no token is held; every failure is transient,
// so the batch waits under backoff rather than being dropped
if err := t.auth.Prepare(reqCtx, t.client, t.baseURL, req); err != nil {
return true, err
}
resp, err := t.client.Do(req)
if err != nil {
t.auth.Invalidate(0, err) // another server certificate: log in anew
return true, err
}
defer resp.Body.Close()
@@ -448,6 +458,9 @@ func (t *HTTPChainSink) post(ctx context.Context, body []byte) (transient bool,
switch {
case resp.StatusCode >= 200 && resp.StatusCode < 300:
return false, nil
case t.auth.Invalidate(resp.StatusCode, nil):
// Token expired or the source reloaded; the retry logs in again
return true, fmt.Errorf("status %s", resp.Status)
case resp.StatusCode == http.StatusRequestTimeout,
resp.StatusCode == http.StatusTooManyRequests,
resp.StatusCode >= 500:
+81
View File
@@ -1,16 +1,24 @@
package httpchain
import (
"crypto/tls"
"net"
"net/http"
"net/http/httptest"
"path/filepath"
"strconv"
"sync/atomic"
"testing"
"time"
"logwisp/internal/authz"
"logwisp/internal/chain"
"logwisp/internal/config"
"logwisp/internal/core"
"logwisp/internal/session"
"logwisp/internal/testutil"
"github.com/lixenwraith/auth"
"github.com/lixenwraith/log"
)
@@ -45,3 +53,76 @@ func TestRedirectIsNotFollowed(t *testing.T) {
t.Fatal("the batch followed the redirect")
}
}
// Under scram a 401 on ingest (an expired token, a reloaded source) logs in
// again, and the held batch is delivered exactly once.
func TestRefusedTokenLogsInAgainAndDeliversOnce(t *testing.T) {
pki := testutil.NewPKI(t, "edge-01")
dir := t.TempDir()
cred, err := auth.NewCredential("edge-01", "edge-01-secret")
if err != nil {
t.Fatal(err)
}
data, err := (&authz.Credentials{DecoyKey: make([]byte, 32), Users: []*auth.Credential{cred}}).Marshal()
if err != nil {
t.Fatal(err)
}
creds, password := filepath.Join(dir, "users.toml"), filepath.Join(dir, "edge-01.pass")
testutil.WriteFile(t, creds, string(data))
testutil.WriteFile(t, password, "edge-01-secret\n")
serverCert, err := tls.LoadX509KeyPair(pki.ServerCert, pki.ServerKey)
if err != nil {
t.Fatal(err)
}
serverTLS := &tls.Config{Certificates: []tls.Certificate{serverCert}}
listener, err := authz.New(&config.AuthOptions{Type: authz.MethodSCRAM, CredentialsFile: creds}, serverTLS, authz.RoleChainListener, authz.HTTP)
if err != nil {
t.Fatal(err)
}
if err := listener.Start(); err != nil {
t.Fatal(err)
}
t.Cleanup(listener.Close)
var refused, delivered atomic.Int64
mux := http.NewServeMux()
mux.HandleFunc("POST "+chain.AuthPath, func(w http.ResponseWriter, r *http.Request) { listener.ServeAuth(w, r) })
mux.HandleFunc("POST /ingest", func(w http.ResponseWriter, r *http.Request) {
if _, status, err := listener.AuthorizeRequest(r); err != nil {
authz.Refuse(w, status)
return
}
if refused.Add(1) == 1 {
authz.Refuse(w, http.StatusUnauthorized) // as after a reload
return
}
delivered.Add(1)
w.WriteHeader(http.StatusNoContent)
})
srv := httptest.NewUnstartedServer(mux)
srv.TLS = serverTLS
srv.StartTLS()
t.Cleanup(srv.Close)
_, port, _ := net.SplitHostPort(srv.Listener.Addr().String())
p, _ := strconv.ParseInt(port, 10, 64)
manager := session.NewManager(time.Hour)
t.Cleanup(manager.Stop)
created, err := NewHTTPChainSinkPlugin("fwd", map[string]any{
"host": "127.0.0.1", "port": p, "backoff_min_ms": int64(10),
"tls": map[string]any{"enabled": true, "ca_file": pki.CA},
"auth": map[string]any{"type": "scram", "username": "edge-01", "password_file": password},
}, log.NewLogger(), session.NewProxy(manager, "fwd"))
if err != nil {
t.Fatal(err)
}
sink := created.(*HTTPChainSink)
sink.append(core.TransportEvent{Time: time.Now(), Payload: []byte("entry")})
if !sink.flush(t.Context()) || delivered.Load() != 1 || sink.droppedBatches.Load() != 0 {
t.Fatalf("delivered %d, dropped %d", delivered.Load(), sink.droppedBatches.Load())
}
if logins := listener.Stats()["auth_allowed"]; logins != uint64(2) {
t.Fatalf("logins = %v, want 2", logins)
}
}
+14 -4
View File
@@ -130,7 +130,7 @@ func NewTCPSinkPlugin(
if err != nil {
return nil, err
}
authPolicy, err := authz.New(opts.Auth, opts.TLS, authz.RoleListener)
authPolicy, err := authz.New(opts.Auth, tlsCfg, authz.RoleListener, authz.TCP)
if err != nil {
return nil, err
}
@@ -205,8 +205,12 @@ func (t *TCPSink) listen() (net.Listener, error) {
// Start binds the listener and launches accept and broadcast loops
func (t *TCPSink) Start(ctx context.Context) error {
if err := t.auth.Start(); err != nil {
return err
}
ln, err := t.listen()
if err != nil {
t.auth.Close()
return fmt.Errorf("tcp sink bind %s: %w", t.addr, err)
}
t.listener = ln
@@ -241,6 +245,7 @@ func (t *TCPSink) Stop() {
t.shutdown()
t.wg.Wait()
t.auth.Close()
t.logger.Info("msg", " TCP sink stopped",
"component", "tcp_sink",
@@ -347,9 +352,13 @@ func (t *TCPSink) handleConn(conn net.Conn) {
}
}
// Authorize before registration, so an unauthorized peer never enters the
// client map and never receives a broadcast
ident, err := t.auth.Authorize(tlsState)
// Admit before registration, so an unauthorized peer never enters the
// client map and never receives a broadcast. Under scram the viewer's
// hello opens the exchange; without it the sink reads nothing.
adm, err := t.auth.Admit(conn, tlsState, false, authz.ExchangeTimeout)
if err == nil {
err = adm.Accept()
}
if err != nil {
t.rejectedConns.Add(1)
t.logger.Warn("msg", "Connection rejected by auth policy",
@@ -359,6 +368,7 @@ func (t *TCPSink) handleConn(conn net.Conn) {
"error", err)
return
}
ident := adm.Identity
ident.Apply(meta)
sess := t.proxy.CreateSession(remote, meta)
+54 -16
View File
@@ -4,6 +4,7 @@ import (
"context"
"crypto/tls"
"encoding/json"
"errors"
"fmt"
"maps"
"net"
@@ -50,7 +51,6 @@ type TCPChainSink struct {
node string
addr string
helloLine []byte
tlsConfig *tls.Config
// Authorization: pins the downstream server's identity
@@ -59,8 +59,12 @@ type TCPChainSink struct {
input chan core.TransportEvent
logger *log.Logger
// conn owned exclusively by run loop goroutine
// conn and the backoff state are owned exclusively by the run loop goroutine.
// failures survives a link that dies right after connecting, so a refusing
// source is retried with growing delays rather than in a tight loop.
conn net.Conn
connectedAt time.Time
failures int
everConnected bool
dialTimeout time.Duration
writeTimeout time.Duration
@@ -125,15 +129,11 @@ func NewTCPChainSinkPlugin(
}
}
helloLine, err := chain.EncodeHello(node)
if err != nil {
return nil, fmt.Errorf("hello: %w", err)
}
tlsCfg, err := tlsx.Client(opts.TLS, opts.Host)
if err != nil {
return nil, err
}
authPolicy, err := authz.New(opts.Auth, opts.TLS, authz.RoleDialer)
authPolicy, err := authz.New(opts.Auth, tlsCfg, authz.RoleDialer, authz.TCP)
if err != nil {
return nil, err
}
@@ -149,7 +149,6 @@ func NewTCPChainSinkPlugin(
config: opts,
node: node,
addr: net.JoinHostPort(opts.Host, strconv.FormatInt(opts.Port, 10)),
helloLine: helloLine,
tlsConfig: tlsCfg,
auth: authPolicy,
input: make(chan core.TransportEvent, opts.BufferSize),
@@ -179,6 +178,7 @@ func NewTCPChainSinkPlugin(
"mtls", tlsCfg != nil && len(tlsCfg.Certificates) > 0,
"auth", authPolicy.Describe())
tlsx.LogWarnings(logger, "tcp_chain_sink", id, opts.TLS, false)
authPolicy.LogStartup(logger, "tcp_chain_sink", id, false)
return t, nil
}
@@ -267,6 +267,17 @@ func (t *TCPChainSink) runLoop(ctx context.Context) {
defer t.wg.Done()
defer t.closeConn()
// Fold done into the context, so a connect or exchange in flight ends on Stop
ctx, cancel := context.WithCancel(ctx)
defer cancel()
go func() {
select {
case <-t.done:
cancel()
case <-ctx.Done():
}
}()
for {
select {
case <-ctx.Done():
@@ -302,22 +313,30 @@ func (t *TCPChainSink) runLoop(ctx context.Context) {
// deliver writes one line, holding it across reconnects until sent or shutdown.
// Backpressure during outage propagates to the pipeline dispatch drop counter.
func (t *TCPChainSink) deliver(ctx context.Context, line []byte) bool {
failures := 0
for {
if t.conn == nil {
if failures > 0 && !t.waitBackoff(ctx, failures) {
if t.failures > 0 && !t.waitBackoff(ctx, t.failures) {
return false
}
if err := t.connect(ctx); err != nil {
if ctx.Err() != nil {
return false
}
failures++
t.failures++
if errors.Is(err, authz.ErrRefused) {
// A refusal is configuration, not weather: show it by default
t.logger.Warn("msg", "Chain connect refused",
"component", "tcp_chain_sink",
"target", t.addr,
"attempt", t.failures,
"error", err)
} else {
t.logger.Debug("msg", "Chain connect failed",
"component", "tcp_chain_sink",
"target", t.addr,
"attempt", failures,
"attempt", t.failures,
"error", err)
}
continue
}
}
@@ -325,7 +344,7 @@ func (t *TCPChainSink) deliver(ctx context.Context, line []byte) bool {
t.conn.SetWriteDeadline(time.Now().Add(t.writeTimeout))
if _, err := t.conn.Write(line); err != nil {
t.writeErrors.Add(1)
failures++
t.failures++
t.logger.Warn("msg", "Chain write failed",
"component", "tcp_chain_sink",
"target", t.addr,
@@ -333,6 +352,10 @@ func (t *TCPChainSink) deliver(ctx context.Context, line []byte) bool {
t.closeConn()
continue
}
// Healthy once it outlives the shortest backoff
if t.failures > 0 && time.Since(t.connectedAt) >= time.Duration(t.config.BackoffMinMS)*time.Millisecond {
t.failures = 0
}
return true
}
}
@@ -363,11 +386,26 @@ func (t *TCPChainSink) connect(ctx context.Context) error {
return err
}
conn.SetWriteDeadline(time.Now().Add(t.writeTimeout))
if _, err := conn.Write(t.helloLine); err != nil {
r, err := t.auth.Greet(ctx, conn, t.node)
if err != nil {
conn.Close()
return fmt.Errorf("hello: %w", err)
return err
}
// A source never writes once a link is up: a line is a refusal this sink
// could not otherwise see, EOF a close. Stop writing into either.
t.wg.Add(1)
go func() {
defer t.wg.Done()
err := authz.AwaitClose(r)
conn.Close()
if errors.Is(err, authz.ErrRefused) {
t.logger.Warn("msg", "Chain link refused",
"component", "tcp_chain_sink",
"target", t.addr,
"error", err)
}
}()
t.connectedAt = time.Now()
t.conn = conn
t.connected.Store(true)
+68
View File
@@ -0,0 +1,68 @@
package tcpchain
import (
"bufio"
"net"
"strconv"
"sync/atomic"
"testing"
"time"
"logwisp/internal/core"
"logwisp/internal/session"
"github.com/lixenwraith/log"
)
// A source that refuses each link right after the hello, as a scram source
// does to a sink without credentials, is retried with growing delays, not in
// a tight loop that logs an established link every round.
func TestRefusingSourceIsRetriedWithBackoff(t *testing.T) {
ln, err := net.Listen("tcp4", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { ln.Close() })
var accepts atomic.Int64
go func() {
for {
conn, err := ln.Accept()
if err != nil {
return
}
accepts.Add(1)
bufio.NewReader(conn).ReadString('\n')
conn.Write([]byte(`{"error":"authentication required"}` + "\n"))
conn.Close()
}
}()
_, port, _ := net.SplitHostPort(ln.Addr().String())
p, _ := strconv.ParseInt(port, 10, 64)
manager := session.NewManager(time.Hour)
t.Cleanup(manager.Stop)
created, err := NewTCPChainSinkPlugin("fwd", map[string]any{
"host": "127.0.0.1", "port": p, "backoff_min_ms": int64(20), "backoff_max_ms": int64(5000),
}, log.NewLogger(), session.NewProxy(manager, "fwd"))
if err != nil {
t.Fatal(err)
}
sink := created.(*TCPChainSink)
if err := sink.Start(t.Context()); err != nil {
t.Fatal(err)
}
t.Cleanup(sink.Stop)
deadline := time.Now().Add(1500 * time.Millisecond)
for time.Now().Before(deadline) {
select {
case sink.Input() <- core.TransportEvent{Time: time.Now(), Payload: []byte("entry")}:
default:
}
time.Sleep(5 * time.Millisecond)
}
// 20 ms doubling reaches 1.5 s in about 7 attempts; a constant delay
// would take dozens
if n := accepts.Load(); n > 10 {
t.Fatalf("%d connections in 1.5 s against a refusing source", n)
}
}
+33 -6
View File
@@ -95,6 +95,8 @@ func NewHTTPChainSourcePlugin(
opts.IngestPath = DefaultHTTPChainSourceIngestPath
} else if !strings.HasPrefix(opts.IngestPath, "/") {
return nil, fmt.Errorf("ingest_path: must start with '/'")
} else if opts.IngestPath == chain.AuthPath {
return nil, fmt.Errorf("ingest_path: %s is reserved for authentication", chain.AuthPath)
}
if opts.BufferSize <= 0 {
opts.BufferSize = DefaultHTTPChainSourceBufferSize
@@ -109,7 +111,7 @@ func NewHTTPChainSourcePlugin(
if err != nil {
return nil, err
}
authPolicy, err := authz.New(opts.Auth, opts.TLS, authz.RoleChainListener)
authPolicy, err := authz.New(opts.Auth, tlsCfg, authz.RoleChainListener, authz.HTTP)
if err != nil {
return nil, err
}
@@ -163,16 +165,22 @@ func (s *HTTPChainSource) Subscribe() <-chan core.LogEntry {
// Start binds the listener and serves the ingest endpoint
func (s *HTTPChainSource) Start() error {
if err := s.auth.Start(); err != nil {
return err
}
addr := net.JoinHostPort(s.config.Host, strconv.FormatInt(s.config.Port, 10))
// IPv4-only, aligns with tcp/http sinks
ln, err := net.Listen("tcp4", addr)
if err != nil {
s.auth.Close()
return fmt.Errorf("listen %s: %w", addr, err)
}
mux := http.NewServeMux()
// Method-scoped pattern: mux answers 405 with Allow header on non-POST
mux.HandleFunc(http.MethodPost+" "+s.config.IngestPath, s.handleIngest)
// Answers 404 unless the policy is scram
mux.HandleFunc(http.MethodPost+" "+chain.AuthPath, s.handleAuth)
s.server = &http.Server{
Handler: mux,
@@ -212,6 +220,7 @@ func (s *HTTPChainSource) Stop() {
defer cancel()
s.server.Shutdown(ctx)
}
s.auth.Close()
s.sessionsMu.Lock()
for _, id := range s.sessions {
@@ -269,10 +278,9 @@ func (s *HTTPChainSource) handleIngest(w http.ResponseWriter, r *http.Request) {
s.totalRequests.Add(1)
// Authorize before the body is read: an unauthorized sender should not get
// to stream max_body_bytes into the process. 403 is distinct from the 400
// used for protocol errors, so a sender can tell "not allowed" from
// "malformed batch".
ident, err := s.auth.Authorize(r.TLS)
// to stream max_body_bytes into the process. 401 (log in again) and 403
// (not allowed) are distinct from the 400 used for protocol errors.
ident, status, err := s.auth.AuthorizeRequest(r)
if err != nil {
s.rejectedRequests.Add(1)
s.logger.Warn("msg", "Request rejected by auth policy",
@@ -280,7 +288,7 @@ func (s *HTTPChainSource) handleIngest(w http.ResponseWriter, r *http.Request) {
"instance_id", s.id,
"remote_addr", r.RemoteAddr,
"error", err)
http.Error(w, "forbidden", http.StatusForbidden)
authz.Refuse(w, status)
return
}
@@ -354,6 +362,25 @@ func (s *HTTPChainSource) handleIngest(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNoContent)
}
// handleAuth runs one step of a SCRAM login and logs its outcome
func (s *HTTPChainSource) handleAuth(w http.ResponseWriter, r *http.Request) {
ident, err := s.auth.ServeAuth(w, r)
switch {
case err != nil:
s.logger.Warn("msg", "Login rejected",
"component", "http_chain_source",
"instance_id", s.id,
"remote_addr", r.RemoteAddr,
"error", err)
case ident.Name != "":
s.logger.Info("msg", "Login accepted",
"component", "http_chain_source",
"instance_id", s.id,
"remote_addr", r.RemoteAddr,
"auth_identity", ident.Name)
}
}
// sessionFor returns the cached session for a remote+node+identity,
// recreating after idle expiry. Identity is part of the key so two peers
// sharing a remote address never share a session.
+24 -24
View File
@@ -96,7 +96,7 @@ func NewTCPChainSourcePlugin(
if err != nil {
return nil, err
}
authPolicy, err := authz.New(opts.Auth, opts.TLS, authz.RoleChainListener)
authPolicy, err := authz.New(opts.Auth, tlsCfg, authz.RoleChainListener, authz.TCP)
if err != nil {
return nil, err
}
@@ -149,11 +149,15 @@ func (s *TCPChainSource) Subscribe() <-chan core.LogEntry {
// Start binds the listener and begins accepting connections
func (s *TCPChainSource) Start() error {
if err := s.auth.Start(); err != nil {
return err
}
addr := net.JoinHostPort(s.config.Host, strconv.FormatInt(s.config.Port, 10))
// IPv4-only. TLS-wrapped when configured; handshake runs explicitly in
// handleConn under tlsx.HandshakeTimeout, pre-hello.
ln, err := net.Listen("tcp4", addr)
if err != nil {
s.auth.Close()
return fmt.Errorf("listen %s: %w", addr, err)
}
if s.tlsConfig != nil {
@@ -189,6 +193,7 @@ func (s *TCPChainSource) Stop() {
s.mu.Unlock()
s.wg.Wait()
s.auth.Close()
s.mu.Lock()
for _, ch := range s.subscribers {
@@ -292,56 +297,51 @@ func (s *TCPChainSource) handleConn(conn net.Conn) {
tlsState = &cs
}
// Authorize before a preamble is parsed on an unauthorized peer's behalf
ident, err := s.auth.Authorize(tlsState)
// Certificates are checked before the hello is read; under scram the hello
// carries the exchange. Nothing is acknowledged until node binding agrees.
adm, err := s.auth.Admit(conn, tlsState, true, time.Duration(s.config.HelloTimeoutMS)*time.Millisecond)
if err != nil {
if errors.Is(err, authz.ErrRefused) {
s.rejectedConns.Add(1)
s.logger.Warn("msg", "Connection rejected by auth policy",
"component", "tcp_chain_source",
"instance_id", s.id,
"remote_addr", remote,
"error", err)
return // deferred cleanup closes conn
}
scanner := bufio.NewScanner(conn)
// Oversized line (> MaxLogEntryBytes) is a protocol violation; scanner is
// unrecoverable after ErrTooLong, connection terminates
scanner.Buffer(make([]byte, 0, 64*1024), core.MaxLogEntryBytes)
// Hello preamble
conn.SetReadDeadline(time.Now().Add(time.Duration(s.config.HelloTimeoutMS) * time.Millisecond))
if !scanner.Scan() {
s.logger.Warn("msg", "Connection closed before hello",
"component", "tcp_chain_source",
"remote_addr", remote,
"error", scanner.Err())
return
}
hello, err := chain.DecodeHello(scanner.Bytes())
if err != nil {
} else {
s.logger.Warn("msg", "Rejected chain connection",
"component", "tcp_chain_source",
"remote_addr", remote,
"error", err)
}
return
}
ident := adm.Identity
fallbackNode := remote
if host, _, splitErr := net.SplitHostPort(remote); splitErr == nil {
fallbackNode = host
}
connNode, err := s.auth.ResolveNode(hello.Node, fallbackNode, s.config.TrustNode, ident)
connNode, err := s.auth.ResolveNode(adm.Hello.Node, fallbackNode, s.config.TrustNode, ident)
if err != nil {
s.rejectedConns.Add(1)
adm.Reject("node label rejected")
s.logger.Warn("msg", "Connection rejected by node binding",
"component", "tcp_chain_source",
"instance_id", s.id,
"remote_addr", remote,
"declared_node", hello.Node,
"declared_node", adm.Hello.Node,
"error", err)
return
}
if err := adm.Accept(); err != nil {
return
}
scanner := bufio.NewScanner(adm.Reader)
// Oversized line (> MaxLogEntryBytes) is a protocol violation; scanner is
// unrecoverable after ErrTooLong, connection terminates
scanner.Buffer(make([]byte, 0, 64*1024), core.MaxLogEntryBytes)
// force relabels every entry, so an edge cannot smuggle a foreign origin
// through the per-entry node field either
trustEntryNode := s.auth.TrustsEntryNode(s.config.TrustNode)
+27 -12
View File
@@ -44,12 +44,16 @@ func WriteFile(t testing.TB, path, contents string) {
// certificate, written as PEM files for plugins that load TLS from disk.
type PKI struct {
CA, ServerCert, ServerKey, ClientCert, ClientKey string
dir string
ca *x509.Certificate
caKey *ecdsa.PrivateKey
serial int64
}
// NewPKI issues the PKI into a test directory; clientCN names the client.
func NewPKI(t testing.TB, clientCN string) PKI {
func NewPKI(t testing.TB, clientCN string) *PKI {
t.Helper()
dir := t.TempDir()
now := time.Now()
caKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
@@ -68,18 +72,33 @@ func NewPKI(t testing.TB, clientCN string) PKI {
if err != nil {
t.Fatal(err)
}
pki := PKI{CA: writePEM(t, dir, "ca.crt", "CERTIFICATE", caDER)}
issue := func(name, cn string, serial int64, usage x509.ExtKeyUsage, ips []net.IP) (string, string) {
pki := &PKI{dir: t.TempDir(), ca: ca, caKey: caKey, serial: 1}
pki.CA = writePEM(t, pki.dir, "ca.crt", "CERTIFICATE", caDER)
pki.ServerCert, pki.ServerKey = pki.Leaf(t, "server", "relay.internal", false)
pki.ClientCert, pki.ClientKey = pki.Leaf(t, "client", clientCN, true)
return pki
}
// Leaf issues another certificate from the same CA: a client certificate, or
// a server certificate valid for 127.0.0.1.
func (p *PKI) Leaf(t testing.TB, name, cn string, client bool) (certFile, keyFile string) {
t.Helper()
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatal(err)
}
p.serial++
now := time.Now()
tmpl := &x509.Certificate{
SerialNumber: big.NewInt(serial), Subject: pkix.Name{CommonName: cn},
SerialNumber: big.NewInt(p.serial), Subject: pkix.Name{CommonName: cn},
NotBefore: now.Add(-time.Hour), NotAfter: now.Add(24 * time.Hour),
KeyUsage: x509.KeyUsageDigitalSignature, ExtKeyUsage: []x509.ExtKeyUsage{usage}, IPAddresses: ips,
KeyUsage: x509.KeyUsageDigitalSignature, ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
IPAddresses: []net.IP{net.IPv4(127, 0, 0, 1)},
}
der, err := x509.CreateCertificate(rand.Reader, tmpl, ca, &key.PublicKey, caKey)
if client {
tmpl.ExtKeyUsage, tmpl.IPAddresses = []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth}, nil
}
der, err := x509.CreateCertificate(rand.Reader, tmpl, p.ca, &key.PublicKey, p.caKey)
if err != nil {
t.Fatal(err)
}
@@ -87,11 +106,7 @@ func NewPKI(t testing.TB, clientCN string) PKI {
if err != nil {
t.Fatal(err)
}
return writePEM(t, dir, name+".crt", "CERTIFICATE", der), writePEM(t, dir, name+".key", "PRIVATE KEY", keyDER)
}
pki.ServerCert, pki.ServerKey = issue("server", "relay.internal", 2, x509.ExtKeyUsageServerAuth, []net.IP{net.IPv4(127, 0, 0, 1)})
pki.ClientCert, pki.ClientKey = issue("client", clientCN, 3, x509.ExtKeyUsageClientAuth, nil)
return pki
return writePEM(t, p.dir, name+".crt", "CERTIFICATE", der), writePEM(t, p.dir, name+".key", "PRIVATE KEY", keyDER)
}
func writePEM(t testing.TB, dir, name, blockType string, der []byte) string {
+8
View File
@@ -43,6 +43,14 @@ func (tb *TokenBucket) AllowN(n float64) bool {
return false
}
// Refund returns n tokens, never beyond capacity, for an attempt that turned
// out not to count against the limit
func (tb *TokenBucket) Refund(n float64) {
tb.mu.Lock()
defer tb.mu.Unlock()
tb.tokens = min(tb.capacity, tb.tokens+n)
}
// Tokens returns the current number of available tokens
func (tb *TokenBucket) Tokens() float64 {
tb.mu.Lock()
+504
View File
@@ -0,0 +1,504 @@
#!/usr/bin/env bash
# logwisp SCRAM authentication test: chain edges and sink viewers log in with
# passwords managed by `logwisp auth`; the guide printed below maps the ports.
# Usage: ./scram-chain-test.sh [--auto [--keep]] manual mode keeps the daemons
# up; --auto runs the checks and tears down, --keep skips that on success.
# Requires: bash 5+, coreutils (timeout), openssl, curl. Linux dev host only.
set -u
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
BIN="${LOGWISP_BIN:-$SCRIPT_DIR/../bin/logwisp}"
RUN="$SCRIPT_DIR/run-scram"
CONF="$RUN/conf"
LOG="$RUN/log"
PKI="$RUN/pki"
AUTH="$RUN/auth"
OUT="$RUN/out"
USERS="$AUTH/users.toml"
PORT_TCP_CHAIN=15821
PORT_HTTP_CHAIN=15822
PORT_TCP_SINK=15823
PORT_HTTP_SINK=15824
PORT_BOUND=15825
AUTO=0; KEEP=0
for a in "$@"; do case "$a" in
--auto) AUTO=1 ;;
--keep) KEEP=1 ;;
*) echo "unknown arg: $a" >&2; exit 1 ;;
esac; done
PIDS=()
cleanup() {
local rc=$?
trap - EXIT INT TERM
if (( ${#PIDS[@]} )); then
echo "--- teardown: stopping ${#PIDS[@]} process(es)"
kill -TERM "${PIDS[@]}" 2>/dev/null
local deadline=$(( SECONDS + 10 ))
for pid in "${PIDS[@]}"; do
while kill -0 "$pid" 2>/dev/null && (( SECONDS < deadline )); do sleep 0.2; done
kill -KILL "$pid" 2>/dev/null
done
fi
exit "$rc"
}
trap cleanup EXIT INT TERM
port_open() { (exec 3<>"/dev/tcp/127.0.0.1/$1") 2>/dev/null && exec 3>&-; }
wait_port() { # port timeout_s
local i; for (( i=0; i < $2 * 10; i++ )); do
port_open "$1" && return 0
sleep 0.1
done
return 1
}
wait_until() { # timeout_s command...
local deadline=$(( SECONDS + $1 )); shift
until "$@"; do
(( SECONDS >= deadline )) && return 1
sleep 0.2
done
}
start_daemon() { # name conf
"$BIN" -c "$CONF/$2" > "$LOG/$1.out" 2>&1 &
PIDS+=($!)
echo "started $1 (pid $!)"
}
# --- Preflight ---
[[ -x "$BIN" ]] || { echo "binary not found: $BIN (build: go build -o bin/logwisp ./cmd/logwisp)" >&2; exit 1; }
command -v openssl >/dev/null || { echo "openssl not found" >&2; exit 1; }
command -v curl >/dev/null || { echo "curl not found" >&2; exit 1; }
for p in $PORT_TCP_CHAIN $PORT_HTTP_CHAIN $PORT_TCP_SINK $PORT_HTTP_SINK $PORT_BOUND; do
port_open "$p" && { echo "port $p already in use" >&2; exit 1; }
done
rm -rf "$RUN"
mkdir -p "$CONF" "$LOG" "$PKI" "$AUTH" "$OUT"
# --- PKI ---
gen_key() { openssl genpkey -algorithm EC -pkeyopt ec_paramgen_curve:P-256 -out "$1" 2>/dev/null; }
gen_leaf() { # name CN eku [SAN]
local name=$1 cn=$2 eku=$3 san=${4:-}
gen_key "$PKI/$name.key"
openssl req -new -key "$PKI/$name.key" -out "$PKI/$name.csr" -subj "/CN=$cn" 2>/dev/null
local ext="extendedKeyUsage=$eku"
[[ -n $san ]] && ext+=$'\n'"subjectAltName=$san"
printf '%s\n' "$ext" > "$PKI/$name.ext"
openssl x509 -req -in "$PKI/$name.csr" -CA "$PKI/ca.crt" -CAkey "$PKI/ca.key" \
-CAcreateserial -out "$PKI/$name.crt" -days 2 -extfile "$PKI/$name.ext" 2>/dev/null
}
echo "--- generating test PKI in $PKI"
gen_key "$PKI/ca.key"
openssl req -x509 -new -key "$PKI/ca.key" -days 2 -out "$PKI/ca.crt" \
-subj "/CN=LogWisp Test CA" 2>/dev/null
gen_leaf relay relay.internal serverAuth "IP:127.0.0.1"
gen_leaf edge-01 edge-01 clientAuth
gen_leaf edge-02 edge-02 clientAuth
[[ -s "$PKI/edge-02.crt" ]] || { echo "PKI generation failed" >&2; exit 1; }
# --- Credentials, through the CLI only ---
echo "--- adding users in $USERS"
for u in edge-01 edge-02 viewer-01; do
"$BIN" auth add-user -credentials "$USERS" -user "$u" -password-file "$AUTH/$u.pass" \
2>>"$LOG/auth-cli.out" || { echo "add-user $u failed (see $LOG/auth-cli.out)" >&2; exit 1; }
done
# A password no user has, for the wrong-password and unknown-user edges
(umask 077; openssl rand -base64 24 > "$AUTH/wrong.pass")
# --- Config generation ---
# One credentials file serves every listener, so revoking viewer-01 leaves the
# file with users (the CLI refuses to remove the last one).
cat > "$CONF/relay.toml" <<EOF
status_reporter = false
[logging]
output = "stdout"
level = "info"
[[pipelines]]
name = "relay_tcp"
[pipelines.flow.format]
type = "json"
sanitizer_policy = "json"
[[pipelines.plugin_sources]]
id = "in_tcp"
type = "tcp_chain"
[pipelines.plugin_sources.config]
host = "127.0.0.1"
port = $PORT_TCP_CHAIN
[pipelines.plugin_sources.config.tls]
enabled = true
cert_file = "$PKI/relay.crt"
key_file = "$PKI/relay.key"
[pipelines.plugin_sources.config.auth]
type = "scram"
credentials_file = "$USERS"
node_binding = "force"
[[pipelines.plugin_sinks]]
id = "file_tcp"
type = "file"
[pipelines.plugin_sinks.config]
directory = "$OUT"
name = "tcp_chain"
flush_interval_ms = 200
[[pipelines.plugin_sinks]]
id = "out_tcp"
type = "tcp"
[pipelines.plugin_sinks.config]
host = "127.0.0.1"
port = $PORT_TCP_SINK
[pipelines.plugin_sinks.config.tls]
enabled = true
cert_file = "$PKI/relay.crt"
key_file = "$PKI/relay.key"
[pipelines.plugin_sinks.config.auth]
type = "scram"
credentials_file = "$USERS"
[[pipelines]]
name = "relay_http"
[pipelines.flow.format]
type = "json"
sanitizer_policy = "json"
[[pipelines.plugin_sources]]
id = "in_http"
type = "http_chain"
[pipelines.plugin_sources.config]
host = "127.0.0.1"
port = $PORT_HTTP_CHAIN
[pipelines.plugin_sources.config.tls]
enabled = true
cert_file = "$PKI/relay.crt"
key_file = "$PKI/relay.key"
[pipelines.plugin_sources.config.auth]
type = "scram"
credentials_file = "$USERS"
[[pipelines.plugin_sinks]]
id = "file_http"
type = "file"
[pipelines.plugin_sinks.config]
directory = "$OUT"
name = "http_chain"
flush_interval_ms = 200
[[pipelines.plugin_sinks]]
id = "out_http"
type = "http"
[pipelines.plugin_sinks.config]
host = "127.0.0.1"
port = $PORT_HTTP_SINK
[pipelines.plugin_sinks.config.tls]
enabled = true
cert_file = "$PKI/relay.crt"
key_file = "$PKI/relay.key"
[pipelines.plugin_sinks.config.auth]
type = "scram"
credentials_file = "$USERS"
[[pipelines]]
name = "relay_bound"
[pipelines.flow.format]
type = "json"
sanitizer_policy = "json"
# identity binds the certificate to the user: a peer needs its own of both
[[pipelines.plugin_sources]]
id = "in_bound"
type = "tcp_chain"
[pipelines.plugin_sources.config]
host = "127.0.0.1"
port = $PORT_BOUND
[pipelines.plugin_sources.config.tls]
enabled = true
cert_file = "$PKI/relay.crt"
key_file = "$PKI/relay.key"
client_auth = true
client_ca_file = "$PKI/ca.crt"
[pipelines.plugin_sources.config.auth]
type = "scram"
credentials_file = "$USERS"
identity = "cn"
[[pipelines.plugin_sinks]]
id = "file_bound"
type = "file"
[pipelines.plugin_sinks.config]
directory = "$OUT"
name = "bound"
flush_interval_ms = 200
EOF
edge_conf() { # name sink_type port node user password_file [client_cert]
local name=$1 type=$2 port=$3 node=$4 user=$5 pass=$6 cert=${7:-}
{
cat <<EOF
status_reporter = false
[logging]
output = "stdout"
level = "info"
[[pipelines]]
name = "$name"
[[pipelines.plugin_sources]]
id = "rand"
type = "random"
[pipelines.plugin_sources.config]
interval_ms = 200
format = "txt"
length = 24
[[pipelines.plugin_sinks]]
id = "to_relay"
type = "$type"
[pipelines.plugin_sinks.config]
host = "127.0.0.1"
port = $port
node = "$node"
[pipelines.plugin_sinks.config.tls]
enabled = true
ca_file = "$PKI/ca.crt"
EOF
[[ -n $cert ]] && printf 'cert_file = "%s"\nkey_file = "%s"\n' "$PKI/$cert.crt" "$PKI/$cert.key"
[[ -n $user ]] && printf '[pipelines.plugin_sinks.config.auth]\ntype = "scram"\nusername = "%s"\npassword_file = "%s"\n' "$user" "$pass"
} > "$CONF/$name.toml"
}
# edge_tcp declares node "edge-tcp": node_binding = "force" must relabel it edge-01.
# Under force a refused edge that got through would carry its username, so
# edge-02 and edge-99 entries in the edge-01 sinks would expose it.
edge_conf edge_tcp tcp_chain $PORT_TCP_CHAIN edge-tcp edge-01 "$AUTH/edge-01.pass"
edge_conf edge_http http_chain $PORT_HTTP_CHAIN edge-http edge-01 "$AUTH/edge-01.pass"
edge_conf edge_wrongpw tcp_chain $PORT_TCP_CHAIN edge-wrongpw edge-02 "$AUTH/wrong.pass"
edge_conf edge_unknown tcp_chain $PORT_TCP_CHAIN edge-unknown edge-99 "$AUTH/wrong.pass"
edge_conf edge_noauth tcp_chain $PORT_TCP_CHAIN edge-noauth "" ""
edge_conf edge_bound tcp_chain $PORT_BOUND edge-bound edge-01 "$AUTH/edge-01.pass" edge-01
edge_conf edge_cross tcp_chain $PORT_BOUND edge-cross edge-02 "$AUTH/edge-02.pass" edge-01
# --- Guide ---
cat <<EOF
================================================================
logwisp SCRAM auth test — port map
$PORT_TCP_CHAIN relay ingest (tcp_chain, scram, node_binding = force)
$PORT_HTTP_CHAIN relay ingest (http_chain, scram)
$PORT_TCP_SINK TCP sink (scram)
$PORT_HTTP_SINK HTTP sink (scram)
$PORT_BOUND relay ingest (tcp_chain, client_auth + scram, certificate CN = user)
Edges: edge_tcp and edge_http log in as edge-01 and deliver; edge_wrongpw,
edge_unknown and edge_noauth are refused on $PORT_TCP_CHAIN; on $PORT_BOUND
edge_bound (edge-01 cert, user edge-01) delivers and edge_cross (edge-01
cert, user edge-02) is refused; $PKI/edge-02.crt pairs with user edge-02.
Read the TCP sink as viewer-01:
$BIN auth stream -addr 127.0.0.1:$PORT_TCP_SINK -user viewer-01 \\
-password-file $AUTH/viewer-01.pass -ca-file $PKI/ca.crt
Read the HTTP sink (the token stays off argv):
token=\$($BIN auth token -url https://127.0.0.1:$PORT_HTTP_SINK -user viewer-01 \\
-password-file $AUTH/viewer-01.pass -ca-file $PKI/ca.crt)
curl -N --noproxy '*' --cacert $PKI/ca.crt \\
-H @<(printf 'Authorization: Bearer %s\n' "\$token") https://127.0.0.1:$PORT_HTTP_SINK/stream
Revoke: $BIN auth remove-user -credentials $USERS -user viewer-01
then kill -HUP the relay.
Ingested entries land in $OUT/ (node label forced to the username).
Passwords and credentials: $AUTH/ Logs: $LOG/
================================================================
EOF
# --- Startup ---
start_daemon relay relay.toml
RELAY_PID=${PIDS[-1]}
for p in $PORT_TCP_CHAIN $PORT_HTTP_CHAIN $PORT_TCP_SINK $PORT_HTTP_SINK $PORT_BOUND; do
wait_port "$p" 10 || { echo "FAIL: relay port $p not listening (see $LOG/relay.out)"; exit 1; }
done
for e in edge_tcp edge_http edge_wrongpw edge_unknown edge_noauth edge_bound edge_cross; do
start_daemon "$e" "$e.toml"
done
if (( AUTO == 0 )); then
echo "--- daemons running; Ctrl-C to stop"
while :; do sleep 1; done
fi
fail=0
check() { # label condition_result
if (( $2 )); then echo "PASS: $1"; else echo "FAIL: $1"; fail=1; fi
}
relay_log="$LOG/relay.out"
ingested() { cat "$OUT"/${1}* 2>/dev/null; }
has_entry() { ingested "$1" | grep -q -- "$2"; } # file_prefix pattern
logged() { # log_name pattern [min_count]
local n; n=$(grep -c -- "$2" "$LOG/$1.out" 2>/dev/null)
(( ${n:-0} >= ${3:-1} ))
}
token_for() { # user -> token on stdout; the exit status is the CLI's
"$BIN" auth token -url "https://127.0.0.1:$PORT_HTTP_SINK" -user "$1" \
-password-file "$AUTH/$1.pass" -ca-file "$PKI/ca.crt" 2>>"$LOG/auth-cli.out"
}
http_get() { # path [token] -> http_code; the header goes through a pipe, never argv
local url="https://127.0.0.1:$PORT_HTTP_SINK$1"
local args=(-s -o /dev/null -w '%{http_code}' --max-time 5 --noproxy '*' --cacert "$PKI/ca.crt")
if [[ -n ${2:-} ]]; then
curl "${args[@]}" -H @<(printf 'Authorization: Bearer %s\n' "$2") "$url" 2>/dev/null || true
else
curl "${args[@]}" "$url" 2>/dev/null || true
fi
}
echo "=== Scenario 1: chained instances over SCRAM ==="
# 1. An edge with the right password delivers on both transports
wait_until 20 has_entry tcp_chain 'edge-01/'
tcp_file="$(ingested tcp_chain)"
n=$(grep -c 'edge-01/' <<< "$tcp_file")
check "tcp_chain: edge-01 entries reached the file sink ($n lines)" $(( n >= 1 ))
wait_until 20 has_entry http_chain 'edge-01/'
http_file="$(ingested http_chain)"
n=$(grep -c 'edge-01/' <<< "$http_file")
check "http_chain: edge-01 entries reached the file sink ($n lines)" $(( n >= 1 ))
# 2. node_binding = "force" replaced the label the sender configured
n=$(grep -c 'edge-tcp/' <<< "$tcp_file")
check "node binding: sender's own label \"edge-tcp\" was not honored ($n lines)" $(( n == 0 ))
n=$(grep -c 'edge-http/' <<< "$http_file")
check "node binding: sender's own label \"edge-http\" was not honored ($n lines)" $(( n == 0 ))
# 3. Wrong password, unknown user and no credentials are all refused
wait_until 20 logged edge_wrongpw 'Chain connect refused'
n=$(grep -c 'Chain connect refused' "$LOG/edge_wrongpw.out")
check "wrong password: edge-02 with a bad password was refused ($n refusals)" $(( n >= 1 ))
wait_until 20 logged edge_unknown 'Chain connect refused'
n=$(grep -c 'Chain connect refused' "$LOG/edge_unknown.out")
check "unknown user: edge-99 was refused ($n refusals)" $(( n >= 1 ))
wait_until 20 logged relay 'peer offered no credentials'
n=$(grep -c 'peer offered no credentials' "$relay_log")
check "no auth: relay refused an edge that offered no credentials ($n refusals)" $(( n >= 1 ))
wait_until 20 logged edge_noauth 'Chain link refused'
n=$(grep -c 'Chain link refused' "$LOG/edge_noauth.out")
check "no auth: the edge read the refusal instead of writing into a closed link ($n)" $(( n >= 1 ))
failed_login='Connection rejected by auth policy.*instance_id in_tcp .*invalid credentials'
wait_until 20 logged relay "$failed_login" 2
n=$(grep -c "$failed_login" "$relay_log")
check "relay logged the failed logins on :$PORT_TCP_CHAIN ($n rejections)" $(( n >= 2 ))
tcp_file="$(ingested tcp_chain)"
n=$(grep -vc 'edge-01/' <<< "$tcp_file")
check "refused edges: nothing but edge-01 entries was ingested ($n foreign lines)" $(( n == 0 ))
# 4. Certificate bound to the user: edge-01's certificate admits only edge-01
wait_until 20 has_entry bound 'edge-01/'
n=$(ingested bound | grep -c 'edge-01/')
check "certificate binding: edge-01 certificate + user edge-01 delivered ($n lines)" $(( n >= 1 ))
wait_until 20 logged edge_cross 'Chain connect refused'
n=$(grep -c 'Chain connect refused' "$LOG/edge_cross.out")
check "certificate binding: edge-01 certificate + user edge-02 was refused ($n refusals)" $(( n >= 1 ))
wait_until 20 logged relay 'does not match user \\"edge-02\\"'
n=$(grep -c 'does not match user \\"edge-02\\"' "$relay_log")
check "certificate binding: relay logged the certificate/user mismatch ($n)" $(( n >= 1 ))
n=$(ingested bound | grep -vc 'edge-01/')
check "certificate binding: the cross-bound edge delivered nothing ($n foreign lines)" $(( n == 0 ))
echo "=== Scenario 2: viewers on SCRAM-gated sinks ==="
# 5. TCP sink: a logged-in viewer keeps streaming past the 10 s exchange
# deadline; a TLS client that sends no hello gets nothing and is dropped at it
"$BIN" auth stream -addr "127.0.0.1:$PORT_TCP_SINK" -user viewer-01 \
-password-file "$AUTH/viewer-01.pass" -ca-file "$PKI/ca.crt" \
> "$RUN/stream.out" 2>"$LOG/stream.err" &
stream_pid=$!
PIDS+=($stream_pid)
timeout 12 openssl s_client -quiet -connect "127.0.0.1:$PORT_TCP_SINK" \
-CAfile "$PKI/ca.crt" </dev/null >"$RUN/raw.out" 2>/dev/null &
raw_pid=$!
sleep 11
n1=$(grep -c 'edge-01/' "$RUN/stream.out")
sleep 2
n2=$(grep -c 'edge-01/' "$RUN/stream.out")
kill -TERM "$stream_pid" 2>/dev/null
wait "$stream_pid" "$raw_pid" 2>/dev/null
check "tcp sink: viewer-01 streamed entries through logwisp auth stream ($n1 lines at 11 s)" $(( n1 >= 1 ))
check "tcp sink: stream still live after the exchange deadline ($n1 -> $n2 lines, 11 s -> 13 s)" $(( n2 > n1 ))
n=$(wc -c < "$RUN/raw.out")
check "tcp sink: a TLS client with no hello received nothing ($n bytes)" $(( n == 0 ))
n=$(grep 'component tcp_sink' "$relay_log" | grep -c 'read hello')
check "tcp sink: relay dropped the silent client at the exchange deadline ($n)" $(( n >= 1 ))
# 6. HTTP sink: a bearer token from logwisp auth token, carried by curl
token="$(token_for viewer-01)"
check "http sink: logwisp auth token issued a token for viewer-01" $([[ -n $token ]] && echo 1 || echo 0)
code="$(http_get /status "$token")"
check "http sink: /status served with the token (HTTP $code)" $([[ $code == 200 ]] && echo 1 || echo 0)
sse="$(timeout 4 curl -sN --noproxy '*' --cacert "$PKI/ca.crt" \
-H @<(printf 'Authorization: Bearer %s\n' "$token") \
"https://127.0.0.1:$PORT_HTTP_SINK/stream" 2>/dev/null || true)"
n=$(grep -c '^data:.*edge-01/' <<< "$sse")
check "http sink: /stream delivered SSE events with the token ($n events)" $(( n >= 1 ))
code="$(http_get /status)"
check "http sink: /status refused without a token (HTTP $code)" $([[ $code == 401 ]] && echo 1 || echo 0)
code="$(http_get /stream not.a.token)"
check "http sink: /stream refused with a garbage token (HTTP $code)" $([[ $code == 401 ]] && echo 1 || echo 0)
# 7. Revocation: remove-user + SIGHUP kills the token and the login
reloads=$(grep -c 'Configuration hot reload completed successfully' "$relay_log")
reloaded() { (( $(grep -c 'Configuration hot reload completed successfully' "$relay_log") > reloads )); }
"$BIN" auth remove-user -credentials "$USERS" -user viewer-01 2>>"$LOG/auth-cli.out"
rc=$?
check "revocation: logwisp auth remove-user removed viewer-01 (exit $rc)" $(( rc == 0 ))
kill -HUP "$RELAY_PID"
wait_until 20 reloaded && wait_port "$PORT_HTTP_SINK" 10
rc=$?
check "revocation: relay reloaded on SIGHUP" $(( rc == 0 ))
code="$(http_get /status "$token")"
check "revocation: the old token is refused after the reload (HTTP $code)" $([[ $code == 401 ]] && echo 1 || echo 0)
token_for viewer-01 >/dev/null
rc=$?
check "revocation: logwisp auth token for viewer-01 now fails (exit $rc)" $(( rc == 1 ))
# 8. Throttling, last: it leaves this address throttled on the http sink.
# At most 4 exchanges may be unfinished per address, so hellos that are
# never completed reach 429 within a few requests.
codes=()
for i in $(seq 1 12); do
hello="$(printf '{"logwisp":1,"scram":{"username":"edge-01","client_nonce":"probe%d"}}' "$i")"
code="$(curl -s -o /dev/null -w '%{http_code}' --max-time 5 --noproxy '*' --cacert "$PKI/ca.crt" \
-H 'Content-Type: application/json' --data "$hello" \
"https://127.0.0.1:$PORT_HTTP_SINK/auth" 2>/dev/null || true)"
codes+=("$code")
[[ $code == 429 ]] && break
done
check "throttling: /auth answered 429 after ${#codes[@]} rapid hellos (${codes[*]})" \
$([[ $code == 429 ]] && (( ${#codes[@]} <= 6 )) && echo 1 || echo 0)
echo "================================================================"
if (( fail == 0 )); then
echo "RESULT: ALL PASS"
(( KEEP )) && { echo "--keep: daemons left running (pids: ${PIDS[*]})"; PIDS=(); }
else
echo "RESULT: FAILURES — inspect $LOG/*.out"
fi
exit "$fail"
+317
View File
@@ -0,0 +1,317 @@
#!/usr/bin/env bash
# logwisp browser login test: an http sink in proxy mode (auth.trusted_proxies)
# behind a TLS-terminating reverse proxy that mounts it under /logs/. Headless
# Chromium logs in through the shipped pages; the CLI logs in unbound.
# Usage: ./scram-proxy-test.sh [--auto [--keep]] manual mode keeps the daemons
# up; --auto runs the checks and tears down, --keep skips that on success.
# Requires: bash 5+, go, openssl, curl; node with playwright for the browser
# checks (skipped, and reported, without it). Linux dev host only.
set -u
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
BIN="${LOGWISP_BIN:-$SCRIPT_DIR/../bin/logwisp}"
RUN="$SCRIPT_DIR/run-proxy"
CONF="$RUN/conf"
LOG="$RUN/log"
PKI="$RUN/pki"
AUTH="$RUN/auth"
PROXY_SRC="$RUN/proxy"
USERS="$AUTH/users.toml"
PORT_PROXY=15831 # the site: TLS ends here, logwisp is under /logs/
PORT_SINK=15832 # logwisp http sink, plaintext, only for 127.0.0.2
PROXY_ADDR=127.0.0.2
SITE="https://127.0.0.1:$PORT_PROXY"
AUTO=0; KEEP=0
for a in "$@"; do case "$a" in
--auto) AUTO=1 ;;
--keep) KEEP=1 ;;
*) echo "unknown arg: $a" >&2; exit 1 ;;
esac; done
PIDS=()
cleanup() {
local rc=$?
trap - EXIT INT TERM
if (( ${#PIDS[@]} )); then
echo "--- teardown: stopping ${#PIDS[@]} process(es)"
kill -TERM "${PIDS[@]}" 2>/dev/null
local deadline=$(( SECONDS + 10 ))
for pid in "${PIDS[@]}"; do
while kill -0 "$pid" 2>/dev/null && (( SECONDS < deadline )); do sleep 0.2; done
kill -KILL "$pid" 2>/dev/null
done
fi
exit "$rc"
}
trap cleanup EXIT INT TERM
port_open() { (exec 3<>"/dev/tcp/127.0.0.1/$1") 2>/dev/null && exec 3>&-; }
wait_port() { # port timeout_s
local i; for (( i=0; i < $2 * 10; i++ )); do
port_open "$1" && return 0
sleep 0.1
done
return 1
}
# --- Preflight ---
[[ -x "$BIN" ]] || { echo "binary not found: $BIN (build: go build -o bin/logwisp ./cmd/logwisp)" >&2; exit 1; }
for tool in go openssl curl; do
command -v "$tool" >/dev/null || { echo "$tool not found" >&2; exit 1; }
done
for p in $PORT_PROXY $PORT_SINK; do
port_open "$p" && { echo "port $p already in use" >&2; exit 1; }
done
BROWSER=0
command -v node >/dev/null && node -e 'require.resolve("playwright")' 2>/dev/null && BROWSER=1
rm -rf "$RUN"
mkdir -p "$CONF" "$LOG" "$PKI" "$AUTH" "$PROXY_SRC"
# --- PKI: the site's certificate; logwisp holds none ---
echo "--- generating the site certificate in $PKI"
openssl genpkey -algorithm EC -pkeyopt ec_paramgen_curve:P-256 -out "$PKI/ca.key" 2>/dev/null
openssl req -x509 -new -key "$PKI/ca.key" -days 2 -out "$PKI/ca.crt" -subj "/CN=LogWisp Test CA" 2>/dev/null
openssl genpkey -algorithm EC -pkeyopt ec_paramgen_curve:P-256 -out "$PKI/site.key" 2>/dev/null
openssl req -new -key "$PKI/site.key" -out "$PKI/site.csr" -subj "/CN=site.internal" 2>/dev/null
printf 'extendedKeyUsage=serverAuth\nsubjectAltName=IP:127.0.0.1\n' > "$PKI/site.ext"
openssl x509 -req -in "$PKI/site.csr" -CA "$PKI/ca.crt" -CAkey "$PKI/ca.key" -CAcreateserial \
-out "$PKI/site.crt" -days 2 -extfile "$PKI/site.ext" 2>/dev/null
[[ -s "$PKI/site.crt" ]] || { echo "PKI generation failed" >&2; exit 1; }
# --- Credentials, through the CLI only (the default 64 MiB profile) ---
"$BIN" auth add-user -credentials "$USERS" -user viewer-01 -password-file "$AUTH/viewer-01.pass" \
2>>"$LOG/auth-cli.out" || { echo "add-user failed (see $LOG/auth-cli.out)" >&2; exit 1; }
# --- The site's reverse proxy ---
# /logs/ forwards to logwisp from $PROXY_ADDR; /plain/ forwards the same way
# but says the site was reached over http, which logwisp must refuse.
cat > "$PROXY_SRC/main.go" <<'EOF'
package main
import (
"log"
"net"
"net/http"
"net/http/httputil"
"net/url"
"os"
"strings"
)
func main() {
listen, upstream, from, cert, key := os.Args[1], os.Args[2], os.Args[3], os.Args[4], os.Args[5]
target, err := url.Parse(upstream)
if err != nil {
log.Fatal(err)
}
dialer := &net.Dialer{LocalAddr: &net.TCPAddr{IP: net.ParseIP(from)}}
transport := &http.Transport{DialContext: dialer.DialContext}
route := func(prefix, proto string) http.Handler {
return &httputil.ReverseProxy{
Rewrite: func(r *httputil.ProxyRequest) {
r.SetURL(target)
r.Out.URL.Path = "/" + strings.TrimPrefix(r.In.URL.Path, prefix)
r.Out.URL.RawPath = ""
r.SetXForwarded()
r.Out.Header.Set("X-Forwarded-Proto", proto)
},
Transport: transport,
FlushInterval: -1, // server-sent events
}
}
mux := http.NewServeMux()
mux.Handle("/logs/", route("/logs/", "https"))
mux.Handle("/plain/", route("/plain/", "http"))
log.Fatal(http.ListenAndServeTLS(listen, cert, key, mux))
}
EOF
(cd "$PROXY_SRC" && go build -o proxy main.go) || { echo "proxy build failed" >&2; exit 1; }
# --- Config ---
cat > "$CONF/logwisp.toml" <<EOF
status_reporter = false
[logging]
output = "stdout"
level = "info"
[[pipelines]]
name = "site"
[[pipelines.plugin_sources]]
id = "rand"
type = "random"
[pipelines.plugin_sources.config]
interval_ms = 200
format = "txt"
length = 24
[[pipelines.plugin_sinks]]
id = "web"
type = "http"
[pipelines.plugin_sinks.config]
host = "127.0.0.1"
port = $PORT_SINK
login_page = true
viewer_page = true
[pipelines.plugin_sinks.config.auth]
type = "scram"
credentials_file = "$USERS"
trusted_proxies = ["$PROXY_ADDR"]
EOF
cat > "$RUN/browser.cjs" <<'EOF'
// Logs in through the shipped pages, as a person would; prints key=value lines
const { chromium } = require("playwright");
const [base, user, password] = process.argv.slice(2);
(async () => {
const out = { csp_violations: 0 };
const browser = await chromium.launch();
try {
const context = await browser.newContext({ ignoreHTTPSErrors: true });
const page = await context.newPage();
page.on("console", (m) => { if (/Content Security Policy/i.test(m.text())) out.csp_violations++; });
const status = () => page.evaluate(() => document.getElementById("status").textContent);
await page.goto(base + "auth/view");
await page.waitForURL(/\/auth\/login\?next=view$/, { timeout: 10000 });
out.redirected_to_login = 1;
await page.fill('input[name="username"]', user);
await page.fill('input[name="password"]', "not-the-password");
await page.click('button[type="submit"]');
await page.waitForFunction(() => /refused/.test(document.getElementById("status").textContent), null, { timeout: 30000 });
out.wrong_password_refused = 1;
await page.fill('input[name="password"]', password);
const started = Date.now();
await page.click('button[type="submit"]');
await page.waitForURL(/\/auth\/view$/, { timeout: 30000 }).catch(async (e) => { throw new Error(`${e.message}: ${await status()}`); });
out.login_ms = Date.now() - started;
await page.waitForFunction(() => document.querySelectorAll("#log > div").length >= 3, null, { timeout: 15000 });
out.events = await page.evaluate(() => document.querySelectorAll("#log > div").length);
const c = (await context.cookies()).find((c) => c.name === "logwisp_session");
out.cookie = c ? `path=${c.path};httponly=${c.httpOnly};secure=${c.secure};samesite=${c.sameSite}` : "none";
out.document_cookie = JSON.stringify(await page.evaluate(() => document.cookie));
await page.click("#logout");
await page.waitForURL(/\/auth\/login$/, { timeout: 10000 });
out.cookie_after_logout = (await context.cookies()).some((c) => c.name === "logwisp_session") ? 1 : 0;
out.status_after_logout = await page.evaluate(async (b) => (await fetch(b + "status")).status, base);
} catch (e) {
out.error = JSON.stringify(e.message.split("\n")[0]);
} finally {
await browser.close();
}
for (const [k, v] of Object.entries(out)) console.log(`${k}=${v}`);
})();
EOF
# --- Guide ---
cat <<EOF
================================================================
logwisp browser login test — port map
$PORT_PROXY the site: TLS-terminating reverse proxy, logwisp under /logs/
$PORT_SINK logwisp http sink, plaintext, trusted_proxies = ["$PROXY_ADDR"]
Open $SITE/logs/auth/view (CA: $PKI/ca.crt) and sign in as viewer-01
with the password in $AUTH/viewer-01.pass.
CLI, unbound through the proxy (the token stays off argv):
token=\$($BIN auth token -unbound -url $SITE/logs -user viewer-01 \\
-password-file $AUTH/viewer-01.pass -ca-file $PKI/ca.crt)
curl -N --noproxy '*' --cacert $PKI/ca.crt \\
-H @<(printf 'Authorization: Bearer %s\n' "\$token") $SITE/logs/stream
Logs: $LOG/
================================================================
EOF
# --- Startup ---
"$BIN" -c "$CONF/logwisp.toml" > "$LOG/logwisp.out" 2>&1 &
PIDS+=($!)
"$PROXY_SRC/proxy" "127.0.0.1:$PORT_PROXY" "http://127.0.0.1:$PORT_SINK" "$PROXY_ADDR" \
"$PKI/site.crt" "$PKI/site.key" > "$LOG/proxy.out" 2>&1 &
PIDS+=($!)
for p in $PORT_SINK $PORT_PROXY; do
wait_port "$p" 10 || { echo "FAIL: port $p not listening (see $LOG/)"; exit 1; }
done
if (( AUTO == 0 )); then
echo "--- daemons running; Ctrl-C to stop"
while :; do sleep 1; done
fi
fail=0
check() { # label condition_result
if (( $2 )); then echo "PASS: $1"; else echo "FAIL: $1"; fail=1; fi
}
is() { [[ $1 == "$2" ]] && echo 1 || echo 0; } # actual expected -> 1 or 0
is_set() { [[ -n $1 ]] && echo 1 || echo 0; }
code_of() { # url [token] -> http_code; the header goes through a pipe, never argv
local args=(-s -o /dev/null -w '%{http_code}' --max-time 5 --noproxy '*' --cacert "$PKI/ca.crt")
if [[ -n ${2:-} ]]; then
curl "${args[@]}" -H @<(printf 'Authorization: Bearer %s\n' "$2") "$1" 2>/dev/null || true
else
curl "${args[@]}" "$1" 2>/dev/null || true
fi
}
echo "=== Scenario 1: a person in a browser ==="
if (( BROWSER )); then
browser_out="$(timeout 120 node "$RUN/browser.cjs" "$SITE/logs/" viewer-01 "$(cat "$AUTH/viewer-01.pass")" 2>"$LOG/browser.err")"
echo "$browser_out" > "$LOG/browser.out"
val() { sed -n "s/^$1=//p" <<< "$browser_out"; }
[[ -n $(val error) ]] && echo "browser error: $(val error)"
check "browser: an unauthenticated viewer is sent to the login page" $(is "$(val redirected_to_login)" 1)
check "browser: a wrong password is refused on the page" $(is "$(val wrong_password_refused)" 1)
check "browser: login (Argon2 in the page, $(val login_ms) ms) opened the viewer" $(is_set "$(val login_ms)")
n=$(val events)
check "browser: the viewer showed events through EventSource (${n:-0} lines)" $(( ${n:-0} >= 3 ))
check "browser: session cookie $(val cookie)" \
$(is "$(val cookie)" "path=/logs;httponly=true;secure=true;samesite=Strict")
check "browser: page scripts cannot read the session (document.cookie $(val document_cookie))" \
$(is "$(val document_cookie)" '""')
check "browser: sign out cleared the cookie" $(is "$(val cookie_after_logout)" 0)
check "browser: status refused after sign out (HTTP $(val status_after_logout))" $(is "$(val status_after_logout)" 401)
check "browser: no CSP violations ($(val csp_violations))" $(is "$(val csp_violations)" 0)
n=$(grep 'Login accepted' "$LOG/logwisp.out" | grep -c 'remote_addr 127.0.0.1')
check "logwisp logged the forwarded client, not the proxy ($n logins from 127.0.0.1)" $(( n >= 1 ))
else
echo "SKIP: browser checks (node with playwright not found)"
fi
echo "=== Scenario 2: the CLI through the proxy ==="
token="$("$BIN" auth token -unbound -url "$SITE/logs" -user viewer-01 -password-file "$AUTH/viewer-01.pass" \
-ca-file "$PKI/ca.crt" 2>>"$LOG/auth-cli.out")"
check "cli: logwisp auth token -unbound logged in through the proxy" $(is_set "$token")
code="$(code_of "$SITE/logs/status" "$token")"
check "cli: /logs/status served with the token (HTTP $code)" $(is "$code" 200)
sse="$(timeout 3 curl -sN --noproxy '*' --cacert "$PKI/ca.crt" \
-H @<(printf 'Authorization: Bearer %s\n' "$token") "$SITE/logs/stream" 2>/dev/null || true)"
n=$(grep -c '^data:' <<< "$sse")
check "cli: /logs/stream delivered SSE events with the token ($n events)" $(( n >= 1 ))
code="$(code_of "$SITE/logs/status")"
check "cli: /logs/status refused without a token (HTTP $code)" $(is "$code" 401)
"$BIN" auth token -url "$SITE/logs" -user viewer-01 -password-file "$AUTH/viewer-01.pass" \
-ca-file "$PKI/ca.crt" >/dev/null 2>>"$LOG/auth-cli.out"
rc=$?
check "cli: a path without -unbound is a usage error (exit $rc)" $(( rc == 2 ))
echo "=== Scenario 3: around the proxy ==="
code="$(code_of "http://127.0.0.1:$PORT_SINK/auth/login")"
check "direct: the login page refuses a peer that is not the proxy (HTTP $code)" $(is "$code" 403)
code="$(code_of "http://127.0.0.1:$PORT_SINK/status" "$token")"
check "direct: a valid token does not open the sink around the proxy (HTTP $code)" $(is "$code" 403)
code="$(code_of "$SITE/plain/status" "$token")"
check "plaintext site: a request the proxy received over http is refused (HTTP $code)" $(is "$code" 403)
echo "================================================================"
if (( fail == 0 )); then
echo "RESULT: ALL PASS"
(( KEEP )) && { echo "--keep: daemons left running (pids: ${PIDS[*]})"; PIDS=(); }
else
echo "RESULT: FAILURES — inspect $LOG/*"
fi
exit "$fail"