diff --git a/cmd/bootstrap.go b/cmd/bootstrap.go index a45f464cee..b770f9faab 100644 --- a/cmd/bootstrap.go +++ b/cmd/bootstrap.go @@ -88,10 +88,7 @@ func (di *Dependencies) bootstrapTequilapi(nodeOptions node.Options, listener ne tequilapi_endpoints.AddRoutesForCurrencyExchange(di.PilvytisAPI), tequilapi_endpoints.AddRoutesForPilvytis(di.PilvytisAPI, di.PilvytisOrderIssuer, di.LocationResolver), tequilapi_endpoints.AddRoutesForTerms, - tequilapi_endpoints.AddEntertainmentRoutes(entertainment.NewEstimator( - config.FlagPaymentPriceGiB.Value, - config.FlagPaymentPriceHour.Value, - )), + tequilapi_endpoints.AddEntertainmentRoutes(entertainment.NewMarketEstimator(di.PricingHelper)), tequilapi_endpoints.AddRoutesForValidator, }, ) diff --git a/cmd/bootstrap_mobile_provider.go b/cmd/bootstrap_mobile_provider.go index e59513757d..fa116e42a1 100644 --- a/cmd/bootstrap_mobile_provider.go +++ b/cmd/bootstrap_mobile_provider.go @@ -92,10 +92,7 @@ func (di *Dependencies) bootstrapTequilapi(nodeOptions node.Options, listener ne tequilapi_endpoints.AddRoutesForCurrencyExchange(di.PilvytisAPI), tequilapi_endpoints.AddRoutesForPilvytis(di.PilvytisAPI, di.PilvytisOrderIssuer, di.LocationResolver), tequilapi_endpoints.AddRoutesForTerms, - tequilapi_endpoints.AddEntertainmentRoutes(entertainment.NewEstimator( - config.FlagPaymentPriceGiB.Value, - config.FlagPaymentPriceHour.Value, - )), + tequilapi_endpoints.AddEntertainmentRoutes(entertainment.NewMarketEstimator(di.PricingHelper)), tequilapi_endpoints.AddRoutesForValidator, }, ) diff --git a/config/config.go b/config/config.go index d73e22a01e..9de02d8ea6 100644 --- a/config/config.go +++ b/config/config.go @@ -27,7 +27,6 @@ import ( "github.com/BurntSushi/toml" "github.com/mysteriumnetwork/node/eventbus" "github.com/mysteriumnetwork/node/metadata" - "github.com/mysteriumnetwork/node/utils/jsonutil" "github.com/pkg/errors" "github.com/rs/zerolog/log" "github.com/spf13/cast" @@ -86,11 +85,9 @@ func (cfg *Config) LoadUserConfig(location string) error { if err != nil { return errors.Wrap(err, "failed to decode configuration file") } - cfgJson, err := jsonutil.ToJson(cfg.user) - if err != nil { - return err - } - log.Info().Msg("User configuration loaded: \n" + cfgJson) + // Do not log the configuration contents. User configuration contains + // credentials (for example mmn.api-key) which must never reach logs. + log.Info().Msg("User configuration loaded") return nil } @@ -111,11 +108,8 @@ func (cfg *Config) SaveUserConfig() error { if err != nil { return errors.Wrap(err, "failed to write configuration to file") } - cfgJson, err := jsonutil.ToJson(cfg.user) - if err != nil { - return err - } - log.Info().Msg("User configuration written: \n" + cfgJson) + // Do not log the configuration contents. See LoadUserConfig. + log.Info().Msg("User configuration written") return nil } @@ -213,19 +207,37 @@ func (cfg *Config) Get(key string) interface{} { defer cfg.mu.RUnlock() cliValue := SearchMap(cfg.cli, segments) if cliValue != nil { - log.Debug().Msgf("Returning CLI value %v:%v", key, cliValue) + logConfigValue("CLI", key, cliValue) return copyValue(cliValue) } userValue := SearchMap(cfg.user, segments) if userValue != nil { - log.Debug().Msgf("Returning user config value %v:%v", key, userValue) + logConfigValue("user config", key, userValue) return copyValue(userValue) } defaultValue := SearchMap(cfg.defaults, segments) - log.Trace().Msgf("Returning default value %v:%v", key, defaultValue) + if isSensitiveConfigKey(key) { + log.Trace().Msgf("Returning default value %v:[REDACTED]", key) + } else { + log.Trace().Msgf("Returning default value %v:%v", key, defaultValue) + } return copyValue(defaultValue) } +func logConfigValue(source, key string, value interface{}) { + if isSensitiveConfigKey(key) { + log.Debug().Msgf("Returning %s value %v:[REDACTED]", source, key) + return + } + log.Debug().Msgf("Returning %s value %v:%v", source, key, value) +} + +func isSensitiveConfigKey(key string) bool { + normalized := strings.ToLower(key) + normalized = strings.NewReplacer("-", "", "_", "", ".", "").Replace(normalized) + return strings.HasSuffix(normalized, "apikey") +} + // returns scalar values as is. deep-copies maps. func copyValue(value interface{}) interface{} { switch v := value.(type) { diff --git a/config/config_test.go b/config/config_test.go index 9824cc712a..86e3d602e4 100644 --- a/config/config_test.go +++ b/config/config_test.go @@ -188,6 +188,13 @@ func TestUserConfig_Get(t *testing.T) { assert.Equal(t, 1003, cfg.Get("openvpn.port")) } +func TestSensitiveConfigKey(t *testing.T) { + assert.True(t, isSensitiveConfigKey("mmn.api-key")) + assert.True(t, isSensitiveConfigKey("service.api_key")) + assert.True(t, isSensitiveConfigKey("service.apikey")) + assert.False(t, isSensitiveConfigKey("mmn.api-address")) +} + func TestUserConfig_GetConfig(t *testing.T) { cfg := NewConfig() diff --git a/config/remote/config.go b/config/remote/config.go index 0709a2cb87..b04bd25aa4 100644 --- a/config/remote/config.go +++ b/config/remote/config.go @@ -63,7 +63,10 @@ func (rc *Config) RefreshRemoteConfig() error { func (rc *Config) Get(key string) interface{} { segments := strings.Split(strings.ToLower(key), ".") value := config.SearchMap(rc.config, segments) - log.Debug().Msgf("Returning remote config value %v:%v", key, value) + // Remote configuration may contain credentials. Unlike the local Config, + // this package does not own a complete list of sensitive keys, so avoid + // logging values altogether. + log.Debug().Msgf("Returning remote config value for key %v", key) return value } diff --git a/consumer/entertainment/estimator.go b/consumer/entertainment/estimator.go index 9e64569126..b306ad371d 100644 --- a/consumer/entertainment/estimator.go +++ b/consumer/entertainment/estimator.go @@ -17,7 +17,13 @@ package entertainment -import "math" +import ( + "math" + + "github.com/mysteriumnetwork/node/market" + "github.com/mysteriumnetwork/payments/crypto" + "github.com/rs/zerolog/log" +) const ( video720pMBPerMin = 15 @@ -30,18 +36,32 @@ type Estimates struct { VideoMinutes uint64 MusicMinutes uint64 BrowsingMinutes uint64 - TrafficMB uint64 - PricePerGiB float64 - PricePerMin float64 + // TrafficMB is decimal megabytes (1 MB = 1,000,000 bytes), not MiB. + TrafficMB uint64 + PricePerGiB float64 + PricePerMin float64 +} + +// PriceProvider supplies current consumer prices from the node's shared pricing cache. +type PriceProvider interface { + GetCurrentPrice(nodeType, country, serviceType string) (market.Price, error) } -// Estimator stores average provider prices to estimate entertainment estimates +// Estimator estimates usage from consumer prices. type Estimator struct { + prices PriceProvider pricePerGiB float64 pricePerMin float64 } -// NewEstimator constructor +// NewMarketEstimator uses current residential WireGuard pricing on each estimate. +// No destination is selected by these APIs, so use the market's global default +// rather than a country-specific rate or the consumer's own location. +func NewMarketEstimator(prices PriceProvider) *Estimator { + return &Estimator{prices: prices} +} + +// NewEstimator constructs an estimator with explicit MYST/GiB and MYST/minute rates. func NewEstimator(pricePerGiB, pricePerMin float64) *Estimator { return &Estimator{ pricePerGiB: pricePerGiB, @@ -51,6 +71,24 @@ func NewEstimator(pricePerGiB, pricePerMin float64) *Estimator { // EstimatedEntertainment calculates average service times func (e *Estimator) EstimatedEntertainment(myst float64) Estimates { + if e.prices != nil { + price, err := e.prices.GetCurrentPrice("residential", "", "wireguard") + if err != nil { + log.Warn().Err(err).Msg("could not obtain entertainment pricing") + return Estimates{} + } + if price.PricePerGiB == nil || price.PricePerHour == nil { + return Estimates{} + } + // Keep rates local: estimates may run concurrently with pricing updates. + return NewEstimator(crypto.BigMystToFloat(price.PricePerGiB), + crypto.BigMystToFloat(price.PricePerHour)/60).EstimatedEntertainment(myst) + } + if myst < 0 || math.IsNaN(myst) || math.IsInf(myst, 0) || + e.pricePerGiB <= 0 || math.IsNaN(e.pricePerGiB) || math.IsInf(e.pricePerGiB, 0) || + e.pricePerMin < 0 || math.IsNaN(e.pricePerMin) || math.IsInf(e.pricePerMin, 0) { + return Estimates{} + } return Estimates{ VideoMinutes: e.minutes(myst, video720pMBPerMin), MusicMinutes: e.minutes(myst, audioNormalMBPerMin), diff --git a/consumer/entertainment/estimator_test.go b/consumer/entertainment/estimator_test.go index 379095d449..d444bcb12a 100644 --- a/consumer/entertainment/estimator_test.go +++ b/consumer/entertainment/estimator_test.go @@ -18,8 +18,13 @@ package entertainment import ( + "errors" + "math" "testing" + "github.com/mysteriumnetwork/node/market" + "github.com/mysteriumnetwork/payments/crypto" + "github.com/stretchr/testify/assert" ) @@ -46,3 +51,58 @@ func TestEstimator(t *testing.T) { assert.Less(t, uint64(0), e.MusicMinutes) assert.Less(t, uint64(0), e.BrowsingMinutes) } + +// The provider is queried for every estimate, so a market refresh is reflected +// without restarting either the mobile node or TequilAPI. +type testPriceProvider struct { + t *testing.T + price market.Price + err error +} + +func (p *testPriceProvider) GetCurrentPrice(nodeType, country, serviceType string) (market.Price, error) { + assert.Equal(p.t, "residential", nodeType) + assert.Empty(p.t, country) + assert.Equal(p.t, "wireguard", serviceType) + return p.price, p.err +} + +func TestMarketEstimator(t *testing.T) { + provider := &testPriceProvider{t: t, price: market.Price{ + PricePerGiB: crypto.FloatToBigMyst(3.73), + PricePerHour: crypto.FloatToBigMyst(0.06), + }} + estimator := NewMarketEstimator(provider) + estimate := estimator.EstimatedEntertainment(29.444) + assert.InDelta(t, 3.73, estimate.PricePerGiB, 1e-12) + assert.InDelta(t, 0.001, estimate.PricePerMin, 1e-12) + // Convert decimal MB back to GiB, allowing truncation of less than 1 MB. + assert.InDelta(t, 29.444/3.73, float64(estimate.TrafficMB)*1e6/(1<<30), 0.001) + assert.Equal(t, uint64(math.Floor(29.444/(15e6/(1<<30)*3.73+0.001))), estimate.VideoMinutes) + + provider.price.PricePerGiB = crypto.FloatToBigMyst(7.46) + refreshed := estimator.EstimatedEntertainment(29.444) + assert.InDelta(t, float64(estimate.TrafficMB)/2, float64(refreshed.TrafficMB), 1) + assert.InDelta(t, 7.46, refreshed.PricePerGiB, 1e-12) +} + +func TestEstimatorUnits(t *testing.T) { + assert.Equal(t, 1048.576, mib2MB(1000)) + assert.Equal(t, 1000.0, mb2MiB(1048.576)) + estimate := NewEstimator(1, 0).EstimatedEntertainment(1) + assert.Equal(t, uint64(1073), estimate.TrafficMB) // 1 GiB = 1073.741824 MB + assert.Equal(t, uint64(71), estimate.VideoMinutes) // 15 decimal MB/minute +} + +func TestMarketEstimatorUnavailablePrice(t *testing.T) { + provider := &testPriceProvider{t: t, err: errors.New("unavailable")} + assert.Equal(t, Estimates{}, NewMarketEstimator(provider).EstimatedEntertainment(29.444)) + provider.err = nil + assert.Equal(t, Estimates{}, NewMarketEstimator(provider).EstimatedEntertainment(29.444)) +} + +func TestEstimatorInvalidAmount(t *testing.T) { + for _, amount := range []float64{-1, math.NaN(), math.Inf(1)} { + assert.Equal(t, Estimates{}, NewEstimator(3.73, 0).EstimatedEntertainment(amount)) + } +} diff --git a/mmn/client.go b/mmn/client.go index e5929d5f86..2e9869ab6c 100644 --- a/mmn/client.go +++ b/mmn/client.go @@ -64,7 +64,8 @@ type client struct { // ClaimNode does an HTTP call to MMN and registers node func (m *client) ClaimNode(info NodeClaimRequest) error { - log.Debug().Msgf("Registering node to MMN: %+v", info) + // APIKey is intentionally omitted: this request contains an MMN credential. + log.Debug().Msgf("Registering node to MMN: identity=%s vendor_id=%s os=%s arch=%s node_version=%s", info.Identity, info.VendorID, info.OS, info.Arch, info.NodeVersion) id := identity.FromAddress(info.Identity) req, err := requests.NewSignedPostRequest(m.mmnAddress, "node", info, m.signer(id)) diff --git a/mobile/mysterium/entrypoint.go b/mobile/mysterium/entrypoint.go index 36bc83e0ef..70289cd7ad 100644 --- a/mobile/mysterium/entrypoint.go +++ b/mobile/mysterium/entrypoint.go @@ -420,20 +420,17 @@ func NewNode(appPath string, options *MobileNodeOptions) (*MobileNode, error) { di.NATProber, time.Duration(options.CacheTTLSeconds)*time.Second, ), - pilvytis: di.PilvytisAPI, - pilvytisOrderIssuer: di.PilvytisOrderIssuer, - startTime: time.Now(), - chainID: nodeOptions.OptionsNetwork.ChainID, - sessionStorage: di.SessionStorage, - identityMover: di.IdentityMover, - entertainmentEstimator: entertainment.NewEstimator( - config.FlagPaymentPriceGiB.Value, - config.FlagPaymentPriceHour.Value, - ), - residentCountry: di.ResidentCountry, - filterPresetStorage: di.FilterPresetStorage, - hermesMigrator: di.HermesMigrator, - earningsProvider: di.HermesChannelRepository, + pilvytis: di.PilvytisAPI, + pilvytisOrderIssuer: di.PilvytisOrderIssuer, + startTime: time.Now(), + chainID: nodeOptions.OptionsNetwork.ChainID, + sessionStorage: di.SessionStorage, + identityMover: di.IdentityMover, + entertainmentEstimator: entertainment.NewMarketEstimator(di.PricingHelper), + residentCountry: di.ResidentCountry, + filterPresetStorage: di.FilterPresetStorage, + hermesMigrator: di.HermesMigrator, + earningsProvider: di.HermesChannelRepository, } if options.IsProvider { diff --git a/mobile/mysterium/estimator.go b/mobile/mysterium/estimator.go index d36393a4a2..caf796541c 100644 --- a/mobile/mysterium/estimator.go +++ b/mobile/mysterium/estimator.go @@ -24,9 +24,11 @@ type Estimates struct { VideoMinutes int64 MusicMinutes int64 BrowsingMinutes int64 - TrafficMB int64 - PricePerGB float64 - PricePerMin float64 + // TrafficMB is decimal MB; convert to GiB with TrafficMB * 1e6 / (1 << 30). + TrafficMB int64 + // PricePerGB is MYST per GiB; the legacy field name is kept for mobile compatibility. + PricePerGB float64 + PricePerMin float64 } func newEstimates(e entertainment.Estimates) *Estimates { diff --git a/tequilapi/contract/entertainment.go b/tequilapi/contract/entertainment.go index 0f4c6db605..6437513602 100644 --- a/tequilapi/contract/entertainment.go +++ b/tequilapi/contract/entertainment.go @@ -52,10 +52,11 @@ func (req *EntertainmentEstimateRequest) Bind(request *http.Request) *apierror.A // EntertainmentEstimateResponse represents estimated entertainment. // swagger:model EntertainmentEstimateResponse type EntertainmentEstimateResponse struct { - VideoMinutes uint64 `json:"video_minutes"` - MusicMinutes uint64 `json:"music_minutes"` - BrowsingMinutes uint64 `json:"browsing_minutes"` - TrafficMB uint64 `json:"traffic_mb"` - PriceGiB float64 `json:"price_gib"` - PriceMin float64 `json:"price_min"` + VideoMinutes uint64 `json:"video_minutes"` + MusicMinutes uint64 `json:"music_minutes"` + BrowsingMinutes uint64 `json:"browsing_minutes"` + // TrafficMB is decimal MB; convert to GiB with TrafficMB * 1e6 / (1 << 30). + TrafficMB uint64 `json:"traffic_mb"` + PriceGiB float64 `json:"price_gib"` + PriceMin float64 `json:"price_min"` }