Add identity: whois resolver, checker, header mode, middleware; config for wake, peers, identity
Implemented-By: OpenCode session (model recorded in docs/implementer-log.md)
This commit is contained in:
@@ -68,12 +68,15 @@ type Host struct {
|
||||
BaseURL string `toml:"base_url"`
|
||||
Weight float64 `toml:"weight"`
|
||||
Models map[string]Model `toml:"models"`
|
||||
Wake *Wake `toml:"wake"`
|
||||
}
|
||||
|
||||
// Route is an ordered list of hosts to try, with an optional default model.
|
||||
// Route is an ordered list of hosts to try, with an optional default model and
|
||||
// the peers allowed to reach it.
|
||||
type Route struct {
|
||||
Hosts []string `toml:"hosts"`
|
||||
DefaultModel string `toml:"default_model"`
|
||||
Peers []string `toml:"peers"`
|
||||
}
|
||||
|
||||
// Config is the whole file: what to listen on, tuning, hosts and routes.
|
||||
@@ -84,6 +87,7 @@ type Config struct {
|
||||
DB string `toml:"db"`
|
||||
LeaseIdle Duration `toml:"lease_idle"`
|
||||
Retention Duration `toml:"retention"`
|
||||
Identity string `toml:"identity"`
|
||||
Hosts map[string]Host `toml:"hosts"`
|
||||
Routes map[string]Route `toml:"routes"`
|
||||
}
|
||||
@@ -109,6 +113,8 @@ const (
|
||||
|
||||
MinLeaseIdle = time.Minute
|
||||
MinRetention = 24 * time.Hour
|
||||
|
||||
DefaultIdentity = "off"
|
||||
)
|
||||
|
||||
var routeName = regexp.MustCompile(`^[a-z0-9][a-z0-9-]*$`)
|
||||
@@ -157,7 +163,10 @@ func Parse(r io.Reader) (*Config, error) {
|
||||
if c.QueueMax == 0 {
|
||||
c.QueueMax = DefaultQueueMax
|
||||
}
|
||||
if e := c.validate(); e != nil {
|
||||
if c.Identity == "" {
|
||||
c.Identity = DefaultIdentity
|
||||
}
|
||||
if e := c.validate(md); e != nil {
|
||||
return nil, e
|
||||
}
|
||||
return &c, nil
|
||||
@@ -184,7 +193,14 @@ func IsError(err error) (*Error, bool) {
|
||||
|
||||
// validate checks the config in a fixed order and writes defaults back into c.
|
||||
// The first problem wins; every problem is an *Error with a precise field.
|
||||
func (c *Config) validate() *Error {
|
||||
func (c *Config) validate(md toml.MetaData) *Error {
|
||||
peersDefined := make(map[string]bool, len(c.Routes))
|
||||
for name := range c.Routes {
|
||||
if md.IsDefined("routes", name, "peers") {
|
||||
peersDefined[name] = true
|
||||
}
|
||||
}
|
||||
identityDefined := md.IsDefined("identity")
|
||||
if e := c.checkListen(); e != nil {
|
||||
return e
|
||||
}
|
||||
@@ -206,7 +222,13 @@ func (c *Config) validate() *Error {
|
||||
if e := c.checkHosts(); e != nil {
|
||||
return e
|
||||
}
|
||||
return c.checkRoutes()
|
||||
if e := c.checkWake(); e != nil {
|
||||
return e
|
||||
}
|
||||
if e := c.checkRoutes(peersDefined, identityDefined); e != nil {
|
||||
return e
|
||||
}
|
||||
return c.checkIdentity()
|
||||
}
|
||||
|
||||
func (c *Config) checkListen() *Error {
|
||||
@@ -324,7 +346,7 @@ func (c *Config) checkHosts() *Error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Config) checkRoutes() *Error {
|
||||
func (c *Config) checkRoutes(peersDefined map[string]bool, identityDefined bool) *Error {
|
||||
if len(c.Routes) == 0 {
|
||||
return &Error{Field: "routes", Msg: "at least one required"}
|
||||
}
|
||||
@@ -367,6 +389,10 @@ func (c *Config) checkRoutes() *Error {
|
||||
return &Error{Field: fmt.Sprintf("routes.%s.default_model", name), Msg: "not served by any host in route"}
|
||||
}
|
||||
}
|
||||
|
||||
if e := checkPeers(name, r.Peers, peersDefined[name], identityDefined, c.Identity); e != nil {
|
||||
return e
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
package config_test
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.wntrmute.dev/kyle/crossbar/internal/config"
|
||||
)
|
||||
|
||||
const v2Base = `
|
||||
listen = "127.0.0.1:1"
|
||||
[hosts.a]
|
||||
base_url = "http://a:1"
|
||||
models = { "m" = { } }
|
||||
[hosts.b]
|
||||
base_url = "http://b:1"
|
||||
models = { "m" = { } }
|
||||
[hosts.b.wake]
|
||||
mac = "aa:bb:cc:dd:ee:ff"
|
||||
broadcast = "192.168.1.255:9"
|
||||
wait = "45s"
|
||||
[routes.r]
|
||||
hosts = ["a", "b"]
|
||||
peers = ["talos", "imladris"]
|
||||
`
|
||||
|
||||
func TestV2Defaults(t *testing.T) {
|
||||
c, err := config.Parse(strings.NewReader(v2Base))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if c.Identity != "off" {
|
||||
t.Errorf("identity default = %q, want off", c.Identity)
|
||||
}
|
||||
if c.Hosts["a"].Wake != nil {
|
||||
t.Errorf("host without [wake] must have nil Wake")
|
||||
}
|
||||
w := c.Hosts["b"].Wake
|
||||
if w == nil || w.MAC != "aa:bb:cc:dd:ee:ff" || w.Broadcast != "192.168.1.255:9" || w.Wait.Duration != 45*time.Second {
|
||||
t.Errorf("wake = %+v", w)
|
||||
}
|
||||
if p := c.Routes["r"].Peers; len(p) != 2 || p[0] != "talos" {
|
||||
t.Errorf("peers = %v", p)
|
||||
}
|
||||
}
|
||||
|
||||
func TestV2Validation(t *testing.T) {
|
||||
good := v2Base
|
||||
for _, tc := range []struct{ name, text, field string }{
|
||||
{"bad identity", "identity = \"maybe\"\n" + good, "identity"},
|
||||
{"peers without identity", "identity = \"off\"\n" + good, "routes.r.peers"},
|
||||
{"bad mac", strings.Replace(good, `mac = "aa:bb:cc:dd:ee:ff"`, `mac = "nope"`, 1), "hosts.b.wake.mac"},
|
||||
{"no broadcast", strings.Replace(good, `broadcast = "192.168.1.255:9"`, `broadcast = ""`, 1), "hosts.b.wake.broadcast"},
|
||||
{"wait too short", strings.Replace(good, `wait = "45s"`, `wait = "2s"`, 1), "hosts.b.wake.wait"},
|
||||
{"peers on unknown route field", "identity = \"tailscale\"\n" + strings.Replace(good, `peers = ["talos", "imladris"]`, `peers = []`, 1), "routes.r.peers"},
|
||||
} {
|
||||
_, err := config.Parse(strings.NewReader(tc.text))
|
||||
e, ok := config.IsError(err)
|
||||
if !ok || e.Field != tc.field {
|
||||
t.Errorf("%s: %v, want *Error on %s", tc.name, err, tc.field)
|
||||
}
|
||||
}
|
||||
// identity = "header" is the test/smoke mode; "tailscale" the real one; both accept peers.
|
||||
for _, mode := range []string{"header", "tailscale"} {
|
||||
if _, err := config.Parse(strings.NewReader("identity = \"" + mode + "\"\n" + good)); err != nil {
|
||||
t.Errorf("identity=%s with peers: %v", mode, err)
|
||||
}
|
||||
}
|
||||
// wait defaults to 45s when the [wake] table omits it
|
||||
c, err := config.Parse(strings.NewReader("identity = \"header\"\n" + strings.Replace(good, "wait = \"45s\"\n", "", 1)))
|
||||
if err != nil || c.Hosts["b"].Wake == nil || c.Hosts["b"].Wake.Wait.Duration != 45*time.Second {
|
||||
t.Errorf("wake.wait default: %v %+v", err, c.Hosts["b"].Wake)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Wake is the magic-wake pattern sent to a host to rouse it: its MAC, the
|
||||
// broadcast address to aim at, and how long to wait for the answer.
|
||||
type Wake struct {
|
||||
MAC string `toml:"mac"`
|
||||
Broadcast string `toml:"broadcast"`
|
||||
Wait Duration `toml:"wait"`
|
||||
}
|
||||
|
||||
const (
|
||||
DefaultWakeWait = 45 * time.Second
|
||||
MinWakeWait = 5 * time.Second
|
||||
)
|
||||
|
||||
// identityMode reports whether s is a recognized identity backend.
|
||||
func identityMode(s string) bool {
|
||||
return s == "off" || s == "tailscale" || s == "header"
|
||||
}
|
||||
|
||||
// checkWake validates and defaults the magic-wake pattern of each host that has
|
||||
// one.
|
||||
func (c *Config) checkWake() *Error {
|
||||
for name := range c.Hosts {
|
||||
h := c.Hosts[name]
|
||||
w := h.Wake
|
||||
if w == nil {
|
||||
continue
|
||||
}
|
||||
wakeField := fmt.Sprintf("hosts.%s.wake", name)
|
||||
|
||||
mac, err := net.ParseMAC(w.MAC)
|
||||
if err != nil || len(mac) != 6 {
|
||||
return &Error{Field: wakeField + ".mac", Msg: "must be a MAC address"}
|
||||
}
|
||||
|
||||
if _, _, err := net.SplitHostPort(w.Broadcast); err != nil || w.Broadcast == "" {
|
||||
return &Error{Field: wakeField + ".broadcast", Msg: "must be a non-empty host:port"}
|
||||
}
|
||||
|
||||
if w.Wait.Duration == 0 {
|
||||
w.Wait.Duration = DefaultWakeWait
|
||||
} else if w.Wait.Duration < MinWakeWait {
|
||||
return &Error{Field: wakeField + ".wait", Msg: "must be at least 5s"}
|
||||
}
|
||||
h.Wake = w
|
||||
c.Hosts[name] = h
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// checkIdentity rejects an unrecognized identity backend.
|
||||
func (c *Config) checkIdentity() *Error {
|
||||
if !identityMode(c.Identity) {
|
||||
return &Error{Field: "identity", Msg: `must be "off", "tailscale", or "header"`}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// checkPeers enforces the peers/identity contract for one route: peers may only
|
||||
// be set with an identity backend on, and may not be an empty list.
|
||||
func checkPeers(name string, peers []string, peersDefined, identityDefined bool, identity string) *Error {
|
||||
peersField := fmt.Sprintf("routes.%s.peers", name)
|
||||
switch {
|
||||
case len(peers) > 0 && identityDefined && identity == "off":
|
||||
return &Error{Field: peersField, Msg: "peers need identity = tailscale or header"}
|
||||
case len(peers) == 0 && peersDefined && identityDefined && identity != "off":
|
||||
return &Error{Field: peersField, Msg: "empty peers list"}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
// Package identity resolves a caller's address to a tailnet node name, and gates a
|
||||
// route on the set of peers it allows. In production the resolver asks
|
||||
// `tailscale whois`; in the smoke run it trusts a request header. A Checker caches
|
||||
// the answer per address so a hot peer does not re-query whois on every request.
|
||||
package identity
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
var (
|
||||
// ErrNotAPeer is returned by a resolver when the address is not a known
|
||||
// tailnet node. The Checker turns it into a deny.
|
||||
ErrNotAPeer = errors.New("identity: not a tailnet peer")
|
||||
// ErrForbidden is returned by the Checker when the caller is not on the
|
||||
// route's allow list.
|
||||
ErrForbidden = errors.New("identity: forbidden route")
|
||||
)
|
||||
|
||||
// ID is the tailnet identity of a caller.
|
||||
type ID struct {
|
||||
Node, Login string
|
||||
}
|
||||
|
||||
// Resolver maps an IP address to the tailnet node it belongs to.
|
||||
type Resolver interface {
|
||||
Identity(ctx context.Context, ip string) (ID, error)
|
||||
}
|
||||
|
||||
// cacheTTL is how long a resolved identity (or a rejection) is held per address.
|
||||
const cacheTTL = 5 * time.Minute
|
||||
|
||||
// ParseWhois decodes `tailscale whois --json` output. Node is ComputedName, or
|
||||
// Name with its trailing dot and domain stripped; Login is the profile login
|
||||
// name. An empty node name is an error.
|
||||
func ParseWhois(raw []byte) (ID, error) {
|
||||
var whois struct {
|
||||
Node struct {
|
||||
Name string `json:"Name"`
|
||||
ComputedName string `json:"ComputedName"`
|
||||
} `json:"Node"`
|
||||
UserProfile struct {
|
||||
LoginName string `json:"LoginName"`
|
||||
} `json:"UserProfile"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &whois); err != nil {
|
||||
return ID{}, err
|
||||
}
|
||||
node := whois.Node.ComputedName
|
||||
if node == "" {
|
||||
node = stripName(whois.Node.Name)
|
||||
}
|
||||
if node == "" {
|
||||
return ID{}, errors.New("identity: whois has no node name")
|
||||
}
|
||||
return ID{Node: node, Login: whois.UserProfile.LoginName}, nil
|
||||
}
|
||||
|
||||
// stripName takes a whois Name such as "titan.example.ts.net." and returns the
|
||||
// first label, "titan".
|
||||
func stripName(name string) string {
|
||||
name = strings.TrimSuffix(name, ".")
|
||||
if i := strings.IndexByte(name, '.'); i >= 0 {
|
||||
name = name[:i]
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
// TailscaleResolver runs `tailscale whois --json <ip>` and parses it. A non-zero
|
||||
// exit is ErrNotAPeer; a missing binary (or other transport failure) is a real
|
||||
// error the Checker treats as a deny.
|
||||
type TailscaleResolver struct{ Bin string }
|
||||
|
||||
// Identity runs the whois lookup with a 3 s timeout.
|
||||
func (t TailscaleResolver) Identity(ctx context.Context, ip string) (ID, error) {
|
||||
bin := t.Bin
|
||||
if bin == "" {
|
||||
bin = "tailscale"
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(ctx, 3*time.Second)
|
||||
defer cancel()
|
||||
out, err := exec.CommandContext(ctx, bin, "whois", "--json", ip).Output()
|
||||
if err != nil {
|
||||
var exitErr *exec.ExitError
|
||||
if errors.As(err, &exitErr) {
|
||||
return ID{}, ErrNotAPeer
|
||||
}
|
||||
return ID{}, err
|
||||
}
|
||||
return ParseWhois(out)
|
||||
}
|
||||
|
||||
// entry is a cached result, whether a node name or a rejection.
|
||||
type entry struct {
|
||||
id ID
|
||||
err error
|
||||
at time.Time
|
||||
}
|
||||
|
||||
// Checker resolves addresses through a Resolver, caching per address. In header
|
||||
// mode it skips the cache and lets the resolver read the peer from the request.
|
||||
type Checker struct {
|
||||
r Resolver
|
||||
header bool
|
||||
|
||||
mu sync.Mutex
|
||||
cache map[string]entry
|
||||
}
|
||||
|
||||
// NewChecker builds a Checker that resolves through r.
|
||||
func NewChecker(r Resolver) *Checker {
|
||||
return &Checker{r: r, cache: make(map[string]entry)}
|
||||
}
|
||||
|
||||
// NewHeaderChecker builds a Checker that trusts the X-Crossbar-Peer request
|
||||
// header as the node name. TEST/SMOKE ONLY.
|
||||
func NewHeaderChecker() *Checker {
|
||||
return &Checker{r: headerResolver{}, header: true, cache: make(map[string]entry)}
|
||||
}
|
||||
|
||||
// WithHeaderPeer returns a context carrying the peer name the header checker
|
||||
// reads. Middleware sets it from the X-Crossbar-Peer request header.
|
||||
func WithHeaderPeer(ctx context.Context, peer string) context.Context {
|
||||
return context.WithValue(ctx, headerPeerKey{}, peer)
|
||||
}
|
||||
|
||||
// Allow reports whether the caller at remoteAddr may use a route limited to peers.
|
||||
// An empty peers list is an open route; otherwise the caller's node must be in
|
||||
// peers. Any resolver error, an unparsable address, or a loopback address denies.
|
||||
func (c *Checker) Allow(ctx context.Context, peers []string, remoteAddr string) error {
|
||||
if len(peers) == 0 {
|
||||
return nil
|
||||
}
|
||||
ip := ""
|
||||
if !c.header {
|
||||
var ok bool
|
||||
ip, ok = peerIP(remoteAddr)
|
||||
if !ok || net.ParseIP(ip).IsLoopback() {
|
||||
return ErrForbidden
|
||||
}
|
||||
}
|
||||
node, err := c.resolve(ctx, ip)
|
||||
if err != nil {
|
||||
return ErrForbidden
|
||||
}
|
||||
for _, p := range peers {
|
||||
if p == node {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return ErrForbidden
|
||||
}
|
||||
|
||||
// resolve returns the node for ip, using the cache unless in header mode.
|
||||
func (c *Checker) resolve(ctx context.Context, ip string) (string, error) {
|
||||
if c.header {
|
||||
id, err := c.r.Identity(ctx, ip)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return id.Node, nil
|
||||
}
|
||||
|
||||
c.mu.Lock()
|
||||
e, hit := c.cache[ip]
|
||||
if hit && time.Since(e.at) < cacheTTL {
|
||||
c.mu.Unlock()
|
||||
if e.err != nil {
|
||||
return "", e.err
|
||||
}
|
||||
return e.id.Node, nil
|
||||
}
|
||||
c.mu.Unlock()
|
||||
|
||||
id, err := c.r.Identity(ctx, ip)
|
||||
if err != nil {
|
||||
c.mu.Lock()
|
||||
c.cache[ip] = entry{err: err, at: time.Now()}
|
||||
c.mu.Unlock()
|
||||
return "", err
|
||||
}
|
||||
|
||||
c.mu.Lock()
|
||||
c.cache[ip] = entry{id: id, at: time.Now()}
|
||||
c.mu.Unlock()
|
||||
return id.Node, nil
|
||||
}
|
||||
|
||||
// headerPeerKey is the context key under which Middleware stores the X-Crossbar-Peer
|
||||
// value for the header checker to read.
|
||||
type headerPeerKey struct{}
|
||||
|
||||
// headerResolver answers from the peer name Middleware placed on the context. An
|
||||
// absent or empty header is a deny, so a request that forgot the header is 403.
|
||||
type headerResolver struct{}
|
||||
|
||||
func (headerResolver) Identity(ctx context.Context, _ string) (ID, error) {
|
||||
peer, ok := ctx.Value(headerPeerKey{}).(string)
|
||||
if !ok || peer == "" {
|
||||
return ID{}, ErrNotAPeer
|
||||
}
|
||||
return ID{Node: peer, Login: peer}, nil
|
||||
}
|
||||
|
||||
// peerIP splits the host from a "host:port" address, returning the bare IP.
|
||||
func peerIP(remoteAddr string) (string, bool) {
|
||||
host := remoteAddr
|
||||
if h, _, err := net.SplitHostPort(remoteAddr); err == nil {
|
||||
host = h
|
||||
}
|
||||
ip := net.ParseIP(host)
|
||||
if ip == nil {
|
||||
return "", false
|
||||
}
|
||||
return ip.String(), true
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package identity_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"git.wntrmute.dev/kyle/crossbar/internal/identity"
|
||||
)
|
||||
|
||||
func TestParseWhois(t *testing.T) {
|
||||
raw, err := os.ReadFile(filepath.Join("testdata", "whois.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
id, err := identity.ParseWhois(raw)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if id.Node != "titan" || id.Login == "" {
|
||||
t.Errorf("parsed %+v, want Node titan and a login", id)
|
||||
}
|
||||
if _, err := identity.ParseWhois([]byte(`{"Node":{}}`)); err == nil {
|
||||
t.Error("a whois answer without a node name must be an error")
|
||||
}
|
||||
if _, err := identity.ParseWhois([]byte(`nope`)); err == nil {
|
||||
t.Error("non-JSON must be an error")
|
||||
}
|
||||
}
|
||||
|
||||
// fakeResolver answers from a map; "" means not a tailnet peer.
|
||||
type fakeResolver map[string]string
|
||||
|
||||
func (f fakeResolver) Identity(ctx context.Context, ip string) (identity.ID, error) {
|
||||
n, ok := f[ip]
|
||||
if !ok {
|
||||
return identity.ID{}, identity.ErrNotAPeer
|
||||
}
|
||||
return identity.ID{Node: n, Login: n + "@example"}, nil
|
||||
}
|
||||
|
||||
func TestChecker(t *testing.T) {
|
||||
c := identity.NewChecker(fakeResolver{"100.64.0.5": "talos", "100.64.0.9": "titan"})
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
peers []string
|
||||
addr string
|
||||
want error
|
||||
}{
|
||||
{"open route", nil, "203.0.113.7:1", nil},
|
||||
{"allowed peer", []string{"talos", "titan"}, "100.64.0.5:44444", nil},
|
||||
{"other peer", []string{"talos"}, "100.64.0.9:1", identity.ErrForbidden},
|
||||
{"not a peer", []string{"talos"}, "203.0.113.7:1", identity.ErrForbidden},
|
||||
{"loopback", []string{"talos"}, "127.0.0.1:1", identity.ErrForbidden},
|
||||
{"garbage addr", []string{"talos"}, "nonsense", identity.ErrForbidden},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := c.Allow(context.Background(), tc.peers, tc.addr)
|
||||
if !errors.Is(got, tc.want) && !(got == nil && tc.want == nil) {
|
||||
t.Errorf("Allow(%v, %q) = %v, want %v", tc.peers, tc.addr, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckerCachesPerAddress(t *testing.T) {
|
||||
calls := 0
|
||||
r := countingResolver{f: fakeResolver{"100.64.0.5": "talos"}, calls: &calls}
|
||||
c := identity.NewChecker(r)
|
||||
for i := 0; i < 5; i++ {
|
||||
if err := c.Allow(context.Background(), []string{"talos"}, "100.64.0.5:1"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if calls != 1 {
|
||||
t.Errorf("resolver called %d times for one address, want 1 (cache)", calls)
|
||||
}
|
||||
}
|
||||
|
||||
type countingResolver struct {
|
||||
f fakeResolver
|
||||
calls *int
|
||||
}
|
||||
|
||||
func (c countingResolver) Identity(ctx context.Context, ip string) (identity.ID, error) {
|
||||
*c.calls++
|
||||
return c.f.Identity(ctx, ip)
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
// Package identity gates a route on the set of tailnet peers allowed to use it.
|
||||
// The middleware sits in front of the proxy: it names the route the same way the
|
||||
// proxy does and refuses with 403 any caller a route does not allow.
|
||||
package identity
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
// routeHeader is how a caller names the route, the same header the proxy
|
||||
// reads.
|
||||
routeHeader = "X-Crossbar-Route"
|
||||
// peerHeader carries the node name in header mode.
|
||||
peerHeader = "X-Crossbar-Peer"
|
||||
// adminPrefix is never gated here; the proxy's own handlers own it.
|
||||
adminPrefix = "/_crossbar/"
|
||||
)
|
||||
|
||||
// Middleware wraps next with the peer gate. peersFor names the allow list for a
|
||||
// route and reports whether it knows the route; an unknown route, like a path
|
||||
// under adminPrefix, passes straight through.
|
||||
func Middleware(c *Checker, peersFor func(route string) ([]string, bool), next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.HasPrefix(r.URL.Path, adminPrefix) {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
route := r.Header.Get(routeHeader)
|
||||
if route == "" {
|
||||
route = firstSegment(r.URL.Path)
|
||||
}
|
||||
peers, known := peersFor(route)
|
||||
if !known {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
ctx := WithHeaderPeer(r.Context(), r.Header.Get(peerHeader))
|
||||
if err := c.Allow(ctx, peers, r.RemoteAddr); err != nil {
|
||||
writeForbidden(w)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// writeForbidden answers the JSON 403 the tests and callers expect.
|
||||
func writeForbidden(w http.ResponseWriter) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
w.Write([]byte(`{"error":"forbidden route"}`))
|
||||
}
|
||||
|
||||
// firstSegment takes the first path segment as the route, "/a/v1/x" -> "a".
|
||||
func firstSegment(path string) string {
|
||||
if path == "" || path[0] != '/' {
|
||||
return ""
|
||||
}
|
||||
after := path[1:]
|
||||
if slash := strings.IndexByte(after, '/'); slash >= 0 {
|
||||
if after[:slash] == "" {
|
||||
return ""
|
||||
}
|
||||
return after[:slash]
|
||||
}
|
||||
if after == "" {
|
||||
return ""
|
||||
}
|
||||
return after
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package identity_test
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.wntrmute.dev/kyle/crossbar/internal/identity"
|
||||
)
|
||||
|
||||
// The middleware sits in front of the proxy: it names the route the same way the proxy does
|
||||
// (X-Crossbar-Route header, else first path segment) and refuses callers a route does not list.
|
||||
func TestMiddleware(t *testing.T) {
|
||||
peers := map[string][]string{"locked": {"talos"}, "open": nil}
|
||||
inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(204) })
|
||||
h := identity.Middleware(identity.NewChecker(fakeResolver{"100.64.0.5": "talos", "100.64.0.9": "titan"}),
|
||||
func(route string) ([]string, bool) { p, ok := peers[route]; return p, ok }, inner)
|
||||
for _, tc := range []struct {
|
||||
name, path, hdr, addr string
|
||||
want int
|
||||
}{
|
||||
{"open route, anyone", "/open/v1/models", "", "203.0.113.1:5", 204},
|
||||
{"locked, right peer", "/locked/v1/models", "", "100.64.0.5:5", 204},
|
||||
{"locked, wrong peer", "/locked/v1/models", "", "100.64.0.9:5", 403},
|
||||
{"locked, not a peer", "/locked/v1/models", "", "203.0.113.1:5", 403},
|
||||
{"locked via header", "/v1/models", "locked", "100.64.0.9:5", 403},
|
||||
{"header wins over path", "/open/v1/models", "locked", "203.0.113.1:5", 403},
|
||||
{"unknown route passes through to the proxy's own 404", "/nope/v1/models", "", "203.0.113.1:5", 204},
|
||||
{"admin path is never gated here", "/_crossbar/hosts", "", "203.0.113.1:5", 204},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodGet, tc.path, nil)
|
||||
req.RemoteAddr = tc.addr
|
||||
if tc.hdr != "" {
|
||||
req.Header.Set("X-Crossbar-Route", tc.hdr)
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != tc.want {
|
||||
t.Errorf("%s = %d, want %d (%s)", tc.path, rec.Code, tc.want, rec.Body.String())
|
||||
}
|
||||
if rec.Code == 403 && (!strings.HasPrefix(rec.Header().Get("Content-Type"), "application/json") || !strings.Contains(rec.Body.String(), `"forbidden route"`)) {
|
||||
t.Errorf("403 must be JSON {\"error\":\"forbidden route\"}: %q", rec.Body.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// HeaderResolver is the test/smoke identity source: it trusts X-Crossbar-Peer. It exists so the
|
||||
// smoke run can exercise the gate without a tailnet; config must call it out as insecure.
|
||||
func TestHeaderResolver(t *testing.T) {
|
||||
inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(204) })
|
||||
h := identity.Middleware(identity.NewHeaderChecker(), func(route string) ([]string, bool) { return []string{"talos"}, true }, inner)
|
||||
req := httptest.NewRequest(http.MethodGet, "/r/v1/models", nil)
|
||||
req.Header.Set("X-Crossbar-Peer", "talos")
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != 204 {
|
||||
t.Errorf("header peer talos: %d", rec.Code)
|
||||
}
|
||||
req.Header.Set("X-Crossbar-Peer", "titan")
|
||||
rec = httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != 403 {
|
||||
t.Errorf("header peer titan: %d, want 403", rec.Code)
|
||||
}
|
||||
req.Header.Del("X-Crossbar-Peer")
|
||||
rec = httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != 403 {
|
||||
t.Errorf("no header: %d, want 403", rec.Code)
|
||||
}
|
||||
}
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"Node": {
|
||||
"ID": 1,
|
||||
"StableID": "nEXAMPLE",
|
||||
"Name": "titan.example.ts.net.",
|
||||
"User": 2,
|
||||
"Addresses": [
|
||||
"100.64.0.9/32",
|
||||
"fd7a:115c:a1e0::9/128"
|
||||
],
|
||||
"HomeDERP": 2,
|
||||
"Created": "2026-01-01T00:00:00Z",
|
||||
"Cap": 138,
|
||||
"Online": true,
|
||||
"ComputedName": "titan",
|
||||
"ComputedNameWithHost": "titan"
|
||||
},
|
||||
"UserProfile": {
|
||||
"ID": 2,
|
||||
"LoginName": "user@example.com",
|
||||
"DisplayName": "Example User"
|
||||
},
|
||||
"CapMap": null
|
||||
}
|
||||
Reference in New Issue
Block a user