diff --git a/.local.dev b/.local.dev index d0fab552..b5e9adf6 100644 --- a/.local.dev +++ b/.local.dev @@ -30,4 +30,4 @@ WEB3_TOKEN_ADDRESS_=0xa513E6E4b8f2a923D98304ec87F64353C4D5C853 WEB3_USERS_ADDRESS=0x0DCd1Bf9A1b36cE34237eEaFef220932846BCD82 BACALHAU_API_HOST=localhost BACALHAU_API_PORT=1234 -SERVER_RATE_EXEMPTED_IPS=127.0.0.1,::1 +ANURA_ADDRESSES=0x1da99b9e884C9e7B15361957577978c1fa66AfBb diff --git a/pkg/http/types.go b/pkg/http/types.go index b8b2db94..7062907f 100644 --- a/pkg/http/types.go +++ b/pkg/http/types.go @@ -13,6 +13,7 @@ type AccessControlOptions struct { ValidationTokenSecret string ValidationTokenExpiration int ValidationTokenKid string + AnuraAddresses []string } type ValidationToken struct { @@ -22,7 +23,6 @@ type ValidationToken struct { type RateLimiterOptions struct { RequestLimit int WindowLength int - ExemptedIPs []string } type ClientOptions struct { diff --git a/pkg/http/utils.go b/pkg/http/utils.go index 9696ee86..00ba7cfe 100644 --- a/pkg/http/utils.go +++ b/pkg/http/utils.go @@ -9,7 +9,6 @@ import ( "fmt" "io" stdlog "log" - "net" "net/http" "net/url" "strings" @@ -30,6 +29,12 @@ const X_LILYPAD_SIGNATURE_HEADER = "X-Lilypad-Signature" // the version run by the client or service const X_LILYPAD_VERSION_HEADER = "X-Lilypad-Version" +// the signature of the anura server +const X_ANURA_SIGNATURE_HEADER = "X-Anura-Key" + +// the address of the anura server +const X_ANURA_SERVER_HEADER = "X-Anura-Server" + // the context name we keep the address const CONTEXT_ADDRESS = "address" @@ -110,6 +115,20 @@ func AddHeaders( return nil } +func AddAnuraHeaders( + req *retryablehttp.Request, + privateKey *ecdsa.PrivateKey, + address string, +) error { + serverPayload, serverSignature, err := encodeUserAddress(privateKey, address) + if err != nil { + return err + } + req.Header.Add(X_ANURA_SERVER_HEADER, serverPayload) + req.Header.Add(X_ANURA_SIGNATURE_HEADER, serverSignature) + return nil +} + // Use the client headers to ensure that a message was signed // by the holder of a private key for a specific address. // The "X-Lilypad-User" header contains the address. @@ -168,6 +187,44 @@ func CheckSignature(req *http.Request) (string, error) { return signatureAddress, nil } +func CheckAnuraSignature(req *http.Request, approvedAddresses []string) (string, error) { + + serverHeader := req.Header.Get(X_ANURA_SERVER_HEADER) + if serverHeader == "" { + return "", HTTPError{ + Message: "missing anura server header", + StatusCode: http.StatusUnauthorized, + } + } + + anuraSignature := req.Header.Get(X_ANURA_SIGNATURE_HEADER) + if anuraSignature == "" { + return "", HTTPError{ + Message: "missing anura signature header", + StatusCode: http.StatusUnauthorized, + } + } + + signatureAddress, err := decodeUserAddress(serverHeader, anuraSignature) + if err != nil { + return "", HTTPError{ + Message: fmt.Sprintf("invalid server header or signature %s", err.Error()), + StatusCode: http.StatusUnauthorized, + } + } + + for _, addr := range approvedAddresses { + if strings.EqualFold(signatureAddress, addr) { + return signatureAddress, nil + } + } + + return "", HTTPError{ + Message: "unauthorized anura signature", + StatusCode: http.StatusUnauthorized, + } +} + func GetVersionFromHeaders(req *http.Request) (string, error) { versionHeader := req.Header.Get(X_LILYPAD_VERSION_HEADER) if versionHeader == "" { @@ -443,31 +500,4 @@ func newRetryClient() *retryablehttp.Client { return nil, fmt.Errorf("%s %s gave up after %d attempt(s): %s", resp.Request.Method, resp.Request.URL, numTries, string(body)) } return retryClient -} - -func CanonicalizeIP(ip string) string { - isIPv6 := false - // This is how net.ParseIP decides if an address is IPv6 - // https://cs.opensource.google/go/go/+/refs/tags/go1.17.7:src/net/ip.go;l=704 - for i := 0; !isIPv6 && i < len(ip); i++ { - switch ip[i] { - case '.': - // IPv4 - return ip - case ':': - // IPv6 - isIPv6 = true - break - } - } - if !isIPv6 { - // Not an IP address at all - return ip - } - - ipv6 := net.ParseIP(ip) - if ipv6 == nil { - return ip - } - return ipv6.Mask(net.CIDRMask(64, 128)).String() -} +} \ No newline at end of file diff --git a/pkg/options/server.go b/pkg/options/server.go index 448b90f7..bb83f151 100644 --- a/pkg/options/server.go +++ b/pkg/options/server.go @@ -2,7 +2,6 @@ package options import ( "fmt" - "net" "github.com/lilypad-tech/lilypad/pkg/http" "github.com/spf13/cobra" ) @@ -24,6 +23,7 @@ func GetDefaultAccessControlOptions() http.AccessControlOptions { ValidationTokenSecret: GetDefaultServeOptionString("SERVER_VALIDATION_TOKEN_SECRET", ""), ValidationTokenExpiration: GetDefaultServeOptionInt("SERVER_VALIDATION_TOKEN_EXPIRATION", 604800), // one week ValidationTokenKid: GetDefaultServeOptionString("SERVER_VALIDATION_TOKEN_KID", ""), + AnuraAddresses: GetDefaultServeOptionStringArray("ANURA_ADDRESSES", []string{}), } } @@ -31,7 +31,6 @@ func GetDefaultRateLimiterOptions() http.RateLimiterOptions { return http.RateLimiterOptions{ RequestLimit: GetDefaultServeOptionInt("SERVER_RATE_REQUEST_LIMIT", 5), WindowLength: GetDefaultServeOptionInt("SERVER_RATE_WINDOW_LENGTH", 10), - ExemptedIPs: GetDefaultServeOptionStringArray("SERVER_RATE_EXEMPTED_IPS", []string{}), } } @@ -77,8 +76,8 @@ func AddServerCliFlags(cmd *cobra.Command, serverOptions *http.ServerOptions) { `The time window over which to limit in seconds (SERVER_RATE_WINDOW_LENGTH).`, ) cmd.PersistentFlags().StringArrayVar( - &serverOptions.RateLimiter.ExemptedIPs, "server-rate-exempted-ips", serverOptions.RateLimiter.ExemptedIPs, - `The IPs to exempt from rate limiting (SERVER_RATE_EXEMPTED_IPS).`, + &serverOptions.AccessControl.AnuraAddresses, "server-anura-addresses", serverOptions.AccessControl.AnuraAddresses, + `The Anura wallet addresses that are allowed to access anura endpoints (ANURA_ADDRESSES).`, ) } @@ -95,12 +94,5 @@ func CheckServerOptions(options http.ServerOptions, storeType string) error { if options.AccessControl.ValidationTokenKid == "" { return fmt.Errorf("SERVER_VALIDATION_TOKEN_KID is required") } - if len(options.RateLimiter.ExemptedIPs) > 0 { - for _, ip := range options.RateLimiter.ExemptedIPs { - if net.ParseIP(ip) == nil { - return fmt.Errorf("invalid IP address: %s", ip) - } - } - } return nil } diff --git a/pkg/solver/anuraLimit_test.go b/pkg/solver/anuraLimit_test.go new file mode 100644 index 00000000..949f9d8b --- /dev/null +++ b/pkg/solver/anuraLimit_test.go @@ -0,0 +1,89 @@ +//go:build integration && solver + +package solver_test + +import ( + "context" + "fmt" + "net/http" + "testing" + "github.com/ethereum/go-ethereum/crypto" + "github.com/hashicorp/go-retryablehttp" + httputil "github.com/lilypad-tech/lilypad/pkg/http" +) + +type anuraTestCase struct { + name string + privateKey string + expectedOK bool +} + +// This test suite tests Anura authentication with valid and invalid signatures +func TestAnuraAuth(t *testing.T) { + paths := []string{ + "/api/v1/anura/job_offers", + } + + validKey := "b3994e7660abe5f65f729bb64163c6cd6b7d0b1a8c67881a7346e3e8c7f026f5" + invalidKey := "1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef" + + t.Run("valid anura signature passes", func(t *testing.T) { + tc := anuraTestCase{ + name: "valid signature should pass", + privateKey: validKey, + expectedOK: true, + } + runAnuraTest(t, paths, tc) + }) + + t.Run("invalid anura signature fails", func(t *testing.T) { + tc := anuraTestCase{ + name: "invalid signature should fail", + privateKey: invalidKey, + expectedOK: false, + } + runAnuraTest(t, paths, tc) + }) +} + +func runAnuraTest(t *testing.T, paths []string, tc anuraTestCase) { + for _, path := range paths { + client := retryablehttp.NewClient() + client.RetryMax = 0 + client.Logger = nil + client.CheckRetry = func(ctx context.Context, resp *http.Response, err error) (bool, error) { + return false, nil + } + + req, err := retryablehttp.NewRequest("GET", fmt.Sprintf("http://localhost:%d%s", 8081, path), nil) + if err != nil { + t.Errorf("Failed to create request: %s\n", err) + return + } + + privateKey, err := crypto.HexToECDSA(tc.privateKey) + if err != nil { + t.Errorf("Failed to parse private key: %s\n", err) + return + } + + err = httputil.AddAnuraHeaders(req, privateKey, crypto.PubkeyToAddress(privateKey.PublicKey).String()) + if err != nil { + t.Errorf("Failed to add Anura headers: %s\n", err) + return + } + + res, err := client.Do(req) + if err != nil { + t.Errorf("Request failed on %s: %s\n", path, err) + return + } + + if tc.expectedOK && res.StatusCode != http.StatusOK { + t.Errorf("%s: Expected status code 200, got %d", path, res.StatusCode) + } + if !tc.expectedOK && res.StatusCode != http.StatusUnauthorized { + t.Errorf("%s: Expected status code 401, got %d", path, res.StatusCode) + } + } +} diff --git a/pkg/solver/ratelimit_test.go b/pkg/solver/ratelimit_test.go index 617255ea..20227c63 100644 --- a/pkg/solver/ratelimit_test.go +++ b/pkg/solver/ratelimit_test.go @@ -25,12 +25,11 @@ type rateTestCase struct { } // This test suite sends 200 requests to three different paths. We send the -// requests in rate limited and exempt test groups. The rate limited group -// should allow 5/100 requests through and the exempt group should allow 100/100. +// requests in rate limited groups. The rate limited group should allow 5/100 +// requests through. // // We assume the solver uses the default rate limiting settings with -// a request limit of 5 and window length of 10 seconds. In addition, the solver -// should be configured to exempt localhost. +// a request limit of 5 and window length of 10 seconds. func TestRateLimiter(t *testing.T) { paths := []string{ "/api/v1/resource_offers", @@ -40,26 +39,15 @@ func TestRateLimiter(t *testing.T) { // The solver should rate limit when forwarded // headers are set to 1.2.3.4. - nonExemptHeaders := []map[string]string{ + forwardedHeaders := []map[string]string{ {"True-Client-IP": "1.2.3.4"}, {"X-Real-IP": "1.2.3.4"}, {"X-Forwarded-For": "1.2.3.4"}, } - // The running solver is configured to exempt localhost. - // When no headers are set, test using the IP address from - // the underlying connection (also localhost) - // TODO: re-enable exempt IP rate limiting - // exemptHeaders := []map[string]string{ - // {"True-Client-IP": "127.0.0.1"}, - // {"X-Real-IP": "127.0.0.1"}, - // {"X-Forwarded-For": "127.0.0.1"}, - // {}, // No headers case - uses RemoteAddr - // } - - t.Run("non-exempt IP is rate limited", func(t *testing.T) { + t.Run("requests are rate limited", func(t *testing.T) { // Select a random header on each test run. Over time we test them all. - headers := nonExemptHeaders[rand.Intn(len(nonExemptHeaders))] + headers := forwardedHeaders[rand.Intn(len(forwardedHeaders))] tc := rateTestCase{ name: fmt.Sprintf("rate limited with headers %v", headers), headers: headers, @@ -68,26 +56,12 @@ func TestRateLimiter(t *testing.T) { } runRateLimitTest(t, paths, tc) }) - - // TODO: re-enable exempt IP rate limiting - // t.Run("exempt IP is not rate limited", func(t *testing.T) { - // // Select a random header on each test run. Over time we test them all. - // headers := exemptHeaders[rand.Intn(len(exemptHeaders))] - // tc := rateTestCase{ - // name: fmt.Sprintf("exempt with headers %v", headers), - // headers: headers, - // expectedOK: 100, - // expectedLimit: 0, - // } - // runRateLimitTest(t, paths, tc) - // }) } func runRateLimitTest(t *testing.T, paths []string, tc rateTestCase) { var wg sync.WaitGroup ch := make(chan rateResult, len(paths)) - // Run the calls against paths in parallel for _, path := range paths { wg.Add(1) go func(path string) { diff --git a/pkg/solver/server.go b/pkg/solver/server.go index 4ceeedce..353fe1cf 100644 --- a/pkg/solver/server.go +++ b/pkg/solver/server.go @@ -71,34 +71,12 @@ func (solverServer *solverServer) ListenAndServe(ctx context.Context, cm *system subrouter.Use(http.CorsMiddleware) subrouter.Use(otelmux.Middleware("solver", otelmux.WithTracerProvider(tracerProvider))) - exemptIPs := solverServer.options.RateLimiter.ExemptedIPs - // TODO: re-enable exempt IP rate limiting - // subrouter.Use(httprate.Limit( - // solverServer.options.RateLimiter.RequestLimit, - // time.Duration(solverServer.options.RateLimiter.WindowLength)*time.Second, - // httprate.WithKeyFuncs( - // exemptIPKeyFunc(exemptIPs), - // httprate.KeyByEndpoint, - // ), - // httprate.WithLimitHandler(func(w corehttp.ResponseWriter, r *corehttp.Request) { - - // key, _ := exemptIPKeyFunc(exemptIPs)(r) - // if strings.HasPrefix(key, "exempt-") { - // return - // } - - // corehttp.Error(w, "Too Many Requests", corehttp.StatusTooManyRequests) - // }), - // )) - subrouter.Use(httprate.Limit( solverServer.options.RateLimiter.RequestLimit, time.Duration(solverServer.options.RateLimiter.WindowLength)*time.Second, httprate.WithKeyFuncs(httprate.KeyByRealIP, httprate.KeyByEndpoint), )) - log.Info().Strs("exemptIPs", exemptIPs).Msg("Loaded rate limit exemptions") - subrouter.HandleFunc("/job_offers", http.GetHandler(solverServer.getJobOffers)).Methods("GET") subrouter.HandleFunc("/job_offers", http.PostHandler(solverServer.addJobOffer)).Methods("POST") @@ -123,6 +101,28 @@ func (solverServer *solverServer) ListenAndServe(ctx context.Context, cm *system subrouter.HandleFunc("/validation_token", http.GetHandler(solverServer.getValidationToken)).Methods("GET") + //anura subrouter + anuraMiddleware := func(next corehttp.Handler) corehttp.Handler { + return corehttp.HandlerFunc(func(w corehttp.ResponseWriter, r *corehttp.Request) { + _, err := http.CheckAnuraSignature(r, solverServer.options.AccessControl.AnuraAddresses) + if err != nil { + corehttp.Error(w, "Unauthorized", corehttp.StatusUnauthorized) + return + } + next.ServeHTTP(w, r) + }) + } + + anurarouter := router.PathPrefix(http.API_SUB_PATH + "/anura").Subrouter() + anurarouter.Use(http.CorsMiddleware) + anurarouter.Use(otelmux.Middleware("solver", otelmux.WithTracerProvider(tracerProvider))) + anurarouter.Use(anuraMiddleware) + + anurarouter.HandleFunc("/job_offers", http.PostHandler(solverServer.addJobOffer)).Methods("POST") + anurarouter.HandleFunc("/job_offers", http.GetHandler(solverServer.getJobOffers)).Methods("GET") + anurarouter.HandleFunc("/job_offers/{id}", http.GetHandler(solverServer.getJobOffer)).Methods("GET") + anurarouter.HandleFunc("/job_offers/{id}/files", solverServer.jobOfferDownloadFiles).Methods("GET") + // this will fan out to all connected web socket connections // we read all events coming from inside the solver controller // and write them to anyone who is connected to us @@ -204,25 +204,6 @@ func (solverServer *solverServer) disconnectCB(connParams http.WSConnectionParam } } -func exemptIPKeyFunc(exemptIPs []string) func(r *corehttp.Request) (string, error) { - return func(r *corehttp.Request) (string, error) { - ip, err := httprate.KeyByRealIP(r) - if err != nil { - log.Error().Err(err).Msg("error getting real ip") - return "", err - } - - // Check if the IP is in the exempt list - for _, exemptIP := range exemptIPs { - if http.CanonicalizeIP(exemptIP) == ip { - return "exempt-" + ip, nil - } - } - - return ip, nil - } -} - /* * *