v0.28.0 option schema in struct tags, schema replacing plugin factory metadata, tui prep

This commit is contained in:
lixen committed 2026-10-09 14:43:44 -04:00
1 parent a1648ce17b
commit e8e5f05e47
43 files changed
+1345 -1211

No files matched your search

+21 -113
View File
@@ -7,6 +7,7 @@ package authz
import (
"bufio"
"cmp"
"context"
"crypto/tls"
"errors"
@@ -42,21 +43,6 @@ const (
BindingForce = "force"
)
// Role selects the validation and behavior appropriate to the call site
type Role int
const (
// RoleListener authenticates peers of a plugin with no node concept: the
// tcp and http sinks
RoleListener Role = iota
// RoleChainListener also binds the node label a peer declares: the
// tcp_chain and http_chain sources
RoleChainListener
// 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
@@ -72,7 +58,7 @@ var ErrRefused = errors.New("auth: refused")
// Policy is the compiled form of config.AuthOptions
type Policy struct {
role Role
side config.Side
transport Transport
method string
identity string // mtls: identity field; scram: certificate-to-user binding, "" = none
@@ -113,38 +99,25 @@ func (id Identity) Apply(meta map[string]any) {
// tlsCfg is what tlsx built from the sibling `tls` block: a policy the
// transport cannot enforce is rejected here rather than silently accepted.
// acl is netacl's from the `acl` block, nil for a dialer.
func New(o *config.AuthOptions, tlsCfg *tls.Config, acl *netacl.Policy, role Role, transport Transport) (*Policy, error) {
if o == nil {
return nil, nil
func New(o *config.AuthOptions, tlsCfg *tls.Config, acl *netacl.Policy, side config.Side, transport Transport) (*Policy, error) {
state := config.TLSState{On: tlsCfg != nil}
if tlsCfg != nil {
state.ClientAuth = tlsCfg.ClientAuth == tls.RequireAndVerifyClientCert
state.Unverified = tlsCfg.InsecureSkipVerify && tlsCfg.VerifyPeerCertificate == nil // a pin verifies there
}
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, MethodSCRAM:
default:
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 && tlsCfg.VerifyPeerCertificate == nil {
// An unverified server makes identities claims and exposes credentials;
// tls.pin_sha256 verifies it in VerifyPeerCertificate instead
return nil, fmt.Errorf("auth: type %q cannot be used with tls.insecure_skip_verify", o.Type)
}
binding, err := nodeBinding(o.NodeBinding, role)
if err != nil {
if err := o.Check(side, transport == HTTP, state); err != nil || o == nil || o.Type == "" || o.Type == MethodNone {
return nil, err
}
p := &Policy{role: role, transport: transport, method: o.Type, binding: binding, acl: acl}
binding := BindingNone
if side == config.ChainListener {
// force is the only binding under which a misconfigured or hostile edge
// cannot mislabel its entries
binding = cmp.Or(o.NodeBinding, BindingForce)
}
p := &Policy{side: side, transport: transport, method: o.Type, binding: binding, acl: acl}
var err error
if o.Type == MethodMTLS {
err = p.compileMTLS(o, tlsCfg)
err = p.compileMTLS(o)
} else {
err = p.compileSCRAM(o, tlsCfg)
}
@@ -154,57 +127,8 @@ func New(o *config.AuthOptions, tlsCfg *tls.Config, acl *netacl.Policy, role Rol
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:
return binding, nil
}
return "", fmt.Errorf("auth: node_binding %q (valid: %q, %q, %q)", binding, BindingNone, BindingAssert, BindingForce)
}
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
}
func (p *Policy) compileMTLS(o *config.AuthOptions) error {
p.identity = cmp.Or(o.Identity, tlsx.IdentityCN)
p.allow = make(map[string]struct{}, len(o.Allow))
for _, a := range o.Allow {
if a = strings.TrimSpace(a); a != "" {
@@ -221,22 +145,6 @@ func (p *Policy) compileMTLS(o *config.AuthOptions, tlsCfg *tls.Config) error {
return nil
}
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.
@@ -566,7 +474,7 @@ func (p *Policy) LogStartup(l *log.Logger, component, id string, trustNode bool)
l.Warn("msg", w, "component", component, "instance_id", id)
}
}
if p.role == RoleDialer {
if p.side == config.Dialer {
return
}
if p.BehindProxy() && p.listener.proxy.exposedHop() {
@@ -687,7 +595,7 @@ func (p *Policy) Stats() map[string]any {
d["auth_identity"] = p.identity
d["auth_unrestricted"] = p.Unrestricted()
}
if p.role == RoleChainListener {
if p.side == config.ChainListener {
d["node_binding"] = p.binding
}
return d
+40 -40
View File
@@ -66,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, nil, RoleListener, TCP)
p, err := New(o, nil, nil, config.Listener, TCP)
if err != nil {
t.Fatalf("New(%+v) error: %v", o, err)
}
@@ -122,41 +122,41 @@ func TestNewValidation(t *testing.T) {
name string
auth *config.AuthOptions
tls *tls.Config
role Role
role config.Side
transport Transport
want string
}{
{"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"},
{"unknown type", &config.AuthOptions{Type: "kerberos"}, mtlsListenerTLS(), config.Listener, TCP, `got "kerberos"`},
{"allow list without a type", &config.AuthOptions{Allow: []string{"edge-01"}}, mtlsListenerTLS(), config.Listener, TCP, "allow is set"},
{"credentials without a type", &config.AuthOptions{CredentialsFile: f.creds}, mtlsListenerTLS(), config.Listener, TCP, "credentials_file is set"},
{"no tls", &config.AuthOptions{Type: MethodMTLS}, nil, config.Listener, TCP, "requires tls.enabled"},
{"no client_auth", &config.AuthOptions{Type: MethodMTLS}, &tls.Config{}, config.Listener, TCP, "requires tls.client_auth"},
{"unknown identity", &config.AuthOptions{Type: MethodMTLS, Identity: "serial"}, mtlsListenerTLS(), config.Listener, TCP, `got "serial"`},
{"bad pattern", &config.AuthOptions{Type: MethodMTLS, AllowPatterns: []string{"^edge-("}}, mtlsListenerTLS(), config.Listener, TCP, "allow_patterns[0]"},
{"unknown binding", &config.AuthOptions{Type: MethodMTLS, NodeBinding: "maybe"}, mtlsListenerTLS(), config.ChainListener, TCP, `got "maybe"`},
{"binding on plain listener", &config.AuthOptions{Type: MethodMTLS, NodeBinding: BindingForce}, mtlsListenerTLS(), config.Listener, TCP, "only to chain sources"},
{"binding on dialer", &config.AuthOptions{Type: MethodMTLS, NodeBinding: BindingForce}, &tls.Config{}, config.Dialer, TCP, "only to chain sources"},
{"dialer skips verify", &config.AuthOptions{Type: MethodMTLS}, &tls.Config{InsecureSkipVerify: true}, config.Dialer, TCP, "insecure_skip_verify"},
{"mtls with a password", &config.AuthOptions{Type: MethodMTLS, PasswordFile: pw}, &tls.Config{}, config.Dialer, TCP, "password_file applies only"},
{"scram without tls", listener(config.AuthOptions{}), nil, config.Listener, TCP, "requires tls.enabled"},
{"scram without credentials", scram(config.AuthOptions{}), f.serverTLS, config.Listener, TCP, "requires credentials_file"},
{"scram with an allow list", listener(config.AuthOptions{Allow: []string{"edge-01"}}), f.serverTLS, config.Listener, TCP, "credentials file is the allow list"},
{"scram listener with a password", listener(config.AuthOptions{Username: "edge-01"}), f.serverTLS, config.Listener, TCP, "only to dialers"},
{"scram certificate binding without client_auth", listener(config.AuthOptions{Identity: "cn"}), f.serverTLS, config.Listener, TCP, "requires tls.client_auth"},
{"token lifetime on tcp", listener(config.AuthOptions{TokenLifetimeMS: 60000}), f.serverTLS, config.Listener, TCP, "only to HTTP listeners"},
{"token lifetime too short", listener(config.AuthOptions{TokenLifetimeMS: 900}), f.serverTLS, config.Listener, HTTP, "must be from 10000 to 86400000"},
{"token lifetime too long", listener(config.AuthOptions{TokenLifetimeMS: 1e13}), f.serverTLS, config.Listener, HTTP, "must be from 10000 to 86400000"},
{"scram listener without a certificate", listener(config.AuthOptions{}), &tls.Config{}, config.Listener, TCP, "listener certificate"},
{"scram dialer without a password", scram(config.AuthOptions{Username: "edge-01"}), &tls.Config{}, config.Dialer, TCP, "requires username and password_file"},
{"scram dialer with credentials", dialer(config.AuthOptions{CredentialsFile: f.creds}), &tls.Config{}, config.Dialer, TCP, "only to listeners"},
{"scram dialer skips verify", dialer(config.AuthOptions{}), &tls.Config{InsecureSkipVerify: true}, config.Dialer, TCP, "insecure_skip_verify"},
{"trusted proxies without a type", &config.AuthOptions{TrustedProxies: proxies}, nil, config.Listener, HTTP, "trusted_proxies is set"},
{"trusted proxies under mtls", &config.AuthOptions{Type: MethodMTLS, TrustedProxies: proxies}, mtlsListenerTLS(), config.Listener, HTTP, "trusted_proxies applies only"},
{"trusted proxies on a chain source", listener(config.AuthOptions{TrustedProxies: proxies}), nil, config.ChainListener, HTTP, "only to the http sink"},
{"trusted proxies on tcp", listener(config.AuthOptions{TrustedProxies: proxies}), nil, config.Listener, TCP, "only to the http sink"},
{"trusted proxies with certificate binding", listener(config.AuthOptions{TrustedProxies: proxies, Identity: "cn"}), f.serverTLS, config.Listener, HTTP, "TLS-terminating proxy"},
{"malformed trusted proxy", listener(config.AuthOptions{TrustedProxies: []string{"localhost"}}), nil, config.Listener, HTTP, "neither an address"},
{"trusted proxies on a dialer", dialer(config.AuthOptions{TrustedProxies: proxies}), &tls.Config{}, config.Dialer, HTTP, "only to listeners"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
@@ -171,16 +171,16 @@ func TestNewValidation(t *testing.T) {
pinned := &tls.Config{InsecureSkipVerify: true, VerifyPeerCertificate: func([][]byte, [][]*x509.Certificate) error { return nil }}
for _, o := range []*config.AuthOptions{{Type: MethodMTLS}, dialer(config.AuthOptions{})} {
for _, c := range []*tls.Config{{}, pinned} {
if _, err := New(o, c, nil, RoleDialer, TCP); err != nil {
if _, err := New(o, c, nil, config.Dialer, TCP); err != nil {
t.Fatalf("dialer policy %s rejected: %v", o.Type, err)
}
}
}
if _, err := New(listener(config.AuthOptions{TokenLifetimeMS: 60000}), f.serverTLS, nil, RoleListener, HTTP); err != nil {
if _, err := New(listener(config.AuthOptions{TokenLifetimeMS: 60000}), f.serverTLS, nil, config.Listener, 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, nil, RoleListener, HTTP); err != nil {
if _, err := New(listener(config.AuthOptions{TrustedProxies: proxies}), nil, nil, config.Listener, HTTP); err != nil {
t.Fatalf("proxy-mode listener rejected: %v", err)
}
}
@@ -190,7 +190,7 @@ func TestAuthorizeMatching(t *testing.T) {
Type: MethodMTLS,
Allow: []string{"edge-01", " edge-02 "},
AllowPatterns: []string{`^relay-\d{2}$`},
}, mtlsListenerTLS(), nil, RoleListener, TCP)
}, mtlsListenerTLS(), nil, config.Listener, TCP)
if err != nil {
t.Fatalf("New: %v", err)
}
@@ -232,7 +232,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(), nil, RoleListener, TCP)
p, err := New(&config.AuthOptions{Type: MethodMTLS}, mtlsListenerTLS(), nil, config.Listener, TCP)
if err != nil {
t.Fatalf("New: %v", err)
}
@@ -256,7 +256,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(), nil, RoleChainListener, TCP)
p, err := New(&config.AuthOptions{Type: MethodMTLS, NodeBinding: binding}, mtlsListenerTLS(), nil, config.ChainListener, TCP)
if err != nil {
t.Fatalf("New(%q): %v", binding, err)
}
@@ -332,7 +332,7 @@ func TestIdentityApply(t *testing.T) {
func TestVerifyConnectionPinsServer(t *testing.T) {
p, err := New(&config.AuthOptions{Type: MethodMTLS, Allow: []string{"relay.internal"}},
&tls.Config{}, nil, RoleDialer, TCP)
&tls.Config{}, nil, config.Dialer, TCP)
if err != nil {
t.Fatalf("New: %v", err)
}
+2 -1
View File
@@ -11,6 +11,7 @@ import (
"sync"
"time"
"github.com/lixenwraith/logwisp/internal/config"
"github.com/lixenwraith/logwisp/internal/netacl"
"github.com/lixenwraith/auth"
@@ -34,7 +35,7 @@ type proxyMode struct {
func parseProxies(entries []string) (*proxyMode, error) {
m := &proxyMode{}
for _, e := range entries {
prefix, err := netacl.Parse(e, "tcp")
prefix, err := config.ParsePrefix(e, "tcp")
if err != nil {
return nil, fmt.Errorf("auth: trusted_proxies entry %q: %w", e, err)
}
+5 -5
View File
@@ -30,7 +30,7 @@ func (f forwarding) RoundTrip(r *http.Request) (*http.Response, error) {
// 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)
l := f.listener(t, config.AuthOptions{TrustedProxies: []string{"127.0.0.1"}}, nil, config.Listener, 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) {
@@ -49,7 +49,7 @@ func (f *fixture) proxyListener(t *testing.T) (*Policy, *httptest.Server) {
// 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)
l := f.listener(t, config.AuthOptions{TrustedProxies: []string{"127.0.0.1", "10.0.0.0/8"}}, nil, config.Listener, 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", "", ""},
@@ -91,7 +91,7 @@ func TestForwardedClientIsRefusedByTheRules(t *testing.T) {
t.Fatal(err)
}
l, err := New(&config.AuthOptions{Type: MethodSCRAM, CredentialsFile: f.creds, TrustedProxies: []string{"127.0.0.1"}},
nil, acl, RoleListener, HTTP)
nil, acl, config.Listener, HTTP)
if err != nil {
t.Fatal(err)
}
@@ -195,7 +195,7 @@ func TestBrowserSessionBehindProxy(t *testing.T) {
t.Fatalf("logins = %d", l.allowed.Load())
}
direct := f.listener(t, config.AuthOptions{}, f.serverTLS, RoleListener, HTTP)
direct := f.listener(t, config.AuthOptions{}, f.serverTLS, config.Listener, 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"}
@@ -210,7 +210,7 @@ func TestBrowserSessionBehindProxy(t *testing.T) {
// only unbound proofs (lw 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)
l := f.listener(t, config.AuthOptions{TrustedProxies: []string{"127.0.0.1"}}, f.serverTLS, config.Listener, HTTP)
srv, _ := f.httpListener(t, l, f.serverTLS)
for _, unbound := range []bool{false, true} {
d := f.dialer(t, "edge-01", "edge-01-secret")
+4 -42
View File
@@ -34,13 +34,8 @@ const (
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
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
@@ -200,36 +195,11 @@ type scramListener struct {
}
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 {
if p.side == config.Dialer {
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
}
p.identity = o.Identity // binds the certificate to the user
}
l := &scramListener{limit: netacl.NewTable(limitBurst, limitRate, limitPending)}
var err error
@@ -573,14 +543,6 @@ type scramDialer struct {
}
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
+24 -24
View File
@@ -99,7 +99,7 @@ func (f *fixture) pool(t *testing.T) *x509.CertPool {
return pool
}
func (f *fixture) listener(t *testing.T, o config.AuthOptions, tlsCfg *tls.Config, role Role, transport Transport) *Policy {
func (f *fixture) listener(t *testing.T, o config.AuthOptions, tlsCfg *tls.Config, role config.Side, transport Transport) *Policy {
t.Helper()
o.Type, o.CredentialsFile = MethodSCRAM, f.creds
p, err := New(&o, tlsCfg, nil, role, transport)
@@ -116,7 +116,7 @@ func (f *fixture) listener(t *testing.T, o config.AuthOptions, tlsCfg *tls.Confi
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, nil, RoleDialer, TCP)
p, err := New(&config.AuthOptions{Type: MethodSCRAM, Username: user, PasswordFile: pw}, f.clientTLS, nil, config.Dialer, TCP)
if err != nil {
t.Fatal(err)
}
@@ -167,7 +167,7 @@ func (f *fixture) connect(t *testing.T, listener *Policy, serverTLS *tls.Config,
// 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)
l := f.listener(t, config.AuthOptions{}, f.serverTLS, config.ChainListener, 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 {
@@ -190,7 +190,7 @@ func TestTCPExchangeAdmitsAndKeepsTheStream(t *testing.T) {
// probe cannot tell which accounts exist.
func TestTCPExchangeRefusesWrongPasswordAndUnknownUserAlike(t *testing.T) {
f := newFixture(t)
l := f.listener(t, config.AuthOptions{}, f.serverTLS, RoleListener, TCP)
l := f.listener(t, config.AuthOptions{}, f.serverTLS, config.Listener, 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)
@@ -211,7 +211,7 @@ 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
l := f.listener(t, config.AuthOptions{}, other, config.Listener, 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)
@@ -225,7 +225,7 @@ func TestTCPExchangeRefusesAnotherCertificate(t *testing.T) {
// 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)
l := f.listener(t, config.AuthOptions{}, f.serverTLS, config.ChainListener, 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)
}
@@ -264,7 +264,7 @@ func TestGreetEndsWithContext(t *testing.T) {
// 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)
l := f.listener(t, config.AuthOptions{}, f.serverTLS, config.Listener, TCP)
sc, cc := net.Pipe()
server, client := tls.Server(sc, f.serverTLS), tls.Client(cc, f.clientTLS)
t.Cleanup(func() { sc.Close(); cc.Close() })
@@ -307,7 +307,7 @@ func TestAbandonedExchangeReleasesItsSlot(t *testing.T) {
// 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)
l := f.listener(t, config.AuthOptions{}, f.serverTLS, config.ChainListener, TCP)
_, _, err := f.connect(t, l, f.serverTLS, f.dialer(t, "edge-01", "edge-01-secret"), func(a *Admission) {
a.Reject("node label rejected")
})
@@ -323,7 +323,7 @@ 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)
l := f.listener(t, config.AuthOptions{Identity: "cn"}, mtls, config.Listener, 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)
}
@@ -331,7 +331,7 @@ func TestCertificateBindsToUser(t *testing.T) {
t.Fatalf("edge-01's certificate admitted user edge-02: %v", res.err)
}
h := f.listener(t, config.AuthOptions{Identity: "cn"}, mtls, RoleListener, HTTP)
h := f.listener(t, config.AuthOptions{Identity: "cn"}, mtls, config.Listener, 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)
@@ -354,7 +354,7 @@ func TestUnknownUserSaltSurvivesRestart(t *testing.T) {
f := newFixture(t)
var salts []string
for range 2 {
l := f.listener(t, config.AuthOptions{}, f.serverTLS, RoleListener, TCP)
l := f.listener(t, config.AuthOptions{}, f.serverTLS, config.Listener, TCP)
c, err := l.listener.server.Load().ProcessClientFirstMessage("nobody", strings.Repeat("n", 32))
if err != nil {
t.Fatal(err)
@@ -370,7 +370,7 @@ func TestUnknownUserSaltSurvivesRestart(t *testing.T) {
// refused before it is parsed.
func TestPreAuthInputIsCapped(t *testing.T) {
f := newFixture(t)
l := f.listener(t, config.AuthOptions{}, f.serverTLS, RoleListener, TCP)
l := f.listener(t, config.AuthOptions{}, f.serverTLS, config.Listener, TCP)
sc, cc := net.Pipe()
server, client := tls.Server(sc, f.serverTLS), tls.Client(cc, f.clientTLS)
t.Cleanup(func() { sc.Close(); cc.Close() })
@@ -389,7 +389,7 @@ func TestPreAuthInputIsCapped(t *testing.T) {
t.Fatalf("oversized hello: %v", err)
}
h := f.listener(t, config.AuthOptions{}, f.serverTLS, RoleListener, HTTP)
h := f.listener(t, config.AuthOptions{}, f.serverTLS, config.Listener, 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)))
@@ -406,7 +406,7 @@ func TestPreAuthInputIsCapped(t *testing.T) {
// 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)
l := f.listener(t, config.AuthOptions{}, f.serverTLS, config.Listener, TCP)
d := f.dialer(t, "edge-01", "edge-01-secret")
minArgonTime, minArgonMemory = auth.DefaultArgonTime, auth.DefaultArgonMemory
t.Cleanup(func() { minArgonTime, minArgonMemory = 1, 64 })
@@ -422,7 +422,7 @@ func TestDialerRefusesACheapChallenge(t *testing.T) {
// 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)
l := f.listener(t, config.AuthOptions{}, f.serverTLS, config.Listener, 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 {
@@ -481,7 +481,7 @@ func get(t *testing.T, client *http.Client, url string, prepare func(*http.Reque
// 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)
l := f.listener(t, config.AuthOptions{}, f.serverTLS, config.Listener, HTTP)
srv, _ := f.httpListener(t, l, f.serverTLS)
d := f.dialer(t, "edge-01", "edge-01-secret")
client := f.httpClient(d)
@@ -494,7 +494,7 @@ func TestHTTPTokenFlow(t *testing.T) {
if resp.StatusCode != http.StatusOK {
t.Fatalf("with a token: %d", resp.StatusCode)
}
other := f.listener(t, config.AuthOptions{}, f.serverTLS, RoleListener, HTTP)
other := f.listener(t, config.AuthOptions{}, f.serverTLS, config.Listener, 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 {
@@ -520,7 +520,7 @@ func TestHTTPTokenFlow(t *testing.T) {
// 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)
l := f.listener(t, config.AuthOptions{}, f.serverTLS, config.Listener, HTTP)
mux := http.NewServeMux()
mux.HandleFunc("POST "+chain.AuthPath, func(w http.ResponseWriter, r *http.Request) {
body := new(bytes.Buffer)
@@ -550,7 +550,7 @@ func TestForgedFinalYieldsNoToken(t *testing.T) {
// (through a proxy) included.
func TestPinnedCertificateAcrossConnections(t *testing.T) {
f := newFixture(t)
l := f.listener(t, config.AuthOptions{}, f.serverTLS, RoleListener, HTTP)
l := f.listener(t, config.AuthOptions{}, f.serverTLS, config.Listener, HTTP)
a, _ := f.httpListener(t, l, f.serverTLS)
otherTLS := f.serverTLS.Clone()
otherTLS.Certificates = []tls.Certificate{f.serverLeaf(t, "relay-b")}
@@ -589,7 +589,7 @@ func TestPinnedCertificateAcrossConnections(t *testing.T) {
// 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)
l := f.listener(t, config.AuthOptions{}, f.serverTLS, config.Listener, HTTP)
srv, _ := f.httpListener(t, l, f.serverTLS)
client := &http.Client{Transport: &http.Transport{TLSClientConfig: f.clientTLS}}
hello := func(user string) int {
@@ -660,7 +660,7 @@ func TestCredentialsFileValidation(t *testing.T) {
// exchange admits nobody rather than everybody.
func TestScramListenerAuthorizeFailsClosed(t *testing.T) {
f := newFixture(t)
l := f.listener(t, config.AuthOptions{}, f.serverTLS, RoleListener, HTTP)
l := f.listener(t, config.AuthOptions{}, f.serverTLS, config.Listener, HTTP)
if _, err := l.Authorize(&tls.ConnectionState{}); !errors.Is(err, ErrRefused) {
t.Fatalf("Authorize under scram = %v, want a refusal", err)
}
@@ -670,7 +670,7 @@ func TestScramListenerAuthorizeFailsClosed(t *testing.T) {
// 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)
l := f.listener(t, config.AuthOptions{TokenLifetimeMS: 10000}, f.serverTLS, config.Listener, HTTP)
srv, _ := f.httpListener(t, l, f.serverTLS)
d := f.dialer(t, "edge-01", "edge-01-secret")
client := f.httpClient(d)
@@ -686,7 +686,7 @@ func TestTokenIsRenewedBeforeExpiry(t *testing.T) {
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 {
if ahead <= 0 || ahead > 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
@@ -701,7 +701,7 @@ func TestTokenIsRenewedBeforeExpiry(t *testing.T) {
// any other refusal, or the token's expiry, ends it.
func TestPutOffRenewalKeepsTheValidToken(t *testing.T) {
f := newFixture(t)
l := f.listener(t, config.AuthOptions{TokenLifetimeMS: MinTokenLifetime.Milliseconds()}, f.serverTLS, RoleListener, HTTP)
l := f.listener(t, config.AuthOptions{TokenLifetimeMS: 10000}, f.serverTLS, config.Listener, HTTP)
srv, _ := f.httpListener(t, l, f.serverTLS)
// An impostor with another certificate of the same CA
var answer, stolen atomic.Int64