refactor(10.2-01): nest framework packages under modules
- Move remaining beach packages and embedded admin assets\n- Rewrite framework, example, build, and gate paths
This commit is contained in:
178
modules/fetchguard/fetch.go
Normal file
178
modules/fetchguard/fetch.go
Normal file
@@ -0,0 +1,178 @@
|
||||
package fetchguard
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"net/url"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/compass"
|
||||
)
|
||||
|
||||
// Result is a successful (including non-2xx, including 3xx) Fetch response.
|
||||
type Result struct {
|
||||
Body []byte
|
||||
ContentType string
|
||||
StatusCode int
|
||||
}
|
||||
|
||||
var errPrivateIP = errors.New("private_ip")
|
||||
|
||||
// Fetch validates url against policy, resolves defaults for any zero
|
||||
// MaxBytes/Timeout via DefaultsFromConfig (or Defaults() when cfg is nil),
|
||||
// then performs the guarded HTTPS GET.
|
||||
//
|
||||
// Redirects are never followed: CheckRedirect returns http.ErrUseLastResponse,
|
||||
// so a 3xx response is returned as a non-error Result. Callers that want to
|
||||
// follow Location must re-invoke Fetch, which re-runs the same guard.
|
||||
//
|
||||
// A non-nil error is always *Error with a Reason from the closed set.
|
||||
func Fetch(ctx context.Context, rawURL string, policy Policy, cfg *compass.Config) (*Result, error) {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
parsed, err := url.Parse(rawURL)
|
||||
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
|
||||
return nil, &Error{Reason: ReasonInvalidURL, Err: err}
|
||||
}
|
||||
if strings.ToLower(parsed.Scheme) != "https" {
|
||||
return nil, &Error{Reason: ReasonScheme}
|
||||
}
|
||||
host := parsed.Hostname()
|
||||
if policy.Mode == AllowHostsMode && !hostAllowed(host, policy.AllowHosts) {
|
||||
return nil, &Error{Reason: ReasonInvalidURL}
|
||||
}
|
||||
|
||||
maxBytes, timeout, err := resolveLimits(policy, cfg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
client := &http.Client{
|
||||
Timeout: timeout,
|
||||
CheckRedirect: func(*http.Request, []*http.Request) error {
|
||||
return http.ErrUseLastResponse
|
||||
},
|
||||
Transport: &http.Transport{
|
||||
// User-supplied URLs must not be forwarded through HTTP_PROXY:
|
||||
// the dial-time IP check would then see the proxy, not the target.
|
||||
Proxy: nil,
|
||||
DialContext: (&net.Dialer{
|
||||
Timeout: timeout,
|
||||
Control: dialControl(policy),
|
||||
}).DialContext,
|
||||
TLSClientConfig: policy.tlsConfig,
|
||||
DisableKeepAlives: true,
|
||||
ForceAttemptHTTP2: true,
|
||||
},
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, parsed.String(), nil)
|
||||
if err != nil {
|
||||
return nil, &Error{Reason: ReasonInvalidURL, Err: err}
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, mapTransportError(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
data, err := io.ReadAll(io.LimitReader(resp.Body, maxBytes+1))
|
||||
if err != nil {
|
||||
return nil, &Error{Reason: ReasonNetworkError, Err: err}
|
||||
}
|
||||
if int64(len(data)) == maxBytes+1 {
|
||||
return nil, &Error{Reason: ReasonTooLarge}
|
||||
}
|
||||
return &Result{
|
||||
Body: data,
|
||||
ContentType: resp.Header.Get("Content-Type"),
|
||||
StatusCode: resp.StatusCode,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func resolveLimits(policy Policy, cfg *compass.Config) (int64, time.Duration, error) {
|
||||
maxBytes := policy.MaxBytes
|
||||
timeout := policy.Timeout
|
||||
if maxBytes <= 0 || timeout <= 0 {
|
||||
var (
|
||||
defMax int64
|
||||
defTO time.Duration
|
||||
err error
|
||||
)
|
||||
if cfg != nil {
|
||||
defMax, defTO, err = DefaultsFromConfig(cfg)
|
||||
if err != nil {
|
||||
return 0, 0, &Error{Reason: ReasonInvalidURL, Err: err}
|
||||
}
|
||||
} else {
|
||||
defMax, defTO = Defaults()
|
||||
}
|
||||
if maxBytes <= 0 {
|
||||
maxBytes = defMax
|
||||
}
|
||||
if timeout <= 0 {
|
||||
timeout = defTO
|
||||
}
|
||||
}
|
||||
if maxBytes <= 0 || timeout <= 0 {
|
||||
return 0, 0, &Error{Reason: ReasonInvalidURL, Err: fmt.Errorf("max bytes and timeout must be positive")}
|
||||
}
|
||||
return maxBytes, timeout, nil
|
||||
}
|
||||
|
||||
func hostAllowed(host string, allowed []string) bool {
|
||||
host = strings.ToLower(host)
|
||||
for _, a := range allowed {
|
||||
a = strings.ToLower(a)
|
||||
if a == "" {
|
||||
continue
|
||||
}
|
||||
if host == a || strings.HasSuffix(host, "."+a) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func dialControl(policy Policy) func(network, address string, c syscall.RawConn) error {
|
||||
return func(network, address string, c syscall.RawConn) error {
|
||||
if policy.skipReservedCheck {
|
||||
return nil
|
||||
}
|
||||
host, _, err := net.SplitHostPort(address)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
addr, err := netip.ParseAddr(host)
|
||||
if err != nil {
|
||||
return fmt.Errorf("fetchguard: unparseable dial address %q: %w", host, err)
|
||||
}
|
||||
if addr.Zone() != "" {
|
||||
return errPrivateIP
|
||||
}
|
||||
addr = addr.Unmap()
|
||||
if isReservedOrPrivate(addr) {
|
||||
return errPrivateIP
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func mapTransportError(err error) *Error {
|
||||
if errors.Is(err, errPrivateIP) {
|
||||
return &Error{Reason: ReasonPrivateIP, Err: err}
|
||||
}
|
||||
var dnsErr *net.DNSError
|
||||
if errors.As(err, &dnsErr) {
|
||||
return &Error{Reason: ReasonUnresolvable, Err: err}
|
||||
}
|
||||
return &Error{Reason: ReasonNetworkError, Err: err}
|
||||
}
|
||||
98
modules/fetchguard/fetch_coverage_test.go
Normal file
98
modules/fetchguard/fetch_coverage_test.go
Normal file
@@ -0,0 +1,98 @@
|
||||
package fetchguard
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Gap (f): PublicOnlyMode private-IP rejection is already asserted by
|
||||
// TestFetchPrivateIPBlockedInBothModes/PublicOnlyMode. This file adds
|
||||
// PublicOnlyMode accepting any host when the dial-time IP check is
|
||||
// satisfied (loopback httptest with skipReservedCheck — a live public
|
||||
// IP dial would require outbound network and is not asserted here).
|
||||
|
||||
func TestFetchPublicOnlyModeAcceptsAnyHostWhenPublic(t *testing.T) {
|
||||
srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/plain")
|
||||
io.WriteString(w, "ok")
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
|
||||
res, err := Fetch(t.Context(), srv.URL, withTestLoopback(srv, Policy{
|
||||
Mode: PublicOnlyMode,
|
||||
MaxBytes: 1024,
|
||||
Timeout: 2 * time.Second,
|
||||
}), nil)
|
||||
if err != nil {
|
||||
t.Fatalf("PublicOnlyMode must accept the httptest host: %v", err)
|
||||
}
|
||||
if string(res.Body) != "ok" {
|
||||
t.Fatalf("body = %q", res.Body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchPublicOnlyModePrivateIPRejected(t *testing.T) {
|
||||
// Explicit restatement of the private-IP case under PublicOnlyMode so
|
||||
// this coverage file names both halves of gap (f).
|
||||
srv := httptest.NewTLSServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
|
||||
t.Error("handler must not run for a private dial")
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
|
||||
_, err := Fetch(t.Context(), srv.URL, Policy{
|
||||
Mode: PublicOnlyMode,
|
||||
Timeout: 2 * time.Second,
|
||||
MaxBytes: 1024,
|
||||
}, nil)
|
||||
if reasonFrom(t, err) != ReasonPrivateIP {
|
||||
t.Fatalf("reason = %q, want %s", reasonFrom(t, err), ReasonPrivateIP)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHostAllowedExactAndDottedSuffix(t *testing.T) {
|
||||
allowed := []string{"discogs.com", ""}
|
||||
if !hostAllowed("discogs.com", allowed) {
|
||||
t.Fatal("exact match")
|
||||
}
|
||||
if !hostAllowed("api.discogs.com", allowed) {
|
||||
t.Fatal("dotted-suffix match")
|
||||
}
|
||||
if hostAllowed("evil-discogs.com", allowed) {
|
||||
t.Fatal("raw suffix must not match")
|
||||
}
|
||||
if hostAllowed("example.test", allowed) {
|
||||
t.Fatal("unrelated host")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMapTransportErrorReasons(t *testing.T) {
|
||||
if got := mapTransportError(errPrivateIP); got.Reason != ReasonPrivateIP {
|
||||
t.Fatalf("private_ip = %s", got.Reason)
|
||||
}
|
||||
dns := &net.DNSError{Err: "no such host", Name: "nope.test", IsNotFound: true}
|
||||
if got := mapTransportError(dns); got.Reason != ReasonUnresolvable {
|
||||
t.Fatalf("dns = %s", got.Reason)
|
||||
}
|
||||
if got := mapTransportError(errors.New("connection reset")); got.Reason != ReasonNetworkError {
|
||||
t.Fatalf("other = %s", got.Reason)
|
||||
}
|
||||
}
|
||||
|
||||
func TestErrorStringWithoutInner(t *testing.T) {
|
||||
e := &Error{Reason: ReasonScheme}
|
||||
if e.Error() != "fetchguard: scheme" {
|
||||
t.Fatalf("Error() = %q", e.Error())
|
||||
}
|
||||
var nilE *Error
|
||||
if nilE.Error() != "fetchguard: error" {
|
||||
t.Fatalf("nil Error() = %q", nilE.Error())
|
||||
}
|
||||
if nilE.Unwrap() != nil {
|
||||
t.Fatal("nil Unwrap")
|
||||
}
|
||||
}
|
||||
351
modules/fetchguard/fetch_test.go
Normal file
351
modules/fetchguard/fetch_test.go
Normal file
@@ -0,0 +1,351 @@
|
||||
package fetchguard
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/compass"
|
||||
)
|
||||
|
||||
func TestFetchMalformedURL(t *testing.T) {
|
||||
_, err := Fetch(t.Context(), "not a url", Policy{Mode: PublicOnlyMode}, nil)
|
||||
if reasonFrom(t, err) != ReasonInvalidURL {
|
||||
t.Fatalf("reason = %q, want %s", reasonFrom(t, err), ReasonInvalidURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchHTTPSchemeRejectedWithoutIO(t *testing.T) {
|
||||
var hits atomic.Int64
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
|
||||
hits.Add(1)
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
|
||||
_, err := Fetch(t.Context(), srv.URL, Policy{Mode: PublicOnlyMode}, nil)
|
||||
if reasonFrom(t, err) != ReasonScheme {
|
||||
t.Fatalf("reason = %q, want %s", reasonFrom(t, err), ReasonScheme)
|
||||
}
|
||||
if hits.Load() != 0 {
|
||||
t.Fatal("http URL must not cause network I/O")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchAllowHostsRejectsUnknownHostBeforeDial(t *testing.T) {
|
||||
var hits atomic.Int64
|
||||
srv := httptest.NewTLSServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
|
||||
hits.Add(1)
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
|
||||
_, err := Fetch(t.Context(), srv.URL, Policy{
|
||||
Mode: AllowHostsMode,
|
||||
AllowHosts: []string{"discogs.com"},
|
||||
}, nil)
|
||||
if reasonFrom(t, err) != ReasonInvalidURL {
|
||||
t.Fatalf("reason = %q, want %s", reasonFrom(t, err), ReasonInvalidURL)
|
||||
}
|
||||
if hits.Load() != 0 {
|
||||
t.Fatal("host allow-list miss must not dial")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchAllowHostsRejectsDottedSuffixBypass(t *testing.T) {
|
||||
_, err := Fetch(t.Context(), "https://evil-discogs.com/cover.jpg", Policy{
|
||||
Mode: AllowHostsMode,
|
||||
AllowHosts: []string{"discogs.com"},
|
||||
}, nil)
|
||||
if reasonFrom(t, err) != ReasonInvalidURL {
|
||||
t.Fatalf("evil-discogs.com must not match discogs.com, reason = %q", reasonFrom(t, err))
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchPrivateIPBlockedInBothModes(t *testing.T) {
|
||||
srv := httptest.NewTLSServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
|
||||
t.Error("handler must not run for a private dial")
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
|
||||
t.Run("AllowHostsMode", func(t *testing.T) {
|
||||
_, err := Fetch(t.Context(), srv.URL, Policy{
|
||||
Mode: AllowHostsMode,
|
||||
AllowHosts: []string{"127.0.0.1"},
|
||||
Timeout: 2 * time.Second,
|
||||
MaxBytes: 1024,
|
||||
}, nil)
|
||||
if reasonFrom(t, err) != ReasonPrivateIP {
|
||||
t.Fatalf("reason = %q, want %s", reasonFrom(t, err), ReasonPrivateIP)
|
||||
}
|
||||
})
|
||||
t.Run("PublicOnlyMode", func(t *testing.T) {
|
||||
_, err := Fetch(t.Context(), srv.URL, Policy{
|
||||
Mode: PublicOnlyMode,
|
||||
Timeout: 2 * time.Second,
|
||||
MaxBytes: 1024,
|
||||
}, nil)
|
||||
if reasonFrom(t, err) != ReasonPrivateIP {
|
||||
t.Fatalf("reason = %q, want %s", reasonFrom(t, err), ReasonPrivateIP)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestDialControlRejectsUnsafeIPv6Transitions(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
ip string
|
||||
}{
|
||||
{name: "nat64 well-known loopback", ip: "64:ff9b::7f00:1"},
|
||||
{name: "nat64 well-known rfc1918", ip: "64:ff9b::a00:1"},
|
||||
{name: "nat64 well-known metadata", ip: "64:ff9b::a9fe:a9fe"},
|
||||
{name: "nat64 local-use loopback", ip: "64:ff9b:1:7f00:0:100::"},
|
||||
{name: "nat64 local-use rfc1918", ip: "64:ff9b:1:a00:0:100::"},
|
||||
{name: "nat64 local-use metadata", ip: "64:ff9b:1:a9fe:a9:fe00::"},
|
||||
{name: "6to4 loopback", ip: "2002:7f00:1::"},
|
||||
{name: "6to4 rfc1918", ip: "2002:a00:1::"},
|
||||
{name: "6to4 metadata", ip: "2002:a9fe:a9fe::"},
|
||||
}
|
||||
|
||||
control := dialControl(Policy{Mode: PublicOnlyMode})
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := control("tcp6", "["+tt.ip+"]:443", nil)
|
||||
if !errors.Is(err, errPrivateIP) {
|
||||
t.Fatalf("dialControl(%s) error = %v, want errPrivateIP", tt.ip, err)
|
||||
}
|
||||
if got := mapTransportError(err).Reason; got != ReasonPrivateIP {
|
||||
t.Fatalf("mapTransportError(%s) reason = %q, want %q", tt.ip, got, ReasonPrivateIP)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchDoesNotFollowRedirect(t *testing.T) {
|
||||
var followed atomic.Bool
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/target", func(w http.ResponseWriter, r *http.Request) {
|
||||
followed.Store(true)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Location", "/target")
|
||||
w.WriteHeader(http.StatusFound)
|
||||
})
|
||||
srv := httptest.NewTLSServer(mux)
|
||||
t.Cleanup(srv.Close)
|
||||
|
||||
res, err := Fetch(t.Context(), srv.URL, withTestLoopback(srv, Policy{
|
||||
Mode: PublicOnlyMode,
|
||||
MaxBytes: 1024,
|
||||
Timeout: 5 * time.Second,
|
||||
}), nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Fetch: %v", err)
|
||||
}
|
||||
if res.StatusCode != http.StatusFound {
|
||||
t.Fatalf("status = %d, want %d (3xx returned, not followed)", res.StatusCode, http.StatusFound)
|
||||
}
|
||||
if followed.Load() {
|
||||
t.Fatal("redirect must not be followed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchTooLargeIsStreaming(t *testing.T) {
|
||||
const maxBytes int64 = 256
|
||||
var written atomic.Int64
|
||||
srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/octet-stream")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
buf := make([]byte, 64)
|
||||
for written.Load() < 8<<20 {
|
||||
n, err := w.Write(buf)
|
||||
written.Add(int64(n))
|
||||
if f, ok := w.(http.Flusher); ok {
|
||||
f.Flush()
|
||||
}
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
|
||||
_, err := Fetch(t.Context(), srv.URL, withTestLoopback(srv, Policy{
|
||||
Mode: PublicOnlyMode,
|
||||
MaxBytes: maxBytes,
|
||||
Timeout: 5 * time.Second,
|
||||
}), nil)
|
||||
if reasonFrom(t, err) != ReasonTooLarge {
|
||||
t.Fatalf("reason = %q, want %s", reasonFrom(t, err), ReasonTooLarge)
|
||||
}
|
||||
// TCP/HTTP buffering can write past maxBytes+1, and under a loaded parallel
|
||||
// test run the handler keeps flushing 64-byte chunks until the client's
|
||||
// close propagates (observed ~80 KiB). The client must not have pulled an
|
||||
// unbounded body first (the handler would hit 8 MiB), so 1 MiB is the
|
||||
// meaningful ceiling.
|
||||
if got := written.Load(); got > 1<<20 {
|
||||
t.Fatalf("server wrote %d bytes, client appears to have buffered unbounded body", got)
|
||||
}
|
||||
if written.Load() < maxBytes+1 {
|
||||
t.Fatalf("server wrote %d bytes, want at least maxBytes+1 so the cap was hit", written.Load())
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchZeroPolicyUsesDefaults(t *testing.T) {
|
||||
max, timeout := Defaults()
|
||||
if max != 10*1024*1024 {
|
||||
t.Fatalf("Defaults maxBytes = %d, want 10MiB", max)
|
||||
}
|
||||
if timeout != 10*time.Second {
|
||||
t.Fatalf("Defaults timeout = %s, want 10s", timeout)
|
||||
}
|
||||
|
||||
srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/plain")
|
||||
io.WriteString(w, "ok")
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
|
||||
res, err := Fetch(t.Context(), srv.URL, withTestLoopback(srv, Policy{Mode: PublicOnlyMode}), nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Fetch with zero MaxBytes/Timeout and nil cfg: %v", err)
|
||||
}
|
||||
if string(res.Body) != "ok" {
|
||||
t.Fatalf("body = %q, want ok", res.Body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultsFromConfigExplicitZeroIsError(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(dir, "http.yaml"), []byte("fetch:\n max_bytes: 0\n timeout_seconds: 10\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg, err := compass.Open(compass.Options{Dir: dir, Environ: []string{}})
|
||||
if err != nil {
|
||||
t.Fatalf("Open: %v", err)
|
||||
}
|
||||
if _, ok := cfg.Lookup("http.fetch.max_bytes"); !ok {
|
||||
t.Fatal("expected http.fetch.max_bytes to be present in loaded YAML")
|
||||
}
|
||||
_, _, err = DefaultsFromConfig(cfg)
|
||||
if err == nil {
|
||||
t.Fatal("explicit http.fetch.max_bytes: 0 must be an error, not a silent fallback")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultsFromConfigExplicitNegativeIsError(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(dir, "http.yaml"), []byte("fetch:\n max_bytes: 10485760\n timeout_seconds: -1\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg, err := compass.Open(compass.Options{Dir: dir, Environ: []string{}})
|
||||
if err != nil {
|
||||
t.Fatalf("Open: %v", err)
|
||||
}
|
||||
_, _, err = DefaultsFromConfig(cfg)
|
||||
if err == nil {
|
||||
t.Fatal("explicit http.fetch.timeout_seconds: -1 must be an error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultsFromConfigAbsentFallsBack(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(dir, "app.yaml"), []byte("name: t\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg, err := compass.Open(compass.Options{Dir: dir, Environ: []string{}})
|
||||
if err != nil {
|
||||
t.Fatalf("Open: %v", err)
|
||||
}
|
||||
max, timeout, err := DefaultsFromConfig(cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("DefaultsFromConfig: %v", err)
|
||||
}
|
||||
wantMax, wantTO := Defaults()
|
||||
if max != wantMax || timeout != wantTO {
|
||||
t.Fatalf("got %d %s, want Defaults() %d %s", max, timeout, wantMax, wantTO)
|
||||
}
|
||||
}
|
||||
|
||||
func TestErrorUnwrap(t *testing.T) {
|
||||
inner := errors.New("dial tcp")
|
||||
err := &Error{Reason: ReasonNetworkError, Err: inner}
|
||||
if err.Error() == "" {
|
||||
t.Fatal("Error() must be non-empty")
|
||||
}
|
||||
if !errors.Is(err, inner) {
|
||||
t.Fatal("Unwrap must expose the inner error")
|
||||
}
|
||||
}
|
||||
|
||||
func reasonFrom(t *testing.T, err error) Reason {
|
||||
t.Helper()
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
var fe *Error
|
||||
if !errors.As(err, &fe) {
|
||||
t.Fatalf("err = %v (%T), want *Error", err, err)
|
||||
}
|
||||
return fe.Reason
|
||||
}
|
||||
|
||||
// withTestLoopback trusts the httptest TLS cert and skips the reserved-IP
|
||||
// dial check so redirect/byte-cap tests can exercise a real listener on
|
||||
// 127.0.0.1. Production Policy values leave both fields unset.
|
||||
func withTestLoopback(srv *httptest.Server, p Policy) Policy {
|
||||
tr, ok := srv.Client().Transport.(*http.Transport)
|
||||
if !ok {
|
||||
panic("httptest client transport is not *http.Transport")
|
||||
}
|
||||
p.tlsConfig = tr.TLSClientConfig
|
||||
p.skipReservedCheck = true
|
||||
return p
|
||||
}
|
||||
|
||||
func TestDialControlRejectsZonedAndSpecialUse(t *testing.T) {
|
||||
probes := []string{"[fe80::1%eth0]:443", "198.18.0.1:443", "192.0.0.1:443", "240.0.0.1:443"}
|
||||
ctl := dialControl(Policy{Mode: PublicOnlyMode})
|
||||
for _, a := range probes {
|
||||
t.Run(a, func(t *testing.T) {
|
||||
err := ctl("tcp", a, nil)
|
||||
if !errors.Is(err, errPrivateIP) {
|
||||
t.Fatalf("dialControl(%s) = %v, want errPrivateIP", a, err)
|
||||
}
|
||||
if got := mapTransportError(err).Reason; got != ReasonPrivateIP {
|
||||
t.Fatalf("reason = %q, want %q", got, ReasonPrivateIP)
|
||||
}
|
||||
})
|
||||
}
|
||||
if err := ctl("tcp", "8.8.8.8:443", nil); err != nil {
|
||||
t.Fatalf("public address rejected: %v", err)
|
||||
}
|
||||
if err := ctl("tcp", "[2606:4700:4700::1111]:443", nil); err != nil {
|
||||
t.Fatalf("public v6 rejected: %v", err)
|
||||
}
|
||||
if err := ctl("tcp", "no-port", nil); err == nil {
|
||||
t.Fatal("address without port accepted")
|
||||
}
|
||||
if err := ctl("tcp", "example.com:443", nil); err == nil || errors.Is(err, errPrivateIP) {
|
||||
t.Fatalf("hostname dial target: err = %v, want unparseable error", err)
|
||||
}
|
||||
if err := dialControl(Policy{skipReservedCheck: true})("tcp", "127.0.0.1:1", nil); err != nil {
|
||||
t.Fatalf("skipReservedCheck: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchPublicOnlyMapsSpecialUseToPrivateIP(t *testing.T) {
|
||||
for _, u := range []string{"https://[fe80::1%25eth0]/", "https://198.18.0.1/", "https://192.0.0.1/", "https://240.0.0.1/"} {
|
||||
t.Run(u, func(t *testing.T) {
|
||||
_, err := Fetch(t.Context(), u, Policy{Mode: PublicOnlyMode, Timeout: 2 * time.Second, MaxBytes: 1024}, nil)
|
||||
if got := reasonFrom(t, err); got != ReasonPrivateIP {
|
||||
t.Fatalf("reason = %q, want %s (err %v)", got, ReasonPrivateIP, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
82
modules/fetchguard/ip.go
Normal file
82
modules/fetchguard/ip.go
Normal file
@@ -0,0 +1,82 @@
|
||||
package fetchguard
|
||||
|
||||
import "net/netip"
|
||||
|
||||
// privateV4 is the IANA IPv4 special-purpose non-public set. It is a strict
|
||||
// superset of ManualCoverUrlFetcher.php's lists (06-VERIFICATION gap 3).
|
||||
var privateV4 = 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",
|
||||
)
|
||||
|
||||
// privateV6 is the IANA IPv6 special-purpose non-public set. 2002::/16 and
|
||||
// 64:ff9b::/96 are handled by embeddedTransitionIPv4 instead.
|
||||
var privateV6 = mustPrefixes(
|
||||
"::/96", "100::/64", "2001::/23", "2001:db8::/32", "3fff::/20",
|
||||
"5f00::/16", "fc00::/7", "fe80::/10", "fec0::/10", "ff00::/8",
|
||||
)
|
||||
|
||||
func mustPrefixes(cidrs ...string) []netip.Prefix {
|
||||
out := make([]netip.Prefix, len(cidrs))
|
||||
for i, c := range cidrs {
|
||||
out[i] = netip.MustParsePrefix(c)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
var (
|
||||
nat64WellKnownPrefix = netip.MustParsePrefix("64:ff9b::/96")
|
||||
nat64LocalUsePrefix = netip.MustParsePrefix("64:ff9b:1::/48")
|
||||
sixToFourPrefix = netip.MustParsePrefix("2002::/16")
|
||||
)
|
||||
|
||||
// isReservedOrPrivate classifies addr against the PHP private/loopback/
|
||||
// reserved/CGNAT table, including IPv4 embedded in supported IPv6 transition
|
||||
// formats.
|
||||
func isReservedOrPrivate(addr netip.Addr) bool {
|
||||
if !addr.IsValid() {
|
||||
return true
|
||||
}
|
||||
addr = addr.WithZone("").Unmap()
|
||||
if addr.IsMulticast() || addr.IsUnspecified() {
|
||||
return true
|
||||
}
|
||||
if embedded, ok := embeddedTransitionIPv4(addr); ok {
|
||||
return isReservedOrPrivate(embedded)
|
||||
}
|
||||
table := privateV4
|
||||
if !addr.Is4() {
|
||||
table = privateV6
|
||||
}
|
||||
for _, prefix := range table {
|
||||
if prefix.Contains(addr) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// embeddedTransitionIPv4 extracts IPv4 from the transition formats supported
|
||||
// by fetchguard. A recognized but malformed RFC 6052 /48 address returns an
|
||||
// invalid address with ok=true so the classifier fails closed.
|
||||
func embeddedTransitionIPv4(addr netip.Addr) (netip.Addr, bool) {
|
||||
if !addr.Is6() {
|
||||
return netip.Addr{}, false
|
||||
}
|
||||
b := addr.As16()
|
||||
switch {
|
||||
case nat64WellKnownPrefix.Contains(addr):
|
||||
return netip.AddrFrom4([4]byte{b[12], b[13], b[14], b[15]}), true
|
||||
case nat64LocalUsePrefix.Contains(addr):
|
||||
if b[8] != 0 {
|
||||
return netip.Addr{}, true
|
||||
}
|
||||
return netip.AddrFrom4([4]byte{b[6], b[7], b[9], b[10]}), true
|
||||
case sixToFourPrefix.Contains(addr):
|
||||
return netip.AddrFrom4([4]byte{b[2], b[3], b[4], b[5]}), true
|
||||
default:
|
||||
return netip.Addr{}, false
|
||||
}
|
||||
}
|
||||
178
modules/fetchguard/ip_test.go
Normal file
178
modules/fetchguard/ip_test.go
Normal file
@@ -0,0 +1,178 @@
|
||||
package fetchguard
|
||||
|
||||
import (
|
||||
"net/netip"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestIsReservedOrPrivate(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
ip string
|
||||
want bool
|
||||
}{
|
||||
{name: "loopback v4", ip: "127.0.0.1", want: true},
|
||||
{name: "rfc1918 10/8", ip: "10.1.2.3", want: true},
|
||||
{name: "rfc1918 172.16/12 start", ip: "172.16.0.1", want: true},
|
||||
{name: "rfc1918 172.16/12 end", ip: "172.31.255.255", want: true},
|
||||
{name: "rfc1918 192.168/16", ip: "192.168.1.1", want: true},
|
||||
{name: "cloud metadata link-local", ip: "169.254.169.254", want: true},
|
||||
{name: "cgnat 100.64/10", ip: "100.64.0.1", want: true},
|
||||
{name: "this-network 0.0.0.0/8", ip: "0.0.0.1", want: true},
|
||||
{name: "public 8.8.8.8", ip: "8.8.8.8", want: false},
|
||||
{name: "public 1.1.1.1", ip: "1.1.1.1", want: false},
|
||||
{name: "public 93.184.216.34", ip: "93.184.216.34", want: false},
|
||||
{name: "loopback v6", ip: "::1", want: true},
|
||||
{name: "link-local v6", ip: "fe80::1", want: true},
|
||||
{name: "unique-local v6", ip: "fc00::1", want: true},
|
||||
{name: "public v6", ip: "2606:4700:4700::1111", want: false},
|
||||
{name: "v4-mapped metadata", ip: "::ffff:169.254.169.254", want: true},
|
||||
{name: "multicast v4", ip: "224.0.0.1", want: true},
|
||||
{name: "unspecified v4", ip: "0.0.0.0", want: true},
|
||||
{name: "just below 172.16.0.0/12", ip: "172.15.255.255", want: false},
|
||||
{name: "just above 172.16.0.0/12", ip: "172.32.0.0", want: false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
addr, err := netip.ParseAddr(tt.ip)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseAddr(%q): %v", tt.ip, err)
|
||||
}
|
||||
got := isReservedOrPrivate(addr)
|
||||
if got != tt.want {
|
||||
t.Fatalf("isReservedOrPrivate(%s) = %v, want %v", addr, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsReservedOrPrivateIPv6Transitions(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
ip string
|
||||
want bool
|
||||
}{
|
||||
{name: "nat64 well-known loopback", ip: "64:ff9b::7f00:1", want: true},
|
||||
{name: "nat64 well-known rfc1918", ip: "64:ff9b::a00:1", want: true},
|
||||
{name: "nat64 well-known metadata", ip: "64:ff9b::a9fe:a9fe", want: true},
|
||||
{name: "nat64 well-known public", ip: "64:ff9b::808:808", want: false},
|
||||
{name: "nat64 local-use loopback", ip: "64:ff9b:1:7f00:0:100::", want: true},
|
||||
{name: "nat64 local-use rfc1918", ip: "64:ff9b:1:a00:0:100::", want: true},
|
||||
{name: "nat64 local-use metadata", ip: "64:ff9b:1:a9fe:a9:fe00::", want: true},
|
||||
{name: "nat64 local-use public", ip: "64:ff9b:1:808:8:800::", want: false},
|
||||
{name: "nat64 local-use non-zero u octet", ip: "64:ff9b:1:7f00:100:100::", want: true},
|
||||
{name: "6to4 loopback", ip: "2002:7f00:1::", want: true},
|
||||
{name: "6to4 rfc1918", ip: "2002:a00:1::", want: true},
|
||||
{name: "6to4 metadata", ip: "2002:a9fe:a9fe::", want: true},
|
||||
{name: "6to4 public", ip: "2002:808:808::", want: false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
addr := netip.MustParseAddr(tt.ip)
|
||||
if got := isReservedOrPrivate(addr); got != tt.want {
|
||||
t.Fatalf("isReservedOrPrivate(%s) = %v, want %v", addr, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsReservedOrPrivateSpecialUseSmoke(t *testing.T) {
|
||||
for _, s := range []string{"198.18.0.1", "192.0.0.1", "240.0.0.1", "255.255.255.255", "2001:db8::1", "fec0::1", "fe80::1%eth0"} {
|
||||
if !isReservedOrPrivate(netip.MustParseAddr(s)) {
|
||||
t.Errorf("%s should be non-public", s)
|
||||
}
|
||||
}
|
||||
for _, s := range []string{"8.8.8.8", "1.1.1.1", "2606:4700:4700::1111"} {
|
||||
if isReservedOrPrivate(netip.MustParseAddr(s)) {
|
||||
t.Errorf("%s should be public", s)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func prefixBounds(p netip.Prefix) (first, last netip.Addr) {
|
||||
first = p.Masked().Addr()
|
||||
b := first.AsSlice()
|
||||
for bit := p.Bits(); bit < len(b)*8; bit++ {
|
||||
b[bit/8] |= 1 << (7 - bit%8)
|
||||
}
|
||||
last, _ = netip.AddrFromSlice(b)
|
||||
return first, last
|
||||
}
|
||||
|
||||
func TestIsReservedOrPrivateIANABoundaries(t *testing.T) {
|
||||
var all []netip.Prefix
|
||||
all = append(all, privateV4...)
|
||||
all = append(all, privateV6...)
|
||||
inAny := func(a netip.Addr) bool {
|
||||
for _, p := range all {
|
||||
if p.Contains(a) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
for _, p := range all {
|
||||
t.Run(p.String(), func(t *testing.T) {
|
||||
first, last := prefixBounds(p)
|
||||
interior := first.Next()
|
||||
for _, a := range []netip.Addr{first, last, interior} {
|
||||
if !p.Contains(a) {
|
||||
t.Fatalf("test bug: %s not in %s", a, p)
|
||||
}
|
||||
if !isReservedOrPrivate(a) {
|
||||
t.Errorf("%s (in %s) classified public", a, p)
|
||||
}
|
||||
}
|
||||
for _, a := range []netip.Addr{first.Prev(), last.Next()} {
|
||||
if !a.IsValid() || inAny(a) || a.IsMulticast() || a.IsUnspecified() {
|
||||
continue
|
||||
}
|
||||
if _, ok := embeddedTransitionIPv4(a); ok {
|
||||
continue
|
||||
}
|
||||
if isReservedOrPrivate(a) {
|
||||
t.Errorf("neighbour %s of %s classified non-public", a, p)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
explicit := []struct {
|
||||
ip string
|
||||
want bool
|
||||
}{
|
||||
{"198.17.255.255", false}, {"198.18.0.0", true}, {"198.19.255.255", true}, {"198.20.0.0", false},
|
||||
{"191.255.255.255", false}, {"192.0.0.0", true}, {"192.0.1.0", false},
|
||||
{"239.255.255.255", true}, {"240.0.0.0", true}, {"255.255.255.255", true},
|
||||
{"100.63.255.255", false}, {"100.64.0.0", true}, {"100.127.255.255", true}, {"100.128.0.0", false},
|
||||
{"172.15.255.255", false}, {"172.32.0.0", false},
|
||||
{"2001:200::1", false}, {"2001:db8::", true}, {"2001:db9::", false},
|
||||
{"3fff:fff:ffff:ffff:ffff:ffff:ffff:ffff", true}, {"3fff:1000::", false},
|
||||
{"8.8.8.8", false}, {"1.1.1.1", false}, {"2606:4700:4700::1111", false},
|
||||
{"::ffff:127.0.0.1", true}, {"::ffff:10.0.0.1", true}, {"::ffff:198.18.0.1", true}, {"::ffff:8.8.8.8", false},
|
||||
}
|
||||
for _, tc := range explicit {
|
||||
if got := isReservedOrPrivate(netip.MustParseAddr(tc.ip)); got != tc.want {
|
||||
t.Errorf("isReservedOrPrivate(%s) = %v, want %v", tc.ip, got, tc.want)
|
||||
}
|
||||
}
|
||||
if !isReservedOrPrivate(netip.Addr{}) {
|
||||
t.Error("invalid addr must fail closed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsReservedOrPrivateIgnoresZone(t *testing.T) {
|
||||
for _, s := range []string{"fe80::1%eth0", "fe80::1%1", "fe80::1", "fc00::1%eth0"} {
|
||||
if !isReservedOrPrivate(netip.MustParseAddr(s)) {
|
||||
t.Errorf("%s should be non-public", s)
|
||||
}
|
||||
}
|
||||
for _, s := range []string{"2606:4700:4700::1111%eth0", "2606:4700:4700::1111%2"} {
|
||||
a := netip.MustParseAddr(s)
|
||||
if isReservedOrPrivate(a) != isReservedOrPrivate(a.WithZone("")) || isReservedOrPrivate(a) {
|
||||
t.Errorf("%s should classify as its unzoned public form", s)
|
||||
}
|
||||
}
|
||||
}
|
||||
147
modules/fetchguard/policy.go
Normal file
147
modules/fetchguard/policy.go
Normal file
@@ -0,0 +1,147 @@
|
||||
package fetchguard
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"math"
|
||||
"time"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/compass"
|
||||
)
|
||||
|
||||
// Mode selects host-allow-list vs any-public-host. The private/loopback/
|
||||
// reserved IP block is always on regardless of Mode (D-11).
|
||||
type Mode int
|
||||
|
||||
const (
|
||||
AllowHostsMode Mode = iota
|
||||
PublicOnlyMode
|
||||
)
|
||||
|
||||
// Reason is the closed set of Fetch failure reasons, matching PHP's
|
||||
// invalid_url/scheme/unresolvable/private_ip/network_error/too_large family.
|
||||
type Reason string
|
||||
|
||||
const (
|
||||
ReasonInvalidURL Reason = "invalid_url"
|
||||
ReasonScheme Reason = "scheme"
|
||||
ReasonUnresolvable Reason = "unresolvable"
|
||||
ReasonPrivateIP Reason = "private_ip"
|
||||
ReasonNetworkError Reason = "network_error"
|
||||
ReasonTooLarge Reason = "too_large"
|
||||
)
|
||||
|
||||
// Policy is supplied per call.
|
||||
type Policy struct {
|
||||
Mode Mode
|
||||
AllowHosts []string // exact or dotted-suffix match; used in AllowHostsMode
|
||||
MaxBytes int64 // 0 means use the config/framework default, never unlimited
|
||||
Timeout time.Duration
|
||||
|
||||
// tlsConfig, if set, is Transport.TLSClientConfig so tests can trust an
|
||||
// httptest certificate. Production callers leave it nil.
|
||||
tlsConfig *tls.Config
|
||||
// skipReservedCheck disables the dial-time private-IP block so tests can
|
||||
// exercise a real httptest listener on 127.0.0.1. Production callers
|
||||
// leave it false.
|
||||
skipReservedCheck bool
|
||||
}
|
||||
|
||||
// Error carries the typed Reason plus the underlying error for logging.
|
||||
type Error struct {
|
||||
Reason Reason
|
||||
Err error
|
||||
}
|
||||
|
||||
func (e *Error) Error() string {
|
||||
if e == nil {
|
||||
return "fetchguard: error"
|
||||
}
|
||||
if e.Err != nil {
|
||||
return fmt.Sprintf("fetchguard: %s: %v", e.Reason, e.Err)
|
||||
}
|
||||
return fmt.Sprintf("fetchguard: %s", e.Reason)
|
||||
}
|
||||
|
||||
func (e *Error) Unwrap() error {
|
||||
if e == nil {
|
||||
return nil
|
||||
}
|
||||
return e.Err
|
||||
}
|
||||
|
||||
// Defaults are the framework fallback: 10 MiB, 10s, matching PHP.
|
||||
func Defaults() (maxBytes int64, timeout time.Duration) {
|
||||
return 10 * 1024 * 1024, 10 * time.Second
|
||||
}
|
||||
|
||||
// DefaultsFromConfig reads http.fetch.max_bytes / http.fetch.timeout_seconds
|
||||
// from cfg, falling back to Defaults() for absent keys. An explicitly
|
||||
// configured zero or negative value is an error (D-14).
|
||||
func DefaultsFromConfig(cfg *compass.Config) (maxBytes int64, timeout time.Duration, err error) {
|
||||
maxBytes, timeout = Defaults()
|
||||
if cfg == nil {
|
||||
return maxBytes, timeout, nil
|
||||
}
|
||||
if v, ok := cfg.Lookup("http.fetch.max_bytes"); ok {
|
||||
n, err := configInt64("http.fetch.max_bytes", v)
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
if n <= 0 {
|
||||
return 0, 0, fmt.Errorf("fetchguard: http.fetch.max_bytes must be positive, got %d", n)
|
||||
}
|
||||
maxBytes = n
|
||||
}
|
||||
if v, ok := cfg.Lookup("http.fetch.timeout_seconds"); ok {
|
||||
n, err := configInt64("http.fetch.timeout_seconds", v)
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
if n <= 0 {
|
||||
return 0, 0, fmt.Errorf("fetchguard: http.fetch.timeout_seconds must be positive, got %d", n)
|
||||
}
|
||||
timeout = time.Duration(n) * time.Second
|
||||
}
|
||||
return maxBytes, timeout, nil
|
||||
}
|
||||
|
||||
func configInt64(path string, v any) (int64, error) {
|
||||
switch n := v.(type) {
|
||||
case int:
|
||||
return int64(n), nil
|
||||
case int8:
|
||||
return int64(n), nil
|
||||
case int16:
|
||||
return int64(n), nil
|
||||
case int32:
|
||||
return int64(n), nil
|
||||
case int64:
|
||||
return n, nil
|
||||
case uint:
|
||||
return int64(n), nil
|
||||
case uint8:
|
||||
return int64(n), nil
|
||||
case uint16:
|
||||
return int64(n), nil
|
||||
case uint32:
|
||||
return int64(n), nil
|
||||
case uint64:
|
||||
if n > math.MaxInt64 {
|
||||
return 0, fmt.Errorf("fetchguard: %s overflows int64", path)
|
||||
}
|
||||
return int64(n), nil
|
||||
case float64:
|
||||
if math.Trunc(n) != n {
|
||||
return 0, fmt.Errorf("fetchguard: %s is not an integer", path)
|
||||
}
|
||||
if n > math.MaxInt64 || n < math.MinInt64 {
|
||||
return 0, fmt.Errorf("fetchguard: %s overflows int64", path)
|
||||
}
|
||||
return int64(n), nil
|
||||
case float32:
|
||||
return configInt64(path, float64(n))
|
||||
default:
|
||||
return 0, fmt.Errorf("fetchguard: %s has unexpected type %T", path, v)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user