diff --git a/.gitignore b/.gitignore index 59839feff..f44b0b37e 100644 --- a/.gitignore +++ b/.gitignore @@ -18,3 +18,4 @@ Dockerfile /rocketpool/rocketpool-daemon-linux-arm64 .vscode-ctags build/ +*api-token diff --git a/rocketpool-cli/service/config/settings-api.go b/rocketpool-cli/service/config/settings-api.go new file mode 100644 index 000000000..f8c5fb307 --- /dev/null +++ b/rocketpool-cli/service/config/settings-api.go @@ -0,0 +1,71 @@ +package config + +import ( + "github.com/rocket-pool/smartnode/shared/services/config" +) + +// The page wrapper for the API config +type ApiConfigPage struct { + mainDisplay *MainDisplay + homePage *page + page *page + layout *standardLayout + masterConfig *config.RocketPoolConfig +} + +func NewApiConfigPage(home *settingsHome) *ApiConfigPage { + configPage := &ApiConfigPage{ + mainDisplay: home.md, + homePage: home.homePage, + masterConfig: home.md.Config, + } + configPage.createContent() + configPage.initPage(false) + return configPage +} + +func NewApiConfigPageForNative(home *settingsNativeHome) *ApiConfigPage { + configPage := &ApiConfigPage{ + mainDisplay: home.md, + homePage: home.homePage, + masterConfig: home.md.Config, + } + configPage.createContent() + configPage.initPage(true) + return configPage +} + +func (configPage *ApiConfigPage) initPage(isNative bool) { + id := "settings-api" + if isNative { + id = "settings-api-native" + } + configPage.page = newPage( + configPage.homePage, + id, + "API", + "Select this to configure the Smart Node HTTP API, including the listen port, how it is exposed, the bearer token, and the request rate limit.", + configPage.layout.grid, + ) +} + +func (configPage *ApiConfigPage) getPage() *page { + return configPage.page +} + +func (configPage *ApiConfigPage) createContent() { + configPage.layout = newStandardLayout() + configPage.layout.createForm(&configPage.masterConfig.Smartnode.Network, "API Settings") + configPage.layout.setupEscapeReturnHomeHandler(configPage.mainDisplay, configPage.homePage) + + _ = configPage.masterConfig.SyncAPITokenFromDisk(true) + + items := createParameterizedFormItems(configPage.masterConfig.Api.GetParameters(), configPage.layout) + configPage.layout.mapParameterizedFormItems(items...) + configPage.layout.addFormItems(items) + configPage.layout.refresh() +} + +func (configPage *ApiConfigPage) handleLayoutChanged() { + configPage.layout.refresh() +} diff --git a/rocketpool-cli/service/config/settings-home.go b/rocketpool-cli/service/config/settings-home.go index c77d3d092..cd75c8b0c 100644 --- a/rocketpool-cli/service/config/settings-home.go +++ b/rocketpool-cli/service/config/settings-home.go @@ -15,6 +15,7 @@ type settingsHome struct { saveButton *tview.Button wizardButton *tview.Button smartnodePage *SmartnodeConfigPage + apiPage *ApiConfigPage ecPage *ExecutionConfigPage fallbackPage *FallbackConfigPage ccPage *ConsensusConfigPage @@ -42,6 +43,7 @@ func newSettingsHome(md *MainDisplay) *settingsHome { // Create the settings subpages home.smartnodePage = NewSmartnodeConfigPage(home) + home.apiPage = NewApiConfigPage(home) home.ecPage = NewExecutionConfigPage(home) home.ccPage = NewConsensusConfigPage(home) home.fallbackPage = NewFallbackConfigPage(home) @@ -52,6 +54,7 @@ func newSettingsHome(md *MainDisplay) *settingsHome { home.addonsPage = NewAddonsPage(home) settingsSubpages := []settingsPage{ home.smartnodePage, + home.apiPage, home.ecPage, home.ccPage, home.fallbackPage, @@ -223,6 +226,10 @@ func (home *settingsHome) refresh() { home.smartnodePage.layout.refresh() }*/ + if home.apiPage != nil { + home.apiPage.handleLayoutChanged() + } + if home.ecPage != nil { home.ecPage.layout.refresh() } diff --git a/rocketpool-cli/service/config/settings-native-home.go b/rocketpool-cli/service/config/settings-native-home.go index bee778a0c..e24e4d7ee 100644 --- a/rocketpool-cli/service/config/settings-native-home.go +++ b/rocketpool-cli/service/config/settings-native-home.go @@ -15,6 +15,7 @@ type settingsNativeHome struct { saveButton *tview.Button wizardButton *tview.Button smartnodePage *NativeSmartnodeConfigPage + apiPage *ApiConfigPage nativePage *NativePage fallbackPage *NativeFallbackConfigPage metricsPage *NativeMetricsConfigPage @@ -38,12 +39,14 @@ func newSettingsNativeHome(md *MainDisplay) *settingsNativeHome { // Create the settings subpages home.smartnodePage = NewNativeSmartnodeConfigPage(home) + home.apiPage = NewApiConfigPageForNative(home) home.nativePage = NewNativePage(home) home.fallbackPage = NewNativeFallbackConfigPage(home) home.metricsPage = NewNativeMetricsConfigPage(home) home.alertingPage = NewAlertingConfigPageForNative(home) settingsSubpages := []*page{ home.smartnodePage.page, + home.apiPage.page, home.nativePage.page, home.fallbackPage.page, home.metricsPage.page, @@ -210,6 +213,10 @@ func (home *settingsNativeHome) refresh() { home.smartnodePage.layout.refresh() }*/ + if home.apiPage != nil { + home.apiPage.handleLayoutChanged() + } + if home.nativePage != nil { home.nativePage.layout.refresh() } diff --git a/rocketpool-cli/service/service.go b/rocketpool-cli/service/service.go index 45b73a7b6..c0afbf435 100644 --- a/rocketpool-cli/service/service.go +++ b/rocketpool-cli/service/service.go @@ -257,6 +257,13 @@ func configureService(configPath string, isNative, yes bool, composeFiles []stri return err } + // Native vs Docker is stored in user-settings.yml. The --daemon-path flag is + // the historical CLI hint, but a native install opened without -d must not + // take the Docker restart path (e.g. rocketpool3_node). + if cfg != nil && cfg.IsNativeMode { + isNative = true + } + isUpdate := !isNew && oldCfg != nil app := tview.NewApplication() diff --git a/rocketpool/api/response/response.go b/rocketpool/api/response/response.go index e73949acb..1377864cd 100644 --- a/rocketpool/api/response/response.go +++ b/rocketpool/api/response/response.go @@ -24,6 +24,16 @@ type NotFoundError struct{ Path string } func (e *NotFoundError) Error() string { return fmt.Sprintf("not found: %s", e.Path) } +// UnauthorizedError signals that the caller did not supply a valid API token. +type UnauthorizedError struct{} + +func (e *UnauthorizedError) Error() string { return "unauthorized" } + +// TooManyRequestsError signals that the caller exceeded the API rate limit. +type TooManyRequestsError struct{} + +func (e *TooManyRequestsError) Error() string { return "too many requests" } + // WriteResponse serialises response as JSON and writes it to w. // response must be a pointer to a struct with string fields named Status and Error. // On error it writes 400 for BadRequestError and 500 for everything else. @@ -66,9 +76,15 @@ func WriteResponse(w http.ResponseWriter, response interface{}, responseError er if ef.String() != "" { var br *BadRequestError var nf *NotFoundError + var unauth *UnauthorizedError + var tooMany *TooManyRequestsError switch { case errors.As(responseError, &br): statusCode = http.StatusBadRequest + case errors.As(responseError, &unauth): + statusCode = http.StatusUnauthorized + case errors.As(responseError, &tooMany): + statusCode = http.StatusTooManyRequests case errors.As(responseError, &nf): statusCode = http.StatusNotFound default: diff --git a/rocketpool/node/http.go b/rocketpool/node/http.go index 3fa950ab9..d11ec9c39 100644 --- a/rocketpool/node/http.go +++ b/rocketpool/node/http.go @@ -5,14 +5,21 @@ import ( "fmt" "log" "net/http" + "strings" + "sync" "time" "github.com/urfave/cli/v3" + "github.com/rocket-pool/smartnode/rocketpool/api/response" "github.com/rocket-pool/smartnode/rocketpool/node/routes" + "github.com/rocket-pool/smartnode/shared/services/apitoken" "github.com/rocket-pool/smartnode/shared/services/config" + cfgtypes "github.com/rocket-pool/smartnode/shared/types/config" ) +const healthzPath = "/healthz" + // statusRecorder wraps http.ResponseWriter to capture the written status code. type statusRecorder struct { http.ResponseWriter @@ -40,33 +47,147 @@ func loggingMiddleware(next http.Handler) http.Handler { }) } +func authMiddleware(expectedToken string, sensitiveOnly bool, next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == healthzPath { + next.ServeHTTP(w, r) + return + } + if sensitiveOnly && !isSensitiveAPIPath(r.URL.Path) { + next.ServeHTTP(w, r) + return + } + + if expectedToken == "" || !apitoken.Equal(expectedToken, bearerToken(r)) { + w.Header().Set("WWW-Authenticate", "Bearer") + response.WriteErrorResponse(w, &response.UnauthorizedError{}) + return + } + next.ServeHTTP(w, r) + }) +} + +// tokenBucket is a simple per-process limiter: refill at `rate` tokens per +// second, capacity equal to the rate (burst of one second). +type tokenBucket struct { + mu sync.Mutex + rate float64 + tokens float64 + last time.Time +} + +func newTokenBucket(perSecond float64) *tokenBucket { + if perSecond <= 0 { + return nil + } + return &tokenBucket{ + rate: perSecond, + tokens: perSecond, + last: time.Now(), + } +} + +func (b *tokenBucket) allow() bool { + if b == nil { + return true + } + b.mu.Lock() + defer b.mu.Unlock() + now := time.Now() + b.tokens += now.Sub(b.last).Seconds() * b.rate + if b.tokens > b.rate { + b.tokens = b.rate + } + b.last = now + if b.tokens < 1 { + return false + } + b.tokens-- + return true +} + +func rateLimitMiddleware(limiter *tokenBucket, next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if limiter.allow() { + next.ServeHTTP(w, r) + return + } + w.Header().Set("Retry-After", "1") + response.WriteErrorResponse(w, &response.TooManyRequestsError{}) + }) +} + +func bearerToken(r *http.Request) string { + header := r.Header.Get("Authorization") + const prefix = "Bearer " + if !strings.HasPrefix(header, prefix) { + return "" + } + return strings.TrimSpace(strings.TrimPrefix(header, prefix)) +} + +func apiListenHost(cfg *config.RocketPoolConfig, mode cfgtypes.RPCMode) (string, bool) { + if cfg.IsNativeMode { + switch mode { + case cfgtypes.RPC_OpenExternal: + return "0.0.0.0", true + case cfgtypes.RPC_OpenLocalhost: + return "127.0.0.1", true + default: + return "", false + } + } + // Docker: always bind on all interfaces inside the container so published + // host ports (and other compose services) can reach the server. + return "0.0.0.0", true +} + // startHTTP starts the node's HTTP API server and returns immediately. // The server runs in the background for the lifetime of the process. func startHTTP(ctx context.Context, c *cli.Command, cfg *config.RocketPoolConfig) { - port, ok := cfg.Smartnode.APIPort.Value.(uint16) + port, ok := cfg.Api.ApiPort.Value.(uint16) if !ok || port == 0 { log.Println("Warning: APIPort not configured, HTTP API server will not start.") return } - var host string - if !cfg.IsNativeMode { - // In Docker mode the server must bind to 0.0.0.0, so other containers can reach it. - host = "0.0.0.0" - } else { - host = "127.0.0.1" + mode, _ := cfg.Api.OpenApiPort.Value.(cfgtypes.RPCMode) + host, listen := apiListenHost(cfg, mode) + if !listen { + log.Println("Node HTTP API server is closed; not listening.") + return } + tokenPath := cfg.Api.GetAPITokenPath() + if err := cfg.SyncAPITokenFromDisk(false); err != nil { + log.Printf("Warning: could not load API token from %s: %v", tokenPath, err) + } + expectedToken, _ := cfg.Api.APIToken.Value.(string) + if expectedToken == "" { + log.Printf("Warning: API token is empty (file %s); authenticated API routes will reject all requests.", tokenPath) + } + + scope, _ := cfg.Api.TokenScope.Value.(cfgtypes.APITokenScope) + sensitiveOnly := scope == cfgtypes.APITokenScope_Sensitive + + var perSecond float64 + v := cfg.Api.RateLimit.Value.(uint16) + perSecond = float64(v) + limiter := newTokenBucket(perSecond) + mux := http.NewServeMux() routes.RegisterRoutes(mux, c) + handler := loggingMiddleware(rateLimitMiddleware(limiter, authMiddleware(expectedToken, sensitiveOnly, mux))) + srv := &http.Server{ - Addr: fmt.Sprintf("%s:%d", host, port), - Handler: loggingMiddleware(mux), + Addr: fmt.Sprintf("%s:%d", host, port), + Handler: handler, + ReadHeaderTimeout: 10 * time.Second, } go func() { - log.Printf("Node HTTP API server listening on %s:%d\n", host, port) + log.Printf("Node HTTP API server listening on %s:%d (token file %s)\n", host, port, tokenPath) if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { log.Printf("Node HTTP API server error: %v\n", err) } diff --git a/rocketpool/node/http_test.go b/rocketpool/node/http_test.go new file mode 100644 index 000000000..ef5906a4c --- /dev/null +++ b/rocketpool/node/http_test.go @@ -0,0 +1,214 @@ +package node + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/rocket-pool/smartnode/shared/services/config" + "github.com/rocket-pool/smartnode/shared/types/api" + cfgtypes "github.com/rocket-pool/smartnode/shared/types/config" +) + +func TestAuthMiddleware(t *testing.T) { + const token = "rpsn_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + + mux := http.NewServeMux() + mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + }) + mux.HandleFunc("/api/version", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"status":"success"}`)) + }) + mux.HandleFunc("/api/node/status", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + }) + mux.HandleFunc("/api/node/send", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + }) + mux.HandleFunc("/api/wallet/export", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + }) + handler := authMiddleware(token, false, mux) + + t.Run("healthz without token", func(t *testing.T) { + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/healthz", nil) + handler.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("status %d", rec.Code) + } + }) + + t.Run("missing header", func(t *testing.T) { + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/version", nil) + handler.ServeHTTP(rec, req) + assertAPIError(t, rec, http.StatusUnauthorized, "unauthorized") + }) + + t.Run("wrong scheme", func(t *testing.T) { + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/version", nil) + req.Header.Set("Authorization", "Basic "+token) + handler.ServeHTTP(rec, req) + assertAPIError(t, rec, http.StatusUnauthorized, "unauthorized") + }) + + t.Run("wrong token", func(t *testing.T) { + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/version", nil) + req.Header.Set("Authorization", "Bearer not-the-token") + handler.ServeHTTP(rec, req) + assertAPIError(t, rec, http.StatusUnauthorized, "unauthorized") + }) + + t.Run("query token ignored", func(t *testing.T) { + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/version?token="+token, nil) + handler.ServeHTTP(rec, req) + assertAPIError(t, rec, http.StatusUnauthorized, "unauthorized") + }) + + t.Run("matching token", func(t *testing.T) { + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/version", nil) + req.Header.Set("Authorization", "Bearer "+token) + handler.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("status %d body %s", rec.Code, rec.Body.String()) + } + }) + + t.Run("loopback still requires token", func(t *testing.T) { + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/version", nil) + req.RemoteAddr = "127.0.0.1:54321" + handler.ServeHTTP(rec, req) + assertAPIError(t, rec, http.StatusUnauthorized, "unauthorized") + }) + + sensitiveOnly := authMiddleware(token, true, mux) + + t.Run("sensitive-only status without token", func(t *testing.T) { + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/node/status", nil) + sensitiveOnly.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("status %d", rec.Code) + } + }) + + t.Run("sensitive-only send without token", func(t *testing.T) { + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/api/node/send", nil) + req.RemoteAddr = "127.0.0.1:54321" + sensitiveOnly.ServeHTTP(rec, req) + assertAPIError(t, rec, http.StatusUnauthorized, "unauthorized") + }) + + t.Run("sensitive-only exit-validator without token", func(t *testing.T) { + mux.HandleFunc("/api/megapool/exit-validator", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + }) + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/megapool/exit-validator?validatorId=0", nil) + req.RemoteAddr = "127.0.0.1:54321" + sensitiveOnly.ServeHTTP(rec, req) + assertAPIError(t, rec, http.StatusUnauthorized, "unauthorized") + }) + + t.Run("sensitive-only wallet export without token", func(t *testing.T) { + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/wallet/export", nil) + sensitiveOnly.ServeHTTP(rec, req) + assertAPIError(t, rec, http.StatusUnauthorized, "unauthorized") + }) + + t.Run("sensitive-only send with token", func(t *testing.T) { + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/api/node/send", nil) + req.Header.Set("Authorization", "Bearer "+token) + sensitiveOnly.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("status %d", rec.Code) + } + }) +} + +func TestRateLimitMiddleware(t *testing.T) { + okHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + }) + + t.Run("unlimited when zero", func(t *testing.T) { + handler := rateLimitMiddleware(newTokenBucket(0), okHandler) + for i := 0; i < 20; i++ { + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/version", nil) + handler.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("request %d: status %d", i, rec.Code) + } + } + }) + + t.Run("rejects after burst", func(t *testing.T) { + handler := rateLimitMiddleware(newTokenBucket(5), okHandler) + var saw429 bool + for i := 0; i < 10; i++ { + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/version", nil) + handler.ServeHTTP(rec, req) + if rec.Code == http.StatusTooManyRequests { + saw429 = true + assertAPIError(t, rec, http.StatusTooManyRequests, "too many requests") + break + } + if rec.Code != http.StatusOK { + t.Fatalf("request %d: status %d", i, rec.Code) + } + } + if !saw429 { + t.Fatal("expected a 429 after exceeding 5 req/s burst") + } + }) +} + +func TestAPIListenHost(t *testing.T) { + cfgNative := &config.RocketPoolConfig{IsNativeMode: true} + _, ok := apiListenHost(cfgNative, cfgtypes.RPC_Closed) + if ok { + t.Fatal("native closed should not listen") + } + host, ok := apiListenHost(cfgNative, cfgtypes.RPC_OpenLocalhost) + if !ok || host != "127.0.0.1" { + t.Fatalf("native localhost: %s %v", host, ok) + } + host, ok = apiListenHost(cfgNative, cfgtypes.RPC_OpenExternal) + if !ok || host != "0.0.0.0" { + t.Fatalf("native external: %s %v", host, ok) + } + + cfgDocker := &config.RocketPoolConfig{IsNativeMode: false} + host, ok = apiListenHost(cfgDocker, cfgtypes.RPC_Closed) + if !ok || host != "0.0.0.0" { + t.Fatalf("docker closed still binds in-container: %s %v", host, ok) + } +} + +func assertAPIError(t *testing.T, rec *httptest.ResponseRecorder, code int, msg string) { + t.Helper() + if rec.Code != code { + t.Fatalf("status %d, want %d body %s", rec.Code, code, rec.Body.String()) + } + var body api.APIResponse + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatal(err) + } + if body.Error != msg { + t.Fatalf("error %q, want %q", body.Error, msg) + } +} diff --git a/rocketpool/node/sensitive.go b/rocketpool/node/sensitive.go new file mode 100644 index 000000000..f575d9956 --- /dev/null +++ b/rocketpool/node/sensitive.go @@ -0,0 +1,218 @@ +package node + +import "strings" + +// submitsTransaction is every HTTP path that submits an execution-layer +// transaction or a consensus-layer voluntary exit. Checked first so a tx +// route cannot be treated as read-only by prefix rules. +var submitsTransaction = map[string]struct{}{ + // node + "/api/node/register": {}, + "/api/node/set-timezone": {}, + "/api/node/set-primary-withdrawal-address": {}, + "/api/node/confirm-primary-withdrawal-address": {}, + "/api/node/set-rpl-withdrawal-address": {}, + "/api/node/confirm-rpl-withdrawal-address": {}, + "/api/node/swap-rpl-approve-rpl": {}, + "/api/node/wait-and-swap-rpl": {}, + "/api/node/swap-rpl": {}, + "/api/node/stake-rpl-approve-rpl": {}, + "/api/node/wait-and-stake-rpl": {}, + "/api/node/stake-rpl": {}, + "/api/node/set-rpl-locking-allowed": {}, + "/api/node/set-stake-rpl-for-allowed": {}, + "/api/node/withdraw-rpl": {}, + "/api/node/unstake-legacy-rpl": {}, + "/api/node/withdraw-rpl-v131": {}, + "/api/node/unstake-rpl": {}, + "/api/node/withdraw-eth": {}, + "/api/node/withdraw-credit": {}, + "/api/node/deposit": {}, + "/api/node/send": {}, + "/api/node/send-all": {}, + "/api/node/burn": {}, + "/api/node/claim-rpl-rewards": {}, + "/api/node/initialize-fee-distributor": {}, + "/api/node/distribute": {}, + "/api/node/claim-rewards": {}, + "/api/node/claim-and-stake-rewards": {}, + "/api/node/set-smoothing-pool-status": {}, + "/api/node/create-vacant-minipool": {}, + "/api/node/send-message": {}, + "/api/node/provision-express-tickets": {}, + "/api/node/claim-unclaimed-rewards": {}, + "/api/wallet/set-ens-name": {}, + // minipool + "/api/minipool/refund": {}, + "/api/minipool/stake": {}, + "/api/minipool/promote": {}, + "/api/minipool/dissolve": {}, + "/api/minipool/exit": {}, + "/api/minipool/close": {}, + "/api/minipool/delegate-upgrade": {}, + "/api/minipool/set-use-latest-delegate": {}, + "/api/minipool/distribute-balance": {}, + "/api/minipool/change-withdrawal-creds": {}, + "/api/minipool/rescue-dissolved": {}, + // megapool + "/api/megapool/claim-refund": {}, + "/api/megapool/repay-debt": {}, + "/api/megapool/reduce-bond": {}, + "/api/megapool/stake": {}, + "/api/megapool/dissolve-validator": {}, + "/api/megapool/dissolve-with-proof": {}, + "/api/megapool/exit-validator": {}, + "/api/megapool/notify-validator-exit": {}, + "/api/megapool/notify-final-balance": {}, + "/api/megapool/exit-queue": {}, + "/api/megapool/distribute": {}, + "/api/megapool/delegate-upgrade": {}, + "/api/megapool/set-use-latest-delegate": {}, + // auction + "/api/auction/create-lot": {}, + "/api/auction/bid-lot": {}, + "/api/auction/claim-lot": {}, + "/api/auction/recover-lot": {}, + // queue + "/api/queue/process": {}, + "/api/queue/assign-deposits": {}, + // pdao + "/api/pdao/vote-proposal": {}, + "/api/pdao/override-vote": {}, + "/api/pdao/execute-proposal": {}, + "/api/pdao/propose-setting": {}, + "/api/pdao/propose-setting-multi": {}, + "/api/pdao/propose-rewards-percentages": {}, + "/api/pdao/propose-one-time-spend": {}, + "/api/pdao/propose-recurring-spend": {}, + "/api/pdao/propose-recurring-spend-update": {}, + "/api/pdao/propose-invite-to-security-council": {}, + "/api/pdao/propose-kick-from-security-council": {}, + "/api/pdao/propose-kick-multi-from-security-council": {}, + "/api/pdao/propose-replace-member-of-security-council": {}, + "/api/pdao/claim-bonds": {}, + "/api/pdao/defeat-proposal": {}, + "/api/pdao/finalize-proposal": {}, + "/api/pdao/set-voting-delegate": {}, + "/api/pdao/set-signalling-address": {}, + "/api/pdao/clear-signalling-address": {}, + "/api/pdao/propose-allow-listed-controllers": {}, + // odao + "/api/odao/propose-invite": {}, + "/api/odao/propose-leave": {}, + "/api/odao/propose-kick": {}, + "/api/odao/cancel-proposal": {}, + "/api/odao/vote-proposal": {}, + "/api/odao/execute-proposal": {}, + "/api/odao/join-approve-rpl": {}, + "/api/odao/join": {}, + "/api/odao/leave": {}, + "/api/odao/penalise-megapool": {}, + "/api/odao/propose-members-quorum": {}, + "/api/odao/propose-members-rplbond": {}, + "/api/odao/propose-proposal-cooldown": {}, + "/api/odao/propose-proposal-vote-timespan": {}, + "/api/odao/propose-proposal-vote-delay-timespan": {}, + "/api/odao/propose-proposal-execute-timespan": {}, + "/api/odao/propose-proposal-action-timespan": {}, + "/api/odao/propose-scrub-period": {}, + "/api/odao/propose-promotion-scrub-period": {}, + "/api/odao/propose-scrub-penalty-enabled": {}, + "/api/odao/propose-bond-reduction-window-start": {}, + "/api/odao/propose-bond-reduction-window-length": {}, + // security council + "/api/security/propose-leave": {}, + "/api/security/propose-setting": {}, + "/api/security/cancel-proposal": {}, + "/api/security/vote-proposal": {}, + "/api/security/execute-proposal": {}, + "/api/security/join": {}, + "/api/security/leave": {}, + "/api/upgrade/execute-upgrade": {}, +} + +// sensitiveLocal is high-impact local state that does not send an EL tx: +// wallet secrets, validator keys, signing, and destructive service actions. +var sensitiveLocal = map[string]struct{}{ + "/api/wallet/set-password": {}, + "/api/wallet/init": {}, + "/api/wallet/recover": {}, + "/api/wallet/search-and-recover": {}, + "/api/wallet/rebuild": {}, + "/api/wallet/export": {}, + "/api/wallet/masquerade": {}, + "/api/wallet/end-masquerade": {}, + "/api/minipool/import-key": {}, + "/api/node/sign": {}, + "/api/node/sign-message": {}, + "/api/service/restart-vc": {}, + "/api/service/terminate-data-folder": {}, + "/api/network/generate-rewards-tree": {}, + "/api/network/download-rewards-file": {}, +} + +// readOnlyExact are last-path-segment names that only return information. +// Prefix rules in isSensitiveAPIPath cover can-*, get-*, is-*, estimate-*, and check-*. +var readOnlyExact = map[string]struct{}{ + "status": {}, + "alerts": {}, + "sync": {}, + "rewards": {}, + "lots": {}, + "members": {}, + "proposals": {}, + "proposal-details": {}, + "stats": {}, + "timezone-map": {}, + "dao-proposals": {}, + "node-fee": {}, + "rpl-price": {}, + "latest-delegate": {}, + "rewards-event": {}, + "recovery-status": {}, + "pending-rewards": {}, + "calculate-rewards": {}, + "latest-block-withdrawals": {}, + "beacon-withdrawal-queue-estimate": {}, + "validator-map-and-balances": {}, + "deposit-contract-info": {}, + "resolve-ens-name": {}, + "reverse-resolve-ens-name": {}, + "test-recover": {}, + "test-search-and-recover": {}, +} + +// isSensitiveAPIPath reports whether path must have a bearer token when +// Token Requirement is "sensitive endpoints only". Transaction-submitting +// routes and high-impact local operations are always sensitive. Status, gas +// estimates (can-*), and similar reads are not. +func isSensitiveAPIPath(path string) bool { + path = strings.TrimSuffix(path, "/") + if _, ok := submitsTransaction[path]; ok { + return true + } + if _, ok := sensitiveLocal[path]; ok { + return true + } + + switch path { + case healthzPath, "/api/version", "/api/wait": + return false + } + + i := strings.LastIndex(path, "/") + name := path + if i >= 0 { + name = path[i+1:] + } + + for _, prefix := range []string{"can-", "get-", "is-", "estimate-", "check-"} { + if strings.HasPrefix(name, prefix) { + return false + } + } + if _, ok := readOnlyExact[name]; ok { + return false + } + return true +} diff --git a/rocketpool/node/sensitive_test.go b/rocketpool/node/sensitive_test.go new file mode 100644 index 000000000..582432e8b --- /dev/null +++ b/rocketpool/node/sensitive_test.go @@ -0,0 +1,75 @@ +package node + +import "testing" + +func TestIsSensitiveAPIPath(t *testing.T) { + safe := []string{ + "/healthz", + "/api/version", + "/api/wait", + "/api/node/status", + "/api/node/sync", + "/api/node/alerts", + "/api/node/rewards", + "/api/node/can-send", + "/api/node/get-eth-balance", + "/api/node/check-collateral", + "/api/wallet/status", + "/api/wallet/recovery-status", + "/api/minipool/status", + "/api/minipool/can-exit", + "/api/network/stats", + "/api/odao/get-member-settings", + "/api/service/get-client-status", + "/api/service/get-gas-price-from-latest-block", + "/api/wallet/estimate-gas-set-ens-name", + "/api/pdao/estimate-set-voting-delegate-gas", + "/api/minipool/can-exit", + "/api/megapool/can-exit-validator", + } + for _, path := range safe { + if isSensitiveAPIPath(path) { + t.Errorf("%s should not be sensitive", path) + } + } + + sensitive := []string{ + "/api/node/send", + "/api/node/send-all", + "/api/node/deposit", + "/api/node/withdraw-eth", + "/api/node/withdraw-rpl", + "/api/node/stake-rpl", + "/api/node/sign", + "/api/wallet/export", + "/api/wallet/init", + "/api/wallet/recover", + "/api/wallet/set-password", + "/api/minipool/exit", + "/api/minipool/dissolve", + "/api/minipool/import-key", + "/api/megapool/exit-validator", + "/api/megapool/dissolve-validator", + "/api/pdao/vote-proposal", + "/api/pdao/execute-proposal", + "/api/odao/join", + "/api/service/restart-vc", + "/api/service/terminate-data-folder", + "/api/network/generate-rewards-tree", + "/api/auction/bid-lot", + "/api/queue/process", + "/api/node/register", + "/api/node/set-primary-withdrawal-address", + "/api/wallet/set-ens-name", + "/api/minipool/close", + "/api/minipool/rescue-dissolved", + "/api/megapool/notify-validator-exit", + "/api/security/execute-proposal", + "/api/upgrade/execute-upgrade", + } + for _, path := range sensitive { + if !isSensitiveAPIPath(path) { + t.Errorf("%s should be sensitive", path) + } + } +} diff --git a/shared/services/apitoken/token.go b/shared/services/apitoken/token.go new file mode 100644 index 000000000..65e17e7fb --- /dev/null +++ b/shared/services/apitoken/token.go @@ -0,0 +1,154 @@ +package apitoken + +import ( + "crypto/rand" + "crypto/subtle" + "encoding/hex" + "fmt" + "os" + "path/filepath" + "strings" +) + +const ( + Prefix = "rpsn_" + rawBytes = 32 + fileMode = 0600 + dirMode = 0700 + tokenBytes = 64 // hex-encoded rawBytes +) + +// Generate returns a new high-entropy API token of the form rpsn_<64 hex chars>. +func Generate() (string, error) { + buf := make([]byte, rawBytes) + if _, err := rand.Read(buf); err != nil { + return "", fmt.Errorf("could not generate API token: %w", err) + } + return Prefix + hex.EncodeToString(buf), nil +} + +// Equal compares two tokens in constant time. Length mismatches still run a +// dummy compare so the timing does not leak the expected length. +func Equal(a, b string) bool { + ab := []byte(a) + bb := []byte(b) + if len(ab) != len(bb) { + dummy := make([]byte, len(ab)) + subtle.ConstantTimeCompare(ab, dummy) + return false + } + return subtle.ConstantTimeCompare(ab, bb) == 1 +} + +// Valid reports whether token has the expected generated format. +func Valid(token string) bool { + if !strings.HasPrefix(token, Prefix) { + return false + } + body := strings.TrimPrefix(token, Prefix) + if len(body) != tokenBytes { + return false + } + _, err := hex.DecodeString(body) + return err == nil +} + +// ReadFile returns the trimmed token stored at path. Missing or empty files +// return ("", nil). +func ReadFile(path string) (string, error) { + data, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return "", nil + } + return "", fmt.Errorf("could not read API token file: %w", err) + } + return strings.TrimSpace(string(data)), nil +} + +// WriteFile writes token to path with 0600 permissions, creating the parent +// directory if needed. +func WriteFile(path, token string) error { + token = strings.TrimSpace(token) + if token == "" { + return fmt.Errorf("API token cannot be empty") + } + if err := os.MkdirAll(filepath.Dir(path), dirMode); err != nil { + return fmt.Errorf("could not create API token directory: %w", err) + } + + tmp, err := os.CreateTemp(filepath.Dir(path), ".tmp-api-token-*") + if err != nil { + return fmt.Errorf("could not create API token temp file: %w", err) + } + tmpName := tmp.Name() + defer func() { + _ = os.Remove(tmpName) + }() + + if err := tmp.Chmod(fileMode); err != nil { + _ = tmp.Close() + return fmt.Errorf("could not set API token file permissions: %w", err) + } + if _, err := tmp.WriteString(token); err != nil { + _ = tmp.Close() + return fmt.Errorf("could not write API token file: %w", err) + } + if err := tmp.Close(); err != nil { + return fmt.Errorf("could not close API token file: %w", err) + } + if err := os.Rename(tmpName, path); err != nil { + return fmt.Errorf("could not replace API token file: %w", err) + } + if err := os.Chmod(path, fileMode); err != nil { + return fmt.Errorf("could not set API token file permissions: %w", err) + } + return nil +} + +// EnsureFile returns the token stored at path, creating a new one if the file +// is missing or empty. Creation uses O_EXCL so concurrent callers converge on +// a single token. +func EnsureFile(path string) (string, error) { + existing, err := ReadFile(path) + if err != nil { + return "", err + } + if existing != "" { + return existing, nil + } + + token, err := Generate() + if err != nil { + return "", err + } + if err := os.MkdirAll(filepath.Dir(path), dirMode); err != nil { + return "", fmt.Errorf("could not create API token directory: %w", err) + } + + f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, fileMode) + if err != nil { + if os.IsExist(err) { + existing, readErr := ReadFile(path) + if readErr != nil { + return "", readErr + } + if existing != "" { + return existing, nil + } + if writeErr := WriteFile(path, token); writeErr != nil { + return "", writeErr + } + return token, nil + } + return "", fmt.Errorf("could not create API token file: %w", err) + } + if _, err := f.WriteString(token); err != nil { + _ = f.Close() + return "", fmt.Errorf("could not write API token file: %w", err) + } + if err := f.Close(); err != nil { + return "", fmt.Errorf("could not close API token file: %w", err) + } + return token, nil +} diff --git a/shared/services/config/api-config.go b/shared/services/config/api-config.go new file mode 100644 index 000000000..74e23c074 --- /dev/null +++ b/shared/services/config/api-config.go @@ -0,0 +1,152 @@ +package config + +import ( + "os" + "path/filepath" + + "github.com/rocket-pool/smartnode/shared/types/config" +) + +const ( + apiPortID string = "apiPort" + openApiPortID string = "openApiPort" + apiTokenID string = "apiToken" + tokenScopeID string = "tokenScope" + rateLimitID string = "rateLimit" + apiTokenFile string = "api-token" + defaultApiPort uint16 = 8280 + defaultOpenPort = config.RPC_OpenLocalhost + defaultTokenScope = config.APITokenScope_All + defaultRateLimit uint16 = 5 +) + +// Configuration for the Smart Node HTTP API +type ApiConfig struct { + Title string `yaml:"-"` + + parent *RocketPoolConfig + + // Port the node's HTTP API server listens on + ApiPort config.Parameter `yaml:"apiPort,omitempty"` + + // How the API port is published + OpenApiPort config.Parameter `yaml:"openApiPort,omitempty"` + + // Bearer token (stored in a sidecar file) + APIToken config.Parameter `yaml:"-"` + + // Which routes require the bearer token + TokenScope config.Parameter `yaml:"tokenScope,omitempty"` + + // Maximum requests per second (0 disables the limit) + RateLimit config.Parameter `yaml:"rateLimit,omitempty"` +} + +func NewApiConfig(cfg *RocketPoolConfig) *ApiConfig { + portModes := config.PortModes("Allow connections from external hosts. The Smart Node API can export your wallet, send funds, and change node settings. Do not expose this to the public internet without TLS (put it behind a reverse proxy). Trusted LAN only otherwise.") + portModes[0].Description = "Do not publish the API port to the host. The rocketpool CLI on this machine will not be able to reach the API." + + return &ApiConfig{ + Title: "API Settings", + parent: cfg, + + ApiPort: config.Parameter{ + ID: apiPortID, + Name: "API Port", + Description: "The port your Smartnode's HTTP API server should listen on.", + Type: config.ParameterType_Uint16, + Default: map[config.Network]interface{}{config.Network_All: defaultApiPort}, + AffectsContainers: []config.ContainerID{config.ContainerID_Node}, + CanBeBlank: false, + OverwriteOnUpgrade: false, + }, + + OpenApiPort: config.Parameter{ + ID: openApiPortID, + Name: "Expose API Port", + Description: "Expose the Smart Node HTTP API to other processes on your machine, or to your local network so other machines can access it. Closed means the rocketpool CLI on this host cannot reach the API.", + Type: config.ParameterType_Choice, + Default: map[config.Network]interface{}{config.Network_All: defaultOpenPort}, + AffectsContainers: []config.ContainerID{config.ContainerID_Node}, + CanBeBlank: false, + OverwriteOnUpgrade: false, + Options: portModes, + }, + + APIToken: config.Parameter{ + ID: apiTokenID, + Name: "API Token", + Description: "Bearer token required by the API according to Token Requirement below. Treat this like a password: copy it to a password manager. " + + "Clients send `Authorization: Bearer `. The rocketpool CLI on this machine sends it automatically. " + + "Clearing the field regenerates a new token on save.", + Type: config.ParameterType_String, + Default: map[config.Network]interface{}{config.Network_All: ""}, + AffectsContainers: []config.ContainerID{config.ContainerID_Node}, + CanBeBlank: true, + OverwriteOnUpgrade: false, + Sensitive: true, + }, + + TokenScope: config.Parameter{ + ID: tokenScopeID, + Name: "Token Requirement", + Description: "Which API routes require the bearer token. Sensitive endpoints include every route that submits an on-chain or validator-exit transaction, plus wallet operations and similar mutating actions. Status, balances, and gas estimates (can-*) stay open if you choose sensitive-only.", + Type: config.ParameterType_Choice, + Default: map[config.Network]interface{}{config.Network_All: defaultTokenScope}, + AffectsContainers: []config.ContainerID{config.ContainerID_Node}, + CanBeBlank: false, + OverwriteOnUpgrade: false, + Options: []config.ParameterOption{{ + Name: "Require token for all endpoints", + Description: "Every API request except /healthz must include the bearer token.", + Value: config.APITokenScope_All, + }, { + Name: "Require token for sensitive endpoints only", + Description: "Only mutating routes need the token: every endpoint that submits a transaction, plus wallet operations, exiting validators, staking, withdrawals, DAO votes, and similar. Read-only status and can-* estimates do not.", + Value: config.APITokenScope_Sensitive, + }}, + }, + + RateLimit: config.Parameter{ + ID: rateLimitID, + Name: "API Rate Limit", + Description: "Maximum number of API requests per second. The default is 5. Set to 0 to disable rate limiting.", + Type: config.ParameterType_Uint16, + Default: map[config.Network]interface{}{config.Network_All: defaultRateLimit}, + AffectsContainers: []config.ContainerID{config.ContainerID_Node}, + CanBeBlank: false, + OverwriteOnUpgrade: false, + }, + } +} + +func (cfg *ApiConfig) GetParameters() []*config.Parameter { + return []*config.Parameter{ + &cfg.ApiPort, + &cfg.OpenApiPort, + &cfg.APIToken, + &cfg.TokenScope, + &cfg.RateLimit, + } +} + +func (cfg *ApiConfig) GetConfigTitle() string { + return cfg.Title +} + +// GetAPITokenPath is the token file path as seen by the node daemon. +func (cfg *ApiConfig) GetAPITokenPath() string { + if cfg.parent != nil && cfg.parent.IsNativeMode { + return tokenPath(cfg.parent.Smartnode.DataPath.Value.(string)) + } + return tokenPath(DaemonDataPath) +} + +// GetAPITokenPathInCLI is the token file path as seen by the host CLI / TUI. +func (cfg *ApiConfig) GetAPITokenPathInCLI() string { + return tokenPath(cfg.parent.Smartnode.DataPath.Value.(string)) +} + +func tokenPath(dataDir string) string { + return filepath.Join(os.ExpandEnv(dataDir), apiTokenFile) +} diff --git a/shared/services/config/api-config_test.go b/shared/services/config/api-config_test.go new file mode 100644 index 000000000..847168e97 --- /dev/null +++ b/shared/services/config/api-config_test.go @@ -0,0 +1,70 @@ +package config + +import ( + "testing" + + "github.com/rocket-pool/smartnode/shared/services/config/migration" + cfgtypes "github.com/rocket-pool/smartnode/shared/types/config" +) + +func TestGetNodeOpenPorts(t *testing.T) { + cfg := NewRocketPoolConfig("/tmp/rp-test", false) + + cfg.Api.OpenApiPort.Value = cfgtypes.RPC_Closed + if got := cfg.GetNodeOpenPorts(); got != "" { + t.Fatalf("closed: %q", got) + } + + cfg.Api.OpenApiPort.Value = cfgtypes.RPC_OpenLocalhost + cfg.Api.ApiPort.Value = uint16(8280) + got := cfg.GetNodeOpenPorts() + if got != `"127.0.0.1:8280:8280/tcp"` { + t.Fatalf("localhost: %q", got) + } + + cfg.Api.OpenApiPort.Value = cfgtypes.RPC_OpenExternal + got = cfg.GetNodeOpenPorts() + if got != `"8280:8280/tcp"` { + t.Fatalf("external: %q", got) + } +} + +func TestDefaultRateLimit(t *testing.T) { + cfg := NewRocketPoolConfig("/tmp/rp-test", false) + got, ok := cfg.Api.RateLimit.Value.(uint16) + if !ok || got != 5 { + t.Fatalf("default rate limit %v (%T), want 5", cfg.Api.RateLimit.Value, cfg.Api.RateLimit.Value) + } +} + +func TestSensitiveTokenNotSerialized(t *testing.T) { + cfg := NewRocketPoolConfig("/tmp/rp-test", false) + cfg.Api.APIToken.Value = "rpsn_secret" + serialized := cfg.Serialize() + if _, exists := serialized["api"]["apiToken"]; exists { + t.Fatal("API token must not be written to user-settings.yml") + } +} + +func TestMigrateApiPort(t *testing.T) { + serialized := map[string]map[string]string{ + "root": { + "version": "v1.21.0", + "isNative": "false", + "rpDir": "/tmp", + }, + "smartnode": { + "apiPort": "9001", + "network": "mainnet", + }, + } + if err := migration.UpdateConfig(serialized); err != nil { + t.Fatal(err) + } + if serialized["smartnode"]["apiPort"] != "" { + t.Fatalf("old key still present: %q", serialized["smartnode"]["apiPort"]) + } + if serialized["api"]["apiPort"] != "9001" { + t.Fatalf("migrated port %q", serialized["api"]["apiPort"]) + } +} diff --git a/shared/services/config/migration/migration-manager.go b/shared/services/config/migration/migration-manager.go index c205cf900..13789d1cf 100644 --- a/shared/services/config/migration/migration-manager.go +++ b/shared/services/config/migration/migration-manager.go @@ -49,6 +49,10 @@ func UpdateConfig(serializedConfig map[string]map[string]string) error { if err != nil { return err } + v1210, err := parseVersion("1.21.0") + if err != nil { + return err + } // Create the collection of upgraders upgraders := []ConfigUpgrader{ @@ -77,6 +81,10 @@ func UpdateConfig(serializedConfig map[string]map[string]string) error { Version: v1205, UpgradeFunc: upgradeFromV1205, }, + { + Version: v1210, + UpgradeFunc: upgradeFromV1210, + }, } // Find the index of the provided config's version diff --git a/shared/services/config/migration/v1210-manager.go b/shared/services/config/migration/v1210-manager.go new file mode 100644 index 000000000..eaf360405 --- /dev/null +++ b/shared/services/config/migration/v1210-manager.go @@ -0,0 +1,24 @@ +package migration + +func upgradeFromV1210(serializedConfig map[string]map[string]string) error { + smartnode, exists := serializedConfig["smartnode"] + if !exists { + return nil + } + + port, exists := smartnode["apiPort"] + if !exists || port == "" { + return nil + } + + api, exists := serializedConfig["api"] + if !exists { + api = map[string]string{} + serializedConfig["api"] = api + } + if api["apiPort"] == "" { + api["apiPort"] = port + } + delete(smartnode, "apiPort") + return nil +} diff --git a/shared/services/config/rocket-pool-config.go b/shared/services/config/rocket-pool-config.go index 8c1e021bc..26966ca35 100644 --- a/shared/services/config/rocket-pool-config.go +++ b/shared/services/config/rocket-pool-config.go @@ -21,6 +21,7 @@ import ( "github.com/rocket-pool/smartnode/addons" "github.com/rocket-pool/smartnode/addons/rescue_node" "github.com/rocket-pool/smartnode/shared" + "github.com/rocket-pool/smartnode/shared/services/apitoken" "github.com/rocket-pool/smartnode/shared/services/config/migration" addontypes "github.com/rocket-pool/smartnode/shared/types/addons" "github.com/rocket-pool/smartnode/shared/types/config" @@ -93,6 +94,9 @@ type RocketPoolConfig struct { // The Smart Node configuration Smartnode *SmartnodeConfig `yaml:"smartnode,omitempty"` + // HTTP API configuration + Api *ApiConfig `yaml:"api,omitempty"` + // Execution client configurations ExecutionCommon *ExecutionCommonConfig `yaml:"executionCommon,omitempty"` Geth *GethConfig `yaml:"geth,omitempty"` @@ -259,6 +263,10 @@ func (cfg *RocketPoolConfig) Save(directory, filename string) error { return fmt.Errorf("error updating permissions of %s: %w", path, err) } + if err := cfg.persistAPIToken(); err != nil { + return err + } + return nil } @@ -559,6 +567,7 @@ func NewRocketPoolConfig(rpDir string, isNativeMode bool) *RocketPoolConfig { cfg.ConsensusClientMode.Default[config.Network_All] = cfg.ConsensusClientMode.Options[0].Value cfg.Smartnode = NewSmartnodeConfig(cfg) + cfg.Api = NewApiConfig(cfg) cfg.ExecutionCommon = NewExecutionCommonConfig(cfg) cfg.Geth = NewGethConfig(cfg) cfg.Nethermind = NewNethermindConfig(cfg) @@ -679,6 +688,7 @@ func (cfg *RocketPoolConfig) GetParameters() []*config.Parameter { func (cfg *RocketPoolConfig) GetSubconfigs() map[string]config.Config { return map[string]config.Config{ "smartnode": cfg.Smartnode, + "api": cfg.Api, "executionCommon": cfg.ExecutionCommon, "geth": cfg.Geth, "nethermind": cfg.Nethermind, @@ -1365,8 +1375,49 @@ func (cfg *RocketPoolConfig) GetECStopSignal() (string, error) { } func (cfg *RocketPoolConfig) GetNodeOpenPorts() string { - port := cfg.Smartnode.APIPort.Value.(uint16) - return fmt.Sprintf("\"127.0.0.1:%d:%d/tcp\"", port, port) + portMode, ok := cfg.Api.OpenApiPort.Value.(config.RPCMode) + if !ok || !portMode.Open() { + return "" + } + port := cfg.Api.ApiPort.Value.(uint16) + return fmt.Sprintf("\"%s\"", portMode.DockerPortMapping(port)) +} + +// SyncAPITokenFromDisk loads the API token from the sidecar file, creating one +// if needed. forCLI selects the host data-dir path; the daemon uses the +// in-container path. +func (cfg *RocketPoolConfig) SyncAPITokenFromDisk(forCLI bool) error { + if cfg.Api == nil { + return nil + } + path := cfg.Api.GetAPITokenPath() + if forCLI { + path = cfg.Api.GetAPITokenPathInCLI() + } + token, err := apitoken.EnsureFile(path) + if err != nil { + return err + } + cfg.Api.APIToken.Value = token + return nil +} + +func (cfg *RocketPoolConfig) persistAPIToken() error { + if cfg.Api == nil { + return nil + } + path := cfg.Api.GetAPITokenPathInCLI() + token, _ := cfg.Api.APIToken.Value.(string) + token = strings.TrimSpace(token) + if token == "" { + generated, err := apitoken.EnsureFile(path) + if err != nil { + return err + } + cfg.Api.APIToken.Value = generated + return nil + } + return apitoken.WriteFile(path, token) } // Gets the stop signal of the ec container @@ -1818,6 +1869,9 @@ func (cfg *RocketPoolConfig) Validate() []string { portMap, errors = addAndCheckForDuplicate(portMap, cfg.MevBoost.Port, errors) portMap, errors = addAndCheckForDuplicate(portMap, cfg.Prometheus.Port, errors) portMap, errors = addAndCheckForDuplicate(portMap, cfg.Alertmanager.Port, errors) + if cfg.Api != nil { + portMap, errors = addAndCheckForDuplicate(portMap, cfg.Api.ApiPort, errors) + } if cfg.ConsensusClient.Value.(config.ConsensusClient) == config.ConsensusClient_Lighthouse { _, errors = addAndCheckForDuplicate(portMap, cfg.Lighthouse.P2pQuicPort, errors) } @@ -1901,6 +1955,10 @@ func getChangedSettings(oldParams []*config.Parameter, newParams []*config.Param oldValString := fmt.Sprint(oldParams[i].Value) newValString := fmt.Sprint(param.Value) if oldValString != newValString { + if param.Sensitive { + oldValString = maskSensitive(oldValString) + newValString = maskSensitive(newValString) + } changedSettings = append(changedSettings, config.ChangedSetting{ Name: param.Name, OldValue: oldValString, @@ -1913,6 +1971,13 @@ func getChangedSettings(oldParams []*config.Parameter, newParams []*config.Param return changedSettings } +func maskSensitive(value string) string { + if value == "" { + return "(empty)" + } + return "********" +} + func getAffectedContainers(param *config.Parameter) map[config.ContainerID]bool { affectedContainers := map[config.ContainerID]bool{} for _, container := range param.AffectsContainers { diff --git a/shared/services/config/smartnode-config.go b/shared/services/config/smartnode-config.go index db4197016..9344e2509 100644 --- a/shared/services/config/smartnode-config.go +++ b/shared/services/config/smartnode-config.go @@ -126,9 +126,6 @@ type SmartnodeConfig struct { // Delay for automatic queue assignment AutoAssignmentDelay config.Parameter `yaml:"autoAssignmentDelay,omitempty"` - // Port for the node's HTTP API webserver - APIPort config.Parameter `yaml:"apiPort,omitempty"` - /////////////////////////// // Non-editable settings // /////////////////////////// @@ -431,17 +428,6 @@ func NewSmartnodeConfig(cfg *RocketPoolConfig) *SmartnodeConfig { OverwriteOnUpgrade: true, }, - APIPort: config.Parameter{ - ID: "apiPort", - Name: "API Port", - Description: "The port your Smartnode's HTTP API server should listen on.", - Type: config.ParameterType_Uint16, - Default: map[config.Network]interface{}{config.Network_All: uint16(8280)}, - AffectsContainers: []config.ContainerID{config.ContainerID_Node}, - CanBeBlank: false, - OverwriteOnUpgrade: false, - }, - txWatchUrl: map[config.Network]string{ config.Network_Mainnet: "https://etherscan.io/tx", config.Network_Devnet: "", @@ -674,7 +660,6 @@ func (cfg *SmartnodeConfig) GetParameters() []*config.Parameter { &cfg.ArchiveECUrl, &cfg.WatchtowerMaxFeeOverride, &cfg.WatchtowerPrioFeeOverride, - &cfg.APIPort, } } diff --git a/shared/services/rocketpool/client.go b/shared/services/rocketpool/client.go index ddcca0744..0de85e0af 100644 --- a/shared/services/rocketpool/client.go +++ b/shared/services/rocketpool/client.go @@ -80,8 +80,11 @@ type Client struct { // apiURL is the base URL for the node's HTTP API server. // It is derived lazily from config on first use. - apiURL string - apiURLOnce sync.Once + apiURL string + apiToken string + apiTokenPath string + apiAuthErr error + apiURLOnce sync.Once } func getClientStatusString(clientStatus api.ClientStatus) string { @@ -208,7 +211,9 @@ func (c *Client) LoadConfig() (*config.RocketPoolConfig, bool, error) { } if cfg != nil { - // A config was loaded, return it now + if err := cfg.SyncAPITokenFromDisk(true); err != nil { + return nil, false, err + } return cfg, false, nil } @@ -1328,21 +1333,26 @@ func (c *Client) composeAddons(cfg *config.RocketPoolConfig, rocketpoolDir strin } -// getAPIURL returns the base URL for the node's HTTP API server, e.g. -// "http://127.0.0.1:8280". The result is derived from config and cached. -func (c *Client) getAPIURL() string { +func (c *Client) loadAPIAuth() { c.apiURLOnce.Do(func() { cfg, _, err := c.LoadConfig() if err != nil { + c.apiAuthErr = err return } - port, ok := cfg.Smartnode.APIPort.Value.(uint16) + port, ok := cfg.Api.ApiPort.Value.(uint16) if !ok || port == 0 { return } c.apiURL = fmt.Sprintf("http://127.0.0.1:%d", port) + if err := cfg.SyncAPITokenFromDisk(true); err != nil { + c.apiAuthErr = err + return + } + token, _ := cfg.Api.APIToken.Value.(string) + c.apiToken = token + c.apiTokenPath = cfg.Api.GetAPITokenPathInCLI() }) - return c.apiURL } // callHTTPAPI calls the node's HTTP API server with a 5-minute safety timeout. @@ -1360,7 +1370,11 @@ func (c *Client) callHTTPAPI(method, path string, params url.Values) ([]byte, er // when a tighter deadline is required (e.g. optional/informational requests // that must not block the user). func (c *Client) callHTTPAPICtx(ctx context.Context, method, path string, params url.Values) ([]byte, error) { - base := c.getAPIURL() + c.loadAPIAuth() + if c.apiAuthErr != nil { + return nil, fmt.Errorf("could not load API token: %w", c.apiAuthErr) + } + base := c.apiURL if base == "" { return nil, fmt.Errorf("node HTTP API URL is not configured (APIPort may be 0)") } @@ -1404,6 +1418,10 @@ func (c *Client) callHTTPAPICtx(ctx context.Context, method, path string, params return nil, fmt.Errorf("error building HTTP request for %s %s: %w", method, path, err) } + if c.apiToken != "" { + req.Header.Set("Authorization", "Bearer "+c.apiToken) + } + if c.globals.DebugPrint { fmt.Printf("HTTP API: %s %s\n", method, target) } @@ -1426,6 +1444,12 @@ func (c *Client) callHTTPAPICtx(ctx context.Context, method, path string, params } if resp.StatusCode != http.StatusOK { + if resp.StatusCode == http.StatusUnauthorized { + if c.apiTokenPath != "" { + return nil, fmt.Errorf("API unauthorized: CLI token file %s does not match the node daemon. Point -c at the same config directory the daemon is using, or copy that install's data/api-token", c.apiTokenPath) + } + return nil, errors.New("API unauthorized: check the API Token under the API category in `rocketpool service config`") + } var apiErr struct { Error string `json:"error"` } diff --git a/shared/types/config/parameter.go b/shared/types/config/parameter.go index d27b0a74e..03ffb7378 100644 --- a/shared/types/config/parameter.go +++ b/shared/types/config/parameter.go @@ -20,6 +20,7 @@ type Parameter struct { AffectsContainers []ContainerID `yaml:"affectsContainers,omitempty"` CanBeBlank bool `yaml:"canBeBlank,omitempty"` OverwriteOnUpgrade bool `yaml:"overwriteOnUpgrade,omitempty"` + Sensitive bool `yaml:"sensitive,omitempty"` Options []ParameterOption `yaml:"options,omitempty"` Value any `yaml:"-"` DescriptionsByNetwork map[Network]string `yaml:"-"` @@ -57,6 +58,10 @@ func (param *Parameter) ChangeNetwork(oldNetwork Network, newNetwork Network) { // Serializes the parameter's value into a string func (param *Parameter) Serialize(serializedParams map[string]string) { + if param.Sensitive { + return + } + var value string if param.Value == nil { value = "" @@ -72,6 +77,10 @@ func (param *Parameter) Deserialize(serializedParams map[string]string, network // Update the description, if applicable param.UpdateDescription(network) + if param.Sensitive { + return param.SetToDefault(network) + } + value, exists := serializedParams[param.ID] if !exists { return param.SetToDefault(network) diff --git a/shared/types/config/port-modes.go b/shared/types/config/port-modes.go index 603d81da3..936474fde 100644 --- a/shared/types/config/port-modes.go +++ b/shared/types/config/port-modes.go @@ -55,17 +55,3 @@ func PortModes(warningOverride string) []ParameterOption { Value: RPC_OpenExternal, }} } - -// RestrictedPortModes returns port mode options limited to Closed or Localhost only. -// Used for ports that must never be exposed externally (e.g. the node API). -func RestrictedPortModes() []ParameterOption { - return []ParameterOption{{ - Name: "Closed", - Description: "Do not allow connections to the port", - Value: RPC_Closed, - }, { - Name: "Open to Localhost", - Description: "Allow connections from this host only", - Value: RPC_OpenLocalhost, - }} -} diff --git a/shared/types/config/types.go b/shared/types/config/types.go index da13bbd5a..1cbaa2bc7 100644 --- a/shared/types/config/types.go +++ b/shared/types/config/types.go @@ -45,6 +45,14 @@ const ( Mode_External Mode = "external" ) +// APITokenScope controls which HTTP API routes require a bearer token. +type APITokenScope string + +const ( + APITokenScope_All APITokenScope = "all" + APITokenScope_Sensitive APITokenScope = "sensitive" +) + // Enum to describe the mode for a client - local (Docker Mode) or external (Hybrid Mode) const ( PruningMode_HistoryExpiry Mode = "historyExpiry"