325 lines
7.6 KiB
Go
325 lines
7.6 KiB
Go
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)
|
|
}
|