diff --git a/.gitignore b/.gitignore index cf8dd90..9000880 100644 --- a/.gitignore +++ b/.gitignore @@ -15,3 +15,5 @@ catalog.txt combined.txt test/run/ test/run-mtls/ +test/run-scram/ +test/run-proxy/ diff --git a/AGENTS.md b/AGENTS.md index cf7be3b..519abad 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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. diff --git a/README.md b/README.md index 8967bb4..ed69bff 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/cmd/logwisp/auth.go b/cmd/logwisp/auth.go new file mode 100644 index 0000000..f1fdd9f --- /dev/null +++ b/cmd/logwisp/auth.go @@ -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 [flags]\n\n") + for _, c := range authCommands { + fmt.Fprintf(w, " %-12s %s\n", c.name, c.summary) + } + fmt.Fprint(w, "\nRun logwisp auth -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 +} diff --git a/cmd/logwisp/auth_test.go b/cmd/logwisp/auth_test.go new file mode 100644 index 0000000..3f1d336 --- /dev/null +++ b/cmd/logwisp/auth_test.go @@ -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) + } +} diff --git a/cmd/logwisp/help.go b/cmd/logwisp/help.go index b40e652..c8372d8 100644 --- a/cmd/logwisp/help.go +++ b/cmd/logwisp/help.go @@ -14,6 +14,10 @@ Usage: logwisp help | -h | --help logwisp --version +Subcommands: + logwisp auth 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: --= 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) diff --git a/cmd/logwisp/main.go b/cmd/logwisp/main.go index 705dda2..8db6571 100644 --- a/cmd/logwisp/main.go +++ b/cmd/logwisp/main.go @@ -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 -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 { diff --git a/cmd/logwisp/status.go b/cmd/logwisp/status.go index bb8e483..54f2ccb 100644 --- a/cmd/logwisp/status.go +++ b/cmd/logwisp/status.go @@ -69,36 +69,8 @@ func statusReporter(service *service.Service, ctx context.Context) { // logStats recursively logs statistics with automatic field extraction func logStats(msg string, name string, stats any) { - // Build base log fields - fields := []any{ - "msg", msg, - "name", name, - } - - // Extract and flatten important metrics from stats map if statsMap, ok := stats.(map[string]any); ok { - // Add scalar values directly - for key, value := range statsMap { - switch v := value.(type) { - case string, bool, int, int64, uint64, float64: - fields = append(fields, key, v) - case time.Time: - if !v.IsZero() { - fields = append(fields, key, v.Format(time.RFC3339)) - } - case map[string]any: - // For nested maps, log summary counts if they contain arrays/maps - if count := getItemCount(v); count > 0 { - fields = append(fields, fmt.Sprintf("%s_count", key), count) - } - case []any, []map[string]any: - // For arrays, just log the count - fields = append(fields, fmt.Sprintf("%s_count", key), getArrayLength(value)) - } - } - - // Log the flattened stats - logger.Debug(fields...) + logger.Debug(statsFields(msg, name, statsMap)...) // Recursively log nested structures with detail for key, value := range statsMap { @@ -122,6 +94,39 @@ func logStats(msg string, name string, stats any) { } } +// statsFields flattens one stats map into log fields. A plugin's details map +// is spread into the line: its auth and TLS rejection counters live there. +func statsFields(msg, name string, stats map[string]any) []any { + fields := []any{"msg", msg, "name", name} + add := func(key string, value any) { + switch v := value.(type) { + case string, bool, int, int64, uint64, float64: + fields = append(fields, key, v) + case time.Time: + if !v.IsZero() { + fields = append(fields, key, v.Format(time.RFC3339)) + } + case map[string]any: + // For nested maps, log summary counts if they contain arrays/maps + if count := getItemCount(v); count > 0 { + fields = append(fields, fmt.Sprintf("%s_count", key), count) + } + case []any, []map[string]any: + fields = append(fields, fmt.Sprintf("%s_count", key), getArrayLength(value)) + } + } + for key, value := range stats { + if details, ok := value.(map[string]any); ok && key == "details" { + for k, v := range details { + add(k, v) + } + continue + } + add(key, value) + } + return fields +} + // getItemCount returns the count of items in a map (for nested structures) func getItemCount(m map[string]any) int { for _, v := range m { @@ -145,4 +150,4 @@ func getArrayLength(v any) int { default: return 0 } -} \ No newline at end of file +} diff --git a/cmd/logwisp/status_test.go b/cmd/logwisp/status_test.go new file mode 100644 index 0000000..b4482b4 --- /dev/null +++ b/cmd/logwisp/status_test.go @@ -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) + } + } +} diff --git a/config/logwisp.toml b/config/logwisp.toml index 1b40fc4..ef9d0fe 100644 --- a/config/logwisp.toml +++ b/config/logwisp.toml @@ -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 diff --git a/doc/README.md b/doc/README.md index 3002900..be43d60 100644 --- a/doc/README.md +++ b/doc/README.md @@ -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. diff --git a/doc/architecture.md b/doc/architecture.md index 8d9435d..7032946 100644 --- a/doc/architecture.md +++ b/doc/architecture.md @@ -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 diff --git a/doc/chaining.md b/doc/chaining.md index f3a839f..100d835 100644 --- a/doc/chaining.md +++ b/doc/chaining.md @@ -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":""} +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 `, 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 diff --git a/doc/cli.md b/doc/cli.md index 5897928..f5ad107 100644 --- a/doc/cli.md +++ b/doc/cli.md @@ -2,14 +2,14 @@ ``` logwisp [options] +logwisp auth [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 -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 +``` diff --git a/doc/configuration.md b/doc/configuration.md index 30be787..06546e1 100644 --- a/doc/configuration.md +++ b/doc/configuration.md @@ -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 diff --git a/doc/installation.md b/doc/installation.md index a06e696..e2349b5 100644 --- a/doc/installation.md +++ b/doc/installation.md @@ -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 diff --git a/doc/mtls-auth-plan.md b/doc/mtls-auth-plan.md index 1be8382..fa38a6d 100644 --- a/doc/mtls-auth-plan.md +++ b/doc/mtls-auth-plan.md @@ -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 diff --git a/doc/networking.md b/doc/networking.md index 53656df..1e8abe7 100644 --- a/doc/networking.md +++ b/doc/networking.md @@ -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 `. **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. diff --git a/doc/operations.md b/doc/operations.md index 861bad3..fad235e 100644 --- a/doc/operations.md +++ b/doc/operations.md @@ -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** diff --git a/doc/scram-auth-plan.md b/doc/scram-auth-plan.md index 602d773..bdbcfdb 100644 --- a/doc/scram-auth-plan.md +++ b/doc/scram-auth-plan.md @@ -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 = "" [[users]] -username = "edge-01" -salt = "" -argon_time = 3 argon_memory = 65536 argon_threads = 4 -stored_key = "" +argon_time = 3 +salt = "" server_key = "" +stored_key = "" +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 `: 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 (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=`) and carries no token in the body. It has +no `Path`: RFC 6265 defaults it to the directory of `POST /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: *`. diff --git a/doc/security.md b/doc/security.md index ed2bf96..63ad7f2 100644 --- a/doc/security.md +++ b/doc/security.md @@ -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 -identity = "cn" # cn | san_dns | san_uri | san_email -allow = [] -allow_patterns = [] -node_binding = "force" # chain sources only +type = "none" # none | mtls | scram +identity = "cn" # cn | san_dns | san_uri | san_email +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= 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 = "" + +[[users]] +argon_memory = 65536 +argon_threads = 4 +argon_time = 3 +salt = "" +server_key = "" +stored_key = "" +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. diff --git a/doc/sinks.md b/doc/sinks.md index 9d02a19..80f61ca 100644 --- a/doc/sinks.md +++ b/doc/sinks.md @@ -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 `; 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 `. 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. diff --git a/doc/sources.md b/doc/sources.md index 90f70fe..98df9c3 100644 --- a/doc/sources.md +++ b/doc/sources.md @@ -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. diff --git a/go.mod b/go.mod index 764d057..f4fced9 100644 --- a/go.mod +++ b/go.mod @@ -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 +) diff --git a/go.sum b/go.sum index 71edf4c..10e32c5 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/internal/authz/authz.go b/internal/authz/authz.go index 528a2b3..e9624db 100644 --- a/internal/authz/authz.go +++ b/internal/authz/authz.go @@ -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" @@ -24,8 +28,9 @@ import ( // Authentication methods const ( - MethodNone = "none" - MethodMTLS = "mtls" + 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 - allow map[string]struct{} - patterns []*regexp.Regexp - binding string + role Role + 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,17 +639,28 @@ 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_allowed": p.allowed.Load(), - "auth_rejected": p.rejected.Load(), + "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 diff --git a/internal/authz/authz_test.go b/internal/authz/authz_test.go index ea4e516..01dbfd7 100644 --- a/internal/authz/authz_test.go +++ b/internal/authz/authz_test.go @@ -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 - role Role + name string + auth *config.AuthOptions + 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) } diff --git a/internal/authz/proxy.go b/internal/authz/proxy.go new file mode 100644 index 0000000..a6bd354 --- /dev/null +++ b/internal/authz/proxy.go @@ -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 +} diff --git a/internal/authz/proxy_test.go b/internal/authz/proxy_test.go new file mode 100644 index 0000000..6e85692 --- /dev/null +++ b/internal/authz/proxy_test.go @@ -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) + } +} diff --git a/internal/authz/scram.go b/internal/authz/scram.go new file mode 100644 index 0000000..778b9b5 --- /dev/null +++ b/internal/authz/scram.go @@ -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) + } + } +} diff --git a/internal/authz/scram_test.go b/internal/authz/scram_test.go new file mode 100644 index 0000000..cddf078 --- /dev/null +++ b/internal/authz/scram_test.go @@ -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) + } +} diff --git a/internal/chain/proto.go b/internal/chain/proto.go index e3f26ab..9663650 100644 --- a/internal/chain/proto.go +++ b/internal/chain/proto.go @@ -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"` + LogWisp int `json:"logwisp"` + Node string `json:"node,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 } diff --git a/internal/config/config.go b/internal/config/config.go index e195058..3470cb6 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -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. - Allow []string `toml:"allow"` - - // RE2 patterns matched against the identity; anchor them yourself + // mtls only: exact identities and RE2 patterns (anchor them yourself). + // Both empty admits any identity the CA vouches for. + Allow []string `toml:"allow"` 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"` diff --git a/internal/config/loader.go b/internal/config/loader.go index 3f092a7..c62e951 100644 --- a/internal/config/loader.go +++ b/internal/config/loader.go @@ -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) } diff --git a/internal/config/loader_test.go b/internal/config/loader_test.go index 9645fa4..371b111 100644 --- a/internal/config/loader_test.go +++ b/internal/config/loader_test.go @@ -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) + } + } +} diff --git a/internal/config/validate.go b/internal/config/validate.go index 994a893..260b097 100644 --- a/internal/config/validate.go +++ b/internal/config/validate.go @@ -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{}), "") +} diff --git a/internal/core/capability.go b/internal/core/capability.go index 6f77976..8d3abcb 100644 --- a/internal/core/capability.go +++ b/internal/core/capability.go @@ -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 @@ -17,4 +18,4 @@ const ( // Stream capabilities CapBidirectional Capability = "bidirectional" CapCompression Capability = "compression" -) \ No newline at end of file +) diff --git a/internal/pipeline/pipeline.go b/internal/pipeline/pipeline.go index 7418ae8..2d07f2e 100644 --- a/internal/pipeline/pipeline.go +++ b/internal/pipeline/pipeline.go @@ -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 diff --git a/internal/sink/http/http.go b/internal/sink/http/http.go index d7d057f..b97f85a 100644 --- a/internal/sink/http/http.go +++ b/internal/sink/http/http.go @@ -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 } diff --git a/internal/sink/http/http_test.go b/internal/sink/http/http_test.go index 5c2e5c7..7445906 100644 --- a/internal/sink/http/http_test.go +++ b/internal/sink/http/http_test.go @@ -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) + } +} diff --git a/internal/sink/http/test_helpers_test.go b/internal/sink/http/test_helpers_test.go index b047e07..ec3894c 100644 --- a/internal/sink/http/test_helpers_test.go +++ b/internal/sink/http/test_helpers_test.go @@ -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 +} diff --git a/internal/sink/http/web.go b/internal/sink/http/web.go new file mode 100644 index 0000000..91cf0a7 --- /dev/null +++ b/internal/sink/http/web.go @@ -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 +} diff --git a/internal/sink/http/web/login.html b/internal/sink/http/web/login.html new file mode 100644 index 0000000..83db93b --- /dev/null +++ b/internal/sink/http/web/login.html @@ -0,0 +1,22 @@ + + + + + +LogWisp sign-in + + + + +
+

LogWisp

+
+ + + +
+ +

+
+ + diff --git a/internal/sink/http/web/login.js b/internal/sink/http/web/login.js new file mode 100644 index 0000000..8452aea --- /dev/null +++ b/internal/sink/http/web/login.js @@ -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; diff --git a/internal/sink/http/web/scram.js b/internal/sink/http/web/scram.js new file mode 100644 index 0000000..197d751 --- /dev/null +++ b/internal/sink/http/web/scram.js @@ -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; +} diff --git a/internal/sink/http/web/scram.test.mjs b/internal/sink/http/web/scram.test.mjs new file mode 100644 index 0000000..5b562fe --- /dev/null +++ b/internal/sink/http/web/scram.test.mjs @@ -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=", + }); +}); diff --git a/internal/sink/http/web/style.css b/internal/sink/http/web/style.css new file mode 100644 index 0000000..5d93ea8 --- /dev/null +++ b/internal/sink/http/web/style.css @@ -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; +} diff --git a/internal/sink/http/web/view.html b/internal/sink/http/web/view.html new file mode 100644 index 0000000..22b898f --- /dev/null +++ b/internal/sink/http/web/view.html @@ -0,0 +1,19 @@ + + + + + +LogWisp stream + + + + + +
+

LogWisp

+ connecting… + +
+
+ + diff --git a/internal/sink/http/web/view.js b/internal/sink/http/web/view.js new file mode 100644 index 0000000..8db2c55 --- /dev/null +++ b/internal/sink/http/web/view.js @@ -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(); diff --git a/internal/sink/httpchain/httpchain.go b/internal/sink/httpchain/httpchain.go index ea8568c..2434482 100644 --- a/internal/sink/httpchain/httpchain.go +++ b/internal/sink/httpchain/httpchain.go @@ -55,8 +55,9 @@ type HTTPChainSink struct { session *session.Session config *config.HTTPChainSinkOptions - node string - url string + node string + baseURL string // scheme://host:port, where /auth lives + url string tlsEnabled bool mtls 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: diff --git a/internal/sink/httpchain/httpchain_test.go b/internal/sink/httpchain/httpchain_test.go index 85a9027..cf7c533 100644 --- a/internal/sink/httpchain/httpchain_test.go +++ b/internal/sink/httpchain/httpchain_test.go @@ -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) + } +} diff --git a/internal/sink/tcp/tcp.go b/internal/sink/tcp/tcp.go index dee046b..3d2ae24 100644 --- a/internal/sink/tcp/tcp.go +++ b/internal/sink/tcp/tcp.go @@ -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) diff --git a/internal/sink/tcpchain/tcpchain.go b/internal/sink/tcpchain/tcpchain.go index 1d958e0..1039758 100644 --- a/internal/sink/tcpchain/tcpchain.go +++ b/internal/sink/tcpchain/tcpchain.go @@ -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.logger.Debug("msg", "Chain connect failed", - "component", "tcp_chain_sink", - "target", t.addr, - "attempt", failures, - "error", err) + 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", 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) diff --git a/internal/sink/tcpchain/tcpchain_test.go b/internal/sink/tcpchain/tcpchain_test.go new file mode 100644 index 0000000..2aa9cf0 --- /dev/null +++ b/internal/sink/tcpchain/tcpchain_test.go @@ -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) + } +} diff --git a/internal/source/httpchain/httpchain.go b/internal/source/httpchain/httpchain.go index d2c8679..1fc2994 100644 --- a/internal/source/httpchain/httpchain.go +++ b/internal/source/httpchain/httpchain.go @@ -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. diff --git a/internal/source/tcpchain/tcpchain.go b/internal/source/tcpchain/tcpchain.go index 1d02bf3..bd6ca89 100644 --- a/internal/source/tcpchain/tcpchain.go +++ b/internal/source/tcpchain/tcpchain.go @@ -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 { - 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 { - s.logger.Warn("msg", "Rejected chain connection", - "component", "tcp_chain_source", - "remote_addr", remote, - "error", err) + 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) + } 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) diff --git a/internal/testutil/fixtures.go b/internal/testutil/fixtures.go index fb8339e..591d452 100644 --- a/internal/testutil/fixtures.go +++ b/internal/testutil/fixtures.go @@ -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,32 +72,43 @@ 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) { - key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) - if err != nil { - t.Fatal(err) - } - tmpl := &x509.Certificate{ - SerialNumber: big.NewInt(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, - } - der, err := x509.CreateCertificate(rand.Reader, tmpl, ca, &key.PublicKey, caKey) - if err != nil { - t.Fatal(err) - } - keyDER, err := x509.MarshalPKCS8PrivateKey(key) - 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) + 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(p.serial), Subject: pkix.Name{CommonName: cn}, + NotBefore: now.Add(-time.Hour), NotAfter: now.Add(24 * time.Hour), + KeyUsage: x509.KeyUsageDigitalSignature, ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + IPAddresses: []net.IP{net.IPv4(127, 0, 0, 1)}, + } + 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) + } + keyDER, err := x509.MarshalPKCS8PrivateKey(key) + if err != nil { + t.Fatal(err) + } + 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 { t.Helper() path := filepath.Join(dir, name) diff --git a/internal/tokenbucket/bucket.go b/internal/tokenbucket/bucket.go index 153aa7b..cb28c3d 100644 --- a/internal/tokenbucket/bucket.go +++ b/internal/tokenbucket/bucket.go @@ -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() @@ -84,4 +92,4 @@ func (tb *TokenBucket) Capacity() float64 { tb.mu.Lock() defer tb.mu.Unlock() return tb.capacity -} \ No newline at end of file +} diff --git a/test/scram-chain-test.sh b/test/scram-chain-test.sh new file mode 100755 index 0000000..923f1da --- /dev/null +++ b/test/scram-chain-test.sh @@ -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" < "$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 </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" "$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" diff --git a/test/scram-proxy-test.sh b/test/scram-proxy-test.sh new file mode 100755 index 0000000..b7bdb64 --- /dev/null +++ b/test/scram-proxy-test.sh @@ -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" < "$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 < "$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"