Files
devplacepy/nodes/client/policy.go
T
retoor 6f80a820da
DevPlace CI / test (push) Failing after 3m13s
Zo doen wijdat in oost
2026-10-16 07:20:44 +02:00

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
}