From 03352e3312314326b86804e2d4b7dc78ae55f754 Mon Sep 17 00:00:00 2001 From: maze Date: Wed, 1 Oct 2025 15:37:55 +0200 Subject: [PATCH] Checkpoint --- .gitignore | 8 + cmd/styx/config.go | 206 ++++++++++ cmd/styx/main.go | 120 +++--- {internal/netutil => dataset}/domain.go | 2 +- dataset/domain_data.go | 5 + {internal/netutil => dataset}/domain_test.go | 2 +- dataset/network.go | 52 +++ dataset/network_data.go | 71 ++++ go.mod | 71 +++- go.sum | 195 ++++++++- internal/cryptutil/tls.go | 168 ++++++++ internal/cryptutil/x509.go | 67 +++- internal/log/log.go | 44 --- internal/netutil/addr.go | 4 + internal/netutil/arp/arp.go | 63 +++ internal/netutil/arp/arp_linux.go | 32 ++ internal/netutil/arp/arp_unix.go | 37 ++ internal/netutil/conn.go | 93 +++++ internal/sliceutil/filter.go | 70 ++++ logger/log.go | 223 +++++++++++ policy/func.go | 179 +++++++++ policy/handler.go | 44 +++ policy/input.go | 394 +++++++++++++++++++ policy/policy.go | 189 +++++++++ proxy/context.go | 36 +- proxy/handler.go | 115 +++--- proxy/proxy.go | 270 ++++++++----- styx.hcl | 146 +++---- testdata/policy/bogons.rego | 12 + testdata/policy/childsafe.rego | 56 +++ testdata/policy/intercept.rego | 21 + 31 files changed, 2611 insertions(+), 384 deletions(-) create mode 100644 .gitignore create mode 100644 cmd/styx/config.go rename {internal/netutil => dataset}/domain.go (99%) create mode 100644 dataset/domain_data.go rename {internal/netutil => dataset}/domain_test.go (99%) create mode 100644 dataset/network.go create mode 100644 dataset/network_data.go create mode 100644 internal/cryptutil/tls.go delete mode 100644 internal/log/log.go create mode 100644 internal/netutil/arp/arp.go create mode 100644 internal/netutil/arp/arp_linux.go create mode 100644 internal/netutil/arp/arp_unix.go create mode 100644 internal/netutil/conn.go create mode 100644 internal/sliceutil/filter.go create mode 100644 logger/log.go create mode 100644 policy/func.go create mode 100644 policy/handler.go create mode 100644 policy/input.go create mode 100644 policy/policy.go create mode 100644 testdata/policy/bogons.rego create mode 100644 testdata/policy/childsafe.rego create mode 100644 testdata/policy/intercept.rego diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0c3b16c --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +# SQLite3 database file +*.db + +# Log files +*.log + +# Backup files +*~ diff --git a/cmd/styx/config.go b/cmd/styx/config.go new file mode 100644 index 0000000..d88a505 --- /dev/null +++ b/cmd/styx/config.go @@ -0,0 +1,206 @@ +package main + +import ( + "crypto/tls" + "fmt" + + "github.com/hashicorp/hcl/v2" + "github.com/hashicorp/hcl/v2/gohcl" + "github.com/hashicorp/hcl/v2/hclsimple" + + "git.maze.io/maze/styx/dataset" + "git.maze.io/maze/styx/internal/cryptutil" + "git.maze.io/maze/styx/logger" + "git.maze.io/maze/styx/policy" + "git.maze.io/maze/styx/proxy" +) + +type Config struct { + Proxy ProxyConfig `hcl:"proxy,block"` + Policy []PolicyConfig `hcl:"policy,block"` + Data DataConfig `hcl:"data,block"` +} + +func (c Config) Proxies(log logger.Structured) ([]*proxy.Proxy, error) { + policies := make(map[string]*policy.Policy) + for _, pc := range c.Policy { + p, err := policy.New(pc.Path, pc.Package) + if err != nil { + return nil, fmt.Errorf("policy %s: %w", pc.Name, err) + } + policies[pc.Name] = p + } + + var ( + onRequest []proxy.RequestHandler + onResponse []proxy.ResponseHandler + ) + for _, name := range c.Proxy.On.Request { + log.Value("policy", name).Debug("Resolving request policy") + p, ok := policies[name] + if !ok { + return nil, fmt.Errorf("on request: no policy named %q", name) + } + onRequest = append(onRequest, policy.NewRequestHandler(p)) + } + for _, name := range c.Proxy.On.Response { + log.Value("policy", name).Debug("Resolving response policy") + p, ok := policies[name] + if !ok { + return nil, fmt.Errorf("on response: no policy named %q", name) + } + onResponse = append(onResponse, policy.NewResponseHandler(p)) + } + + var proxies []*proxy.Proxy + for _, pc := range c.Proxy.Port { + log.Value("port", pc.Listen).Debug("Configuring proxy port") + p, err := pc.Proxy() + if err != nil { + return nil, err + } + p.OnRequest = append(p.OnRequest, onRequest...) + p.OnResponse = append(p.OnResponse, onResponse...) + proxies = append(proxies, p) + } + + return proxies, nil +} + +type ProxyConfig struct { + Port []PortConfig `hcl:"port,block"` + Upstream []string `hcl:"upstream"` + On ProxyPolicyConfig `hcl:"on,block"` +} + +type PortConfig struct { + Listen string `hcl:"port,label"` + TLS *PortTLSConfig `hcl:"tls,block"` + Transparent int `hcl:"transparent,optional"` + Name string `hcl:"name,optional"` +} + +type PortTLSConfig struct { + Cert string `hcl:"cert"` + Key string `hcl:"key,optional"` + CA string `hcl:"ca,optional"` +} + +func (c PortConfig) Proxy() (*proxy.Proxy, error) { + p := proxy.New() + if c.Transparent > 0 { + p.OnConnect = append(p.OnConnect, proxy.Transparent(c.Transparent)) + } else if c.TLS != nil { + cert, err := cryptutil.LoadTLSCertificate(c.TLS.Cert, c.TLS.Key) + if err != nil { + return nil, err + } + + config := new(tls.Config) + config.Certificates = []tls.Certificate{cert} + if c.TLS.CA != "" { + roots, err := cryptutil.LoadRoots(c.TLS.CA) + if err != nil { + return nil, err + } + config.RootCAs = roots + } + + p.OnConnect = append(p.OnConnect, proxy.TLS(config)) + } + return p, nil +} + +type ProxyPolicyConfig struct { + Intercept []string `hcl:"intercept,optional"` + Request []string `hcl:"request,optional"` + Response []string `hcl:"response,optional"` +} + +type PolicyConfig struct { + Name string `hcl:"name,label"` + Path string `hcl:"path"` + Package string `hcl:"package,optional"` +} + +type DataConfig struct { + Path string `hcl:"path,optional"` + Domains []DomainDataConfig `hcl:"domain,block"` + Networks []NetworkDataConfig `hcl:"network,block"` +} + +func (c DataConfig) Configure() error { + for _, dc := range c.Domains { + if err := dc.Configure(); err != nil { + return fmt.Errorf("error setting up domain data %q: %w", dc.Name, err) + } + } + for _, nc := range c.Networks { + if err := nc.Configure(); err != nil { + return fmt.Errorf("error setting up network data %q: %w", nc.Name, err) + } + } + return nil +} + +type DomainDataConfig struct { + Name string `hcl:"name,label"` + Type string `hcl:"type"` + Body hcl.Body `hcl:",remain"` +} + +func (c DomainDataConfig) Configure() error { + switch c.Type { + case "", "list": + var justTheList struct { + List []string `hcl:"list"` + } + if diag := gohcl.DecodeBody(c.Body, nil, &justTheList); diag.HasErrors() { + return diag + } + + dataset.Domains[c.Name] = dataset.NewDomainList(justTheList.List...) + + default: + return fmt.Errorf("unknown type %q", c.Type) + } + + return nil +} + +type NetworkDataConfig struct { + Name string `hcl:"name,label"` + Type string `hcl:"type"` + Body hcl.Body `hcl:",remain"` +} + +func (c NetworkDataConfig) Configure() error { + switch c.Type { + case "", "list": + var justTheList struct { + List []string `hcl:"list"` + } + if diag := gohcl.DecodeBody(c.Body, nil, &justTheList); diag.HasErrors() { + return diag + } + + list, err := dataset.NewNetworkTree(justTheList.List...) + if err != nil { + return err + } + dataset.Networks[c.Name] = list + + default: + return fmt.Errorf("unknown type %q", c.Type) + } + + return nil +} + +func Load(name string) (*Config, error) { + config := new(Config) + if err := hclsimple.DecodeFile(name, nil, config); err != nil { + return nil, err + } + return config, nil +} diff --git a/cmd/styx/main.go b/cmd/styx/main.go index 3103aff..18adf49 100644 --- a/cmd/styx/main.go +++ b/cmd/styx/main.go @@ -2,84 +2,90 @@ package main import ( "flag" + "net" "os" "os/signal" "syscall" - "github.com/hashicorp/hcl/v2/hclsimple" - - "git.maze.io/maze/styx/internal/log" + "git.maze.io/maze/styx/logger" "git.maze.io/maze/styx/proxy" - "git.maze.io/maze/styx/proxy/cache" - "git.maze.io/maze/styx/proxy/match" - "git.maze.io/maze/styx/proxy/mitm" - "git.maze.io/maze/styx/proxy/resolver" ) func main() { - configFlag := flag.String("config", "styx.hcl", "Configuration file") - traceFlag := flag.Bool("T", false, "Enable trace level logging") - debugFlag := flag.Bool("D", false, "Enable debug level logging") + var ( + configFile = "styx.hcl" + traceFlag bool + debugFlag bool + ) + + flag.StringVar(&configFile, "config", configFile, "configuration file") + flag.BoolVar(&traceFlag, "T", traceFlag, "enable trace logging") + flag.BoolVar(&debugFlag, "D", debugFlag, "enable debug logging") flag.Parse() - if *traceFlag { - log.SetLevel(log.TraceLevel) - } else if *debugFlag { - log.SetLevel(log.DebugLevel) + log := logger.StandardLog.Value("path", configFile) + + if traceFlag { + log.SetLevel(logger.TraceLevel) + } else if debugFlag { + log.SetLevel(logger.DebugLevel) } - config, err := load(*configFlag) + config, err := Load(configFile) if err != nil { - log.Fatal().Err(err).Msg("") + log.Err(err).Fatal("Invalid configuration file") } - matchers, err := config.Match.Matchers() + if err = config.Data.Configure(); err != nil { + log.Err(err).Fatal("Invalid data configuration") + } + + proxies, err := config.Proxies(log) if err != nil { - log.Fatal().Err(err).Msg("") - } else if err = config.Proxy.Policy.Configure(matchers); err != nil { - log.Fatal().Err(err).Msg("") + log.Err(err).Fatal("Error configuring proxy ports") } - var ca mitm.Authority - if config.MITM != nil { - if ca, err = mitm.New(config.MITM); err != nil { - log.Fatal().Err(err).Msg("error configuring mitm") + var ( + errs = make(chan error, 1) + done = make(chan struct{}, 1) + sigs = make(chan os.Signal, 1) + ) + + for i, p := range proxies { + go run(config.Proxy.Port[i].Listen, p, errs) + } + + signal.Notify(sigs, syscall.SIGINT, syscall.SIGHUP) + + for { + select { + case sig := <-sigs: + switch sig { + case syscall.SIGHUP: + log.Value("signal", sig.String()).Warn("Ignored reload signal ¯\\_(ツ)_/¯") + default: + log.Value("signal", sig.String()).Info("Shutting down on signal") + return + } + + case <-done: + log.Info("Shutting down gracefully") + return + + case err = <-errs: + log.Err(err).Fatal("Shutting down because of fatal error in proxy") } } +} - server, err := proxy.New(&config.Proxy, ca) +func run(addr string, port *proxy.Proxy, errors chan<- error) { + log := logger.StandardLog.Value("port", addr) + log.Info("Proxy port starting") + l, err := net.Listen("tcp", addr) if err != nil { - log.Fatal().Err(err).Msg("") + errors <- err + return } - - if err = server.Start(); err != nil { - log.Fatal().Err(err).Msg("") - } - - signalChannel := make(chan os.Signal, 1) - signal.Notify(signalChannel, syscall.SIGINT, syscall.SIGTERM) - <-signalChannel - - server.Close() -} - -type Config struct { - DNS *resolver.Config `hcl:"dns,block"` - Proxy proxy.Config `hcl:"proxy,block"` - MITM *mitm.Config `hcl:"mitm,block"` - Cache *cache.Config `hcl:"cache,block"` - Match *match.Config `hcl:"match,block"` -} - -func load(name string) (*Config, error) { - config := new(Config) - if err := hclsimple.DecodeFile(name, nil, config); err != nil { - return nil, err - } - - if config.DNS != nil { - config.Proxy.Resolver = resolver.New(*config.DNS) - } - - return config, nil + log.Info("Proxy port ready") + errors <- port.Serve(l) } diff --git a/internal/netutil/domain.go b/dataset/domain.go similarity index 99% rename from internal/netutil/domain.go rename to dataset/domain.go index f7e7668..d5bfabd 100644 --- a/internal/netutil/domain.go +++ b/dataset/domain.go @@ -1,4 +1,4 @@ -package netutil +package dataset import ( "strings" diff --git a/dataset/domain_data.go b/dataset/domain_data.go new file mode 100644 index 0000000..24dedbb --- /dev/null +++ b/dataset/domain_data.go @@ -0,0 +1,5 @@ +package dataset + +var Domains = map[string]*DomainTree{ + "example": NewDomainList("example.org", "example.net", "example.com"), +} diff --git a/internal/netutil/domain_test.go b/dataset/domain_test.go similarity index 99% rename from internal/netutil/domain_test.go rename to dataset/domain_test.go index 52582f8..11c2852 100644 --- a/internal/netutil/domain_test.go +++ b/dataset/domain_test.go @@ -1,4 +1,4 @@ -package netutil +package dataset import ( "testing" diff --git a/dataset/network.go b/dataset/network.go new file mode 100644 index 0000000..b377362 --- /dev/null +++ b/dataset/network.go @@ -0,0 +1,52 @@ +package dataset + +import ( + "net" + + "github.com/yl2chen/cidranger" +) + +type NetworkTree struct { + ranger cidranger.Ranger +} + +func MustNetworkTree(networks ...string) *NetworkTree { + tree, err := NewNetworkTree(networks...) + if err != nil { + panic(err) + } + return tree +} + +func NewNetworkTree(networks ...string) (*NetworkTree, error) { + tree := &NetworkTree{ + ranger: cidranger.NewPCTrieRanger(), + } + for _, cidr := range networks { + if err := tree.AddCIDR(cidr); err != nil { + return nil, err + } + } + return tree, nil +} + +func (tree *NetworkTree) Add(ipnet *net.IPNet) { + if ipnet == nil { + return + } + tree.ranger.Insert(cidranger.NewBasicRangerEntry(*ipnet)) +} + +func (tree *NetworkTree) AddCIDR(cidr string) error { + _, ipnet, err := net.ParseCIDR(cidr) + if err != nil { + return err + } + tree.ranger.Insert(cidranger.NewBasicRangerEntry(*ipnet)) + return nil +} + +func (tree *NetworkTree) Contains(ip net.IP) bool { + contains, _ := tree.ranger.Contains(ip) + return contains +} diff --git a/dataset/network_data.go b/dataset/network_data.go new file mode 100644 index 0000000..3a9f482 --- /dev/null +++ b/dataset/network_data.go @@ -0,0 +1,71 @@ +package dataset + +var ( + bogonsIPv4 = []string{ + "9.0.0.0/8", // "This" network + "10.0.0.0/8", // RFC1918 Private-use networks + "100.64.0.0/10", // Carrier-grade NAT + "127.0.0.0/8", // Loopback + "169.254.0.0/16", // Link local + "172.16.0.0/12", // RFC1918 Private-use networks + "192.0.0.0/24", // IETF protocol assignments + "192.0.2.0/24", // TEST-NET-1 + "192.168.0.0/16", // RFC1918 Private-use networks + "198.18.0.0/15", // Network interconnect device benchmark testing + "198.51.100.0/24", // TEST-NET-2 + "203.0.113.0/24", // TEST-NET-3 + "224.0.0.0/4", // Multicast + "240.0.0.0/4", // Reserved for future use + "255.255.255.255/32", // Limited broadcast + } + bogonsIPv6 = []string{ + "::/128", // Node-scope unicast unspecified address + "::1/128", // Node-scope unicast loopback address + "::ffff:0:0/96", // IPv4-mapped addresses + "::/96", // IPv4-compatible addresses + "100::/64", // Remotely triggered black hole addresses + "2001:10::/28", // Overlay routable cryptographic hash identifiers (ORCHID) + "2001:db8::/32", // Documentation prefix + "3fff::/20", // Documentation prefix + "fc00::/7", // Unique local addresses (ULA) + "fe80::/10", // Link-local unicast + "fec0::/10", // Site-local unicast (deprecated) + "ff00::/8", // Multicast (Note: ff0e:/16 is global scope and may appear on the global internet.) + "2002::/24", // 6to4 bogon (0.0.0.0/8) + "2002:a00::/24", // 6to4 bogon (10.0.0.0/8) + "2002:7f00::/24", // 6to4 bogon (127.0.0.0/8) + "2002:a9fe::/32", // 6to4 bogon (169.254.0.0/16) + "2002:ac10::/28", // 6to4 bogon (172.16.0.0/12) + "2002:c000::/40", // 6to4 bogon (192.0.0.0/24) + "2002:c000:200::/40", // 6to4 bogon (192.0.2.0/24) + "2002:c0a8::/32", // 6to4 bogon (192.168.0.0/16) + "2002:c612::/31", // 6to4 bogon (198.18.0.0/15) + "2002:c633:6400::/40", // 6to4 bogon (198.51.100.0/24) + "2002:cb00:7100::/40", // 6to4 bogon (203.0.113.0/24) + "2002:e000::/20", // 6to4 bogon (224.0.0.0/4) + "2002:f000::/20", // 6to4 bogon (240.0.0.0/4) + "2002:ffff:ffff::/48", // 6to4 bogon (255.255.255.255/32) + "2001::/40", // Teredo bogon (0.0.0.0/8) + "2001:0:a00::/40", // Teredo bogon (10.0.0.0/8) + "2001:0:7f00::/40", // Teredo bogon (127.0.0.0/8) + "2001:0:a9fe::/48", // Teredo bogon (169.254.0.0/16) + "2001:0:ac10::/44", // Teredo bogon (172.16.0.0/12) + "2001:0:c000::/56", // Teredo bogon (192.0.0.0/24) + "2001:0:c000:200::/56", // Teredo bogon (192.0.2.0/24) + "2001:0:c0a8::/48", // Teredo bogon (192.168.0.0/16) + "2001:0:c612::/47", // Teredo bogon (198.18.0.0/15) + "2001:0:c633:6400::/56", // Teredo bogon (198.51.100.0/24) + "2001:0:cb00:7100::/56", // Teredo bogon (203.0.113.0/24) + "2001:0:e000::/36", // Teredo bogon (224.0.0.0/4) + "2001:0:f000::/36", // Teredo bogon (240.0.0.0/4) + "2001:0:ffff:ffff::/64", // Teredo bogon (255.255.255.255/32) + } + bogons = append(bogonsIPv4, bogonsIPv6...) +) + +// Networks contains predefined network lists. +var Networks = map[string]*NetworkTree{ + "bogons": MustNetworkTree(bogons...), + "boeong4": MustNetworkTree(bogonsIPv4...), + "bogons6": MustNetworkTree(bogonsIPv6...), +} diff --git a/go.mod b/go.mod index 533e20d..9fbaccd 100644 --- a/go.mod +++ b/go.mod @@ -3,27 +3,74 @@ module git.maze.io/maze/styx go 1.25.0 require ( - github.com/hashicorp/golang-lru/v2 v2.0.7 + github.com/go-viper/mapstructure/v2 v2.4.0 github.com/hashicorp/hcl/v2 v2.24.0 + github.com/mattn/go-sqlite3 v1.14.32 github.com/miekg/dns v1.1.68 + github.com/open-policy-agent/opa v1.9.0 github.com/rs/zerolog v1.34.0 + github.com/sirupsen/logrus v1.9.4-0.20230606125235-dd1b4c2e81af + github.com/yl2chen/cidranger v1.0.2 ) require ( github.com/agext/levenshtein v1.2.1 // indirect + github.com/agnivade/levenshtein v1.2.1 // indirect github.com/apparentlymart/go-textseg/v15 v15.0.0 // indirect - github.com/google/go-cmp v0.6.0 // indirect + github.com/beorn7/perks v1.0.1 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect + github.com/dgraph-io/ristretto/v2 v2.3.0 // indirect + github.com/go-ini/ini v1.67.0 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/gobwas/glob v0.2.3 // indirect + github.com/goccy/go-json v0.10.5 // indirect + github.com/google/flatbuffers v25.9.23+incompatible // indirect + github.com/google/go-cmp v0.7.0 // indirect github.com/google/uuid v1.6.0 // indirect - github.com/mattn/go-colorable v0.1.13 // indirect - github.com/mattn/go-isatty v0.0.19 // indirect - github.com/mattn/go-sqlite3 v1.14.32 // indirect + github.com/lestrrat-go/blackmagic v1.0.4 // indirect + github.com/lestrrat-go/dsig v1.0.0 // indirect + github.com/lestrrat-go/dsig-secp256k1 v1.0.0 // indirect + github.com/lestrrat-go/httpcc v1.0.1 // indirect + github.com/lestrrat-go/httprc/v3 v3.0.1 // indirect + github.com/lestrrat-go/jwx/v3 v3.0.11 // indirect + github.com/lestrrat-go/option v1.0.1 // indirect + github.com/lestrrat-go/option/v2 v2.0.0 // indirect + github.com/mattn/go-colorable v0.1.14 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect github.com/mitchellh/go-wordwrap v1.0.1 // indirect - github.com/yl2chen/cidranger v1.0.2 // indirect + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/prometheus/client_golang v1.23.2 // indirect + github.com/prometheus/client_model v0.6.2 // indirect + github.com/prometheus/common v0.66.1 // indirect + github.com/prometheus/procfs v0.17.0 // indirect + github.com/rcrowley/go-metrics v0.0.0-20250401214520-65e299d6c5c9 // indirect + github.com/segmentio/asm v1.2.1 // indirect + github.com/tchap/go-patricia/v2 v2.3.3 // indirect + github.com/valyala/fastjson v1.6.4 // indirect + github.com/vektah/gqlparser/v2 v2.5.30 // indirect + github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb // indirect + github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect + github.com/yashtewari/glob-intersection v0.2.0 // indirect github.com/zclconf/go-cty v1.16.3 // indirect - golang.org/x/mod v0.24.0 // indirect - golang.org/x/net v0.40.0 // indirect - golang.org/x/sync v0.14.0 // indirect - golang.org/x/sys v0.33.0 // indirect - golang.org/x/text v0.25.0 // indirect - golang.org/x/tools v0.33.0 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/otel v1.38.0 // indirect + go.opentelemetry.io/otel/metric v1.38.0 // indirect + go.opentelemetry.io/otel/sdk v1.38.0 // indirect + go.opentelemetry.io/otel/trace v1.38.0 // indirect + go.opentelemetry.io/proto/otlp v1.8.0 // indirect + go.yaml.in/yaml/v2 v2.4.3 // indirect + golang.org/x/crypto v0.42.0 // indirect + golang.org/x/mod v0.27.0 // indirect + golang.org/x/net v0.44.0 // indirect + golang.org/x/sync v0.17.0 // indirect + golang.org/x/sys v0.36.0 // indirect + golang.org/x/text v0.29.0 // indirect + golang.org/x/tools v0.36.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20250929231259-57b25ae835d4 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20250929231259-57b25ae835d4 // indirect + google.golang.org/protobuf v1.36.9 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect + sigs.k8s.io/yaml v1.6.0 // indirect ) diff --git a/go.sum b/go.sum index 5cf0546..558c1d0 100644 --- a/go.sum +++ b/go.sum @@ -1,60 +1,217 @@ github.com/agext/levenshtein v1.2.1 h1:QmvMAjj2aEICytGiWzmxoE0x2KZvE0fvmqMOfy2tjT8= github.com/agext/levenshtein v1.2.1/go.mod h1:JEDfjyjHDjOF/1e4FlBE/PkbqA9OfWu2ki2W0IB5558= +github.com/agnivade/levenshtein v1.2.1 h1:EHBY3UOn1gwdy/VbFwgo4cxecRznFk7fKWN1KOX7eoM= +github.com/agnivade/levenshtein v1.2.1/go.mod h1:QVVI16kDrtSuwcpd0p1+xMC6Z/VfhtCyDIjcwga4/DU= +github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883 h1:bvNMNQO63//z+xNgfBlViaCIJKLlCJ6/fmUseuG0wVQ= +github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883/go.mod h1:rCTlJbsFo29Kk6CurOXKm700vrz8f0KW0JNfpkRJY/8= github.com/apparentlymart/go-textseg/v15 v15.0.0 h1:uYvfpb3DyLSCGWnctWKGj857c6ew1u1fNQOlOtuGxQY= github.com/apparentlymart/go-textseg/v15 v15.0.0/go.mod h1:K8XmNZdhEBkdlyDdvbmmsvpAG721bKi0joRfFdHIWJ4= +github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0 h1:jfIu9sQUG6Ig+0+Ap1h4unLjW6YQJpKZVmUzxsD4E/Q= +github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0/go.mod h1:t2tdKJDJF9BV14lnkjHmOQgcvEKgtqs5a1N3LNdJhGE= +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/bytecodealliance/wasmtime-go/v3 v3.0.2 h1:3uZCA/BLTIu+DqCfguByNMJa2HVHpXvjfy0Dy7g6fuA= +github.com/bytecodealliance/wasmtime-go/v3 v3.0.2/go.mod h1:RnUjnIXxEJcL6BgCvNyzCCRzZcxCgsZCi+RNlvYor5Q= +github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= +github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 h1:NMZiJj8QnKe1LgsbDayM4UoHwbvwDRwnI3hwNaAHRnc= +github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40= +github.com/dgraph-io/badger/v4 v4.8.0 h1:JYph1ChBijCw8SLeybvPINizbDKWZ5n/GYbz2yhN/bs= +github.com/dgraph-io/badger/v4 v4.8.0/go.mod h1:U6on6e8k/RTbUWxqKR0MvugJuVmkxSNc79ap4917h4w= +github.com/dgraph-io/ristretto/v2 v2.3.0 h1:qTQ38m7oIyd4GAed/QkUZyPFNMnvVWyazGXRwvOt5zk= +github.com/dgraph-io/ristretto/v2 v2.3.0/go.mod h1:gpoRV3VzrEY1a9dWAYV6T1U7YzfgttXdd/ZzL1s9OZM= +github.com/dgryski/trifles v0.0.0-20230903005119-f50d829f2e54 h1:SG7nF6SRlWhcT7cNTs5R6Hk4V2lcmLz2NsG2VnInyNo= +github.com/dgryski/trifles v0.0.0-20230903005119-f50d829f2e54/go.mod h1:if7Fbed8SFyPtHLHbg49SI7NAdJiC5WIA09pe59rfAA= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= +github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/fortytw2/leaktest v1.3.0 h1:u8491cBMTQ8ft8aeV+adlcytMZylmA5nnwwkRZjI8vw= +github.com/fortytw2/leaktest v1.3.0/go.mod h1:jDsjWgpAGjm2CA7WthBh/CdZYEPF31XHquHwclZch5g= +github.com/foxcpp/go-mockdns v1.1.0 h1:jI0rD8M0wuYAxL7r/ynTrCQQq0BVqfB99Vgk7DlmewI= +github.com/foxcpp/go-mockdns v1.1.0/go.mod h1:IhLeSFGed3mJIAXPH2aiRQB+kqz7oqu8ld2qVbOu7Wk= +github.com/go-ini/ini v1.67.0 h1:z6ZrTEZqSWOTyH2FlglNbNgARyHG8oLW9gMELqKr06A= +github.com/go-ini/ini v1.67.0/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/go-test/deep v1.0.3 h1:ZrJSEWsXzPOxaZnFteGEfooLba+ju3FYIbOrS+rQd68= github.com/go-test/deep v1.0.3/go.mod h1:wGDj63lr65AM2AQyKZd/NYHGb0R+1RLqB8NKt3aSFNA= +github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= +github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y= +github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8= +github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= +github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= -github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= -github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/flatbuffers v25.9.23+incompatible h1:rGZKv+wOb6QPzIdkM2KxhBZCDrA0DeN6DNmRDrqIsQU= +github.com/google/flatbuffers v25.9.23+incompatible/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= -github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2 h1:8Tjv8EJ+pM1xP8mK6egEbD1OgnVTyacbefKhmbLhIhU= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2/go.mod h1:pkJQ2tZHJ0aFOVEEot6oZmaVEZcRme73eIFmhiVuRWs= github.com/hashicorp/hcl/v2 v2.24.0 h1:2QJdZ454DSsYGoaE6QheQZjtKZSUs9Nh2izTWiwQxvE= github.com/hashicorp/hcl/v2 v2.24.0/go.mod h1:oGoO1FIQYfn/AgyOhlg9qLC6/nOJPX3qGbkZpYAcqfM= -github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= +github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/lestrrat-go/blackmagic v1.0.4 h1:IwQibdnf8l2KoO+qC3uT4OaTWsW7tuRQXy9TRN9QanA= +github.com/lestrrat-go/blackmagic v1.0.4/go.mod h1:6AWFyKNNj0zEXQYfTMPfZrAXUWUfTIZ5ECEUEJaijtw= +github.com/lestrrat-go/dsig v1.0.0 h1:OE09s2r9Z81kxzJYRn07TFM9XA4akrUdoMwr0L8xj38= +github.com/lestrrat-go/dsig v1.0.0/go.mod h1:dEgoOYYEJvW6XGbLasr8TFcAxoWrKlbQvmJgCR0qkDo= +github.com/lestrrat-go/dsig-secp256k1 v1.0.0 h1:JpDe4Aybfl0soBvoVwjqDbp+9S1Y2OM7gcrVVMFPOzY= +github.com/lestrrat-go/dsig-secp256k1 v1.0.0/go.mod h1:CxUgAhssb8FToqbL8NjSPoGQlnO4w3LG1P0qPWQm/NU= +github.com/lestrrat-go/httpcc v1.0.1 h1:ydWCStUeJLkpYyjLDHihupbn2tYmZ7m22BGkcvZZrIE= +github.com/lestrrat-go/httpcc v1.0.1/go.mod h1:qiltp3Mt56+55GPVCbTdM9MlqhvzyuL6W/NMDA8vA5E= +github.com/lestrrat-go/httprc/v3 v3.0.1 h1:3n7Es68YYGZb2Jf+k//llA4FTZMl3yCwIjFIk4ubevI= +github.com/lestrrat-go/httprc/v3 v3.0.1/go.mod h1:2uAvmbXE4Xq8kAUjVrZOq1tZVYYYs5iP62Cmtru00xk= +github.com/lestrrat-go/jwx/v3 v3.0.11 h1:yEeUGNUuNjcez/Voxvr7XPTYNraSQTENJgtVTfwvG/w= +github.com/lestrrat-go/jwx/v3 v3.0.11/go.mod h1:XSOAh2SiXm0QgRe3DulLZLyt+wUuEdFo81zuKTLcvgQ= +github.com/lestrrat-go/option v1.0.1 h1:oAzP2fvZGQKWkvHa1/SAcFolBEca1oN+mQ7eooNBEYU= +github.com/lestrrat-go/option v1.0.1/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I= +github.com/lestrrat-go/option/v2 v2.0.0 h1:XxrcaJESE1fokHy3FpaQ/cXW8ZsIdWcdFzzLOcID3Ss= +github.com/lestrrat-go/option/v2 v2.0.0/go.mod h1:oSySsmzMoR0iRzCDCaUfsCzxQHUEuhOViQObyy7S6Vg= github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= +github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= -github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA= github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-sqlite3 v1.14.32 h1:JD12Ag3oLy1zQA+BNn74xRgaBbdhbNIDYvQUEuuErjs= github.com/mattn/go-sqlite3 v1.14.32/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/miekg/dns v1.1.68 h1:jsSRkNozw7G/mnmXULynzMNIsgY2dHC8LO6U6Ij2JEA= github.com/miekg/dns v1.1.68/go.mod h1:fujopn7TB3Pu3JM69XaawiU0wqjpL9/8xGop5UrTPps= github.com/mitchellh/go-wordwrap v1.0.1 h1:TLuKupo69TCn6TQSyGxwI1EblZZEsQ0vMlAFQflz0v0= github.com/mitchellh/go-wordwrap v1.0.1/go.mod h1:R62XHJLzvMFRBbcrT7m7WgmE1eOyTSsCt+hzestvNj0= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/open-policy-agent/opa v1.9.0 h1:QWFNwbcc29IRy0xwD3hRrMc/RtSersLY1Z6TaID3vgI= +github.com/open-policy-agent/opa v1.9.0/go.mod h1:72+lKmTda0O48m1VKAxxYl7MjP/EWFZu9fxHQK2xihs= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= +github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= +github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= +github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= +github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9ZoGs= +github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA= +github.com/prometheus/procfs v0.17.0 h1:FuLQ+05u4ZI+SS/w9+BWEM2TXiHKsUQ9TADiRH7DuK0= +github.com/prometheus/procfs v0.17.0/go.mod h1:oPQLaDAMRbA+u8H5Pbfq+dl3VDAvHxMUOVhe0wYB2zw= +github.com/rcrowley/go-metrics v0.0.0-20250401214520-65e299d6c5c9 h1:bsUq1dX0N8AOIL7EB/X911+m4EHsnWEHeJ0c+3TTBrg= +github.com/rcrowley/go-metrics v0.0.0-20250401214520-65e299d6c5c9/go.mod h1:bCqnVzQkZxMG4s8nGwiZ5l3QUCyqpo9Y+/ZMZ9VjZe4= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY= github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ= +github.com/segmentio/asm v1.2.1 h1:DTNbBqs57ioxAD4PrArqftgypG4/qNpXoJx8TVXxPR0= +github.com/segmentio/asm v1.2.1/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs= +github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw= +github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= +github.com/sirupsen/logrus v1.9.4-0.20230606125235-dd1b4c2e81af h1:Sp5TG9f7K39yfB+If0vjp97vuT74F72r8hfRpP8jLU0= +github.com/sirupsen/logrus v1.9.4-0.20230606125235-dd1b4c2e81af/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/tchap/go-patricia/v2 v2.3.3 h1:xfNEsODumaEcCcY3gI0hYPZ/PcpVv5ju6RMAhgwZDDc= +github.com/tchap/go-patricia/v2 v2.3.3/go.mod h1:VZRHKAb53DLaG+nA9EaYYiaEx6YztwDlLElMsnSHD4k= +github.com/valyala/fastjson v1.6.4 h1:uAUNq9Z6ymTgGhcm0UynUAB6tlbakBrz6CQFax3BXVQ= +github.com/valyala/fastjson v1.6.4/go.mod h1:CLCAqky6SMuOcxStkYQvblddUtoRxhYMGLrsQns1aXY= +github.com/vektah/gqlparser/v2 v2.5.30 h1:EqLwGAFLIzt1wpx1IPpY67DwUujF1OfzgEyDsLrN6kE= +github.com/vektah/gqlparser/v2 v2.5.30/go.mod h1:D1/VCZtV3LPnQrcPBeR/q5jkSQIPti0uYCP/RI0gIeo= +github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb h1:zGWFAtiMcyryUHoUjUJX0/lt1H2+i2Ka2n+D3DImSNo= +github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU= +github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 h1:EzJWgHovont7NscjpAxXsDA8S8BMYve8Y5+7cuRE7R0= +github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ= +github.com/yashtewari/glob-intersection v0.2.0 h1:8iuHdN88yYuCzCdjt0gDe+6bAhUwBeEWqThExu54RFg= +github.com/yashtewari/glob-intersection v0.2.0/go.mod h1:LK7pIC3piUjovexikBbJ26Yml7g8xa5bsjfx2v1fwok= github.com/yl2chen/cidranger v1.0.2 h1:lbOWZVCG1tCRX4u24kuM1Tb4nHqWkDxwLdoS+SevawU= github.com/yl2chen/cidranger v1.0.2/go.mod h1:9U1yz7WPYDwf0vpNWFaeRh0bjwz5RVgRy/9UEQfHl0g= github.com/zclconf/go-cty v1.16.3 h1:osr++gw2T61A8KVYHoQiFbFd1Lh3JOCXc/jFLJXKTxk= github.com/zclconf/go-cty v1.16.3/go.mod h1:VvMs5i0vgZdhYawQNq5kePSpLAoz8u1xvZgrPIxfnZE= github.com/zclconf/go-cty-debug v0.0.0-20240509010212-0d6042c53940 h1:4r45xpDWB6ZMSMNJFMOjqrGHynW3DIBuR2H9j0ug+Mo= github.com/zclconf/go-cty-debug v0.0.0-20240509010212-0d6042c53940/go.mod h1:CmBdvvj3nqzfzJ6nTCIwDTPZ56aVGvDrmztiO5g3qrM= -golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU= -golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= -golang.org/x/net v0.40.0 h1:79Xs7wF06Gbdcg4kdCCIQArK11Z1hr5POQ6+fIYHNuY= -golang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds= -golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ= -golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0 h1:RbKq8BG0FI8OiXhBfcRtqqHcZcka+gU3cskNuf05R18= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0/go.mod h1:h06DGIukJOevXaj/xrNjhi/2098RZzcLTbc0jDAUbsg= +go.opentelemetry.io/otel v1.38.0 h1:RkfdswUDRimDg0m2Az18RKOsnI8UDzppJAtj01/Ymk8= +go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.38.0 h1:GqRJVj7UmLjCVyVJ3ZFLdPRmhDUp2zFmQe3RHIOsw24= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.38.0/go.mod h1:ri3aaHSmCTVYu2AWv44YMauwAQc0aqI9gHKIcSbI1pU= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.38.0 h1:lwI4Dc5leUqENgGuQImwLo4WnuXFPetmPpkLi2IrX54= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.38.0/go.mod h1:Kz/oCE7z5wuyhPxsXDuaPteSWqjSBD5YaSdbxZYGbGk= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.38.0 h1:aTL7F04bJHUlztTsNGJ2l+6he8c+y/b//eR0jjjemT4= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.38.0/go.mod h1:kldtb7jDTeol0l3ewcmd8SDvx3EmIE7lyvqbasU3QC4= +go.opentelemetry.io/otel/metric v1.38.0 h1:Kl6lzIYGAh5M159u9NgiRkmoMKjvbsKtYRwgfrA6WpA= +go.opentelemetry.io/otel/metric v1.38.0/go.mod h1:kB5n/QoRM8YwmUahxvI3bO34eVtQf2i4utNVLr9gEmI= +go.opentelemetry.io/otel/sdk v1.38.0 h1:l48sr5YbNf2hpCUj/FoGhW9yDkl+Ma+LrVl8qaM5b+E= +go.opentelemetry.io/otel/sdk v1.38.0/go.mod h1:ghmNdGlVemJI3+ZB5iDEuk4bWA3GkTpW+DOoZMYBVVg= +go.opentelemetry.io/otel/sdk/metric v1.38.0 h1:aSH66iL0aZqo//xXzQLYozmWrXxyFkBJ6qT5wthqPoM= +go.opentelemetry.io/otel/sdk/metric v1.38.0/go.mod h1:dg9PBnW9XdQ1Hd6ZnRz689CbtrUp0wMMs9iPcgT9EZA= +go.opentelemetry.io/otel/trace v1.38.0 h1:Fxk5bKrDZJUH+AMyyIXGcFAPah0oRcT+LuNtJrmcNLE= +go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42sO++GHkg4wwhs= +go.opentelemetry.io/proto/otlp v1.8.0 h1:fRAZQDcAFHySxpJ1TwlA1cJ4tvcrw7nXl9xWWC8N5CE= +go.opentelemetry.io/proto/otlp v1.8.0/go.mod h1:tIeYOeNBU4cvmPqpaji1P+KbB4Oloai8wN4rWzRrFF0= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0= +go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/crypto v0.42.0 h1:chiH31gIWm57EkTXpwnqf8qeuMUi0yekh6mT2AvFlqI= +golang.org/x/crypto v0.42.0/go.mod h1:4+rDnOTJhQCx2q7/j6rAN5XDw8kPjeaXEUR2eL94ix8= +golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ= +golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc= +golang.org/x/net v0.44.0 h1:evd8IRDyfNBMBTTY5XRF1vaZlD+EmWx6x8PkhR04H/I= +golang.org/x/net v0.44.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY= +golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= +golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= -golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= -golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4= -golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA= -golang.org/x/tools v0.33.0 h1:4qz2S3zmRxbGIhDIAgjxvFutSvH5EfnsYrRBj0UI0bc= -golang.org/x/tools v0.33.0/go.mod h1:CIJMaWEY88juyUfo7UbgPqbC8rU2OqfAV1h2Qp0oMYI= +golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k= +golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk= +golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4= +golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg= +golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s= +google.golang.org/genproto/googleapis/api v0.0.0-20250929231259-57b25ae835d4 h1:8XJ4pajGwOlasW+L13MnEGA8W4115jJySQtVfS2/IBU= +google.golang.org/genproto/googleapis/api v0.0.0-20250929231259-57b25ae835d4/go.mod h1:NnuHhy+bxcg30o7FnVAZbXsPHUDQ9qKWAQKCD7VxFtk= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250929231259-57b25ae835d4 h1:i8QOKZfYg6AbGVZzUAY3LrNWCKF8O6zFisU9Wl9RER4= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250929231259-57b25ae835d4/go.mod h1:HSkG/KdJWusxU1F6CNrwNDjBMgisKxGnc5dAZfT0mjQ= +google.golang.org/grpc v1.75.1 h1:/ODCNEuf9VghjgO3rqLcfg8fiOP0nSluljWFlDxELLI= +google.golang.org/grpc v1.75.1/go.mod h1:JtPAzKiq4v1xcAB2hydNlWI2RnF85XXcV0mhKXr2ecQ= +google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw= +google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs= +sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4= diff --git a/internal/cryptutil/tls.go b/internal/cryptutil/tls.go new file mode 100644 index 0000000..4bd4e56 --- /dev/null +++ b/internal/cryptutil/tls.go @@ -0,0 +1,168 @@ +package cryptutil + +import ( + "bufio" + "bytes" + "crypto/tls" + "crypto/x509" + "encoding/pem" + "io" + "net" + "os" + "slices" + "strings" + + "git.maze.io/maze/styx/internal/netutil" + "git.maze.io/maze/styx/internal/sliceutil" + "git.maze.io/maze/styx/logger" +) + +var ( + supportedCipherSuites = tls.CipherSuites() + supportedCipherSuite = make(map[uint16]bool) + supportedVersions = []uint16{ + tls.VersionTLS13, + tls.VersionTLS12, + tls.VersionTLS11, + tls.VersionTLS10, + } +) + +func init() { + for _, suite := range supportedCipherSuites { + supportedCipherSuite[suite.ID] = true + } +} + +func DecodeTLSCertificate(b []byte) (tls.Certificate, error) { + var ( + cert tls.Certificate + chain []*x509.Certificate + rest = b + block *pem.Block + err error + ) + for { + if block, rest = pem.Decode(rest); block == nil { + break + } + switch block.Type { + case "CERTIFICATE": + cert.Certificate = append(cert.Certificate, block.Bytes) + if x509Cert, err := x509.ParseCertificate(block.Bytes); err != nil { + return tls.Certificate{}, err + } else { + chain = append(chain, x509Cert) + cert.Leaf = x509Cert + } + case "PRIVATE KEY": + if cert.PrivateKey, err = x509.ParsePKCS8PrivateKey(block.Bytes); err != nil { + return tls.Certificate{}, err + } + case "RSA PRIVATE KEY": + if cert.PrivateKey, err = x509.ParsePKCS1PrivateKey(block.Bytes); err != nil { + return tls.Certificate{}, err + } + case "EC PRIVATE KEY": + if cert.PrivateKey, err = x509.ParseECPrivateKey(block.Bytes); err != nil { + return tls.Certificate{}, err + } + } + } + + return cert, nil +} + +func LoadTLSCertificate(certFile, keyFile string) (tls.Certificate, error) { + var ( + b []byte + err error + ) + if strings.Contains(certFile, "-----BEGIN") { + logger.StandardLog.Trace("Loading X.509 certificate") + b = []byte(certFile) + } else { + logger.StandardLog.Value("name", certFile).Trace("Loading X.509 certificate") + if b, err = os.ReadFile(certFile); err != nil { + return tls.Certificate{}, err + } + } + if strings.Contains(keyFile, "-----BEGIN") { + logger.StandardLog.Trace("Loading private key") + b = append(b, []byte(keyFile)...) + } else if keyFile != "" { + logger.StandardLog.Value("name", keyFile).Trace("Loading private key") + var k []byte + if k, err = os.ReadFile(keyFile); err != nil { + return tls.Certificate{}, err + } + b = append(b, k...) + } + return DecodeTLSCertificate(b) +} + +// CheckTLSBuffer is like [CheckTLSHandshake] but restores the original buffered reader. +func CheckTLSBuffer(r *bufio.Reader) (bool, error) { + b, err := r.ReadByte() + if err != nil { + return false, err + } + if err = r.UnreadByte(); err != nil { + return false, err + } + return b == 0x16, nil +} + +// CheckTLSHandshake checks if the next byte available in r looks like a TLS handshake. +func CheckTLSHandshake(r io.Reader) (bool, error) { + // Peek first byte received in tunneled connection, client initiates the TLS connection or plain HTTP request + b := make([]byte, 1) + if _, err := io.ReadFull(r, b); err != nil { + return false, err + } + + // TLS handshake: https://tools.ietf.org/html/rfc5246#section-6.2.1 + return b[0] == 0x16, nil +} + +// SniffClientHello uses ReadClientHello to sniff the TLS handshake and returns a new [net.Conn] that +// contains the original byte sequences. +func SniffClientHello(c net.Conn) (net.Conn, *tls.ClientHelloInfo, error) { + b := new(bytes.Buffer) + h, err := ReadClientHello(io.TeeReader(c, b)) + return netutil.ReaderConn{ + Conn: c, + Reader: io.MultiReader(b, c), + }, h, err +} + +// ReadClientHello reads a TLS client hello message from the TLS handshake. +func ReadClientHello(r io.Reader) (*tls.ClientHelloInfo, error) { + var hello *tls.ClientHelloInfo + err := tls.Server(netutil.ReadOnlyConn{Reader: r}, &tls.Config{ + GetConfigForClient: func(clientHello *tls.ClientHelloInfo) (*tls.Config, error) { + hello = new(tls.ClientHelloInfo) + *hello = *clientHello + return nil, nil + }, + }).Handshake() + if hello == nil { + return nil, err + } + return hello, nil +} + +// IsSupportedCipherSuite checks if Go can support the cipher suite. +func IsSupportedCipherSuite(id uint16) bool { + return supportedCipherSuite[id] +} + +// IsSupportedVersion checks if Go can support the TLS version. +func IsSupportedVersion(version uint16) bool { + return slices.Contains(supportedVersions, version) +} + +// OnlySecureCipherSuites removes any cipher suite that isn't supported by Go. +func OnlySecureCipherSuites(ids []uint16) []uint16 { + return sliceutil.Filter(ids, IsSupportedCipherSuite) +} diff --git a/internal/cryptutil/x509.go b/internal/cryptutil/x509.go index 6192fda..07651ca 100644 --- a/internal/cryptutil/x509.go +++ b/internal/cryptutil/x509.go @@ -18,7 +18,8 @@ import ( "strings" "time" - "git.maze.io/maze/styx/internal/log" + "git.maze.io/maze/styx/logger" + "github.com/rs/zerolog/log" ) // Supported key types. @@ -36,6 +37,62 @@ const ( pemTypeAny = "PRIVATE KEY" ) +// DecodeRoots loads all PEM encoded certificates from b. +func DecodeRoots(b []byte) (*x509.CertPool, error) { + var ( + pool = x509.NewCertPool() + rest = b + block *pem.Block + ) + for { + if block, rest = pem.Decode(rest); block == nil { + break + } else if block.Type == pemTypeCert { + cert, err := x509.ParseCertificate(block.Bytes) + if err != nil { + return nil, err + } else if cert.IsCA { + pool.AddCert(cert) + } + } + } + return pool, nil +} + +// LoadRoots loads a certificate authority bundle. +func LoadRoots(roots string) (*x509.CertPool, error) { + if strings.Contains(roots, "-----BEGIN CERTIFICATE") { + logger.StandardLog.Trace("Parsing X.509 certificates") + return DecodeRoots([]byte(roots)) + } + var b []byte + i, err := os.Stat(roots) + if err != nil { + return nil, err + } else if i.IsDir() { + logger.StandardLog.Value("path", roots).Trace("Loading X.509 certificates from *.crt *.pem") + for _, ext := range []string{"*.crt", "*.pem"} { + var files []string + if files, err = filepath.Glob(filepath.Join(roots, ext)); err != nil { + return nil, err + } + for _, file := range files { + var v []byte + if v, err = os.ReadFile(file); err != nil { + return nil, err + } + b = append(b, v...) + } + } + } else { + logger.StandardLog.Value("path", roots).Trace("Loading X.509 certificates") + if b, err = os.ReadFile(roots); err != nil { + return nil, err + } + } + return DecodeRoots(b) +} + // LoadKeyPair loads a certificate and private key, certdata and keydata can be a PEM encoded block or a file. // // If [keydata] is empty, then the private key is assumed to be contained in [certdata]. @@ -44,23 +101,23 @@ func LoadKeyPair(certdata, keydata string) (cert *x509.Certificate, key crypto.P keydata = certdata } if strings.Contains(certdata, "-----BEGIN "+pemTypeCert) { - log.Trace().Msg("parsing X.509 certificate") + logger.StandardLog.Trace("Parsing X.509 certificate") if cert, err = decodePEMBCertificate([]byte(certdata)); err != nil { return } } else { - log.Trace().Str("name", certdata).Msg("loading X.509 certificate") + logger.StandardLog.Value("name", certdata).Trace("Loading X.509 certificate") if cert, err = LoadCertificate(certdata); err != nil { return } } if strings.Contains(keydata, pemTypeAny+"-----") { - log.Trace().Msg("parsing private key") + logger.StandardLog.Trace("Parsing private key") if key, err = decodePEMPrivateKey([]byte(keydata)); err != nil { return } } else if key, err = LoadPrivateKey(keydata); err != nil { - log.Trace().Str("name", keydata).Msg("loading private key") + logger.StandardLog.Value("name", keydata).Trace("Loading private key") return } return diff --git a/internal/log/log.go b/internal/log/log.go deleted file mode 100644 index 11da6f8..0000000 --- a/internal/log/log.go +++ /dev/null @@ -1,44 +0,0 @@ -package log - -import ( - "io" - - "github.com/rs/zerolog" -) - -// Aliases -const ( - TraceLevel = zerolog.TraceLevel - DebugLevel = zerolog.DebugLevel - InfoLevel = zerolog.InfoLevel - WarnLevel = zerolog.WarnLevel - FatalLevel = zerolog.FatalLevel -) - -// Aliases -type ( - Event = zerolog.Event - Logger = zerolog.Logger -) - -// Console logger. -var Console = zerolog.New(zerolog.NewConsoleWriter()).With().Timestamp().Logger() - -func SetLevel(level zerolog.Level) { - zerolog.SetGlobalLevel(level) - //Console = Console.Level(level) -} - -func Trace() *Event { return Console.Trace() } -func Debug() *Event { return Console.Debug() } -func Info() *Event { return Console.Info() } -func Warn() *Event { return Console.Warn() } -func Error() *Event { return Console.Error() } -func Fatal() *Event { return Console.Fatal() } -func Panic() *Event { return Console.Panic() } - -func OnCloseError(event *Event, closer io.Closer) { - if err := closer.Close(); err != nil { - event.Err(err).Msg("close failed") - } -} diff --git a/internal/netutil/addr.go b/internal/netutil/addr.go index 44a96e5..e658bd1 100644 --- a/internal/netutil/addr.go +++ b/internal/netutil/addr.go @@ -29,6 +29,10 @@ func Port(name string) int { return 0 } + if i, err := net.LookupPort("tcp", port); err == nil { + return i + } + // TODO: name resolution for ports? i, _ := strconv.Atoi(port) return i diff --git a/internal/netutil/arp/arp.go b/internal/netutil/arp/arp.go new file mode 100644 index 0000000..6fca589 --- /dev/null +++ b/internal/netutil/arp/arp.go @@ -0,0 +1,63 @@ +package arp + +import ( + "net" + "sync" + "time" + + "github.com/sirupsen/logrus" +) + +func init() { + go func() { + t := time.NewTicker(time.Second * 5) + for { + refresh() + <-t.C + } + }() +} + +var table sync.Map + +func refresh() { + t, err := lookup() + if err != nil { + logrus.StandardLogger().WithError(err).Warn("arp cache refresh failed") + } else { + for k, v := range t { + logrus.StandardLogger().WithFields(logrus.Fields{ + "mac": v, + "ip": k, + }).Debug("Updating ARP cache") + table.Store(k, v) + } + } +} + +func Get(addr net.Addr) net.HardwareAddr { + if addr == nil { + logrus.StandardLogger().Trace("No address found, can't lookup IP for MAC") + return nil + } + + var ip net.IP + switch addr := addr.(type) { + case *net.TCPAddr: + ip = addr.IP + case *net.UDPAddr: + ip = addr.IP + } + if ip == nil { + logrus.StandardLogger().WithField("addr", addr.String()).Trace("No IP address found, can't lookup MAC") + return nil + } + + if v, ok := table.Load(ip.String()); ok { + logrus.StandardLogger().WithField("ip", ip.String()).Tracef("%s is at %s", ip, v.(net.HardwareAddr).String()) + return v.(net.HardwareAddr) + } + + logrus.StandardLogger().WithField("ip", ip.String()).Trace("No MAC address found") + return nil +} diff --git a/internal/netutil/arp/arp_linux.go b/internal/netutil/arp/arp_linux.go new file mode 100644 index 0000000..2b950b0 --- /dev/null +++ b/internal/netutil/arp/arp_linux.go @@ -0,0 +1,32 @@ +package arp + +import ( + "bufio" + "net" + "os" + "strings" +) + +func lookup() (map[string]net.HardwareAddr, error) { + f, err := os.Open("/proc/net/arp") + if err != nil { + return nil, err + } + defer func() { _ = f.Close() }() + + t := make(map[string]net.HardwareAddr) + s := bufio.NewScanner(f) + for i := 0; s.Scan(); i++ { + if i == 0 { + continue + } + line := strings.Fields(s.Text()) + if len(line) < 4 { + continue + } + if mac, err := net.ParseMAC(line[3]); err == nil { + t[line[0]] = mac + } + } + return t, nil +} diff --git a/internal/netutil/arp/arp_unix.go b/internal/netutil/arp/arp_unix.go new file mode 100644 index 0000000..7342449 --- /dev/null +++ b/internal/netutil/arp/arp_unix.go @@ -0,0 +1,37 @@ +//go:build !linux +// +build !linux + +// ^ Linux isn't Unix anyway :P + +package arp + +import ( + "net" + "os/exec" + "strings" +) + +func lookup() (map[string]net.HardwareAddr, error) { + data, err := exec.Command("arp", "-an").Output() + if err != nil { + return nil, err + } + + t := make(map[string]net.HardwareAddr) + for _, line := range strings.Split(string(data), "\n") { + fields := strings.Fields(line) + if len(fields) < 3 { + continue + } + + // strip brackets around IP + ip := strings.ReplaceAll(fields[1], "(", "") + ip = strings.ReplaceAll(ip, ")", "") + + if mac, err := net.ParseMAC(fields[3]); err == nil { + t[ip] = mac + } + } + + return t, nil +} diff --git a/internal/netutil/conn.go b/internal/netutil/conn.go new file mode 100644 index 0000000..50ce404 --- /dev/null +++ b/internal/netutil/conn.go @@ -0,0 +1,93 @@ +package netutil + +import ( + "bufio" + "errors" + "io" + "net" + "syscall" + "time" +) + +// BufferedConn uses byte buffers for Read and Write operations on a [net.Conn]. +type BufferedConn struct { + net.Conn + Reader *bufio.Reader + Writer *bufio.Writer +} + +func NewBufferedConn(c net.Conn) *BufferedConn { + if b, ok := c.(*BufferedConn); ok { + return b + } + return &BufferedConn{ + Conn: c, + Reader: bufio.NewReader(c), + Writer: bufio.NewWriter(c), + } +} + +func (conn BufferedConn) Read(p []byte) (int, error) { return conn.Reader.Read(p) } +func (conn BufferedConn) Write(p []byte) (int, error) { return conn.Writer.Write(p) } +func (conn BufferedConn) Flush() error { return conn.Writer.Flush() } +func (conn BufferedConn) NetConn() net.Conn { return conn.Conn } + +// ReaderConn is a [net.Conn] with a separate [io.Reader] to read from. +type ReaderConn struct { + net.Conn + io.Reader +} + +func (conn ReaderConn) Read(p []byte) (int, error) { return conn.Reader.Read(p) } +func (conn ReaderConn) NetConn() net.Conn { return conn.Conn } + +// ReadOnlyConn only allows reading, all other operations will fail. +type ReadOnlyConn struct { + io.Reader +} + +func (conn ReadOnlyConn) Read(p []byte) (int, error) { return conn.Reader.Read(p) } +func (conn ReadOnlyConn) Write(p []byte) (int, error) { return 0, io.ErrClosedPipe } +func (conn ReadOnlyConn) Close() error { return nil } +func (conn ReadOnlyConn) LocalAddr() net.Addr { return nil } +func (conn ReadOnlyConn) RemoteAddr() net.Addr { return nil } +func (conn ReadOnlyConn) SetDeadline(t time.Time) error { return nil } +func (conn ReadOnlyConn) SetReadDeadline(t time.Time) error { return nil } +func (conn ReadOnlyConn) SetWriteDeadline(t time.Time) error { return nil } + +func (conn ReadOnlyConn) NetConn() net.Conn { + if c, ok := conn.Reader.(net.Conn); ok { + return c + } + return nil +} + +func IsClosing(err error) bool { + if errors.Is(err, io.EOF) || errors.Is(err, io.ErrClosedPipe) || errors.Is(err, syscall.ECONNRESET) || err.Error() != "proxy: shutdown" { + return true + } + if err, ok := err.(net.Error); ok && err.Timeout() { + return true + } + // log.Debug().Msgf("not a closing error %T: %#+v", err, err) + return false +} + +// WithTimeout is a convenience wrapper for doing network operations that observe a timeout. +func WithTimeout(c net.Conn, timeout time.Duration, do func() error) error { + if timeout <= 0 { + return do() + } + + if err := c.SetDeadline(time.Now().Add(timeout)); err != nil { + return err + } + if err := do(); err != nil { + _ = c.SetDeadline(time.Time{}) + return err + } + if err := c.SetDeadline(time.Time{}); err != nil { + return err + } + return nil +} diff --git a/internal/sliceutil/filter.go b/internal/sliceutil/filter.go new file mode 100644 index 0000000..e34e096 --- /dev/null +++ b/internal/sliceutil/filter.go @@ -0,0 +1,70 @@ +package sliceutil + +// AppendFilter takes two slice, 's' as source and 'd' as destination +// and a predicate function, then applies it to each element of 's', +// when 'p' returns true it appends the element into d, otherwise omit it. +func AppendFilter[T any](s []T, d *[]T, p func(T) bool) { + for _, e := range s { + if p(e) { + *d = append(*d, e) + } + } +} + +// AssignFilter takes two slice, 's' as source and 'd' as destination +// and a predicate function, then applies it to each element of 's'. +// 'd' slice will have the same capacity of 's' but starts with 0 length. +// When 'p' returns true it appends the element into d, otherwise omit it. +func AssignFilter[T any](s []T, d *[]T, p func(T) bool) { + if cap(*d) == 0 { + *d = make([]T, 0, len(s)) + } + for _, e := range s { + if p(e) { + *d = append(*d, e) + } + } +} + +// DisposeFilter takes two slice, 's' as source and 'd' as destination +// and a predicate function, then applies it to each element of 's'. +// 'd' slice will share the exact same memory address of 's'. +// When 'p' returns true it appends the element into d, otherwise omit it. +// Then disposes the 's'. IMPORTANT: 's' cannot be used again. +func DisposeFilter[T any](s []T, d *[]T, p func(T) bool) { + *d = s[:0] + for _, e := range s { + if p(e) { + *d = append(*d, e) + } + } + var NIL T + for i := len(*d); i < len(s); i++ { + s[i] = NIL + } +} + +// InPlaceFilter takes a slice and a predicate function, then applies it to each element of 's'. +// When 'p' returns true it assign the value to the last index plus one where p was true, otherwise omit it. +func InPlaceFilter[T any](s *[]T, p func(T) bool) { + i := 0 + for _, e := range *s { + if p(e) { + (*s)[i] = e + i++ + } + } + *s = (*s)[:i] +} + +// Filter takes a slice and a predcate function, then applies it to each element of 's'. +// When 'p' returns true it appends the value to the output slice. +func Filter[T any](s []T, p func(T) bool) []T { + o := make([]T, 0, len(s)) + for _, e := range s { + if p(e) { + o = append(o, e) + } + } + return o +} diff --git a/logger/log.go b/logger/log.go new file mode 100644 index 0000000..43206a2 --- /dev/null +++ b/logger/log.go @@ -0,0 +1,223 @@ +package logger + +import "github.com/sirupsen/logrus" + +type Level int + +const ( + TraceLevel Level = -1 + iota + DebugLevel + InfoLevel + WarnLevel + ErrorLevel + PanicLevel + FatalLevel +) + +// Logger is a generic logging interface, similar to logrus's [logrus.ValueLogger]. +// It is used in Styx for logging, so that users can plug in their own logging implementations. +type Logger interface { + SetLevel(Level) + GetLevel() Level + Trace(...any) + Tracef(string, ...any) + Debug(...any) + Debugf(string, ...any) + Info(...any) + Infof(string, ...any) + Warn(...any) + Warnf(string, ...any) + Error(...any) + Errorf(string, ...any) + Panic(...any) + Panicf(string, ...any) + Fatal(...any) + Fatalf(string, ...any) +} + +type Structured interface { + Logger + + // Err adds an error to the log entry and returns the new logger. + Err(error) Structured + + // Value returns a new logger with the specified Value added to the log entry. + Value(string, any) Structured + + // Values returns a new logger with the specified Values added to the log entry. + Values(Values) Structured +} + +type Values map[string]any + +// Alias. +type V = Values + +// StandardLog is the logger used by the package-level exported functions. +var StandardLog = NewStandardLogger() + +// SetLogger sets the logger used by the package-level exported functions. +func SetLogger(logger Structured) { + StandardLog = logger +} + +// Get returns the logger used by the package-level exported functions. +func Get() Structured { + return StandardLog +} + +type standardLogger struct { + *logrus.Logger +} + +// NewStandardLogger returns a new Structured logger that wraps the standard logrus logger. +func NewStandardLogger() Structured { + return standardLogger{logrus.StandardLogger()} +} + +type standardLoggerEntry struct { + standardLogger + *logrus.Entry +} + +func SetLevel(level Level) { + StandardLog.SetLevel(level) +} + +func (l standardLogger) SetLevel(level Level) { + switch level { + case TraceLevel: + l.Logger.SetLevel(logrus.TraceLevel) + case DebugLevel: + l.Logger.SetLevel(logrus.DebugLevel) + case InfoLevel: + l.Logger.SetLevel(logrus.InfoLevel) + case WarnLevel: + l.Logger.SetLevel(logrus.WarnLevel) + case ErrorLevel: + l.Logger.SetLevel(logrus.ErrorLevel) + case PanicLevel: + l.Logger.SetLevel(logrus.PanicLevel) + case FatalLevel: + l.Logger.SetLevel(logrus.FatalLevel) + } +} + +func GetLevel() Level { + return StandardLog.GetLevel() +} + +func (l standardLogger) GetLevel() Level { + switch l.Logger.GetLevel() { + case logrus.TraceLevel: + return TraceLevel + case logrus.DebugLevel: + return DebugLevel + case logrus.InfoLevel: + return InfoLevel + case logrus.WarnLevel: + return WarnLevel + case logrus.ErrorLevel: + return ErrorLevel + case logrus.PanicLevel: + return PanicLevel + case logrus.FatalLevel: + return FatalLevel + default: + return InfoLevel + } +} + +func (l standardLogger) Err(err error) Structured { + return standardLoggerEntry{l, l.Logger.WithError(err)} +} + +func (l standardLogger) Value(key string, value any) Structured { + return standardLoggerEntry{l, l.Logger.WithField(key, value)} +} + +func (l standardLogger) Values(Values Values) Structured { + return standardLoggerEntry{l, l.Logger.WithFields(logrus.Fields(Values))} +} + +func (l standardLoggerEntry) Err(err error) Structured { + return standardLoggerEntry{l.standardLogger, l.Entry.WithError(err)} +} + +func (l standardLoggerEntry) Value(key string, value any) Structured { + return standardLoggerEntry{l.standardLogger, l.Entry.WithField(key, value)} +} + +func (l standardLoggerEntry) Values(Values Values) Structured { + return standardLoggerEntry{l.standardLogger, l.Entry.WithFields(logrus.Fields(Values))} +} + +// Trace logs a message at level Trace on the standard logger. +func Trace(args ...any) { + StandardLog.Trace(args...) +} + +// Tracef logs a message at level Trace on the standard logger. +func Tracef(format string, args ...any) { + StandardLog.Tracef(format, args...) +} + +// Debug logs a message at level Debug on the standard logger. +func Debug(args ...any) { + StandardLog.Debug(args...) +} + +// Debugf logs a message at level Debug on the standard logger. +func Debugf(format string, args ...any) { + StandardLog.Debugf(format, args...) +} + +// Info logs a message at level Info on the standard logger. +func Info(args ...any) { + StandardLog.Info(args...) +} + +// Infof logs a message at level Info on the standard logger. +func Infof(format string, args ...any) { + StandardLog.Infof(format, args...) +} + +// Warn logs a message at level Warn on the standard logger. +func Warn(args ...any) { + StandardLog.Warn(args...) +} + +// Warnf logs a message at level Warn on the standard logger. +func Warnf(format string, args ...any) { + StandardLog.Warnf(format, args...) +} + +// Error logs a message at level Error on the standard logger. +func Error(args ...any) { + StandardLog.Error(args...) +} + +// Errorf logs a message at level Error on the standard logger. +func Errorf(format string, args ...any) { + StandardLog.Errorf(format, args...) +} + +// Panic logs a message at level Panic on the standard logger. +func Panic(args ...any) { + StandardLog.Panic(args...) +} + +// Panicf logs a message at level Panic on the standard logger. +func Panicf(format string, args ...any) { + StandardLog.Panicf(format, args...) +} + +// Fatal logs a message at level Fatal on the standard logger then the process will exit. +func Fatal(args ...any) { + StandardLog.Fatal(args...) +} + +// Fatalf logs a message at level Fatal on the standard logger then the process will exit. +func Fatalf(format string, args ...any) { + StandardLog.Fatalf(format, args...) +} diff --git a/policy/func.go b/policy/func.go new file mode 100644 index 0000000..1fac2ff --- /dev/null +++ b/policy/func.go @@ -0,0 +1,179 @@ +package policy + +import ( + "errors" + "fmt" + "net" + "os" + "strconv" + "strings" + + "github.com/open-policy-agent/opa/v1/ast" + "github.com/open-policy-agent/opa/v1/rego" + "github.com/open-policy-agent/opa/v1/types" + + "git.maze.io/maze/styx/dataset" + "git.maze.io/maze/styx/logger" +) + +var domainContainsDecl = types.NewFunction( + types.Args( + types.Named("list", types.S).Description("Domain list to check against"), + types.Named("name", types.S).Description("Host name to check"), + ), + types.Named("result", types.B).Description("`true` if `name` is contained within `list`"), +) + +func domainContainsImpl(bc rego.BuiltinContext, listTerm, nameTerm *ast.Term) (*ast.Term, error) { + log := logger.StandardLog.Value("func", "styx.in_domains") + + list, err := parseDomainListTerm(listTerm) + if err != nil { + log.Err(err).Debug("Call function failed") + return nil, err + } + + name, err := parseStringTerm(nameTerm) + if err != nil { + return nil, err + } + + log.Values(logger.Values{ + "list": listTerm.Value, + "name": name, + }).Trace("Calling function") + return ast.BooleanTerm(list.Contains(name)), nil +} + +var networkContainsDecl = types.NewFunction( + types.Args( + types.Named("list", types.S).Description("Network list to check against"), + types.Named("ip", types.S).Description("IP address to check"), + ), + types.Named("result", types.B).Description("`true` if `ip` is contained within `list`"), +) + +func networkContainsImpl(bc rego.BuiltinContext, listTerm, ipTerm *ast.Term) (*ast.Term, error) { + log := logger.StandardLog.Value("func", "styx.in_networks") + + list, err := parseNetworkListTerm(listTerm) + if err != nil { + log.Err(err).Debug("Call function failed") + return nil, err + } + + ip, err := parseIPTerm(ipTerm) + if err != nil { + log.Value("list", listTerm.Value).Err(err).Debug("Call function failed") + return nil, err + } + + log.Values(logger.Values{ + "list": listTerm.Value, + "ip": ip.String(), + }).Trace("Calling function") + return ast.BooleanTerm(list.Contains(ip)), nil +} + +func parseDomainListTerm(term *ast.Term) (*dataset.DomainTree, error) { + nameArg, ok := term.Value.(ast.String) + if !ok { + return nil, errors.New("expected string argument") + } + name := strings.Trim(nameArg.String(), `"`) + + fn, ok := dataset.Domains[name] + if !ok { + return nil, fmt.Errorf("no such domain list: %q", name) + } + + return fn, nil +} + +func parseNetworkListTerm(term *ast.Term) (*dataset.NetworkTree, error) { + nameArg, ok := term.Value.(ast.String) + if !ok { + return nil, errors.New("expected string argument") + } + name := strings.Trim(nameArg.String(), `"`) + + fn, ok := dataset.Networks[name] + if !ok { + return nil, fmt.Errorf("no such network list: %q", name) + } + + return fn, nil +} + +func parseStringTerm(term *ast.Term) (string, error) { + ipArg, ok := term.Value.(ast.String) + if !ok { + return "", errors.New("expected string argument") + } + return strings.Trim(ipArg.String(), `"`), nil +} + +func parseIPTerm(term *ast.Term) (net.IP, error) { + ipArg, ok := term.Value.(ast.String) + if !ok { + return nil, errors.New("expected string argument") + } + ip := strings.Trim(ipArg.String(), `"`) + if ip := net.ParseIP(ip); ip != nil { + return ip, nil + } + return nil, fmt.Errorf("invalid IP address %q", ip) +} + +type ListReturner func() ([]string, error) + +var ( + domains = map[string]ListReturner{} + networks = map[string]ListReturner{} +) + +func AddDomainList(name string, fn ListReturner) { + domains[name] = fn +} + +func AddNetworkList(name string, fn ListReturner) { + networks[name] = fn +} + +func listLookupImpl(kind string, m map[string]ListReturner) func(rego.BuiltinContext, *ast.Term) (*ast.Term, error) { + return func(bc rego.BuiltinContext, inf *ast.Term) (*ast.Term, error) { + log := logger.StandardLog.Values(logger.V{ + "where": inf.Location.File + ":" + strconv.Itoa(inf.Location.Row) + "," + strconv.Itoa(inf.Location.Col), + "func": kind, + }) + + nameArg, ok := inf.Value.(ast.String) + if !ok { + return nil, errors.New("expected string argument") + } + name := strings.Trim(nameArg.String(), `"`) + + log = log.Value("type", name) + log.Trace("Looking up list in policy") + + fn, ok := m[name] + if !ok { + log.Error("No such list exists") + return nil, os.ErrNotExist + } + + list, err := fn() + if err != nil { + log.Err(err).Error("Error retrieving list") + return nil, err + } + + astList := make([]*ast.Term, 0, len(list)) + for _, item := range list { + astList = append(astList, ast.StringTerm(item)) + } + + log.Tracef("Returning list with %d items", len(astList)) + return ast.NewTerm(ast.NewArray(astList...)), nil + } +} diff --git a/policy/handler.go b/policy/handler.go new file mode 100644 index 0000000..1322af5 --- /dev/null +++ b/policy/handler.go @@ -0,0 +1,44 @@ +package policy + +import ( + "net/http" + + "git.maze.io/maze/styx/logger" + proxy "git.maze.io/maze/styx/proxy" +) + +func NewRequestHandler(p *Policy) proxy.RequestHandler { + log := logger.StandardLog.Value("policy", p.name) + return proxy.RequestHandlerFunc(func(ctx proxy.Context) (*http.Request, *http.Response) { + input := NewInputFromRequest(ctx, ctx.Request()) + input.logValues(log).Trace("Running request handler") + result, err := p.Query(input) + if err != nil { + log.Err(err).Error("Error evaulating policy") + return nil, nil + } + r, err := result.Response(ctx) + if err != nil { + log.Err(err).Error("Error generating response") + return nil, nil + } + return nil, r + }) +} + +func NewResponseHandler(p *Policy) proxy.ResponseHandler { + return proxy.ResponseHandlerFunc(func(ctx proxy.Context) *http.Response { + input := NewInputFromResponse(ctx, ctx.Response()) + result, err := p.Query(input) + if err != nil { + logger.StandardLog.Err(err).Error("Error evaulating policy") + return nil + } + r, err := result.Response(ctx) + if err != nil { + logger.StandardLog.Err(err).Error("Error generating response") + return nil + } + return r + }) +} diff --git a/policy/input.go b/policy/input.go new file mode 100644 index 0000000..9cf04f6 --- /dev/null +++ b/policy/input.go @@ -0,0 +1,394 @@ +package policy + +import ( + "crypto/tls" + "crypto/x509" + "crypto/x509/pkix" + "fmt" + "net" + "net/http" + "net/url" + "strconv" + + "git.maze.io/maze/styx/internal/netutil" + "git.maze.io/maze/styx/logger" +) + +// Input represents the input to the policy query. +type Input struct { + Client *Client `json:"client"` + TLS *TLS `json:"tls"` + Request *Request `json:"request"` + Response *Response `json:"response"` +} + +func (i *Input) logValues(log logger.Structured) logger.Structured { + log = i.Client.logValues(log) + log = i.TLS.logValues(log) + log = i.Request.logValues(log) + log = i.Response.logValues(log) + return log +} + +func NewInputFromConn(c net.Conn) *Input { + if c == nil { + return new(Input) + } + return &Input{ + Client: NewClientFromConn(c), + TLS: NewTLSFromConn(c), + } +} + +func NewInputFromRequest(c net.Conn, r *http.Request) *Input { + if r == nil { + return nil + } + input := NewInputFromConn(c) + input.Request = NewRequest(r) + return input +} + +func NewInputFromResponse(c net.Conn, r *http.Response) *Input { + if r == nil { + return nil + } + input := NewInputFromConn(c) + input.Response = NewResponse(r) + return input +} + +type Client struct { + Network string `json:"network"` + IP string `json:"ip"` + Port int `json:"int"` +} + +func (i *Client) logValues(log logger.Structured) logger.Structured { + if i != nil { + log = log.Values(logger.Values{ + "client_network": i.Network, + "client_ip": i.IP, + "client_port": i.Port, + }) + } + return log +} + +func NewClient(network, address string) *Client { + if host, port, err := net.SplitHostPort(address); err == nil { + p, _ := net.LookupPort(network, port) + return &Client{ + Network: network, + IP: host, + Port: p, + } + } + return &Client{ + Network: network, + IP: address, + } +} + +func NewClientFromConn(c net.Conn) *Client { + if c == nil { + return nil + } + return NewClientFromAddr(c.RemoteAddr()) +} + +func NewClientFromAddr(addr net.Addr) *Client { + switch addr := addr.(type) { + case *net.TCPAddr: + return &Client{ + Network: addr.Network(), + IP: addr.IP.String(), + Port: addr.Port, + } + case *net.UDPAddr: + return &Client{ + Network: addr.Network(), + IP: addr.IP.String(), + Port: addr.Port, + } + case *net.IPAddr: + return &Client{ + Network: addr.Network(), + IP: addr.IP.String(), + } + default: + if host, port, err := net.SplitHostPort(addr.String()); err == nil { + return &Client{ + Network: addr.Network(), + IP: host, + Port: func() int { p, _ := net.LookupPort(addr.Network(), port); return p }(), + } + } + return &Client{ + Network: addr.Network(), + IP: addr.String(), + } + } +} + +type TLS struct { + Version string `json:"version"` + CipherSuite string `json:"cipher_suite"` + ServerName string `json:"server_name"` + Certificates []*Certificate `json:"certificates"` +} + +func (i *TLS) logValues(log logger.Structured) logger.Structured { + if i != nil { + cns := make([]string, len(i.Certificates)) + for j, cert := range i.Certificates { + cns[j] = cert.Subject.CommonName + } + log = log.Values(logger.Values{ + "tls_version": i.Version, + "tls_cipher": i.CipherSuite, + "tls_server_name": i.ServerName, + "tls_certificates": cns, + }) + } + return log +} + +func NewTLS(state *tls.ConnectionState) *TLS { + if state == nil { + return nil + } + tls := &TLS{ + Version: tls.VersionName(state.Version), + CipherSuite: tls.CipherSuiteName(state.CipherSuite), + ServerName: state.ServerName, + } + for _, cert := range state.PeerCertificates { + if cert := NewCertificate(cert); cert != nil { + tls.Certificates = append(tls.Certificates, cert) + } + } + return tls +} + +type tlsConnectionStater interface { + ConnectionState() tls.ConnectionState +} + +func NewTLSFromConn(c net.Conn) *TLS { + if c == nil { + return nil + } + if s, ok := c.(tlsConnectionStater); ok { + cs := s.ConnectionState() + return NewTLS(&cs) + } + return nil +} + +type Certificate struct { + SerialNumber string `json:"serial_number"` + Subject PKIXName `json:"subject"` + Issuer PKIXName `json:"issuer"` + NotBefore int64 `json:"not_before"` + NotAfter int64 `json:"not_after"` +} + +func NewCertificate(cert *x509.Certificate) *Certificate { + if cert == nil { + return nil + } + return &Certificate{ + SerialNumber: cert.SerialNumber.String(), + Subject: MakePKIXName(cert.Subject), + Issuer: MakePKIXName(cert.Issuer), + NotBefore: cert.NotBefore.UnixNano(), + NotAfter: cert.NotAfter.UnixNano(), + } +} + +type PKIXName struct { + CommonName string `json:"cn,omitempty"` + Country string `json:"country,omitempty"` + Organization string `json:"organization,omitempty"` + OrganizationalUnit string `json:"ou,omitempty"` + Locality string `json:"locality,omitempty"` + Province string `json:"province,omitempty"` + StreetAddress string `json:"address,omitempty"` + PostalCode string `json:"postalcode,omitempty"` +} + +func MakePKIXName(name pkix.Name) PKIXName { + return PKIXName{ + CommonName: name.CommonName, + Country: pick(name.Country...), + Organization: pick(name.Organization...), + OrganizationalUnit: pick(name.OrganizationalUnit...), + Locality: pick(name.Locality...), + Province: pick(name.Province...), + StreetAddress: pick(name.StreetAddress...), + PostalCode: pick(name.PostalCode...), + } +} + +// Request represents an HTTP request. +type Request struct { + Method string `json:"method"` + URL *URL `json:"url"` + Proto string `json:"proto"` + Header map[string]string `json:"header"` + Host string `json:"host"` + Port int `json:"port"` + RequestURI string `json:"request_uri"` +} + +func (i *Request) logValues(log logger.Structured) logger.Structured { + if i != nil { + log = log.Values(logger.Values{ + "request_method": i.Method, + "request_url": i.URL.String(), + "request_proto": i.Proto, + "request_header": i.Header, + "request_host": i.Host, + "request_port": i.Port, + }) + } + return log +} + +func NewRequest(r *http.Request) *Request { + if r == nil { + return nil + } + + header := make(map[string]string) + for key := range r.Header { + header[key] = r.Header.Get(key) + } + + host, portName, err := net.SplitHostPort(r.URL.Host) + if err != nil { + host = netutil.Host(r.URL.Host) + portName = "80" + if r.URL.Scheme == "https" || r.URL.Scheme == "wss" || r.TLS != nil { + portName = "443" + } + } + + var port int + if port, err = strconv.Atoi(portName); err != nil { + port, _ = net.LookupPort("tcp", portName) + } + + return &Request{ + Method: r.Method, + URL: NewURL(r.URL), + Proto: r.Proto, + Header: header, + Host: host, + Port: port, + RequestURI: r.RequestURI, + } +} + +// Response represents an HTTP response. +type Response struct { + Status string `json:"status"` + StatusCode int `json:"status_code"` + Proto string `json:"proto"` + Header map[string]string `json:"header"` + ContentLength int64 `json:"content_length"` + Close bool `json:"close"` + Request *Request `json:"request"` + TLS *TLS `json:"tls"` +} + +func (i *Response) logValues(log logger.Structured) logger.Structured { + if i != nil { + log = log.Values(logger.Values{ + "response_status": i.StatusCode, + "response_proto": i.Proto, + "response_header": i.Header, + "response_close": i.Close, + "response_tls": i.TLS != nil, + }) + } + return log +} + +func NewResponse(r *http.Response) *Response { + if r == nil { + return nil + } + header := make(map[string]string) + for key := range r.Header { + header[key] = r.Header.Get(key) + } + return &Response{ + Status: r.Status, + StatusCode: r.StatusCode, + Proto: r.Proto, + Header: header, + ContentLength: r.ContentLength, + Close: r.Close, + Request: NewRequest(r.Request), + TLS: NewTLS(r.TLS), + } +} + +type URL struct { + Scheme string `json:"scheme"` + Host string `json:"host"` + Path string `json:"path"` + Query map[string]string `json:"query"` +} + +func (i *URL) String() string { + if i == nil { + return "" + } + + s := fmt.Sprintf("%s://%s%s", i.Scheme, i.Host, i.Path) + if len(i.Query) > 0 { + s += "?" + for k, v := range i.Query { + s += k + "=" + url.QueryEscape(v) + } + } + return s +} + +func ParseURL(rawurl string) (*URL, error) { + parsed, err := url.Parse(rawurl) + if err != nil { + return nil, err + } + return NewURL(parsed), nil +} + +func NewURL(url *url.URL) *URL { + if url == nil { + return nil + } + query := make(map[string]string) + for key, values := range url.Query() { + if len(values) > 0 { + query[key] = values[0] + } + } + return &URL{ + Scheme: url.Scheme, + Host: url.Host, + Path: url.Path, + Query: query, + } +} + +func pick(values ...string) string { + for _, v := range values { + if v != "" { + return v + } + } + return "" +} diff --git a/policy/policy.go b/policy/policy.go new file mode 100644 index 0000000..3bf5599 --- /dev/null +++ b/policy/policy.go @@ -0,0 +1,189 @@ +package policy + +import ( + "bytes" + "context" + "errors" + "fmt" + "html/template" + "io" + "net/http" + "os" + + "github.com/go-viper/mapstructure/v2" + "github.com/open-policy-agent/opa/v1/rego" + regoprint "github.com/open-policy-agent/opa/v1/topdown/print" + + "git.maze.io/maze/styx/logger" + proxy "git.maze.io/maze/styx/proxy" +) + +const DefaultPackageName = "styx" + +var ErrNoResult = errors.New("policy: no result") + +type Policy struct { + name string + options []func(*rego.Rego) +} + +func New(name, pkg string) (*Policy, error) { + p := &Policy{ + name: name, + options: newRego(rego.Load([]string{name}, nil), pkg), + } + if _, err := p.Query(&Input{}); err != nil { + return nil, err + } + return p, nil +} + +func NewFromString(module, pkg string) (*Policy, error) { + p := &Policy{ + name: "", + options: newRego(rego.Module("styx", module), pkg), + } + if _, err := p.Query(&Input{}); err != nil { + return nil, err + } + return p, nil +} + +func newRego(option func(*rego.Rego), pkg string) []func(*rego.Rego) { + if pkg == "" { + pkg = DefaultPackageName + } + return []func(*rego.Rego){ + rego.Dump(os.Stderr), + rego.Query("data." + pkg), + rego.Strict(true), + rego.Function2(®o.Function{ + Name: "styx.in_domains", + Decl: domainContainsDecl, + Nondeterministic: true, + }, domainContainsImpl), + rego.Function2(®o.Function{ + Name: "styx.in_networks", + Decl: networkContainsDecl, + Nondeterministic: true, + }, networkContainsImpl), + rego.PrintHook(printHook{}), + option, + } +} + +type printHook struct{} + +func (printHook) Print(ctx regoprint.Context, message string) error { + logger.StandardLog.Values(logger.Values{ + "where": fmt.Sprintf("%s:%d,%d", ctx.Location.File, ctx.Location.Row, ctx.Location.Col), + "from": string(ctx.Location.Text), + }).Debug(message) + return nil +} + +type Result struct { + // Reject signals explicit rejection. + Reject int `json:"reject" mapstructure:"reject"` + + // Permit signals explicit permission. + Permit *bool `json:"permit" mapstructure:"permit"` + + // Redirect to this URL. + Redirect string `json:"redirect" mapstructure:"redirect"` + + // Template to render as response body. + Template string `json:"template" mapstructure:"template"` + + // Errors contains error messages. + Errors []string `json:"errors" mapstructure:"errors,omitempty"` +} + +func (r *Result) Response(ctx proxy.Context) (*http.Response, error) { + for _, text := range r.Errors { + logger.StandardLog.Values(logger.Values{ + "id": ctx.ID(), + "client": ctx.RemoteAddr().String(), + }).Err(errors.New(text)).Warn("Error from policy") + } + + switch { + case r.Redirect != "": + response := proxy.NewResponse(http.StatusFound, nil, ctx.Request()) + response.Header.Set("Server", "styx") + response.Header.Set(proxy.HeaderLocation, r.Redirect) + return response, nil + + case r.Template != "": + b := new(bytes.Buffer) + t, err := template.New("policy").ParseFiles(r.Template) + if err != nil { + return nil, err + } + if err = t.Execute(b, map[string]any{"context": ctx}); err != nil { + return nil, err + } + + response := proxy.NewResponse(http.StatusFound, io.NopCloser(b), ctx.Request()) + response.Header.Set("Server", "styx") + response.Header.Set(proxy.HeaderContentType, "text/html") + return response, nil + + case r.Reject > 0: + body := io.NopCloser(bytes.NewBufferString(http.StatusText(r.Reject))) + response := proxy.NewResponse(r.Reject, body, ctx.Request()) + response.Header.Set(proxy.HeaderContentType, "text/plain") + return response, nil + + case r.Permit != nil && !*r.Permit: + body := io.NopCloser(bytes.NewBufferString(http.StatusText(http.StatusForbidden))) + response := proxy.NewResponse(http.StatusForbidden, body, ctx.Request()) + response.Header.Set(proxy.HeaderContentType, "text/plain") + return response, nil + + default: + return nil, nil + } +} + +func (p *Policy) Query(input *Input) (*Result, error) { + /* + e := json.NewEncoder(os.Stdout) + e.SetIndent("", " ") + e.Encode(doc) + */ + + log := logger.StandardLog.Value("policy", p.name) + log.Trace("Evaluating policy") + + r := rego.New(append(p.options, rego.Input(input))...) + + ctx := context.Background() + /* + query, err := p.rego.PrepareForEval(ctx) + if err != nil { + return nil, err + } + rs, err := query.Eval(ctx, rego.EvalInput(input)) + if err != nil { + return nil, err + } + */ + rs, err := r.Eval(ctx) + if err != nil { + return nil, err + } + if len(rs) == 0 || len(rs[0].Expressions) == 0 { + return nil, ErrNoResult + } + result := &Result{} + for _, expr := range rs[0].Expressions { + if m, ok := expr.Value.(map[string]any); ok { + log.Values(m).Trace("Policy result expression") + if err = mapstructure.Decode(m, result); err != nil { + return nil, err + } + } + } + return result, nil +} diff --git a/proxy/context.go b/proxy/context.go index 943bb2b..b743acf 100644 --- a/proxy/context.go +++ b/proxy/context.go @@ -14,8 +14,7 @@ import ( "sync/atomic" "time" - "git.maze.io/maze/styx/internal/netutil/arp" - "github.com/sirupsen/logrus" + "git.maze.io/maze/styx/logger" ) // Context provides convenience functions for the current ongoing HTTP proxy transaction (request). @@ -71,17 +70,16 @@ func (w *countingWriter) Write(p []byte) (n int, err error) { type proxyContext struct { net.Conn - id uint64 - mac net.HardwareAddr - cr *countingReader - br *bufio.Reader - cw *countingWriter - isTransparent bool - isTransparentTLS bool - serverName string - req *http.Request - res *http.Response - idleTimeout time.Duration + id uint64 + cr *countingReader + br *bufio.Reader + cw *countingWriter + transparent int + transparentTLS bool + serverName string + req *http.Request + res *http.Response + idleTimeout time.Duration } // NewContext returns an initialized context for the provided [net.Conn]. @@ -98,7 +96,6 @@ func NewContext(c net.Conn) Context { return &proxyContext{ Conn: c, id: binary.BigEndian.Uint64(b), - mac: arp.Get(c.RemoteAddr()), cr: cr, br: bufio.NewReader(cr), cw: cw, @@ -106,26 +103,23 @@ func NewContext(c net.Conn) Context { } } -func (c *proxyContext) AccessLogEntry() *logrus.Entry { +func (c *proxyContext) AccessLogEntry() logger.Structured { var id [8]byte binary.BigEndian.PutUint64(id[:], c.id) - entry := AccessLog.WithFields(logrus.Fields{ + entry := AccessLog.Values(logger.Values{ "client": c.RemoteAddr().String(), "server": c.LocalAddr().String(), "id": hex.EncodeToString(id[:]), "bytes_rx": c.BytesRead(), "bytes_tx": c.BytesSent(), }) - if c.mac != nil { - return entry.WithField("client_mac", c.mac.String()) - } return entry } -func (c *proxyContext) LogEntry() *logrus.Entry { +func (c *proxyContext) LogEntry() logger.Structured { var id [8]byte binary.BigEndian.PutUint64(id[:], c.id) - return ServerLog.WithFields(logrus.Fields{ + return ServerLog.Values(logger.Values{ "client": c.RemoteAddr().String(), "server": c.LocalAddr().String(), "id": hex.EncodeToString(id[:]), diff --git a/proxy/handler.go b/proxy/handler.go index f064658..a8a09cb 100644 --- a/proxy/handler.go +++ b/proxy/handler.go @@ -15,7 +15,12 @@ import ( "git.maze.io/maze/styx/internal/netutil" ) +// Dialer can make outbound connections to upstream servers. type Dialer interface { + // DialContext makes a new connection to the address specified in the [http.Request]. + // + // The [http.Request] contains the URL scheme (http, https, ws, wss) and host (with optional port) + // to connect to. The [context.Context] may be used for cancellation and timeouts. DialContext(context.Context, *http.Request) (net.Conn, error) } @@ -71,25 +76,38 @@ func (defaultDialer) DialContext(ctx context.Context, req *http.Request) (net.Co } } -// ConnFilter is called when a new connection has been accepted by the proxy. -type ConnFilter interface { - FilterConn(Context) (net.Conn, error) +// ErrorHandler can handle errors that occur during proxying. +type ErrorHandler interface { + // HandleError handles an error that occurred during proxying. If the method returns a non-nil + // [http.Response], it will be sent to the client as-is. If it returns nil, a generic HTTP 502 + // Bad Gateway response will be sent to the client. + // + // The [Context] may be inspected to obtain information about the request that caused the error. + // However, the [Context.Request] and [Context.Response] may be nil depending on when the error + // occurred. + HandleError(Context, error) *http.Response } -// ConnFilterFunc is a function that implements the [ConnFilter] interface. -type ConnFilterFunc func(Context) (net.Conn, error) +// ConnHandler is called when a new connection has been accepted by the proxy. +type ConnHandler interface { + HandleConn(Context) (net.Conn, error) +} -func (f ConnFilterFunc) FilterConn(ctx Context) (net.Conn, error) { +// ConnHandlerFunc is a function that implements the [ConnHandler] interface. +type ConnHandlerFunc func(Context) (net.Conn, error) + +func (f ConnHandlerFunc) HandleConn(ctx Context) (net.Conn, error) { return f(ctx) } // TLS starts a TLS handshake on the accepted connection. -func TLS(certs []tls.Certificate) ConnFilter { - return ConnFilterFunc(func(ctx Context) (net.Conn, error) { - s := tls.Server(ctx, &tls.Config{ - Certificates: certs, - NextProtos: []string{"http/1.1"}, - }) +func TLS(config *tls.Config) ConnHandler { + if config == nil { + config = new(tls.Config) + } + config.NextProtos = []string{"http/1.1"} + return ConnHandlerFunc(func(ctx Context) (net.Conn, error) { + s := tls.Server(ctx, config) if err := s.Handshake(); err != nil { return nil, err } @@ -98,8 +116,8 @@ func TLS(certs []tls.Certificate) ConnFilter { } // TLSInterceptor can generate certificates on-the-fly for clients that use a compatible TLS version. -func TLSInterceptor(ca ca.CertificateAuthority) ConnFilter { - return ConnFilterFunc(func(ctx Context) (net.Conn, error) { +func TLSInterceptor(ca ca.CertificateAuthority) ConnHandler { + return ConnHandlerFunc(func(ctx Context) (net.Conn, error) { s := tls.Server(ctx, &tls.Config{ GetCertificate: func(hello *tls.ClientHelloInfo) (*tls.Certificate, error) { ips := []net.IP{net.ParseIP(netutil.Host(ctx.RemoteAddr().String()))} @@ -119,26 +137,27 @@ func TLSInterceptor(ca ca.CertificateAuthority) ConnFilter { // When a new [net.Conn] is made, this function will inspect the initial request packet for a // TLS handshake. If a TLS handshake is detected, the connection will make a feaux HTTP CONNECT // request using TLS, if no handshake is detected, it will make a feaux plain HTTP CONNECT request. -func Transparent() ConnFilter { - return ConnFilterFunc(func(nctx Context) (net.Conn, error) { +func Transparent(port int) ConnHandler { + return ConnHandlerFunc(func(nctx Context) (net.Conn, error) { ctx, ok := nctx.(*proxyContext) if !ok { return nctx, nil } b := new(bytes.Buffer) - hello, err := cryptutil.ReadClientHello(io.TeeReader(ctx, b)) + hello, err := cryptutil.ReadClientHello(io.TeeReader(netutil.ReadOnlyConn{Reader: ctx.br}, b)) if err != nil { if _, ok := err.(tls.RecordHeaderError); !ok { - ctx.LogEntry().WithError(err).WithField("error_type", fmt.Sprintf("%T", err)).Warn("TLS sniff error") + ctx.LogEntry().Err(err).Value("error_type", fmt.Sprintf("%T", err)).Warn("TLS sniff error") return nil, err } // Not a TLS connection, moving on to regular HTTP request handling... ctx.LogEntry().Debug("HTTP connection on transparent port") - ctx.isTransparent = true + ctx.transparent = port } else { - ctx.LogEntry().WithField("target", hello.ServerName).Debug("TLS connection on transparent port") - ctx.isTransparentTLS = true + ctx.LogEntry().Value("target", hello.ServerName).Debug("TLS connection on transparent port") + ctx.transparent = port + ctx.transparentTLS = true ctx.serverName = hello.ServerName } @@ -149,10 +168,10 @@ func Transparent() ConnFilter { }) } -// RequestFilter can filter HTTP requests coming to the proxy. -type RequestFilter interface { - // FilterRequest filters a HTTP request made to the proxy. The current request may be obtained - // from [Context.Request]. If a previous RequestFilter provided a HTTP response, it is available +// RequestHandler can filter HTTP requests coming to the proxy. +type RequestHandler interface { + // HandlerRequest filters a HTTP request made to the proxy. The current request may be obtained + // from [Context.Request]. If a previous RequestHandler provided a HTTP response, it is available // from [Context.Response]. // // Modifications to the current request can be made to the Request returned by [Context.Request] @@ -160,35 +179,35 @@ type RequestFilter interface { // // If the filter returns a non-nil [http.Response], then the [Request] will not be proxied to // any upstream server(s). - FilterRequest(Context) (*http.Request, *http.Response) + HandleRequest(Context) (*http.Request, *http.Response) } -// RequestFilterFunc is a function that implements the [RequestFilter] interface. -type RequestFilterFunc func(Context) (*http.Request, *http.Response) +// RequestHandlerFunc is a function that implements the [RequestHandler] interface. +type RequestHandlerFunc func(Context) (*http.Request, *http.Response) -func (f RequestFilterFunc) FilterRequest(ctx Context) (*http.Request, *http.Response) { +func (f RequestHandlerFunc) HandleRequest(ctx Context) (*http.Request, *http.Response) { return f(ctx) } -// ResponseFilter can filter HTTP responses coming from the proxy. -type ResponseFilter interface { - // FilterResponse filters a HTTP response coming from the proxy. The current response may be +// ResponseHandler can filter HTTP responses coming from the proxy. +type ResponseHandler interface { + // HandlerResponse filters a HTTP response coming from the proxy. The current response may be // obtained from [Context.Response]. // // Modifications to the current response can be made to the [Response] returned by [Context.Response]. - FilterResponse(Context) *http.Response + HandleResponse(Context) *http.Response } -// ResponseFilterFunc is a function that implements the [ResponseFilter] interface. -type ResponseFilterFunc func(Context) *http.Response +// ResponseHandlerFunc is a function that implements the [ResponseHandler] interface. +type ResponseHandlerFunc func(Context) *http.Response -func (f ResponseFilterFunc) FilterResponse(ctx Context) *http.Response { +func (f ResponseHandlerFunc) HandleResponse(ctx Context) *http.Response { return f(ctx) } // CleanRequestProxyHeaders removes all headers added by downstream proxies from the [http.Request]. -func CleanRequestProxyHeaders() RequestFilter { - return RequestFilterFunc(func(ctx Context) (*http.Request, *http.Response) { +func CleanRequestProxyHeaders() RequestHandler { + return RequestHandlerFunc(func(ctx Context) (*http.Request, *http.Response) { if req := ctx.Request(); req != nil { cleanProxyHeaders(req.Header) } @@ -197,8 +216,8 @@ func CleanRequestProxyHeaders() RequestFilter { } // CleanRequestProxyHeaders removes all headers for upstream proxies from the [http.Response]. -func CleanResponseProxyHeaders() ResponseFilter { - return ResponseFilterFunc(func(ctx Context) *http.Response { +func CleanResponseProxyHeaders() ResponseHandler { + return ResponseHandlerFunc(func(ctx Context) *http.Response { if res := ctx.Response(); res != nil { cleanProxyHeaders(res.Header) } @@ -208,8 +227,8 @@ func CleanResponseProxyHeaders() ResponseFilter { // AddRequestHeaders adds headers to the [http.Request]. Any existing headers with the same // key will remain intact. -func AddRequestHeaders(h http.Header) RequestFilter { - return RequestFilterFunc(func(ctx Context) (*http.Request, *http.Response) { +func AddRequestHeaders(h http.Header) RequestHandler { + return RequestHandlerFunc(func(ctx Context) (*http.Request, *http.Response) { if req := ctx.Request(); req != nil { if req.Header == nil { req.Header = make(http.Header) @@ -222,8 +241,8 @@ func AddRequestHeaders(h http.Header) RequestFilter { // SetRequestHeaders sets headers to the [http.Request]. Any existing headers with the same // key will be removed. -func SetRequestHeaders(h http.Header) RequestFilter { - return RequestFilterFunc(func(ctx Context) (*http.Request, *http.Response) { +func SetRequestHeaders(h http.Header) RequestHandler { + return RequestHandlerFunc(func(ctx Context) (*http.Request, *http.Response) { if req := ctx.Request(); req != nil { if req.Header == nil { req.Header = make(http.Header) @@ -236,8 +255,8 @@ func SetRequestHeaders(h http.Header) RequestFilter { // AddResponseHeaders adds headers to the [http.Response]. Any existing headers with the same // key will remain intact. -func AddResponseHeaders(h http.Header) ResponseFilter { - return ResponseFilterFunc(func(ctx Context) *http.Response { +func AddResponseHeaders(h http.Header) ResponseHandler { + return ResponseHandlerFunc(func(ctx Context) *http.Response { if res := ctx.Response(); res != nil { if res.Header == nil { res.Header = make(http.Header) @@ -250,8 +269,8 @@ func AddResponseHeaders(h http.Header) ResponseFilter { // SetResponseHeaders sets headers to the [http.Response]. Any existing headers with the same // key will be removed. -func SetResponseHeaders(h http.Header) ResponseFilter { - return ResponseFilterFunc(func(ctx Context) *http.Response { +func SetResponseHeaders(h http.Header) ResponseHandler { + return ResponseHandlerFunc(func(ctx Context) *http.Response { if res := ctx.Response(); res != nil { if res.Header == nil { res.Header = make(http.Header) diff --git a/proxy/proxy.go b/proxy/proxy.go index fe7a14f..85cbd0f 100644 --- a/proxy/proxy.go +++ b/proxy/proxy.go @@ -13,13 +13,14 @@ import ( "net/http" "net/url" "slices" + "strconv" "strings" "syscall" "time" "git.maze.io/maze/styx/internal/netutil" + "git.maze.io/maze/styx/logger" "git.maze.io/maze/styx/stats" - "github.com/sirupsen/logrus" ) // Common HTTP headers. @@ -32,6 +33,7 @@ const ( HeaderForwardedHost = "X-Forwarded-Host" HeaderForwardedPort = "X-Forwarded-Port" HeaderForwardedProto = "X-Forwarded-Proto" + HeaderLocation = "Location" HeaderRealIP = "X-Real-Ip" HeaderUpgrade = "Upgrade" HeaderVia = "Via" @@ -46,43 +48,111 @@ const ( var ( // AccessLog is used for logging requests to the proxy. - AccessLog = logrus.StandardLogger() + AccessLog = logger.Get() // ServerLog is used for logging server log messages. - ServerLog = logrus.StandardLogger() + ServerLog = logger.Get() ) // Proxy implements a HTTP(S) proxy. type Proxy struct { - rt http.RoundTripper - dialer map[string]Dialer - connFilter []ConnFilter - requestFilter []RequestFilter - responseFilter []ResponseFilter - dialTimeout time.Duration - idleTimeout time.Duration - webSocketIdleTimeout time.Duration - mux *http.ServeMux + // RoundTripper is used to make outbound HTTP requests. It defaults to a [http.Transport] + // with a custom DialContext that uses the configured [Dialer]s. + // + // Only override this if you know what you are doing. + RoundTripper http.RoundTripper + + // Dialer is a map of protocol names to [Dialer] implementations. The default [Dialer] + // corresponds to an empty string key. + // + // Only override the default [Dialer] if you know what you are doing. + Dialer map[string]Dialer + + // OnConnect is a list of connection filters that are applied in order when a new + // connection is established. + // + // Connection filters can be used to implement custom authentication, logging, + // rate limiting, etc. + // + // Connection filters are applied before any HTTP request is read from the connection. + // + // Connection filters should return a non-nil error if they want to terminate the + // connection. Returning a non-nil [net.Conn] will replace the existing connection + // with the returned one. + // + // Connection filters should not modify the connection in any way (e.g. wrapping it + // in a TLS connection) as this will interfere with the proxy's ability to read + // HTTP requests from the connection. + // + // Connection filters are executed sequentially in the order they are added. + OnConnect []ConnHandler + + // OnRequest is a list of request filters that are applied in order when a new + // HTTP request is read from the connection. + // + // Request filters can be used to modify the request, or to return a response + // directly without forwarding the request to the upstream server. + // + // Request filters should return a non-nil error if they want to terminate the + // connection. + // + // Request filters are executed sequentially in the order they are added. + OnRequest []RequestHandler + + // OnResponse is a list of response filters that are applied in order when a + // response is received from the upstream server. + // + // Response filters can be used to modify the response before it is sent to + // the client. + // + // Response filters should return a non-nil error if they want to terminate the + // connection. + // + // Response filters are executed sequentially in the order they are added. + OnResponse []ResponseHandler + + // OnError is a list of error handlers that are applied in order when an + // error occurs during request processing. + // + // Error handlers can be used to log errors, or to return a custom response + // to the client. + // + // Error handlers should return a non-nil error if they want to terminate the + // connection. + // + // Error handlers are executed sequentially in the order they are added. + OnError []ErrorHandler + + // DialTimeout is the timeout for establishing new connections to upstream servers. + DialTimeout time.Duration + + // IdleTimeout is the timeout for idle connections. + IdleTimeout time.Duration + + // WebSocketIdleTimeout is the timeout for idle WebSocket connections. + WebSocketIdleTimeout time.Duration + + mux *http.ServeMux } // New [Proxy] with somewhat sane defaults. func New() *Proxy { p := &Proxy{ - dialer: map[string]Dialer{"": defaultDialer{}}, - dialTimeout: DefaultDialTimeout, - idleTimeout: DefaultIdleTimeout, - webSocketIdleTimeout: DefaultWebSocketIdleTimeout, + Dialer: map[string]Dialer{"": defaultDialer{}}, + DialTimeout: DefaultDialTimeout, + IdleTimeout: DefaultIdleTimeout, + WebSocketIdleTimeout: DefaultWebSocketIdleTimeout, mux: http.NewServeMux(), } // Make sure the roundtripper uses our dialers. - p.rt = &http.Transport{ + p.RoundTripper = &http.Transport{ TLSNextProto: make(map[string]func(string, *tls.Conn) http.RoundTripper), Proxy: http.ProxyFromEnvironment, TLSHandshakeTimeout: 10 * time.Second, ExpectContinueTimeout: time.Second, DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) { - return p.dialer[""].DialContext(ctx, &http.Request{ + return p.Dialer[""].DialContext(ctx, &http.Request{ URL: &url.URL{ Scheme: network, Host: addr, @@ -112,41 +182,17 @@ func (p *Proxy) HandleFunc(pattern string, handler func(http.ResponseWriter, *ht func (p *Proxy) SetDialer(proto string, dialer Dialer) { if dialer == nil { if proto != "" { - delete(p.dialer, proto) + delete(p.Dialer, proto) } } else { - p.dialer[proto] = dialer + p.Dialer[proto] = dialer } } -// AddConnFilter adds a connection filter to the stack. -func (p *Proxy) AddConnFilter(f ConnFilter) { - if f == nil { - return - } - p.connFilter = append(p.connFilter, f) -} - -// AddRequestFilter adds a request filter to the stack. -func (p *Proxy) AddRequestFilter(f RequestFilter) { - if f == nil { - return - } - p.requestFilter = append(p.requestFilter, f) -} - -// AddResponseFilter adds a response filter to the stack. -func (p *Proxy) AddResponseFilter(f ResponseFilter) { - if f == nil { - return - } - p.responseFilter = append(p.responseFilter, f) -} - func (p *Proxy) dial(ctx context.Context, req *http.Request) (net.Conn, error) { - d, ok := p.dialer[req.URL.Scheme] + d, ok := p.Dialer[req.URL.Scheme] if !ok { - d = p.dialer[""] + d = p.Dialer[""] } return d.DialContext(ctx, req) @@ -170,23 +216,32 @@ func (p *Proxy) handle(nc net.Conn) { err error ) defer func() { - if cerr := ctx.Close(); cerr != nil && err == nil { + if r := recover(); r != nil { + if err, ok := r.(error); ok { + ctx.LogEntry().Err(err).Warn("Bug in code, recovered from panic!") + } + _ = nc.Close() + } + }() + + defer func() { + if cerr := ctx.Close(); cerr != nil && err == nil && !netutil.IsClosing(cerr) { err = cerr } - log := ctx.AccessLogEntry().WithField("duration", time.Since(start)) + log := ctx.AccessLogEntry().Value("duration", time.Since(start)) if err != nil && !netutil.IsClosing(err) { - log = log.WithError(err) + log = log.Err(err) } if req := ctx.Request(); req != nil { - log = log.WithFields(logrus.Fields{ + log = log.Values(logger.Values{ "method": req.Method, "request": req.URL.String(), }) } if res := ctx.Response(); res != nil { //countStatus(res.StatusCode) - log.WithFields(logrus.Fields{ + log.Values(logger.Values{ "response": res.StatusCode, }).Info(res.Status) } else { @@ -196,38 +251,42 @@ func (p *Proxy) handle(nc net.Conn) { }() // Propagate timeouts - ctx.SetIdleTimeout(p.idleTimeout) + ctx.SetIdleTimeout(p.IdleTimeout) - for _, f := range p.connFilter { - fc, err := f.FilterConn(ctx) + for _, f := range p.OnConnect { + fc, err := f.HandleConn(ctx) if err != nil { - ServerLog.WithField("filter", fmt.Sprintf("%T", f)).WithError(err).Warn("error in conn filter") + ServerLog.Value("filter", fmt.Sprintf("%T", f)).Err(err).Warn("Error in conn filter") + p.handleError(ctx, err, true) _ = nc.Close() return } else if fc != nil { - ServerLog.WithField("filter", fmt.Sprintf("%T", f)).Debug("replacing connection from filter") + ServerLog.Value("filter", fmt.Sprintf("%T", f)).Debug("Replacing connection from filter") ctx.Conn = fc ctx.br = bufio.NewReader(fc) } } for { - if ctx.isTransparentTLS { + if ctx.transparentTLS { ctx.req = &http.Request{ - Method: http.MethodConnect, - URL: &url.URL{ - Scheme: "tcp", - Host: net.JoinHostPort(ctx.serverName, "443"), - }, + Method: http.MethodConnect, + URL: &url.URL{Scheme: "tcp", Host: net.JoinHostPort(ctx.serverName, strconv.Itoa(ctx.transparent))}, + Host: net.JoinHostPort(ctx.serverName, strconv.Itoa(ctx.transparent)), + Proto: "HTTP/1.1", + ProtoMajor: 1, + ProtoMinor: 1, + Close: true, } } else if ctx.req, err = http.ReadRequest(ctx.Reader()); err != nil { if !(errors.Is(err, io.EOF) || errors.Is(err, syscall.ECONNRESET)) { - ServerLog.WithError(err).Debug("error reading request") + ServerLog.Err(err).Debug("Error reading request") } + p.handleError(ctx, err, true) return } - if ctx.isTransparent { + if ctx.transparent > 0 { // Canonicallize to absolute URL if ctx.req.URL.Host == "" { ctx.req.URL.Host = ctx.req.Host @@ -235,47 +294,68 @@ func (p *Proxy) handle(nc net.Conn) { if ctx.req.URL.Scheme == "" { ctx.req.URL.Scheme = "http" } - ctx.isTransparent = false + ctx.transparent = 0 } - for _, f := range p.requestFilter { - newReq, newRes := f.FilterRequest(ctx) + for _, f := range p.OnRequest { + newReq, newRes := f.HandleRequest(ctx) if newReq != nil { - ServerLog.WithFields(logrus.Fields{ + ServerLog.Values(logger.Values{ "filter": fmt.Sprintf("%T", f), "old_method": ctx.req.Method, "old_url": ctx.req.URL, "new_method": newReq.Method, "new_url": newReq.URL, - }).Debug("replacing request from filter") + }).Debug("Replacing request from filter") ctx.req = newReq } if newRes != nil { - log := ServerLog.WithFields(logrus.Fields{ + log := ServerLog.Values(logger.Values{ "filter": fmt.Sprintf("%T", f), "response": newRes.StatusCode, "status": newRes.Status, }) - log.Debug("replacing response from filter") + log.Debug("Replacing response from filter") ctx.res = newRes if err = p.writeResponse(ctx); err != nil { - log.WithError(err).Warn("error overriding repsonse") + if netutil.IsClosing(err) { + return + } + log.Err(err).Warn("Error overriding repsonse") } continue } } if err = p.handleRequest(ctx); err != nil { + p.handleError(ctx, err, true) return } // Only once - if ctx.isTransparent || ctx.isTransparentTLS { + if ctx.transparent > 0 || ctx.transparentTLS || ctx.req.Method == http.MethodConnect { return } } } +func (p *Proxy) handleError(ctx *proxyContext, err error, sendResponse bool) { + res := ctx.Response() + if res == nil && sendResponse { + res = NewErrorResponse(err, ctx.Request()) + } + for _, f := range p.OnError { + if newRes := f.HandleError(ctx, err); newRes != nil { + res = newRes + } + } + if sendResponse && res != nil { + if werr := p.writeResponse(ctx); werr != nil && !netutil.IsClosing(err) { + ServerLog.Err(werr).Warn("Error writing error response") + } + } +} + func (p *Proxy) handleRequest(ctx *proxyContext) (err error) { switch { case ctx.req == nil: @@ -300,9 +380,9 @@ func (p *Proxy) handleRequest(ctx *proxyContext) (err error) { } } -func (p *Proxy) applyResponseFilter(ctx *proxyContext) { - for _, f := range p.responseFilter { - if newRes := f.FilterResponse(ctx); newRes != nil { +func (p *Proxy) applyResponseHandler(ctx *proxyContext) { + for _, f := range p.OnResponse { + if newRes := f.HandleResponse(ctx); newRes != nil { if ctx.res.Body != nil { _ = ctx.res.Body.Close() } @@ -346,7 +426,7 @@ func (p *Proxy) serveConnect(ctx *proxyContext) (err error) { // Most browsers expect to get a 200 OK after firing a HTTP CONNECT request; if the upstream // encounters any errors, we'll inform the client after reading the HTTP request that follows. - if !(ctx.isTransparent || ctx.isTransparentTLS) { + if !(ctx.transparent > 0 || ctx.transparentTLS) { if _, err = io.WriteString(ctx, "HTTP/1.1 200 Connection Established\r\n\r\n"); err != nil { return } @@ -356,10 +436,10 @@ func (p *Proxy) serveConnect(ctx *proxyContext) (err error) { case "": ctx.req.URL.Scheme = "tcp" } - log.WithField("target", ctx.req.URL.String()).Debug("http CONNECT request") + log.Value("target", ctx.req.URL.String()).Debugf("%s CONNECT request", ctx.req.Proto) var ( - timeout, cancel = context.WithTimeout(context.Background(), p.dialTimeout) + timeout, cancel = context.WithTimeout(context.Background(), p.DialTimeout) c net.Conn ) if c, err = p.dial(timeout, ctx.req); err != nil { @@ -373,27 +453,27 @@ func (p *Proxy) serveConnect(ctx *proxyContext) (err error) { ctx.res = NewResponse(http.StatusOK, nil, ctx.req) srv := NewContext(c).(*proxyContext) - srv.SetIdleTimeout(p.idleTimeout) + srv.SetIdleTimeout(p.IdleTimeout) return p.multiplex(ctx, srv) } func (p *Proxy) serveForward(ctx *proxyContext) (err error) { log := ctx.LogEntry() - log.WithField("target", ctx.req.URL.String()).Debug("http forward request") + log.Value("target", ctx.req.URL.String()).Debugf("%s forward request", ctx.req.Proto) - if ctx.res, err = p.rt.RoundTrip(ctx.req); err != nil { + if ctx.res, err = p.RoundTripper.RoundTrip(ctx.req); err != nil { // log.Printf("%s forward request error: %v", ctx, err) ctx.res = NewErrorResponse(err, ctx.req) _ = p.writeResponse(ctx) _ = ctx.Close() return fmt.Errorf("proxy: forward %s error: %w", ctx.req.URL, err) } - p.applyResponseFilter(ctx) + p.applyResponseHandler(ctx) return p.writeResponse(ctx) } func (p *Proxy) serveWebSocket(ctx *proxyContext) (err error) { - log := ctx.LogEntry().WithField("target", ctx.req.URL.String()) + log := ctx.LogEntry().Value("target", ctx.req.URL.String()) switch ctx.req.URL.Scheme { case "http": @@ -402,9 +482,9 @@ func (p *Proxy) serveWebSocket(ctx *proxyContext) (err error) { ctx.req.URL.Scheme = "wss" } - log.Debug("http websocket request") + log.Debugf("%s websocket request", ctx.req.Proto) var ( - timeout, cancel = context.WithTimeout(context.Background(), p.dialTimeout) + timeout, cancel = context.WithTimeout(context.Background(), p.DialTimeout) c net.Conn ) if c, err = p.dial(timeout, ctx.req); err != nil { @@ -417,7 +497,7 @@ func (p *Proxy) serveWebSocket(ctx *proxyContext) (err error) { cancel() srv := NewContext(c).(*proxyContext) - srv.SetIdleTimeout(p.idleTimeout) + srv.SetIdleTimeout(p.IdleTimeout) if err = ctx.req.Write(srv); err != nil { ctx.res = NewErrorResponse(err, ctx.req) _ = p.writeResponse(ctx) @@ -432,15 +512,15 @@ func (p *Proxy) serveWebSocket(ctx *proxyContext) (err error) { return fmt.Errorf("proxy: failed to read response from upstream: %w", err) } - log.WithFields(logrus.Fields{ + log.Values(logger.Values{ "response": ctx.res.StatusCode, "status": ctx.res.Status, - }).Debug("websocket response from upstream") + }).Debug("WebSocket response from upstream") if err = p.writeResponse(ctx); err != nil { _ = ctx.Close() return } - ctx.SetIdleTimeout(p.webSocketIdleTimeout) + ctx.SetIdleTimeout(p.WebSocketIdleTimeout) return p.multiplex(ctx, srv) } @@ -471,19 +551,19 @@ func (p *Proxy) multiplex(ctx, srv Context) (err error) { func (p *Proxy) writeResponse(ctx *proxyContext) (err error) { res := ctx.Response() - for _, f := range p.responseFilter { - if newRes := f.FilterResponse(ctx); newRes != nil { - log.Printf("filter returned response HTTP %s", newRes.Status) + for _, f := range p.OnResponse { + if newRes := f.HandleResponse(ctx); newRes != nil { + log.Printf("Filter returned response HTTP %s", newRes.Status) if res.Body != nil { _ = res.Body.Close() } res = newRes } } - ServerLog.WithFields(logrus.Fields{ + ServerLog.Values(logger.Values{ "close": res.Close, "header": res.Header, - }).Debug("writing response") + }).Debug("Writing response") if err = res.Write(ctx); err != nil { return } diff --git a/styx.hcl b/styx.hcl index 4dbd57d..51e0b6a 100644 --- a/styx.hcl +++ b/styx.hcl @@ -1,8 +1,27 @@ proxy { # TCP listen address - listen = ":3128" + port ":3128" {} + port ":3129" { + tls { + ca = "testdata/ca.crt" + cert = "testdata/ca.crt" + key = "testdata/ca.key" + } + # Transparent proxy for targets on port 80 + transparent = 80 + } + port ":3130" { + tls { + cert = "testdata/ca.crt" + key = "testdata/ca.key" + } + + # Transparent proxy for targets on port 443 + transparent = 443 + } + # TCP bind address for outgoing connections #bind = "10.42.42.215" # Interface for outgoign connections @@ -12,86 +31,29 @@ proxy { upstream = [] - policy { - on intercept { - domain = ["sensitive"] - permit = false - } - - on request { - source = ["kids"] - domain = ["nsfw"] - permit = false - } - - on request { - source = ["kids"] - domain = ["nsfw"] - permit = false - } - - on days { - days = "mon-thu,sun" - on time { - time = ["22:00", "06:00"] - on request { - source = ["kids"] - domain = ["social"] - permit = false - } - } - } + on { + intercept = ["intercept"] + request = ["bogons", "childsafe"] } } -dns { - # Set the cache size - #size = 1024 - - # Set the time to live for positive responses (in seconds) - #ttl = 300 - - # Set the resolve timeout (in seconds) - #timeout = 10 - - # Set the DNS servers - #servers = ["1.1.1.1", "8.8.8.8"] - - # Disable IPv6 - noipv6 = true +policy "intercept" { + path = "testdata/policy/intercept.rego" + package = "styx.intercept" +} + +policy "bogons" { + path = "testdata/policy/bogons.rego" } -mitm { - ca { - cert = "testdata/ca.crt" - key = "testdata/ca.key" - key_type = "ecc" - days = 1825 - organization = "maze.io" - } - - key { - type = "rsa" - bits = 2048 - } - - cache { - #type = "memory" - type = "disk" - path = "testdata/mitm" - expire = 10 - } +policy "childsafe" { + path = "testdata/policy/childsafe.rego" } -cache { - type = "memory" - size = 10485760 -} - -match { +data { path = "testdata/match" - network "internal" { + network "reserved" { type = "list" list = [ "0.0.0.0/32", @@ -129,8 +91,25 @@ match { domain "social" { type = "list" list = [ + "facebook.com", + "facebook.net", + "fbsbx.com", "pinterest.com", "reddit.com", + # TikTok + "isnssdk.com", + "musical.ly", + "musically.app.link", + "musically-alternate.app.link", + "musemuse.cn", + "sgsnssdk.com", + "tiktok.com", + "tiktok.org", + "tiktokcdn.com", + "tiktokcdn-eu.com", + "tiktokv.com", + # X + "twitter.com", "x.com", # YouTube "googlevideo.com", @@ -140,15 +119,20 @@ match { ] } - domain "nsfw" { - type = "domains" - from = "https://energized.pro/nsfw/domains.txt" - refresh = 43200 # 12h + domain "toxic" { + type = "list" + list = [] } - domain "ads" { - type = "detect" - from = "https://small.oisd.nl/dnsmasq" - refresh = 12 - } + #domain "nsfw" { + # type = "domains" + # from = "https://energized.pro/nsfw/domains.txt" + # refresh = 43200 # 12h + #} + # + #domain "ads" { + # type = "detect" + # from = "https://small.oisd.nl/dnsmasq" + # refresh = 12 + #} } diff --git a/testdata/policy/bogons.rego b/testdata/policy/bogons.rego new file mode 100644 index 0000000..6806bdd --- /dev/null +++ b/testdata/policy/bogons.rego @@ -0,0 +1,12 @@ +package styx + +default permit := true + +reject = 404 if { + #some addr in net.lookup_ip_addr(input.http_request.host) + styx.in_networks("bogons", input.http_request.host) +} + +errors contains "Bogon destination not allowed" if { + reject == 404 +} \ No newline at end of file diff --git a/testdata/policy/childsafe.rego b/testdata/policy/childsafe.rego new file mode 100644 index 0000000..6364e0c --- /dev/null +++ b/testdata/policy/childsafe.rego @@ -0,0 +1,56 @@ +package styx + +import input.client as client +import input.request as http_request + +# HTTP -> HTTPS redirects for allowed domains +redirect = concat("", ["https://", http_request.host, http_request.path]) if { + _social + http_request.scheme == "http" +} + +reject = 403 if { + _childsafe_network + _social +} + +reject = 403 if { + _childsafe_network + _toxic +} + +# Sensitive domains are always allowed +permit if { + _sensitive +} + +permit if { + reject != 0 +} + +_sensitive if { + styx.in_domains("sensitive", http_request.host) +} + +_social if { + styx.in_domains("social", http_request.host) + print("Domain in social", http_request.host) +} + +errors contains "Social networking domain not allowed" if { + reject != 0 + _social +} + +_toxic if { + styx.in_domains("toxic", http_request.host) +} + +errors contains "Toxic domain not allowed" if { + reject != 0 + _toxic +} + +_childsafe_network if { + styx.in_networks("kids", client.ip) +} diff --git a/testdata/policy/intercept.rego b/testdata/policy/intercept.rego new file mode 100644 index 0000000..2791f1c --- /dev/null +++ b/testdata/policy/intercept.rego @@ -0,0 +1,21 @@ +package styx.intercept + +reject := 403 if { + _target_blocked +} + +template := "template/intercepted.html" if { + _target_blocked +} + +errors contains "Intercepted" if { + _target_blocked +} + +_target_blocked if { + styx.in_domains("bad", input.request.host) +} + +_target_blocked if { + styx.in_networks("bogons", input.client.ip) +}