Skip to content

Commit

Permalink
feat: periodic version check and json config (#10438)
Browse files Browse the repository at this point in the history
Co-authored-by: Lucas Molas <schomatis@gmail.com>
Co-authored-by: Marcin Rataj <lidel@lidel.org>
  • Loading branch information
3 people committed Jul 24, 2024
1 parent ddfd776 commit 225dbe6
Show file tree
Hide file tree
Showing 10 changed files with 315 additions and 10 deletions.
57 changes: 54 additions & 3 deletions cmd/ipfs/kubo/daemon.go
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
package kubo

import (
"context"
"errors"
_ "expvar"
"fmt"
"math"
"net"
"net/http"
_ "net/http/pprof"
Expand Down Expand Up @@ -438,9 +440,11 @@ func daemonFunc(req *cmds.Request, re cmds.ResponseEmitter, env cmds.Environment
return fmt.Errorf("unrecognized routing option: %s", routingOption)
}

agentVersionSuffixString, _ := req.Options[agentVersionSuffix].(string)
if agentVersionSuffixString != "" {
version.SetUserAgentSuffix(agentVersionSuffixString)
// Set optional agent version suffix
versionSuffixFromCli, _ := req.Options[agentVersionSuffix].(string)
versionSuffix := cfg.Version.AgentSuffix.WithDefault(versionSuffixFromCli)
if versionSuffix != "" {
version.SetUserAgentSuffix(versionSuffix)
}

node, err := core.NewNode(req.Context, ncfg)
Expand Down Expand Up @@ -610,6 +614,15 @@ take effect.
}
if len(peers) == 0 {
log.Error("failed to bootstrap (no peers found): consider updating Bootstrap or Peering section of your config")
} else {
// After 1 minute we should have enough peers
// to run informed version check
startVersionChecker(
cctx.Context(),
node,
cfg.Version.SwarmCheckEnabled.WithDefault(true),
cfg.Version.SwarmCheckPercentThreshold.WithDefault(config.DefaultSwarmCheckPercentThreshold),
)
}
})
}
Expand Down Expand Up @@ -1056,3 +1069,41 @@ func printVersion() {
fmt.Printf("System version: %s\n", runtime.GOARCH+"/"+runtime.GOOS)
fmt.Printf("Golang version: %s\n", runtime.Version())
}

func startVersionChecker(ctx context.Context, nd *core.IpfsNode, enabled bool, percentThreshold int64) {
if !enabled {
return
}
ticker := time.NewTicker(time.Hour)
defer ticker.Stop()
go func() {
for {
o, err := commands.DetectNewKuboVersion(nd, percentThreshold)
if err != nil {
// The version check is best-effort, and may fail in custom
// configurations that do not run standard WAN DHT. If it
// errors here, no point in spamming logs: og once and exit.
log.Errorw("initial version check failed, will not be run again", "error", err)
return
}
if o.UpdateAvailable {
newerPercent := fmt.Sprintf("%.0f%%", math.Round(float64(o.WithGreaterVersion)/float64(o.PeersSampled)*100))
log.Errorf(`
⚠️ A NEW VERSION OF KUBO DETECTED
This Kubo node is running an outdated version (%s).
%s of the sampled Kubo peers are running a higher version.
Visit https://github.com/ipfs/kubo/releases or https://dist.ipfs.tech/#kubo and update to version %s or later.`,
o.RunningVersion, newerPercent, o.GreatestVersion)
}
select {
case <-ctx.Done():
return
case <-nd.Process.Closing():
return
case <-ticker.C:
continue
}
}
}()
}
1 change: 1 addition & 0 deletions config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ type Config struct {
Plugins Plugins
Pinning Pinning
Import Import
Version Version

Internal Internal // experimental/unstable options
}
Expand Down
14 changes: 14 additions & 0 deletions config/version.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package config

const DefaultSwarmCheckPercentThreshold = 5

// Version allows controling things like custom user agent and update checks.
type Version struct {
// Optional suffix to the AgentVersion presented by `ipfs id` and exposed
// via libp2p identify protocol.
AgentSuffix *OptionalString `json:",omitempty"`

// Detect when to warn about new version when observed via libp2p identify
SwarmCheckEnabled Flag `json:",omitempty"`
SwarmCheckPercentThreshold *OptionalInteger `json:",omitempty"`
}
1 change: 1 addition & 0 deletions core/commands/commands_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,7 @@ func TestCommands(t *testing.T) {
"/swarm/resources",
"/update",
"/version",
"/version/check",
"/version/deps",
}

Expand Down
181 changes: 174 additions & 7 deletions core/commands/version.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,17 +5,25 @@ import (
"fmt"
"io"
"runtime/debug"
"strings"

version "github.com/ipfs/kubo"

versioncmp "github.com/hashicorp/go-version"
cmds "github.com/ipfs/go-ipfs-cmds"
version "github.com/ipfs/kubo"
"github.com/ipfs/kubo/config"
"github.com/ipfs/kubo/core"
"github.com/ipfs/kubo/core/commands/cmdenv"
"github.com/libp2p/go-libp2p-kad-dht/fullrt"
peer "github.com/libp2p/go-libp2p/core/peer"
pstore "github.com/libp2p/go-libp2p/core/peerstore"
)

const (
versionNumberOptionName = "number"
versionCommitOptionName = "commit"
versionRepoOptionName = "repo"
versionAllOptionName = "all"
versionNumberOptionName = "number"
versionCommitOptionName = "commit"
versionRepoOptionName = "repo"
versionAllOptionName = "all"
versionCheckThresholdOptionName = "min-percent"
)

var VersionCmd = &cmds.Command{
Expand All @@ -24,7 +32,8 @@ var VersionCmd = &cmds.Command{
ShortDescription: "Returns the current version of IPFS and exits.",
},
Subcommands: map[string]*cmds.Command{
"deps": depsVersionCommand,
"deps": depsVersionCommand,
"check": checkVersionCommand,
},

Options: []cmds.Option{
Expand Down Expand Up @@ -130,3 +139,161 @@ Print out all dependencies and their versions.`,
}),
},
}

const DefaultMinimalVersionFraction = 0.05 // 5%

type VersionCheckOutput struct {
UpdateAvailable bool
RunningVersion string
GreatestVersion string
PeersSampled int
WithGreaterVersion int
}

var checkVersionCommand = &cmds.Command{
Helptext: cmds.HelpText{
Tagline: "Checks Kubo version against connected peers.",
ShortDescription: `
This command uses the libp2p identify protocol to check the 'AgentVersion'
of connected peers and see if the Kubo version we're running is outdated.
Peers with an AgentVersion that doesn't start with 'kubo/' are ignored.
'UpdateAvailable' is set to true only if the 'min-fraction' criteria are met.
The 'ipfs daemon' does the same check regularly and logs when a new version
is available. You can stop these regular checks by setting
Version.SwarmCheckEnabled:false in the config.
`,
},
Options: []cmds.Option{
cmds.IntOption(versionCheckThresholdOptionName, "t", "Percentage (1-100) of sampled peers with the new Kubo version needed to trigger an update warning.").WithDefault(config.DefaultSwarmCheckPercentThreshold),
},
Type: VersionCheckOutput{},

Run: func(req *cmds.Request, res cmds.ResponseEmitter, env cmds.Environment) error {
nd, err := cmdenv.GetNode(env)
if err != nil {
return err
}

if !nd.IsOnline {
return ErrNotOnline
}

minPercent, _ := req.Options[versionCheckThresholdOptionName].(int64)
output, err := DetectNewKuboVersion(nd, minPercent)
if err != nil {
return err
}

if err := cmds.EmitOnce(res, output); err != nil {
return err
}
return nil
},
}

// DetectNewKuboVersion observers kubo version reported by other peers via
// libp2p identify protocol and notifies when threshold fraction of seen swarm
// is running updated Kubo. It is used by RPC and CLI at 'ipfs version check'
// and also periodically when 'ipfs daemon' is running.
func DetectNewKuboVersion(nd *core.IpfsNode, minPercent int64) (VersionCheckOutput, error) {
ourVersion, err := versioncmp.NewVersion(version.CurrentVersionNumber)
if err != nil {
return VersionCheckOutput{}, fmt.Errorf("could not parse our own version %q: %w",
version.CurrentVersionNumber, err)
}
// MAJOR.MINOR.PATCH without any suffix
ourVersion = ourVersion.Core()

greatestVersionSeen := ourVersion
totalPeersSampled := 1 // Us (and to avoid division-by-zero edge case)
withGreaterVersion := 0

recordPeerVersion := func(agentVersion string) {
// We process the version as is it assembled in GetUserAgentVersion
segments := strings.Split(agentVersion, "/")
if len(segments) < 2 {
return
}
if segments[0] != "kubo" {
return
}
versionNumber := segments[1] // As in our CurrentVersionNumber

peerVersion, err := versioncmp.NewVersion(versionNumber)
if err != nil {
// Do not error on invalid remote versions, just ignore
return
}

// Ignore prerelases and development releases (-dev, -rcX)
if peerVersion.Metadata() != "" || peerVersion.Prerelease() != "" {
return
}

// MAJOR.MINOR.PATCH without any suffix
peerVersion = peerVersion.Core()

// Valid peer version number
totalPeersSampled += 1
if ourVersion.LessThan(peerVersion) {
withGreaterVersion += 1
}
if peerVersion.GreaterThan(greatestVersionSeen) {
greatestVersionSeen = peerVersion
}
}

processPeerstoreEntry := func(id peer.ID) {
if v, err := nd.Peerstore.Get(id, "AgentVersion"); err == nil {
recordPeerVersion(v.(string))
} else if errors.Is(err, pstore.ErrNotFound) { // ignore noop
} else { // a bug, usually.
log.Errorw("failed to get agent version from peerstore", "error", err)
}
}

// Amino DHT client keeps information about previously seen peers
if nd.DHTClient != nd.DHT && nd.DHTClient != nil {
client, ok := nd.DHTClient.(*fullrt.FullRT)
if !ok {
return VersionCheckOutput{}, errors.New("could not perform version check due to missing or incompatible DHT configuration")
}
for _, p := range client.Stat() {
processPeerstoreEntry(p)
}
} else if nd.DHT != nil && nd.DHT.WAN != nil {
for _, pi := range nd.DHT.WAN.RoutingTable().GetPeerInfos() {
processPeerstoreEntry(pi.Id)
}
} else if nd.DHT != nil && nd.DHT.LAN != nil {
for _, pi := range nd.DHT.LAN.RoutingTable().GetPeerInfos() {
processPeerstoreEntry(pi.Id)
}
} else {
return VersionCheckOutput{}, errors.New("could not perform version check due to missing or incompatible DHT configuration")
}

if minPercent < 1 || minPercent > 100 {
if minPercent == 0 {
minPercent = config.DefaultSwarmCheckPercentThreshold
} else {
return VersionCheckOutput{}, errors.New("Version.SwarmCheckPercentThreshold must be between 1 and 100")
}
}

minFraction := float64(minPercent) / 100.0

// UpdateAvailable flag is set only if minFraction was reached
greaterFraction := float64(withGreaterVersion) / float64(totalPeersSampled)

// Gathered metric are returned every time
return VersionCheckOutput{
UpdateAvailable: (greaterFraction >= minFraction),
RunningVersion: ourVersion.String(),
GreatestVersion: greatestVersionSeen.String(),
PeersSampled: totalPeersSampled,
WithGreaterVersion: withGreaterVersion,
}, nil
}
17 changes: 17 additions & 0 deletions docs/changelogs/v0.30.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,30 @@

- [Overview](#overview)
- [πŸ”¦ Highlights](#-highlights)
- [Automated `ipfs version check`](#automated-ipfs-version-check)
- [Version Suffix Configuration](#version-suffix-configuration)
- [πŸ“ Changelog](#-changelog)
- [πŸ‘¨β€πŸ‘©β€πŸ‘§β€πŸ‘¦ Contributors](#-contributors)

### Overview

### πŸ”¦ Highlights

#### Automated `ipfs version check`

Kubo now performs privacy-preserving version checks using the [libp2p identify protocol](https://github.com/libp2p/specs/blob/master/identify/README.md) on peers detected by the Amino DHT client.
If more than 5% of Kubo peers seen by your node are running a newer version, you will receive a log message notification.

- For manual checks, refer to `ipfs version check --help` for details.
- To disable automated checks, set [`Version.SwarmCheckEnabled`](https://github.com/ipfs/kubo/blob/master/docs/config.md#versionswarmcheckenabled) to `false`.

#### Version Suffix Configuration

Defining the optional agent version suffix is now simpler. The [`Version.AgentSuffix`](https://github.com/ipfs/kubo/blob/master/docs/config.md#agentsuffix) value from the Kubo config takes precedence over any value provided via `ipfs daemon --agent-version-suffix` (which is still supported).

> [!NOTE]
> Setting a custom version suffix helps with ecosystem analysis, such as Amino DHT reports published at https://stats.ipfs.network
### πŸ“ Changelog

### πŸ‘¨β€πŸ‘©β€πŸ‘§β€πŸ‘¦ Contributors
Loading

0 comments on commit 225dbe6

Please sign in to comment.