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
|
||||
}
|
||||
Reference in New Issue
Block a user