v0.17.0 mtls added to network chain, sinks, and sources
This commit is contained in:
@@ -0,0 +1,318 @@
|
||||
// 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
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
|
||||
"logwisp/internal/config"
|
||||
"logwisp/internal/tlsx"
|
||||
)
|
||||
|
||||
// Authentication methods
|
||||
const (
|
||||
MethodNone = "none"
|
||||
MethodMTLS = "mtls"
|
||||
)
|
||||
|
||||
// Node label binding modes. See ResolveNode and TrustsEntryNode for the
|
||||
// difference between assert and force.
|
||||
const (
|
||||
BindingNone = "none"
|
||||
BindingAssert = "assert"
|
||||
BindingForce = "force"
|
||||
)
|
||||
|
||||
// Role selects the validation and behavior appropriate to the call site
|
||||
type Role int
|
||||
|
||||
const (
|
||||
// RoleListener authorizes client certificates on 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
|
||||
// RoleDialer pins the server's identity beyond hostname verification:
|
||||
// the tcp_chain and http_chain sinks
|
||||
RoleDialer
|
||||
)
|
||||
|
||||
// Policy is the compiled form of config.AuthOptions
|
||||
type Policy struct {
|
||||
role Role
|
||||
identity string
|
||||
allow map[string]struct{}
|
||||
patterns []*regexp.Regexp
|
||||
binding string
|
||||
|
||||
// Statistics
|
||||
allowed atomic.Uint64
|
||||
rejected atomic.Uint64
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// Apply stamps an authenticated identity onto session metadata. A zero
|
||||
// Identity (auth disabled) leaves the map untouched.
|
||||
func (id Identity) Apply(meta map[string]any) {
|
||||
if id.Name == "" {
|
||||
return
|
||||
}
|
||||
meta["auth_method"] = id.Method
|
||||
meta["auth_identity"] = id.Name
|
||||
}
|
||||
|
||||
// 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) {
|
||||
if o == nil {
|
||||
return nil, nil
|
||||
}
|
||||
switch o.Type {
|
||||
case "", MethodNone:
|
||||
return nil, nil
|
||||
case MethodMTLS:
|
||||
default:
|
||||
return nil, fmt.Errorf("auth: type %q (valid: %q, %q)", o.Type, MethodNone, MethodMTLS)
|
||||
}
|
||||
|
||||
if tlsOpts == nil || !tlsOpts.Enabled {
|
||||
return nil, fmt.Errorf("auth: type %q requires tls.enabled", MethodMTLS)
|
||||
}
|
||||
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)
|
||||
} else {
|
||||
binding = BindingNone
|
||||
}
|
||||
switch binding {
|
||||
case BindingNone, BindingAssert, BindingForce:
|
||||
default:
|
||||
return nil, 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)),
|
||||
}
|
||||
for _, a := range o.Allow {
|
||||
if a = strings.TrimSpace(a); a != "" {
|
||||
p.allow[a] = struct{}{}
|
||||
}
|
||||
}
|
||||
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)
|
||||
}
|
||||
p.patterns = append(p.patterns, re)
|
||||
}
|
||||
return p, 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 (p *Policy) Authorize(cs *tls.ConnectionState) (Identity, error) {
|
||||
if p == nil {
|
||||
return Identity{}, nil
|
||||
}
|
||||
if cs == nil {
|
||||
p.rejected.Add(1)
|
||||
return Identity{}, fmt.Errorf("auth: peer is not on a TLS connection")
|
||||
}
|
||||
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)
|
||||
}
|
||||
if !p.permits(name) {
|
||||
p.rejected.Add(1)
|
||||
return Identity{}, fmt.Errorf("auth: identity %q is not allowed", 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.
|
||||
func (p *Policy) VerifyConnection(cs tls.ConnectionState) error {
|
||||
_, err := p.Authorize(&cs)
|
||||
return err
|
||||
}
|
||||
|
||||
// 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.
|
||||
// Identities are not secrets, so ordinary comparison is fine.
|
||||
func (p *Policy) permits(name string) bool {
|
||||
if len(p.allow) == 0 && len(p.patterns) == 0 {
|
||||
return true
|
||||
}
|
||||
if _, ok := p.allow[name]; ok {
|
||||
return true
|
||||
}
|
||||
for _, re := range p.patterns {
|
||||
if re.MatchString(name) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ResolveNode returns the node label for a connection. With no policy, or
|
||||
// node_binding "none", trust_node governs as before: the declared label stands
|
||||
// only when trusted and non-empty, otherwise fallback (the remote address) is
|
||||
// used. Otherwise the label is bound to the authenticated identity.
|
||||
func (p *Policy) ResolveNode(declared, fallback string, trustNode bool, id Identity) (string, error) {
|
||||
if p == nil || p.binding == BindingNone {
|
||||
if declared == "" || !trustNode {
|
||||
return fallback, nil
|
||||
}
|
||||
return declared, nil
|
||||
}
|
||||
if id.Name == "" {
|
||||
return "", fmt.Errorf("auth: node_binding %q requires an authenticated identity", p.binding)
|
||||
}
|
||||
if p.binding == BindingForce {
|
||||
return id.Name, nil
|
||||
}
|
||||
// BindingAssert: a mismatch is loud rather than silently corrected
|
||||
if declared == "" {
|
||||
return "", fmt.Errorf("auth: node_binding %q: peer %q declared no node label", BindingAssert, id.Name)
|
||||
}
|
||||
if declared != id.Name {
|
||||
return "", fmt.Errorf("auth: node_binding %q: declared node %q does not match identity %q",
|
||||
BindingAssert, declared, id.Name)
|
||||
}
|
||||
return declared, nil
|
||||
}
|
||||
|
||||
// TrustsEntryNode reports whether node labels carried by individual entries
|
||||
// survive the policy. force relabels every entry, so an ingest boundary that
|
||||
// does not trust its peer gets exact attribution; assert pins only the
|
||||
// connection's own label, so a relay forwarding other nodes' entries proves
|
||||
// who it is while preserving their origin.
|
||||
func (p *Policy) TrustsEntryNode(trustNode bool) bool {
|
||||
if p == nil {
|
||||
return trustNode
|
||||
}
|
||||
if p.binding == BindingForce {
|
||||
return false
|
||||
}
|
||||
return trustNode
|
||||
}
|
||||
|
||||
// BindsNode reports whether the policy overrides trust_node
|
||||
func (p *Policy) BindsNode() bool {
|
||||
return p != nil && p.binding != BindingNone
|
||||
}
|
||||
|
||||
// NodeBinding returns the effective binding mode
|
||||
func (p *Policy) NodeBinding() string {
|
||||
if p == nil {
|
||||
return BindingNone
|
||||
}
|
||||
return p.binding
|
||||
}
|
||||
|
||||
// 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.
|
||||
func (p *Policy) Unrestricted() bool {
|
||||
return p != nil && len(p.allow) == 0 && len(p.patterns) == 0
|
||||
}
|
||||
|
||||
// Describe renders the policy for a startup log line
|
||||
func (p *Policy) Describe() string {
|
||||
if p == nil {
|
||||
return MethodNone
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
// Rejected returns the number of authorization failures
|
||||
func (p *Policy) Rejected() uint64 {
|
||||
if p == nil {
|
||||
return 0
|
||||
}
|
||||
return p.rejected.Load()
|
||||
}
|
||||
|
||||
// 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.
|
||||
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(),
|
||||
}
|
||||
if p.role == RoleChainListener {
|
||||
d["node_binding"] = p.binding
|
||||
}
|
||||
return d
|
||||
}
|
||||
@@ -0,0 +1,298 @@
|
||||
package authz
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"net/url"
|
||||
"testing"
|
||||
|
||||
"logwisp/internal/config"
|
||||
"logwisp/internal/tlsx"
|
||||
)
|
||||
|
||||
// peerState fakes a completed handshake. Only the leaf's identity fields are
|
||||
// read: the chain is verified by crypto/tls before a policy ever sees it.
|
||||
func peerState(leaf *x509.Certificate) *tls.ConnectionState {
|
||||
return &tls.ConnectionState{PeerCertificates: []*x509.Certificate{leaf}}
|
||||
}
|
||||
|
||||
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 TestPeerIdentityModes(t *testing.T) {
|
||||
uri, err := url.Parse("spiffe://example.org/edge-01")
|
||||
if err != nil {
|
||||
t.Fatalf("parse uri: %v", err)
|
||||
}
|
||||
leaf := &x509.Certificate{
|
||||
Subject: pkix.Name{CommonName: "edge-01"},
|
||||
DNSNames: []string{"edge-01.internal", "alt.internal"},
|
||||
URIs: []*url.URL{uri},
|
||||
EmailAddresses: []string{"ops@example.org"},
|
||||
}
|
||||
cs := peerState(leaf)
|
||||
|
||||
cases := map[string]string{
|
||||
tlsx.IdentityCN: "edge-01",
|
||||
tlsx.IdentitySANDNS: "edge-01.internal",
|
||||
tlsx.IdentitySANURI: "spiffe://example.org/edge-01",
|
||||
tlsx.IdentitySANEmail: "ops@example.org",
|
||||
"": "edge-01", // empty mode defaults to CN
|
||||
"nonsense": "",
|
||||
}
|
||||
for mode, want := range cases {
|
||||
if got := tlsx.PeerIdentity(*cs, mode); got != want {
|
||||
t.Errorf("PeerIdentity(%q) = %q, want %q", mode, got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// A mode the certificate does not carry yields no identity
|
||||
bare := peerState(leafCN("edge-01"))
|
||||
if got := tlsx.PeerIdentity(*bare, tlsx.IdentitySANDNS); got != "" {
|
||||
t.Errorf("PeerIdentity(san_dns) on bare cert = %q, want empty", got)
|
||||
}
|
||||
// No peer certificate at all
|
||||
if got := tlsx.PeerIdentity(tls.ConnectionState{}, tlsx.IdentityCN); got != "" {
|
||||
t.Errorf("PeerIdentity with no peer certs = %q, want empty", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewDisabled(t *testing.T) {
|
||||
for _, o := range []*config.AuthOptions{nil, {}, {Type: MethodNone}} {
|
||||
p, err := New(o, nil, RoleListener)
|
||||
if err != nil {
|
||||
t.Fatalf("New(%+v) error: %v", o, err)
|
||||
}
|
||||
if p != nil {
|
||||
t.Fatalf("New(%+v) = %v, want nil policy", o, p)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A nil policy must behave as if auth were never configured
|
||||
func TestNilPolicyIsTransparent(t *testing.T) {
|
||||
var p *Policy
|
||||
id, err := p.Authorize(nil)
|
||||
if err != nil || id.Name != "" {
|
||||
t.Fatalf("nil Authorize = (%+v, %v), want (zero, nil)", id, err)
|
||||
}
|
||||
if p.Enabled() || p.BindsNode() || p.Unrestricted() {
|
||||
t.Fatal("nil policy reports itself active")
|
||||
}
|
||||
if p.NodeBinding() != BindingNone {
|
||||
t.Fatalf("nil NodeBinding = %q", p.NodeBinding())
|
||||
}
|
||||
if !p.TrustsEntryNode(true) || p.TrustsEntryNode(false) {
|
||||
t.Fatal("nil policy must defer to trust_node")
|
||||
}
|
||||
// trust_node semantics are unchanged without a policy
|
||||
node, err := p.ResolveNode("edge-01", "10.0.0.5", true, Identity{})
|
||||
if err != nil || node != "edge-01" {
|
||||
t.Fatalf("nil ResolveNode(trust) = (%q, %v), want edge-01", node, err)
|
||||
}
|
||||
node, err = p.ResolveNode("edge-01", "10.0.0.5", false, Identity{})
|
||||
if err != nil || node != "10.0.0.5" {
|
||||
t.Fatalf("nil ResolveNode(no trust) = (%q, %v), want 10.0.0.5", node, err)
|
||||
}
|
||||
node, err = p.ResolveNode("", "10.0.0.5", true, Identity{})
|
||||
if err != nil || node != "10.0.0.5" {
|
||||
t.Fatalf("nil ResolveNode(no label) = (%q, %v), want 10.0.0.5", node, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewValidation(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
auth *config.AuthOptions
|
||||
tls *config.TLSOptions
|
||||
role Role
|
||||
}{
|
||||
{"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},
|
||||
}
|
||||
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")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthorizeMatching(t *testing.T) {
|
||||
p, err := New(&config.AuthOptions{
|
||||
Type: MethodMTLS,
|
||||
Allow: []string{"edge-01", " edge-02 "},
|
||||
AllowPatterns: []string{`^relay-\d{2}$`},
|
||||
}, mtlsListenerTLS(), RoleListener)
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
if p.Unrestricted() {
|
||||
t.Fatal("policy with an allow list reports unrestricted")
|
||||
}
|
||||
|
||||
allowed := []string{"edge-01", "edge-02", "relay-07"}
|
||||
for _, cn := range allowed {
|
||||
id, err := p.Authorize(peerState(leafCN(cn)))
|
||||
if err != nil {
|
||||
t.Errorf("Authorize(%q): %v", cn, err)
|
||||
continue
|
||||
}
|
||||
if id.Name != cn || id.Method != MethodMTLS {
|
||||
t.Errorf("Authorize(%q) = %+v", cn, id)
|
||||
}
|
||||
}
|
||||
|
||||
denied := []string{"edge-99", "relay-007", "prefix-relay-07", "", "EDGE-01"}
|
||||
for _, cn := range denied {
|
||||
if _, err := p.Authorize(peerState(leafCN(cn))); err == nil {
|
||||
t.Errorf("Authorize(%q) allowed, want rejection", cn)
|
||||
}
|
||||
}
|
||||
|
||||
if got, want := p.Rejected(), uint64(len(denied)); got != want {
|
||||
t.Errorf("Rejected = %d, want %d", got, want)
|
||||
}
|
||||
stats := p.Stats()
|
||||
if stats["auth_allowed"].(uint64) != uint64(len(allowed)) {
|
||||
t.Errorf("auth_allowed = %v, want %d", stats["auth_allowed"], len(allowed))
|
||||
}
|
||||
if _, ok := stats["node_binding"]; ok {
|
||||
t.Error("plain listener stats report node_binding")
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
if !p.Unrestricted() {
|
||||
t.Fatal("empty allow list should be unrestricted")
|
||||
}
|
||||
id, err := p.Authorize(peerState(leafCN("anyone")))
|
||||
if err != nil || id.Name != "anyone" {
|
||||
t.Fatalf("Authorize = (%+v, %v)", id, err)
|
||||
}
|
||||
if _, err := p.Authorize(peerState(leafCN(""))); err == nil {
|
||||
t.Error("certificate with no CN was authorized")
|
||||
}
|
||||
if _, err := p.Authorize(&tls.ConnectionState{}); err == nil {
|
||||
t.Error("connection with no peer certificate was authorized")
|
||||
}
|
||||
if _, err := p.Authorize(nil); err == nil {
|
||||
t.Error("non-TLS connection was authorized")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveNodeBindings(t *testing.T) {
|
||||
newChain := func(binding string) *Policy {
|
||||
p, err := New(&config.AuthOptions{Type: MethodMTLS, NodeBinding: binding}, mtlsListenerTLS(), RoleChainListener)
|
||||
if err != nil {
|
||||
t.Fatalf("New(%q): %v", binding, err)
|
||||
}
|
||||
return p
|
||||
}
|
||||
id := Identity{Name: "edge-01", Method: MethodMTLS}
|
||||
|
||||
// Default under mtls is force
|
||||
if got := newChain("").NodeBinding(); got != BindingForce {
|
||||
t.Errorf("default node_binding = %q, want %q", got, BindingForce)
|
||||
}
|
||||
|
||||
// force ignores the declared label, however it was spoofed
|
||||
force := newChain(BindingForce)
|
||||
for _, declared := range []string{"edge-99", "", "edge-01"} {
|
||||
node, err := force.ResolveNode(declared, "10.0.0.5", true, id)
|
||||
if err != nil || node != "edge-01" {
|
||||
t.Errorf("force ResolveNode(%q) = (%q, %v), want edge-01", declared, node, err)
|
||||
}
|
||||
}
|
||||
if force.TrustsEntryNode(true) {
|
||||
t.Error("force must not trust per-entry node labels")
|
||||
}
|
||||
|
||||
// assert rejects a mismatch and an omission, and leaves per-entry labels
|
||||
// alone so a relay can forward other nodes' entries
|
||||
assert := newChain(BindingAssert)
|
||||
node, err := assert.ResolveNode("edge-01", "10.0.0.5", true, id)
|
||||
if err != nil || node != "edge-01" {
|
||||
t.Errorf("assert ResolveNode(match) = (%q, %v)", node, err)
|
||||
}
|
||||
if _, err := assert.ResolveNode("edge-99", "10.0.0.5", true, id); err == nil {
|
||||
t.Error("assert accepted a mismatched node label")
|
||||
}
|
||||
if _, err := assert.ResolveNode("", "10.0.0.5", true, id); err == nil {
|
||||
t.Error("assert accepted a missing node label")
|
||||
}
|
||||
if !assert.TrustsEntryNode(true) || assert.TrustsEntryNode(false) {
|
||||
t.Error("assert must leave per-entry node labels to trust_node")
|
||||
}
|
||||
|
||||
// none leaves trust_node governing entirely
|
||||
none := newChain(BindingNone)
|
||||
if none.BindsNode() {
|
||||
t.Error("node_binding none should not bind")
|
||||
}
|
||||
node, err = none.ResolveNode("edge-99", "10.0.0.5", true, id)
|
||||
if err != nil || node != "edge-99" {
|
||||
t.Errorf("none ResolveNode = (%q, %v), want edge-99", node, err)
|
||||
}
|
||||
node, err = none.ResolveNode("edge-99", "10.0.0.5", false, id)
|
||||
if err != nil || node != "10.0.0.5" {
|
||||
t.Errorf("none ResolveNode(no trust) = (%q, %v), want 10.0.0.5", node, err)
|
||||
}
|
||||
|
||||
// Binding without an authenticated identity is a refusal, not a fallback
|
||||
if _, err := force.ResolveNode("edge-01", "10.0.0.5", true, Identity{}); err == nil {
|
||||
t.Error("force resolved a node without an identity")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIdentityApply(t *testing.T) {
|
||||
meta := map[string]any{"type": "tcp_chain"}
|
||||
Identity{}.Apply(meta)
|
||||
if len(meta) != 1 {
|
||||
t.Fatalf("zero identity stamped metadata: %v", meta)
|
||||
}
|
||||
Identity{Name: "edge-01", Method: MethodMTLS}.Apply(meta)
|
||||
if meta["auth_identity"] != "edge-01" || meta["auth_method"] != MethodMTLS {
|
||||
t.Fatalf("metadata = %v", meta)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyConnectionPinsServer(t *testing.T) {
|
||||
p, err := New(&config.AuthOptions{Type: MethodMTLS, Allow: []string{"relay.internal"}},
|
||||
&config.TLSOptions{Enabled: true}, RoleDialer)
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
if err := p.VerifyConnection(*peerState(leafCN("relay.internal"))); err != nil {
|
||||
t.Errorf("pinned server rejected: %v", err)
|
||||
}
|
||||
if err := p.VerifyConnection(*peerState(leafCN("impostor.internal"))); err == nil {
|
||||
t.Error("unpinned server accepted")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user