Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -18,3 +18,4 @@ Dockerfile
/rocketpool/rocketpool-daemon-linux-arm64
.vscode-ctags
build/
*api-token
71 changes: 71 additions & 0 deletions rocketpool-cli/service/config/settings-api.go
Original file line number Diff line number Diff line change
@@ -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()
}
7 changes: 7 additions & 0 deletions rocketpool-cli/service/config/settings-home.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ type settingsHome struct {
saveButton *tview.Button
wizardButton *tview.Button
smartnodePage *SmartnodeConfigPage
apiPage *ApiConfigPage
ecPage *ExecutionConfigPage
fallbackPage *FallbackConfigPage
ccPage *ConsensusConfigPage
Expand Down Expand Up @@ -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)
Expand All @@ -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,
Expand Down Expand Up @@ -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()
}
Expand Down
7 changes: 7 additions & 0 deletions rocketpool-cli/service/config/settings-native-home.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ type settingsNativeHome struct {
saveButton *tview.Button
wizardButton *tview.Button
smartnodePage *NativeSmartnodeConfigPage
apiPage *ApiConfigPage
nativePage *NativePage
fallbackPage *NativeFallbackConfigPage
metricsPage *NativeMetricsConfigPage
Expand All @@ -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,
Expand Down Expand Up @@ -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()
}
Expand Down
7 changes: 7 additions & 0 deletions rocketpool-cli/service/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
16 changes: 16 additions & 0 deletions rocketpool/api/response/response.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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:
Expand Down
141 changes: 131 additions & 10 deletions rocketpool/node/http.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
}
Expand Down
Loading
Loading