318 lines
8.1 KiB
Go
318 lines
8.1 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"net"
|
|
"net/netip"
|
|
"net/url"
|
|
"slices"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
type PolicyError struct {
|
|
Code string
|
|
Reason string
|
|
}
|
|
|
|
func (e *PolicyError) Error() string {
|
|
if e.Reason == "" {
|
|
return e.Code
|
|
}
|
|
return e.Code + ": " + e.Reason
|
|
}
|
|
|
|
func refuse(code, reason string) error {
|
|
return &PolicyError{Code: code, Reason: reason}
|
|
}
|
|
|
|
type Target struct {
|
|
URL string
|
|
Method string
|
|
Host string
|
|
Port int
|
|
Dest Destination
|
|
}
|
|
|
|
type Policy struct {
|
|
mu sync.RWMutex
|
|
dests []Destination
|
|
lookup func(ctx context.Context, host string) ([]netip.Addr, error)
|
|
public func(netip.Addr) bool
|
|
dial func(ctx context.Context, network, address string) (net.Conn, error)
|
|
}
|
|
|
|
func NewPolicy() *Policy {
|
|
dialer := &net.Dialer{Timeout: time.Duration(TunnelDialSeconds) * time.Second}
|
|
return &Policy{
|
|
dests: cloneDestinations(BakedDestinations),
|
|
lookup: systemLookup,
|
|
public: IsPublicAddr,
|
|
dial: dialer.DialContext,
|
|
}
|
|
}
|
|
|
|
func systemLookup(ctx context.Context, host string) ([]netip.Addr, error) {
|
|
return net.DefaultResolver.LookupNetIP(ctx, "ip", host)
|
|
}
|
|
|
|
func cloneDestinations(list []Destination) []Destination {
|
|
out := make([]Destination, len(list))
|
|
for i, item := range list {
|
|
item.Methods = slices.Clone(item.Methods)
|
|
item.Ports = slices.Clone(item.Ports)
|
|
item.Modes = slices.Clone(item.Modes)
|
|
out[i] = item
|
|
}
|
|
return out
|
|
}
|
|
|
|
func normalizeHost(host string) string {
|
|
return strings.TrimRight(strings.ToLower(strings.TrimSpace(host)), ".")
|
|
}
|
|
|
|
func suffixMatches(suffix, host string) bool {
|
|
return host == suffix || strings.HasSuffix(host, "."+suffix)
|
|
}
|
|
|
|
func intersectStrings(a, b []string) []string {
|
|
out := []string{}
|
|
for _, item := range a {
|
|
if slices.ContainsFunc(b, func(other string) bool { return strings.EqualFold(item, other) }) {
|
|
out = append(out, item)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func intersectPorts(a, b []int) []int {
|
|
out := []int{}
|
|
for _, item := range a {
|
|
if slices.Contains(b, item) && slices.Contains(AllowedPorts, item) {
|
|
out = append(out, item)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func (p *Policy) Refine(offered []Destination) []string {
|
|
var rejected []string
|
|
narrowed := []Destination{}
|
|
seen := map[string]bool{}
|
|
for _, item := range offered {
|
|
suffix := normalizeHost(item.Suffix)
|
|
var base *Destination
|
|
for i := range BakedDestinations {
|
|
if suffixMatches(BakedDestinations[i].Suffix, suffix) {
|
|
base = &BakedDestinations[i]
|
|
break
|
|
}
|
|
}
|
|
if base == nil || seen[suffix] {
|
|
rejected = append(rejected, item.Suffix)
|
|
continue
|
|
}
|
|
seen[suffix] = true
|
|
narrowed = append(narrowed, Destination{
|
|
Suffix: suffix,
|
|
Adapter: base.Adapter,
|
|
Methods: intersectStrings(base.Methods, item.Methods),
|
|
Ports: intersectPorts(base.Ports, item.Ports),
|
|
Modes: intersectStrings(base.Modes, item.Modes),
|
|
MaxRPS: item.MaxRPS,
|
|
})
|
|
}
|
|
p.mu.Lock()
|
|
p.dests = narrowed
|
|
p.mu.Unlock()
|
|
return rejected
|
|
}
|
|
|
|
func (p *Policy) Destinations() []Destination {
|
|
p.mu.RLock()
|
|
defer p.mu.RUnlock()
|
|
return cloneDestinations(p.dests)
|
|
}
|
|
|
|
func (p *Policy) destinationFor(host string) (Destination, bool) {
|
|
p.mu.RLock()
|
|
defer p.mu.RUnlock()
|
|
var best Destination
|
|
found := false
|
|
for _, item := range p.dests {
|
|
if suffixMatches(item.Suffix, host) && (!found || len(item.Suffix) > len(best.Suffix)) {
|
|
best, found = item, true
|
|
}
|
|
}
|
|
return best, found
|
|
}
|
|
|
|
func isAddressLiteral(host string) bool {
|
|
_, err := netip.ParseAddr(strings.Trim(host, "[]"))
|
|
return err == nil
|
|
}
|
|
|
|
func (p *Policy) CheckHostPort(host string, port int, mode string) (Destination, error) {
|
|
host = normalizeHost(host)
|
|
if host == "" {
|
|
return Destination{}, refuse(RefuseBadURL, "no host")
|
|
}
|
|
if isAddressLiteral(host) {
|
|
return Destination{}, refuse(RefuseHost, "address literals are not allowed")
|
|
}
|
|
dest, ok := p.destinationFor(host)
|
|
if !ok {
|
|
return Destination{}, refuse(RefuseHost, "destination is not on the allowlist")
|
|
}
|
|
if !slices.Contains(AllowedPorts, port) || !slices.Contains(dest.Ports, port) {
|
|
return Destination{}, refuse(RefusePort, "port is not allowed")
|
|
}
|
|
if mode != "" && mode != "any" && !slices.Contains(dest.Modes, mode) {
|
|
return Destination{}, refuse(RefuseHost, "mode is not allowed for this destination")
|
|
}
|
|
return dest, nil
|
|
}
|
|
|
|
func (p *Policy) Check(rawURL, method, mode string) (Target, error) {
|
|
if rawURL == "" || len(rawURL) > MaxURLChars {
|
|
return Target{}, refuse(RefuseBadURL, "url is missing or too long")
|
|
}
|
|
parsed, err := url.Parse(strings.TrimSpace(rawURL))
|
|
if err != nil {
|
|
return Target{}, refuse(RefuseBadURL, "url cannot be parsed")
|
|
}
|
|
defaultPort := 0
|
|
switch parsed.Scheme {
|
|
case "http":
|
|
defaultPort = 80
|
|
case "https":
|
|
defaultPort = 443
|
|
default:
|
|
return Target{}, refuse(RefuseBadURL, "only http and https are allowed")
|
|
}
|
|
if parsed.User != nil {
|
|
return Target{}, refuse(RefuseBadURL, "credentials in a url are not allowed")
|
|
}
|
|
port := defaultPort
|
|
if text := parsed.Port(); text != "" {
|
|
port, err = strconv.Atoi(text)
|
|
if err != nil {
|
|
return Target{}, refuse(RefuseBadURL, "port is not a number")
|
|
}
|
|
}
|
|
dest, err := p.CheckHostPort(parsed.Hostname(), port, mode)
|
|
if err != nil {
|
|
return Target{}, err
|
|
}
|
|
verb := strings.ToUpper(strings.TrimSpace(method))
|
|
if !slices.Contains(upperAll(dest.Methods), verb) {
|
|
return Target{}, refuse(RefuseMethod, "method is not allowed here")
|
|
}
|
|
return Target{URL: parsed.String(), Method: verb, Host: normalizeHost(parsed.Hostname()), Port: port, Dest: dest}, nil
|
|
}
|
|
|
|
func upperAll(list []string) []string {
|
|
out := make([]string, len(list))
|
|
for i, item := range list {
|
|
out[i] = strings.ToUpper(item)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func (p *Policy) CheckRedirect(location, method string) (Target, error) {
|
|
target, err := p.Check(location, method, "fetch")
|
|
if err != nil {
|
|
var policyErr *PolicyError
|
|
if errors.As(err, &policyErr) {
|
|
return Target{}, refuse(RefuseRedirect, policyErr.Reason)
|
|
}
|
|
return Target{}, refuse(RefuseRedirect, err.Error())
|
|
}
|
|
return target, nil
|
|
}
|
|
|
|
func (p *Policy) Resolve(ctx context.Context, host string) ([]netip.Addr, error) {
|
|
host = normalizeHost(host)
|
|
addrs, err := p.lookup(ctx, host)
|
|
if err != nil {
|
|
return nil, refuse(FailDNS, "host did not resolve")
|
|
}
|
|
if len(addrs) == 0 {
|
|
return nil, refuse(FailDNS, "host has no addresses")
|
|
}
|
|
if len(addrs) > 16 {
|
|
addrs = addrs[:16]
|
|
}
|
|
for _, addr := range addrs {
|
|
if !p.public(addr.Unmap()) {
|
|
return nil, refuse(RefuseAddress, "host resolves to a non public address")
|
|
}
|
|
}
|
|
return addrs, nil
|
|
}
|
|
|
|
func (p *Policy) DialContext(ctx context.Context, network, address string) (net.Conn, error) {
|
|
host, portText, err := net.SplitHostPort(address)
|
|
if err != nil {
|
|
return nil, refuse(RefuseBadURL, "bad address")
|
|
}
|
|
port, err := strconv.Atoi(portText)
|
|
if err != nil {
|
|
return nil, refuse(RefuseBadURL, "bad port")
|
|
}
|
|
if _, err := p.CheckHostPort(host, port, ""); err != nil {
|
|
return nil, err
|
|
}
|
|
addrs, err := p.Resolve(ctx, host)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var last error
|
|
for _, addr := range addrs {
|
|
conn, err := p.dial(ctx, network, net.JoinHostPort(addr.Unmap().String(), portText))
|
|
if err == nil {
|
|
return conn, nil
|
|
}
|
|
last = err
|
|
}
|
|
return nil, fmt.Errorf("connect failed: %w", last)
|
|
}
|
|
|
|
var blockedPrefixes = mustPrefixes(
|
|
"0.0.0.0/8", "10.0.0.0/8", "100.64.0.0/10", "127.0.0.0/8", "169.254.0.0/16",
|
|
"172.16.0.0/12", "192.0.0.0/24", "192.0.2.0/24", "192.88.99.0/24", "192.168.0.0/16",
|
|
"198.18.0.0/15", "198.51.100.0/24", "203.0.113.0/24", "224.0.0.0/4", "240.0.0.0/4",
|
|
"::/128", "::1/128", "::ffff:0:0/96", "64:ff9b::/96", "64:ff9b:1::/48", "100::/64",
|
|
"2001::/32", "2001:db8::/32", "2002::/16", "fc00::/7", "fe80::/10", "ff00::/8",
|
|
)
|
|
|
|
func mustPrefixes(list ...string) []netip.Prefix {
|
|
out := make([]netip.Prefix, len(list))
|
|
for i, item := range list {
|
|
out[i] = netip.MustParsePrefix(item)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func IsPublicAddr(addr netip.Addr) bool {
|
|
addr = addr.Unmap()
|
|
if !addr.IsValid() || addr.Zone() != "" {
|
|
return false
|
|
}
|
|
if addr.IsUnspecified() || addr.IsLoopback() || addr.IsPrivate() || addr.IsMulticast() ||
|
|
addr.IsLinkLocalUnicast() || addr.IsLinkLocalMulticast() || addr.IsInterfaceLocalMulticast() ||
|
|
!addr.IsGlobalUnicast() {
|
|
return false
|
|
}
|
|
for _, prefix := range blockedPrefixes {
|
|
if prefix.Contains(addr) {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|