v0.21.0 scram password auth, cred and token management, browser tls-terminating proxy, viewer client
Test / test (push) Canceled after 0s
Test / test (push) Canceled after 0s
This commit is contained in:
1 parent
f192c26fa2
commit
9ab8c356d2
60 files changed
+6744
-700
No files matched your search
@@ -15,3 +15,5 @@ catalog.txt
|
||||
combined.txt
|
||||
test/run/
|
||||
test/run-mtls/
|
||||
test/run-scram/
|
||||
test/run-proxy/
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,395 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/tls"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"syscall"
|
||||
|
||||
"logwisp/internal/authz"
|
||||
"logwisp/internal/config"
|
||||
"logwisp/internal/tlsx"
|
||||
|
||||
"github.com/lixenwraith/auth"
|
||||
)
|
||||
|
||||
const reloadHint = "send SIGHUP to logwisp to apply (auto_reload does not watch the credentials file)"
|
||||
|
||||
// authCommand is one `logwisp auth` subcommand: define registers its flags
|
||||
// and returns the command, which runs once they are parsed and checked.
|
||||
type authCommand struct {
|
||||
name, synopsis, summary string
|
||||
required []string
|
||||
define func(fs *flag.FlagSet) func(stdout, stderr io.Writer) error
|
||||
}
|
||||
|
||||
var authCommands = []authCommand{
|
||||
{"add-user", "-credentials FILE -user NAME [-password-file FILE] [-generate]",
|
||||
"Add a user to a credentials file, or replace its password",
|
||||
[]string{"credentials", "user"}, defineAddUser},
|
||||
{"remove-user", "-credentials FILE -user NAME",
|
||||
"Remove a user from a credentials file",
|
||||
[]string{"credentials", "user"}, defineRemoveUser},
|
||||
{"token", "-url https://HOST:PORT -user NAME -password-file FILE [TLS flags]",
|
||||
"Log in to an http sink or http_chain source and print a bearer token",
|
||||
[]string{"url", "user", "password-file"}, defineToken},
|
||||
{"stream", "-addr HOST:PORT -user NAME -password-file FILE [TLS flags]",
|
||||
"Log in to a tcp sink and copy its stream to stdout until interrupted",
|
||||
[]string{"addr", "user", "password-file"}, defineStream},
|
||||
}
|
||||
|
||||
// usageError is a command-line mistake: exit status 2 rather than 1
|
||||
type usageError string
|
||||
|
||||
func (e usageError) Error() string { return string(e) }
|
||||
|
||||
// runAuth runs `logwisp auth` and returns the exit status: 0 success or
|
||||
// help, 1 failure, 2 usage error.
|
||||
func runAuth(args []string, stdout, stderr io.Writer) int {
|
||||
if len(args) == 0 || slices.Contains([]string{"-h", "-help", "--help", "help"}, args[0]) {
|
||||
printAuthUsage(stderr)
|
||||
if len(args) == 0 {
|
||||
return 2
|
||||
}
|
||||
return 0
|
||||
}
|
||||
i := slices.IndexFunc(authCommands, func(c authCommand) bool { return c.name == args[0] })
|
||||
if i < 0 {
|
||||
fmt.Fprintf(stderr, "logwisp auth: unknown command %q\n\n", args[0])
|
||||
printAuthUsage(stderr)
|
||||
return 2
|
||||
}
|
||||
c := authCommands[i]
|
||||
fs := flag.NewFlagSet("logwisp auth "+c.name, flag.ContinueOnError)
|
||||
fs.SetOutput(stderr)
|
||||
fs.Usage = func() {
|
||||
fmt.Fprintf(stderr, "Usage: %s %s\n\n%s.\n\n", fs.Name(), c.synopsis, c.summary)
|
||||
fs.PrintDefaults()
|
||||
}
|
||||
run := c.define(fs)
|
||||
if err := fs.Parse(args[1:]); err != nil {
|
||||
if errors.Is(err, flag.ErrHelp) {
|
||||
return 0
|
||||
}
|
||||
return 2 // already reported by flag, with the usage
|
||||
}
|
||||
err := checkArgs(fs, c.required)
|
||||
if err == nil {
|
||||
err = run(stdout, stderr)
|
||||
}
|
||||
if err == nil {
|
||||
return 0
|
||||
}
|
||||
fmt.Fprintf(stderr, "%s: %v\n", fs.Name(), err)
|
||||
if _, ok := errors.AsType[usageError](err); ok {
|
||||
fs.Usage()
|
||||
return 2
|
||||
}
|
||||
return 1
|
||||
}
|
||||
|
||||
func printAuthUsage(w io.Writer) {
|
||||
fmt.Fprint(w, "Usage: logwisp auth <command> [flags]\n\n")
|
||||
for _, c := range authCommands {
|
||||
fmt.Fprintf(w, " %-12s %s\n", c.name, c.summary)
|
||||
}
|
||||
fmt.Fprint(w, "\nRun logwisp auth <command> -h for its flags. Credential changes apply on SIGHUP.\n"+
|
||||
"Exit status: 0 success, 1 failure, 2 usage error.\n")
|
||||
}
|
||||
|
||||
func checkArgs(fs *flag.FlagSet, required []string) error {
|
||||
if fs.NArg() > 0 {
|
||||
return usageError(fmt.Sprintf("unexpected argument %q", fs.Arg(0)))
|
||||
}
|
||||
for _, name := range required {
|
||||
if fs.Lookup(name).Value.String() == "" {
|
||||
return usageError("-" + name + " is required")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func defineAddUser(fs *flag.FlagSet) func(stdout, stderr io.Writer) error {
|
||||
path := fs.String("credentials", "", "credentials `file`, created when missing")
|
||||
user := fs.String("user", "", "user `name`")
|
||||
passwordFile := fs.String("password-file", "", "read the password from `file` when it exists, else write a generated one there")
|
||||
generate := fs.Bool("generate", false, "generate a new password, replacing any -password-file; needed to rotate without one")
|
||||
return func(stdout, stderr io.Writer) error {
|
||||
creds, err := authz.LoadCredentials(*path)
|
||||
// An empty file is one created beforehand to choose its owner
|
||||
if fi, serr := os.Stat(*path); errors.Is(err, os.ErrNotExist) || serr == nil && fi.Size() == 0 {
|
||||
creds, err = &authz.Credentials{DecoyKey: make([]byte, 32)}, nil
|
||||
rand.Read(creds.DecoyKey)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
i := slices.IndexFunc(creds.Users, func(c *auth.Credential) bool { return c.Username == *user })
|
||||
|
||||
var password string
|
||||
if *passwordFile != "" && !*generate {
|
||||
password, err = authz.ReadPassword(*passwordFile)
|
||||
switch {
|
||||
case err == nil && len(password) < 8:
|
||||
return fmt.Errorf("the password in %s is shorter than 8 bytes", *passwordFile)
|
||||
case err != nil && !errors.Is(err, os.ErrNotExist):
|
||||
return err
|
||||
}
|
||||
}
|
||||
generated := password == ""
|
||||
if generated && i >= 0 && !*generate {
|
||||
// A mistyped -password-file must not replace a working password
|
||||
return fmt.Errorf("user %q exists: replacing its password needs an existing -password-file or -generate", *user)
|
||||
}
|
||||
if generated {
|
||||
password = rand.Text()
|
||||
}
|
||||
|
||||
var cred *auth.Credential
|
||||
if len(creds.Users) == 0 {
|
||||
cred, err = auth.NewCredential(*user, password)
|
||||
} else {
|
||||
// One profile per file: mixed ones would tell which users exist
|
||||
p := creds.Users[0]
|
||||
salt := make([]byte, len(p.Salt))
|
||||
rand.Read(salt)
|
||||
cred, err = auth.DeriveCredential(*user, password, salt, p.ArgonTime, p.ArgonMemory, p.ArgonThreads)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
done := "added user %q to %s\n"
|
||||
if i >= 0 {
|
||||
creds.Users[i] = cred
|
||||
done = "replaced the password of user %q in %s\n"
|
||||
} else {
|
||||
creds.Users = append(creds.Users, cred)
|
||||
}
|
||||
data, err := encodeCredentials(creds)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if generated && *passwordFile != "" {
|
||||
// First, so a failure leaves the verifiers untouched and a rerun
|
||||
// without -generate completes the change
|
||||
if err := writeAtomic(*passwordFile, []byte(password+"\n")); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintf(stderr, "wrote the generated password to %s\n", *passwordFile)
|
||||
}
|
||||
if err := writeAtomic(*path, data); err != nil {
|
||||
return err
|
||||
}
|
||||
if generated && *passwordFile == "" {
|
||||
fmt.Fprintf(stderr, "generated password for %q, shown once:\n", *user)
|
||||
fmt.Fprintln(stdout, password)
|
||||
}
|
||||
fmt.Fprintf(stderr, done+"%s\n", *user, *path, reloadHint)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func defineRemoveUser(fs *flag.FlagSet) func(stdout, stderr io.Writer) error {
|
||||
path := fs.String("credentials", "", "credentials `file`")
|
||||
user := fs.String("user", "", "user `name`")
|
||||
return func(_, stderr io.Writer) error {
|
||||
creds, err := authz.LoadCredentials(*path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
i := slices.IndexFunc(creds.Users, func(c *auth.Credential) bool { return c.Username == *user })
|
||||
switch {
|
||||
case i < 0:
|
||||
return fmt.Errorf("no user %q in %s", *user, *path)
|
||||
case len(creds.Users) == 1:
|
||||
// The daemon rejects a file without users
|
||||
return fmt.Errorf("refusing to remove %q, the last user: remove the listener's auth block instead", *user)
|
||||
}
|
||||
creds.Users = slices.Delete(creds.Users, i, i+1)
|
||||
data, err := encodeCredentials(creds)
|
||||
if err == nil {
|
||||
err = writeAtomic(*path, data)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintf(stderr, "removed user %q from %s\n%s\n", *user, *path, reloadHint)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func defineToken(fs *flag.FlagSet) func(stdout, stderr io.Writer) error {
|
||||
rawURL := fs.String("url", "", "listener `URL`, https://HOST:PORT (with -unbound, a proxy's mount URL may carry a path)")
|
||||
user := fs.String("user", "", "user `name`")
|
||||
passwordFile := fs.String("password-file", "", "`file` holding the password")
|
||||
unbound := fs.Bool("unbound", false, "log in without channel binding, to an http sink behind a TLS-terminating proxy (auth.trusted_proxies)")
|
||||
tlsOpts := tlsFlags(fs)
|
||||
return func(stdout, _ io.Writer) error {
|
||||
u, err := url.Parse(*rawURL)
|
||||
if err != nil || u.Scheme != "https" || u.Host == "" || u.RawQuery != "" || u.Fragment != "" || u.User != nil {
|
||||
return usageError("-url must be https://HOST:PORT")
|
||||
}
|
||||
if u.Path = strings.TrimSuffix(u.Path, "/"); u.Path != "" && !*unbound {
|
||||
// TLS passthrough cannot route by path: only a terminating proxy can
|
||||
return usageError("-url takes a path only with -unbound")
|
||||
}
|
||||
tlsCfg, policy, err := dialPolicy(tlsOpts, u.Hostname(), *user, *passwordFile, authz.HTTP)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if *unbound {
|
||||
policy.Unbind()
|
||||
}
|
||||
client := &http.Client{
|
||||
Transport: &http.Transport{
|
||||
DialContext: func(ctx context.Context, _, addr string) (net.Conn, error) {
|
||||
var d net.Dialer
|
||||
return d.DialContext(ctx, "tcp4", addr)
|
||||
},
|
||||
TLSClientConfig: tlsCfg,
|
||||
TLSHandshakeTimeout: tlsx.HandshakeTimeout,
|
||||
},
|
||||
// A redirect would carry the login to another endpoint
|
||||
CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse },
|
||||
}
|
||||
token, err := policy.Token(context.Background(), client, "https://"+u.Host+u.Path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintln(stdout, token)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func defineStream(fs *flag.FlagSet) func(stdout, stderr io.Writer) error {
|
||||
addr := fs.String("addr", "", "tcp sink `address`, HOST:PORT")
|
||||
user := fs.String("user", "", "user `name`")
|
||||
passwordFile := fs.String("password-file", "", "`file` holding the password")
|
||||
tlsOpts := tlsFlags(fs)
|
||||
return func(stdout, _ io.Writer) error {
|
||||
host, _, err := net.SplitHostPort(*addr)
|
||||
if err != nil || host == "" {
|
||||
return usageError("-addr must be HOST:PORT")
|
||||
}
|
||||
tlsCfg, policy, err := dialPolicy(tlsOpts, host, *user, *passwordFile, authz.TCP)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
defer stop()
|
||||
dctx, cancel := context.WithTimeout(ctx, tlsx.HandshakeTimeout)
|
||||
conn, err := (&tls.Dialer{Config: tlsCfg}).DialContext(dctx, "tcp4", *addr)
|
||||
cancel()
|
||||
if err == nil {
|
||||
defer conn.Close()
|
||||
var r io.Reader
|
||||
if r, err = policy.Greet(ctx, conn, ""); err == nil {
|
||||
defer context.AfterFunc(ctx, func() { conn.Close() })()
|
||||
if _, err = io.Copy(stdout, r); err == nil {
|
||||
err = errors.New("stream closed by server")
|
||||
}
|
||||
}
|
||||
}
|
||||
if ctx.Err() != nil {
|
||||
return nil // interrupted: how a viewer normally ends
|
||||
}
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// tlsFlags registers the dialer TLS flags. There is no insecure flag: an
|
||||
// unverified server could relay the login.
|
||||
func tlsFlags(fs *flag.FlagSet) *config.TLSOptions {
|
||||
o := &config.TLSOptions{Enabled: true}
|
||||
fs.StringVar(&o.CAFile, "ca-file", "", "CA `file` that verifies the server (default: system roots)")
|
||||
fs.StringVar(&o.ServerName, "server-name", "", "`name` the server certificate must carry (default: the host)")
|
||||
fs.StringVar(&o.CertFile, "cert-file", "", "client certificate `file`, for listeners with tls.client_auth")
|
||||
fs.StringVar(&o.KeyFile, "key-file", "", "client key `file`")
|
||||
return o
|
||||
}
|
||||
|
||||
// dialPolicy builds what a scram dialer plugin builds from its tls and auth blocks
|
||||
func dialPolicy(o *config.TLSOptions, host, user, passwordFile string, transport authz.Transport) (*tls.Config, *authz.Policy, error) {
|
||||
tlsCfg, err := tlsx.Client(o, host)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
policy, err := authz.New(&config.AuthOptions{Type: authz.MethodSCRAM, Username: user, PasswordFile: passwordFile},
|
||||
tlsCfg, authz.RoleDialer, transport)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
tlsCfg.VerifyConnection = policy.VerifyConnection
|
||||
return tlsCfg, policy, nil
|
||||
}
|
||||
|
||||
// encodeCredentials renders creds and refuses what the daemon would not load
|
||||
func encodeCredentials(creds *authz.Credentials) ([]byte, error) {
|
||||
data, err := creds.Marshal()
|
||||
if err == nil {
|
||||
_, err = authz.ParseCredentials(data)
|
||||
}
|
||||
return data, err
|
||||
}
|
||||
|
||||
// writeAtomic replaces path (a symlink's target) by rename, so the daemon
|
||||
// never reads a partial file, keeping an existing file's mode and owner (0600
|
||||
// for a new one). A change that would lose the owner fails: the daemon could
|
||||
// no longer read the file and would keep the old users.
|
||||
func writeAtomic(path string, data []byte) error {
|
||||
if resolved, err := filepath.EvalSymlinks(path); err == nil {
|
||||
path = resolved
|
||||
} else if !errors.Is(err, os.ErrNotExist) {
|
||||
return err
|
||||
} else if fi, err := os.Lstat(path); err == nil && fi.Mode()&os.ModeSymlink != 0 {
|
||||
// Replacing the link would leave its target, what the daemon reads, unwritten
|
||||
return fmt.Errorf("%s is a symlink to a missing file; create the target first", path)
|
||||
}
|
||||
mode, uid, gid := os.FileMode(0o600), -1, -1
|
||||
if fi, err := os.Stat(path); err == nil {
|
||||
mode = fi.Mode().Perm()
|
||||
if st, ok := fi.Sys().(*syscall.Stat_t); ok {
|
||||
uid, gid = int(st.Uid), int(st.Gid)
|
||||
}
|
||||
} else if !errors.Is(err, os.ErrNotExist) {
|
||||
return err
|
||||
}
|
||||
tmp, err := os.CreateTemp(filepath.Dir(path), "."+filepath.Base(path)+".*")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = tmp.Write(data)
|
||||
if err == nil {
|
||||
err = tmp.Chmod(mode)
|
||||
}
|
||||
if err == nil && uid >= 0 {
|
||||
if err = tmp.Chown(uid, gid); err != nil {
|
||||
err = fmt.Errorf("%s: cannot keep owner %d:%d (run as root or that owner): %w", path, uid, gid, err)
|
||||
}
|
||||
}
|
||||
if err == nil {
|
||||
err = tmp.Sync()
|
||||
}
|
||||
if cerr := tmp.Close(); err == nil {
|
||||
err = cerr
|
||||
}
|
||||
if err == nil {
|
||||
err = os.Rename(tmp.Name(), path)
|
||||
}
|
||||
if err != nil {
|
||||
os.Remove(tmp.Name())
|
||||
}
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"logwisp/internal/authz"
|
||||
|
||||
"github.com/lixenwraith/auth"
|
||||
)
|
||||
|
||||
func runAuthTest(t *testing.T, args ...string) (code int, stdout, stderr string) {
|
||||
t.Helper()
|
||||
var out, errOut bytes.Buffer
|
||||
code = runAuth(args, &out, &errOut)
|
||||
return code, out.String(), errOut.String()
|
||||
}
|
||||
|
||||
// writeCredentials writes users sharing a cheap Argon2 profile. add-user
|
||||
// reuses the file's profile, so only a new file costs the default 64 MiB.
|
||||
func writeCredentials(t *testing.T, path string, users ...string) *authz.Credentials {
|
||||
t.Helper()
|
||||
c := &authz.Credentials{DecoyKey: bytes.Repeat([]byte{7}, 32)}
|
||||
for _, u := range users {
|
||||
cred, err := auth.DeriveCredential(u, "password-"+u, bytes.Repeat([]byte{1}, 16), 1, 64, 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c.Users = append(c.Users, cred)
|
||||
}
|
||||
data, err := c.Marshal()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(path, data, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
func TestAddUserCreatesPrivateFilesWithAMatchingVerifier(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
creds, pass := filepath.Join(dir, "users.toml"), filepath.Join(dir, "edge-01.pass")
|
||||
code, stdout, stderr := runAuthTest(t, "add-user", "-credentials", creds, "-user", "edge-01", "-password-file", pass)
|
||||
if code != 0 || stdout != "" {
|
||||
t.Fatalf("exit %d, stdout %q, stderr: %s", code, stdout, stderr)
|
||||
}
|
||||
for _, path := range []string{creds, pass} {
|
||||
fi, err := os.Stat(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if fi.Mode().Perm() != 0o600 {
|
||||
t.Fatalf("%s has mode %v, want 0600", path, fi.Mode().Perm())
|
||||
}
|
||||
}
|
||||
raw, err := os.ReadFile(pass)
|
||||
if err != nil || !strings.HasSuffix(string(raw), "\n") {
|
||||
t.Fatalf("password file %q, %v: want one line", raw, err)
|
||||
}
|
||||
c, err := authz.LoadCredentials(creds)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
password, err := authz.ReadPassword(pass)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
u := c.Users[0]
|
||||
want, err := auth.DeriveCredential("edge-01", password, u.Salt, u.ArgonTime, u.ArgonMemory, u.ArgonThreads)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !bytes.Equal(want.StoredKey, u.StoredKey) || !bytes.Equal(want.ServerKey, u.ServerKey) {
|
||||
t.Fatal("stored verifier does not match the written password")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddUserNeverReplacesAPasswordWithoutASource(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
creds, typo := filepath.Join(dir, "users.toml"), filepath.Join(dir, "edge01.pass")
|
||||
writeCredentials(t, creds, "edge-01")
|
||||
before, err := os.ReadFile(creds)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, extra := range [][]string{nil, {"-password-file", typo}} {
|
||||
args := append([]string{"add-user", "-credentials", creds, "-user", "edge-01"}, extra...)
|
||||
if code, _, stderr := runAuthTest(t, args...); code != 1 {
|
||||
t.Fatalf("%v: exit %d, want 1; stderr: %s", extra, code, stderr)
|
||||
}
|
||||
}
|
||||
after, err := os.ReadFile(creds)
|
||||
if err != nil || !bytes.Equal(before, after) {
|
||||
t.Fatalf("credentials file changed (%v)", err)
|
||||
}
|
||||
if _, err := os.Stat(typo); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatalf("mistyped password file was created (%v)", err)
|
||||
}
|
||||
}
|
||||
|
||||
// The daemon reads a link's target: a rewrite through the link updates it
|
||||
func TestRewriteKeepsFileModeDecoyKeyAndLink(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
creds, target, pass := filepath.Join(dir, "users.toml"), filepath.Join(dir, "real.toml"), filepath.Join(dir, "edge-02.pass")
|
||||
orig := writeCredentials(t, target, "edge-01")
|
||||
if err := os.Chmod(target, 0o640); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Symlink(target, creds); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(pass, []byte("correct horse battery\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if code, _, stderr := runAuthTest(t, "add-user", "-credentials", creds, "-user", "edge-02", "-password-file", pass); code != 0 {
|
||||
t.Fatalf("exit %d; stderr: %s", code, stderr)
|
||||
}
|
||||
if fi, err := os.Lstat(creds); err != nil || fi.Mode()&os.ModeSymlink == 0 {
|
||||
t.Fatalf("link replaced by a file (%v)", err)
|
||||
}
|
||||
fi, err := os.Stat(target)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if fi.Mode().Perm() != 0o640 {
|
||||
t.Fatalf("mode %v, want 0640", fi.Mode().Perm())
|
||||
}
|
||||
c, err := authz.LoadCredentials(target)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !bytes.Equal(c.DecoyKey, orig.DecoyKey) || len(c.Users) != 2 || c.Users[1].Username != "edge-02" {
|
||||
t.Fatalf("decoy key kept: %v, users: %d", bytes.Equal(c.DecoyKey, orig.DecoyKey), len(c.Users))
|
||||
}
|
||||
}
|
||||
|
||||
// A dangling link is refused rather than replaced: its missing target is the
|
||||
// file the daemon would read
|
||||
func TestAddUserRefusesADanglingLink(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
creds := filepath.Join(dir, "users.toml")
|
||||
if err := os.Symlink(filepath.Join(dir, "missing", "users.toml"), creds); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if code, _, _ := runAuthTest(t, "add-user", "-credentials", creds, "-user", "edge-01"); code != 1 {
|
||||
t.Fatalf("exit %d, want 1", code)
|
||||
}
|
||||
if fi, err := os.Lstat(creds); err != nil || fi.Mode()&os.ModeSymlink == 0 {
|
||||
t.Fatalf("link replaced (%v)", err)
|
||||
}
|
||||
}
|
||||
|
||||
// An empty file is how an operator picks the owner before the first user
|
||||
func TestAddUserFillsAnEmptyFile(t *testing.T) {
|
||||
creds := filepath.Join(t.TempDir(), "users.toml")
|
||||
if err := os.WriteFile(creds, nil, 0o640); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if code, _, stderr := runAuthTest(t, "add-user", "-credentials", creds, "-user", "edge-01"); code != 0 {
|
||||
t.Fatalf("exit %d; stderr: %s", code, stderr)
|
||||
}
|
||||
fi, err := os.Stat(creds)
|
||||
if err != nil || fi.Mode().Perm() != 0o640 {
|
||||
t.Fatalf("mode %v (%v), want 0640", fi.Mode().Perm(), err)
|
||||
}
|
||||
if c, err := authz.LoadCredentials(creds); err != nil || len(c.Users) != 1 {
|
||||
t.Fatalf("credentials after add-user: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoveUserRefusesTheLastUser(t *testing.T) {
|
||||
creds := filepath.Join(t.TempDir(), "users.toml")
|
||||
writeCredentials(t, creds, "edge-01", "edge-02")
|
||||
if code, _, stderr := runAuthTest(t, "remove-user", "-credentials", creds, "-user", "edge-01"); code != 0 {
|
||||
t.Fatalf("exit %d; stderr: %s", code, stderr)
|
||||
}
|
||||
if code, _, _ := runAuthTest(t, "remove-user", "-credentials", creds, "-user", "edge-02"); code != 1 {
|
||||
t.Fatalf("removing the last user: exit %d, want 1", code)
|
||||
}
|
||||
c, err := authz.LoadCredentials(creds)
|
||||
if err != nil || len(c.Users) != 1 || c.Users[0].Username != "edge-02" {
|
||||
t.Fatalf("users after refusal: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthCommandHelpPrintsAuthUsage(t *testing.T) {
|
||||
code, _, stderr := runAuthTest(t, "add-user", "-h")
|
||||
if code != 0 || !strings.Contains(stderr, "Usage: logwisp auth add-user -credentials FILE") {
|
||||
t.Fatalf("exit %d, stderr: %s", code, stderr)
|
||||
}
|
||||
}
|
||||
+4
-1
@@ -14,6 +14,10 @@ Usage:
|
||||
logwisp help | -h | --help
|
||||
logwisp --version
|
||||
|
||||
Subcommands:
|
||||
logwisp auth <command> SCRAM credentials files, bearer tokens and tcp
|
||||
sink viewing; logwisp auth -h lists commands
|
||||
|
||||
Scalar configuration keys are settable as flags using their TOML paths:
|
||||
--<path>=<value> e.g. --logging.level=debug
|
||||
|
||||
@@ -62,7 +66,6 @@ func handleHelp(args []string) {
|
||||
}
|
||||
}
|
||||
|
||||
// printHelp writes usage to stdout and exits with success
|
||||
func printHelp() {
|
||||
fmt.Printf(helpText, version.Short())
|
||||
os.Exit(0)
|
||||
|
||||
+6
-14
@@ -16,20 +16,21 @@ import (
|
||||
"github.com/lixenwraith/log"
|
||||
)
|
||||
|
||||
// logger is the global logger instance for the application
|
||||
var logger *log.Logger
|
||||
|
||||
// main is the entry point for the LogWisp application
|
||||
func main() {
|
||||
// Before handleHelp, so `logwisp auth <command> -h` prints the auth usage
|
||||
if len(os.Args) > 1 && os.Args[1] == "auth" {
|
||||
os.Exit(runAuth(os.Args[2:], os.Stdout, os.Stderr))
|
||||
}
|
||||
|
||||
// --- 1. Initial setup ---
|
||||
// Emulates nohup
|
||||
signal.Ignore(syscall.SIGHUP)
|
||||
|
||||
// Help handled before config parsing; loader has no help flag.
|
||||
// Also the future dispatch point for subcommands (tls, etc.)
|
||||
// Before config parsing: the loader has no help flag
|
||||
handleHelp(os.Args[1:])
|
||||
|
||||
// Load configuration with automatic CLI parsing
|
||||
manager, err := config.Load(os.Args[1:])
|
||||
if err != nil {
|
||||
if errors.Is(err, config.ErrConfigNotFound) {
|
||||
@@ -46,27 +47,22 @@ func main() {
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Initialize output handler
|
||||
InitOutputHandler(cfg.Quiet)
|
||||
|
||||
// Handle version
|
||||
if cfg.ShowVersion {
|
||||
fmt.Println(version.String())
|
||||
os.Exit(0)
|
||||
}
|
||||
|
||||
// Initialize logger instance and apply configuration
|
||||
if err := initializeLogger(cfg); err != nil {
|
||||
FatalError(1, "Failed to initialize logger: %v\n", err)
|
||||
}
|
||||
defer shutdownLogger()
|
||||
|
||||
// Start the logger
|
||||
if err := logger.Start(); err != nil {
|
||||
FatalError(1, "Failed to start logger: %v\n", err)
|
||||
}
|
||||
|
||||
// Log startup information
|
||||
logger.Info("msg", "LogWisp starting",
|
||||
"version", version.String(),
|
||||
"config_file", cfg.ConfigFile,
|
||||
@@ -74,7 +70,6 @@ func main() {
|
||||
"status_reporter", cfg.StatusReporter,
|
||||
"auto_reload", cfg.ConfigAutoReload)
|
||||
|
||||
// Create context for shutdown
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
@@ -98,7 +93,6 @@ func main() {
|
||||
logger.Info("msg", "Config auto-reload disabled")
|
||||
}
|
||||
|
||||
// Service shutdown sequence
|
||||
defer func() {
|
||||
logger.Info("msg", "Shutdown initiated")
|
||||
if statusReporterCancel != nil {
|
||||
@@ -109,7 +103,6 @@ func main() {
|
||||
}
|
||||
manager.Close()
|
||||
logger.Info("msg", "Shutdown complete")
|
||||
// Deferred logger shutdown will run after this
|
||||
}()
|
||||
|
||||
reload := func(fromDisk bool) {
|
||||
@@ -164,7 +157,6 @@ func main() {
|
||||
}
|
||||
}
|
||||
|
||||
// shutdownLogger gracefully shuts down the global logger.
|
||||
func shutdownLogger() {
|
||||
if logger != nil {
|
||||
if err := logger.Shutdown(core.LoggerShutdownTimeout); err != nil {
|
||||
|
||||
+34
-29
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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.
|
||||
@@ -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
|
||||
)
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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"`
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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{}), "")
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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=",
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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();
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
|
||||
Executable
+504
@@ -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"
|
||||
Executable
+317
@@ -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"
|
||||
Reference in new issue
Block a user