package match import ( "fmt" "net" "net/http" "os" "regexp" "slices" "strconv" "strings" "time" "git.maze.io/maze/styx/internal/log" "git.maze.io/maze/styx/internal/netutil" "github.com/hashicorp/hcl/v2" "github.com/hashicorp/hcl/v2/gohcl" ) type Config struct { Path string `hcl:"path,optional"` Refresh time.Duration `hcl:"refresh,optional"` Domain []*Domain `hcl:"domain,block"` Network []*Network `hcl:"network,block"` Content []*Content `hcl:"content,block"` } func (config Config) Matchers() (Matchers, error) { all := make(Matchers) if config.Domain != nil { all["domain"] = make(map[string]Matcher) for _, domain := range config.Domain { m, err := domain.Matcher() if err != nil { return nil, fmt.Errorf("matcher domain %q invalid: %w", domain.Name, err) } all["domain"][domain.Name] = m } } if config.Network != nil { all["network"] = make(map[string]Matcher) for _, network := range config.Network { m, err := network.Matcher(true) if err != nil { return nil, fmt.Errorf("matcher network %q invalid: %w", network.Name, err) } all["network"][network.Name] = m } } return all, nil } type Content struct { Name string `hcl:"name,label"` Type string `hcl:"type"` Body hcl.Body `hcl:",remain"` } type contentHeader struct { Key string `hcl:"name"` Value string `hcl:"value,optional"` List []string `hcl:"list,optional"` name string keyRe *regexp.Regexp valueRe *regexp.Regexp } func (m contentHeader) Name() string { return m.name } func (m contentHeader) MatchesResponse(r *http.Response) bool { for k, vv := range r.Header { if m.keyRe.MatchString(k) { for _, v := range vv { if slices.Contains(m.List, v) { return true } if m.valueRe != nil && m.valueRe.MatchString(v) { return true } } } } return false } type contentType struct { List []string `hcl:"list"` name string } func (m contentType) Name() string { return m.name } func (m contentType) MatchesResponse(r *http.Response) bool { return slices.Contains(m.List, r.Header.Get("Content-Type")) } type contentSizeLargerThan struct { Size int64 `hcl:"size"` name string } func (m contentSizeLargerThan) Name() string { return m.name } func (m contentSizeLargerThan) MatchesResponse(r *http.Response) bool { size, err := strconv.ParseInt(r.Header.Get("Content-Length"), 10, 64) if err != nil { return false } return size >= m.Size } type contentStatus struct { Code []int `hcl:"code"` name string } func (m contentStatus) Name() string { return m.name } func (m contentStatus) MatchesResponse(r *http.Response) bool { return slices.Contains(m.Code, r.StatusCode) } func (config Content) Matcher() (Response, error) { switch strings.ToLower(config.Type) { case "content", "contenttype", "content-type", "type": var matcher = contentType{name: config.Name} if err := gohcl.DecodeBody(config.Body, nil, &matcher); err != nil { return nil, err } return matcher, nil case "header": var ( matcher = contentHeader{name: config.Name} err error ) if err = gohcl.DecodeBody(config.Body, nil, &matcher); err != nil { return nil, err } if matcher.Value == "" && len(matcher.List) == 0 { return nil, fmt.Errorf("invalid content %q: must contain either list or value", config.Name) } if matcher.keyRe, err = regexp.Compile(matcher.Key); err != nil { return nil, fmt.Errorf("invalid regular expression on content %q key: %w", config.Name, err) } if matcher.Value != "" { if matcher.valueRe, err = regexp.Compile(matcher.Value); err != nil { return nil, fmt.Errorf("invalid regular expression on content %q value: %w", config.Name, err) } } return matcher, nil case "size": var matcher = contentSizeLargerThan{name: config.Name} if err := gohcl.DecodeBody(config.Body, nil, &matcher); err != nil { return nil, err } return matcher, nil case "status": var matcher = contentStatus{name: config.Name} if err := gohcl.DecodeBody(config.Body, nil, &matcher); err != nil { return nil, err } return matcher, nil default: return nil, fmt.Errorf("unknown content matcher type %q", config.Type) } } type Domain struct { Name string `hcl:"name,label"` Type string `hcl:"type"` Body hcl.Body `hcl:",remain"` } func (config Domain) Matcher() (Request, error) { switch config.Type { case "list": var matcher = domainList{Title: config.Name} if err := gohcl.DecodeBody(config.Body, nil, &matcher); err != nil { return nil, err } matcher.list = netutil.NewDomainList(matcher.List...) return matcher, nil case "adblock", "dnsmasq", "hosts", "detect", "domains": var matcher = DomainFile{ Title: config.Name, Type: config.Type, } if err := gohcl.DecodeBody(config.Body, nil, &matcher); err != nil { return nil, err } if matcher.Path == "" && matcher.From == "" { return nil, fmt.Errorf("matcher: domain %q must have either file or from configured", config.Name) } if err := matcher.Update(); err != nil { return nil, err } return matcher, nil default: return nil, fmt.Errorf("unknown domain matcher type %q", config.Type) } } type domainList struct { Title string `json:"title"` List []string `hcl:"list" json:"list"` list *netutil.DomainTree } func (m domainList) Name() string { return m.Title } func (m domainList) MatchesRequest(r *http.Request) bool { host := netutil.Host(r.URL.Host) log.Debug().Str("host", host).Msgf("match domain list (%d domains)", len(m.List)) return m.list.Contains(host) } type DomainFile struct { Title string `json:"name"` Type string `json:"type"` Path string `hcl:"path,optional" json:"path,omitempty"` From string `hcl:"from,optional" json:"from,omitempty"` Refresh time.Duration `hcl:"refresh,optional" json:"refresh"` } func (m DomainFile) Name() string { return m.Title } func (m DomainFile) MatchesRequest(_ *http.Request) bool { return false } func (m *DomainFile) Update() (err error) { var data []byte if m.Path != "" { if data, err = os.ReadFile(m.Path); err != nil { return } } else { /* var response *http.Response if response, err = http.DefaultClient.Get(m.From); err != nil { return } defer func() { _ = response.Body.Close() }() if response.StatusCode != http.StatusOK { return fmt.Errorf("match: domain %q update failed: %s", m.name, response.Status) } if data, err = io.ReadAll(response.Body); err != nil { return } */ } switch m.Type { case "hosts": } _ = data return nil } type Network struct { Name string `hcl:"name,label"` Type string `hcl:"type"` Body hcl.Body `hcl:",remain"` } func (config *Network) Matcher(target bool) (Matcher, error) { switch config.Type { case "list": var ( matcher = networkList{Title: config.Name} err error ) if diag := gohcl.DecodeBody(config.Body, nil, &matcher); diag.HasErrors() { return nil, diag } if matcher.tree, err = netutil.NewNetworkTree(matcher.List...); err != nil { return nil, err } return &matcher, nil default: return nil, fmt.Errorf("unknown network matcher type %q", config.Type) } } type networkList struct { Title string `json:"name"` List []string `hcl:"list" json:"list"` tree *netutil.NetworkTree target bool } func (m *networkList) Name() string { return m.Title } func (m *networkList) MatchesIP(ip net.IP) bool { return m.tree.Contains(ip) } func (m *networkList) MatchesRequest(r *http.Request) bool { var ( host string err error ) if m.target { host, _, err = net.SplitHostPort(r.URL.Host) } else { host, _, err = net.SplitHostPort(r.RemoteAddr) } if err != nil { return false } ip := net.ParseIP(host) return m.MatchesIP(ip) }